Compare commits
202 Commits
mai/fermi/
...
mai/brunel
| Author | SHA1 | Date | |
|---|---|---|---|
| 940df95418 | |||
| 538c2d2da9 | |||
| a9a9adbd2a | |||
| f24a90b722 | |||
| 55bfe439f2 | |||
| 0ac26fe0ee | |||
| 72b64140e9 | |||
| 50cd80a4a6 | |||
| 716f6d7ece | |||
| 1bf62c78e3 | |||
| 9a774ba3ad | |||
| 8caaf6a631 | |||
| 228ae1b263 | |||
| cdd3747c2b | |||
| 02255c4234 | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 | |||
| 0059e3f15b | |||
| a911a2d0ee | |||
| b26f04ffe0 | |||
| 8e195cb497 | |||
| 1f7de99493 | |||
| 0adcc2c826 | |||
| 2c7ac6423f | |||
| 436c1b41bb | |||
| 2c5f85b802 | |||
| d3aade5aac | |||
| 17d2fff661 | |||
| c6a5416611 | |||
| d590be4bb7 | |||
| f7374a67cd | |||
| 3ff1b23238 | |||
| a88269c7c1 | |||
| 3d85ce5444 | |||
| 903225b593 | |||
| 4cd2f05d33 | |||
| b4f5af7f70 | |||
| 83b00d13fe | |||
| 34372ca4c8 | |||
| 65308651dd | |||
| d088de95eb | |||
| becf4f0ce3 | |||
| 924dbd9768 | |||
| 6c40823038 | |||
| 007ebc2794 | |||
| cdd27d674e | |||
| 28de2e56d0 | |||
| af073f87da | |||
| f22e918048 | |||
| 79d98cfeb8 | |||
| 19d95d6f5b | |||
| 17d149c09e | |||
| 7c7030c5bf | |||
| da8389b6e3 | |||
| 7967839f78 | |||
| d86cac0b53 | |||
| 69f45893a3 | |||
| 9f339747e5 | |||
| 7c3c84454d | |||
| 61210943d9 | |||
| 74783e7a89 | |||
| 062afb6cc5 | |||
| 47b869dddf | |||
| c4c4fa267f | |||
| d555d5f679 | |||
| 875d0c149a | |||
| 92d0340d74 | |||
| f8c6206afe | |||
| f8245a06a6 | |||
| ca71162543 | |||
| 6b565be830 | |||
| 0857c1c078 | |||
| 4bf0a719b0 | |||
| 15ce176ebd | |||
| e56cb3b210 | |||
| fffddcc71a | |||
| b850eb755c | |||
| a93277a072 | |||
| c3cd51eb85 | |||
| 6b634207c2 | |||
| 794617cbfd | |||
| b418705775 | |||
| 7a1fd81d23 | |||
| a4e2f3526d | |||
| 1c8cdd3079 | |||
| 82ecbe3b8e | |||
| badbffa6e0 | |||
| 0f98d2cd39 | |||
| d0f732d0ec | |||
| e83b150eda | |||
| 2320cb765d | |||
| 668558380d | |||
| 9dd47a0591 | |||
| 3d3a4fa36d | |||
| 1c021ed515 | |||
| 35217fab4f | |||
| 225204cf1c | |||
| ea0715a8c7 | |||
| 3fdc969902 | |||
| 5dea0a703b | |||
| cc23e9e537 | |||
| ca770636f7 | |||
| ea9823db80 | |||
| 111c7c39e8 | |||
| 25cee32d01 | |||
| 2ed0ef3177 | |||
| a5ae2148fa | |||
| 5a0674a2cf | |||
| 13bb01ec96 | |||
| 072b3d0c3d | |||
| e39c4eb62d | |||
| dc5f11ddef | |||
| e343b759da | |||
| 7288cf3c9c | |||
| 7f9e2ce7ed | |||
| bbb8c962a1 | |||
| 3966394a39 | |||
| 5dacc97a6b | |||
| 15bcba5d7c | |||
| 48f78a713b | |||
| a421bff856 | |||
| 0aa81139a3 | |||
| fbd087e0cd | |||
| 8bac1b4f88 | |||
| 1fcfab7791 | |||
| 12ed8bb8da | |||
| 7654ce6833 | |||
| f3b947e3ad | |||
| f0b08e9d06 | |||
| 760a0de931 | |||
| bc8dc9d048 | |||
| 694c7a53ad | |||
| 81cb89f68e | |||
| a6b2979a94 | |||
| 8f1f88b517 | |||
| d5c80febb1 | |||
| 1765d5e55f | |||
| c85c382b1b | |||
| 7a359989a9 | |||
| 1a8eee2a10 | |||
| 4472faf224 | |||
| 2504e50f29 | |||
| d244ff5158 | |||
| 741cab4d25 | |||
| 0263a0e932 | |||
| 0fd02bf033 | |||
| dce98e273b | |||
| c1c5532d52 | |||
| ee837815e1 | |||
| e035512e70 | |||
| 6401a8198d | |||
| 6a202411f6 | |||
| d924ab9743 | |||
| fb2896c836 | |||
| 705e1a2e79 | |||
| d8acbd613c | |||
| c01f3f2db8 | |||
| 2fa47278ce | |||
| 6c7e9ef44d | |||
| 17cd5b3b0c | |||
| d127c768f7 | |||
| dab06e068f | |||
| defa516e4f | |||
| 6ff26e8a6e | |||
| 2c94420a4b | |||
| 3677c81fbe | |||
| 8ea3509b98 | |||
| 5ff637ab70 | |||
| 265f240151 | |||
| 1039680878 | |||
| 773654523e | |||
| f7585376df | |||
| f9ff7b93e8 | |||
| 86d20ed6d4 | |||
| 1639b3919a | |||
| bf31935767 | |||
| aee177a303 | |||
| 28c7215458 | |||
| 9aebe5780b | |||
| 8a43aed100 | |||
| 52b3feb9d2 | |||
| 586ba29b86 | |||
| 0b57ec5257 | |||
| 2007ad39bb | |||
| b7c4de9ac9 | |||
| 8e0e4c9dcc | |||
| 023f32d4f2 | |||
| 621fe35d79 | |||
| 139c4a6406 | |||
| 6e8e2e7653 | |||
| de20356cec | |||
| 8414aa4c14 | |||
| 1e1c84b0f6 | |||
| e1b91a9481 | |||
| 92780cf726 | |||
| a0082d2b0d | |||
| c921925c68 | |||
| 22cfdb909f |
@@ -1,6 +1,3 @@
|
||||
# Project-specific mai configuration
|
||||
# Auto-generated by 'mai init' — run 'mai setup' to customize
|
||||
|
||||
provider: claude
|
||||
providers:
|
||||
claude:
|
||||
@@ -47,21 +44,13 @@ worker:
|
||||
name_scheme: role
|
||||
default_level: standard
|
||||
auto_discard: false
|
||||
max_workers: 5
|
||||
max_workers: 7
|
||||
persistent: true
|
||||
head:
|
||||
name: "paliadin"
|
||||
max_loops: 50
|
||||
infinity_mode: false
|
||||
max_idle_duration: 2h0m0s
|
||||
backoff_intervals:
|
||||
- 5
|
||||
- 10
|
||||
- 15
|
||||
- 30
|
||||
name: paliadin
|
||||
capacity:
|
||||
global:
|
||||
max_workers: 5
|
||||
max_workers: 7
|
||||
max_heads: 3
|
||||
per_worker:
|
||||
max_tasks_lifetime: 0
|
||||
|
||||
73
Makefile
Normal file
73
Makefile
Normal file
@@ -0,0 +1,73 @@
|
||||
# Paliad — developer entrypoints.
|
||||
#
|
||||
# Targets here are the gate tier from the test-strategy design
|
||||
# (docs/design-paliad-test-strategy-2026-05-19.md). Slice 1 lands:
|
||||
#
|
||||
# make verify-migrations — dry-run every pending migration (BEGIN..ROLLBACK)
|
||||
# plus the full boot smoke (apply + tracker
|
||||
# advances + /healthz returns 200).
|
||||
# make verify-mig — alias for verify-migrations.
|
||||
# make test — short test pass: go test ./internal/... -short
|
||||
# plus the cmd/server package. Includes the
|
||||
# live-DB tests when TEST_DATABASE_URL is set,
|
||||
# skips them otherwise.
|
||||
# make test-go — go test ./... -race (full Go suite).
|
||||
#
|
||||
# Future slices will extend this with:
|
||||
# make test-frontend — bun test (Slice 3 / Slice 6)
|
||||
# make e2e — Playwright golden-path suite (Slice 4)
|
||||
#
|
||||
# All targets are idempotent. None of them write to the filesystem outside
|
||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||
# files.
|
||||
|
||||
.PHONY: help verify-migrations verify-mig test test-go
|
||||
|
||||
help:
|
||||
@echo "Paliad — developer targets"
|
||||
@echo ""
|
||||
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
|
||||
@echo " verify-mig Alias for verify-migrations"
|
||||
@echo " test Short test pass — covers gate tier"
|
||||
@echo " test-go Full Go suite with race detector"
|
||||
@echo ""
|
||||
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||
|
||||
# Gate target — the test that would have caught mig 098 / mig 099 before
|
||||
# deploy. Combines:
|
||||
# - TestMigrations_DryRun (internal/db): per-migration BEGIN..ROLLBACK
|
||||
# - TestBootSmoke (cmd/server): apply-end-to-end + tracker advances
|
||||
# + /healthz 200
|
||||
#
|
||||
# Requires TEST_DATABASE_URL. Without it, both tests skip and the target
|
||||
# is effectively a no-op — guard against that explicitly so CI doesn't
|
||||
# silently green a missing env var.
|
||||
verify-migrations:
|
||||
@if [ -z "$$TEST_DATABASE_URL" ]; then \
|
||||
echo "ERROR: TEST_DATABASE_URL is not set."; \
|
||||
echo " The migration gate cannot run without a scratch DB."; \
|
||||
echo " Set TEST_DATABASE_URL to a Postgres URL the test can"; \
|
||||
echo " open transactions against, e.g."; \
|
||||
echo " export TEST_DATABASE_URL=postgres://paliad:PW@localhost:11833/paliad_test"; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> migration dry-run (per-mig BEGIN..ROLLBACK)"
|
||||
go test -count=1 -run TestMigrations_DryRun ./internal/db/
|
||||
@echo "==> boot smoke (apply + tracker + /healthz)"
|
||||
go test -count=1 -run TestBootSmoke ./cmd/server/
|
||||
|
||||
verify-mig: verify-migrations
|
||||
|
||||
# Gate-tier test pass. -short skips the slow live-DB tests when the
|
||||
# author opts out via `if testing.Short() { t.Skip(...) }`; today most of
|
||||
# paliad's live-DB tests gate on TEST_DATABASE_URL instead, so -short is
|
||||
# forward-compatible rather than load-bearing.
|
||||
test:
|
||||
go test -short ./internal/... ./cmd/...
|
||||
|
||||
# Full Go suite with race detection. Slower but catches concurrent-map
|
||||
# regressions that -short would skip; intended for the merge-to-main gate
|
||||
# (full suite, not per-PR).
|
||||
test-go:
|
||||
go test -race ./...
|
||||
@@ -117,7 +117,9 @@ func main() {
|
||||
}
|
||||
|
||||
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
|
||||
bindingSvc := services.NewCalendarBindingService(pool)
|
||||
targetSvc := services.NewAppointmentTargetService(pool)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
|
||||
// Wire the push hook so user-driven mutations sync to the external
|
||||
// calendar without waiting for the next 60-second tick.
|
||||
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
||||
@@ -126,6 +128,20 @@ func main() {
|
||||
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
||||
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
|
||||
|
||||
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
|
||||
// new "Konto direkt anlegen" path on /admin/team. The key is
|
||||
// optional: when unset the client still wires (so dependents
|
||||
// don't panic) but every call short-circuits with
|
||||
// ErrSupabaseAdminUnavailable so the rest of the server stays
|
||||
// runnable.
|
||||
supabaseAdminClient := services.LoadSupabaseAdminClient()
|
||||
if supabaseAdminClient.Enabled() {
|
||||
log.Println("supabase admin API configured — /admin/team Add-User path active")
|
||||
} else {
|
||||
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
|
||||
}
|
||||
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
|
||||
|
||||
// Wire EmailTemplateService onto the MailService so DB-backed admin
|
||||
// edits propagate without a process restart. The constructor is split
|
||||
// from MailService creation because the DB pool isn't available yet
|
||||
@@ -135,14 +151,29 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
partySvc := services.NewPartyService(pool, projectSvc)
|
||||
// t-paliad-238 — dedicated submission draft editor. The variable
|
||||
// bag service is shared between the renderer (export) and the
|
||||
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
|
||||
// (commits 3677c81 + 1765d5e + 8ea3509).
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
CalDAVBindings: bindingSvc,
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
@@ -162,7 +193,11 @@ func main() {
|
||||
EventType: eventTypeSvc,
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
|
||||
ChecklistCatalog: checklistCatalogSvc,
|
||||
ChecklistTemplate: checklistTemplateSvc,
|
||||
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
||||
@@ -175,10 +210,39 @@ func main() {
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
DashboardLayout: services.NewDashboardLayoutService(pool),
|
||||
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
// the dashboard, and DashboardService can render its other widgets
|
||||
// without approvals — so keeping this a setter keeps both
|
||||
// constructors simple).
|
||||
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
|
||||
// Slice C wires PinService into DashboardService for the
|
||||
// pinned-projects widget. Pin pre-dates t-paliad-219; no new
|
||||
// schema, no circular dependency (Pin doesn't know about the
|
||||
// dashboard).
|
||||
svcBundle.Dashboard.SetPinService(svcBundle.Pin)
|
||||
// Slice C wires the firm-wide dashboard default into the
|
||||
// per-user layout service so GetOrSeed/ResetToDefault prefer
|
||||
// the admin-set firm default over the code-resident factory.
|
||||
// Nil-safe: empty firm row falls back to the factory layout.
|
||||
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
|
||||
|
||||
// t-paliad-230 — submission generator (format-only). No
|
||||
// service wiring needed: handlers/submissions.go reuses the
|
||||
// existing files.go HL Patents Style cache and calls
|
||||
// services.ConvertDotmToDocx (stateless function).
|
||||
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
|
||||
|
||||
210
cmd/server/main_smoke_test.go
Normal file
210
cmd/server/main_smoke_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Boot smoke test — assert paliad reaches a serving state.
|
||||
//
|
||||
// Three checks against TEST_DATABASE_URL:
|
||||
//
|
||||
// 1. db.ApplyMigrations does not panic and returns nil.
|
||||
// 2. paliad.applied_migrations covers every on-disk *.up.sql — no
|
||||
// migration was silently skipped, no version is missing. The set
|
||||
// contract is stronger than the old single-counter check: applied
|
||||
// set must EQUAL on-disk set, not just reach the max version.
|
||||
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
|
||||
//
|
||||
// This is the lightweight cousin of the migration dry-run gate
|
||||
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
|
||||
// errors before merge; this smoke confirms the apply+bind path the
|
||||
// container actually runs at boot. Together they cover the mig-098 /
|
||||
// mig-099 class of crash-loops end-to-end, plus the mig-103 parallel-merge
|
||||
// skip-hole that t-paliad-218 closed (m/paliad#44).
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/handlers"
|
||||
)
|
||||
|
||||
func TestBootSmoke(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping boot smoke")
|
||||
}
|
||||
|
||||
// (1) Apply migrations end-to-end. The same code path the prod
|
||||
// container runs at boot before `http.ListenAndServe`. A regression
|
||||
// like mig-098's digit-regex would surface here as a non-nil error.
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||
}
|
||||
|
||||
// (2) Assert the applied set equals the on-disk set. The new runner
|
||||
// tracks applied state per-migration; a silently-skipped version
|
||||
// would surface as a row missing from paliad.applied_migrations even
|
||||
// though max(version) matches. Comparing sets — not just max —
|
||||
// catches the failure mode the t-paliad-218 post-mortem documented.
|
||||
onDisk := embeddedMigrationVersions(t)
|
||||
applied := appliedMigrationVersions(t, url)
|
||||
|
||||
if missing := setDiff(onDisk, applied); len(missing) > 0 {
|
||||
t.Errorf("paliad.applied_migrations missing %d on-disk versions: %v "+
|
||||
"(a migration was skipped — investigate before deploying)",
|
||||
len(missing), missing)
|
||||
}
|
||||
if extra := setDiff(applied, onDisk); len(extra) > 0 {
|
||||
t.Errorf("paliad.applied_migrations has %d versions with no on-disk file: %v "+
|
||||
"(orphan rows — either restore the file or DELETE the row)",
|
||||
len(extra), extra)
|
||||
}
|
||||
|
||||
// (3) Mount the public handlers (the same Register call main() makes,
|
||||
// minus the DB-backed Services bundle which the /healthz route doesn't
|
||||
// need) and assert /healthz returns 200. This is the bind-and-serve
|
||||
// half of the smoke: catches a regression that would make /healthz
|
||||
// 404 or break the mux registration order.
|
||||
//
|
||||
// We deliberately do not boot the full main() — that would require
|
||||
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, an open
|
||||
// listening socket and a real auth client. The /healthz handler is
|
||||
// auth-independent by design, and Register registers it on the outer
|
||||
// mux before any DB-backed route, so this minimal setup exercises the
|
||||
// exact code path main() takes.
|
||||
mux := http.NewServeMux()
|
||||
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
|
||||
handlers.Register(mux, authClient, "", nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET /healthz: status=%d, body=%q; want 200 OK", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
|
||||
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
|
||||
// internal/db/migrations/ on disk. The boot smoke compares this set
|
||||
// against paliad.applied_migrations to detect skipped or orphan
|
||||
// migrations.
|
||||
//
|
||||
// Read from disk (not the embed.FS inside the db package — it's unexported)
|
||||
// since the test runs from the repo. The two views must agree for the
|
||||
// build to be self-consistent; if they diverge, the smoke test is the
|
||||
// wrong place to learn about it (the build is). We trust them to match.
|
||||
func embeddedMigrationVersions(t *testing.T) []int {
|
||||
t.Helper()
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("locate repo root: %v", err)
|
||||
}
|
||||
dir := filepath.Join(root, "internal", "db", "migrations")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations dir %s: %v", dir, err)
|
||||
}
|
||||
var versions []int
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
base := strings.TrimSuffix(name, ".up.sql")
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, v)
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
t.Fatalf("no *.up.sql files found in %s", dir)
|
||||
}
|
||||
sort.Ints(versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// appliedMigrationVersions reads paliad.applied_migrations and returns
|
||||
// the sorted list of versions. Fails the test if the table doesn't exist —
|
||||
// db.ApplyMigrations is supposed to have created it by this point.
|
||||
func appliedMigrationVersions(t *testing.T, url string) []int {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setDiff returns the elements of a that are not in b. Inputs are sorted
|
||||
// ascending; output preserves that ordering.
|
||||
func setDiff(a, b []int) []int {
|
||||
bset := make(map[int]bool, len(b))
|
||||
for _, v := range b {
|
||||
bset[v] = true
|
||||
}
|
||||
var out []int
|
||||
for _, v := range a {
|
||||
if !bset[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// repoRoot walks upward from the test binary's working directory until it
|
||||
// finds a go.mod. `go test` runs in the package dir, so we typically have
|
||||
// to climb a couple of levels.
|
||||
func repoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
||||
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
|
||||
|
||||
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
332
docs/design-approval-suggest-changes-2026-05-19.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Design — "Suggest changes" action on approval flow
|
||||
|
||||
**Author:** hertz (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Task:** t-paliad-216 (m/paliad in-flight)
|
||||
**Branch:** `mai/hertz/inventor-suggest-changes`
|
||||
**Status:** DESIGN — open questions await m before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Add a fourth action **"Änderungen vorschlagen"** ("Suggest changes") to the approval flow, alongside Approve / Reject / Revoke. Use case: the approver doesn't want to accept the proposed change as-is, but doesn't want to reject outright — they edit the proposed values into a counter-proposal and submit it back into the same approval flow.
|
||||
|
||||
**Mental model (m, 2026-05-19):** suggest-changes is not "ping the requester to fix it" — it's the approver **authoring a counter-proposal** that gets re-injected into the approval flow as a fresh `pending` row. The original requester (now potentially an eligible approver of the counter, since they're no longer the requested_by) sees:
|
||||
- the **old row** in their /inbox as `changes_requested` ("Abgelehnt mit Vorschlag" / "Declined with changes") — historical record of their original attempt;
|
||||
- the **new row** in /inbox as `pending` — the counter, which they can approve, reject, revoke (n/a, not theirs), or suggest changes back on. Everyone else eligible sees the new row too. 4-Augen still holds: the counter's requested_by (the approver who suggested it) cannot self-approve.
|
||||
|
||||
Click flow:
|
||||
1. Approver opens an editable modal on the pending row showing the requester's proposed values. Edits any field. Writes a free-text note ("Bitte den Termin um 9:00 statt 8:00, weil der Raum sonst kollidiert").
|
||||
2. POST `/api/approval-requests/{id}/suggest-changes` with `{note, counter_payload}`.
|
||||
3. Server, in one tx: closes the old row (`changes_requested`, `decision_note=note`), reverts the entity from `pre_image`, then immediately inserts a **new** `pending` approval_requests row authored by the approver with `payload=counter_payload`, re-applies the counter to the entity, marks `pending_request_id` to the new row, emits two events (`*_approval_changes_suggested` + `*_approval_requested`). `previous_request_id` FK links new → old for chain traversal.
|
||||
|
||||
The pending audience for the new row is the same as any fresh `Submit*` — the existing notification + visibility plumbing handles it without special-casing.
|
||||
|
||||
---
|
||||
|
||||
## 0a. m's decisions (2026-05-19)
|
||||
|
||||
| # | Header | m picked | Reasoning note (when different from recommendation) |
|
||||
|---|---|---|---|
|
||||
| Q1 | State machine | **(a) New status `changes_requested`.** | As recommended. |
|
||||
| Q2 | Entity state | **(a) Reverts to pre_image, same as Reject.** | As recommended. The counter is then re-applied in the same tx by the new approval row's write-then-approve cycle. |
|
||||
| Q3 | Chain depth | **(a) Yes, across chained rows.** | As recommended. |
|
||||
| Q4 | Note shape | **Hybrid: approver can edit the proposed values (counter-proposal) AND/OR leave free-text in `decision_note`.** | Differs from (a). Inventor picked free-text-only; m's twist: the suggestion should ALSO carry concrete edits. This adds a `counter_payload jsonb` column on `approval_requests` and turns "suggest-changes" into an action that authors a real counter-proposal, not just a hint. |
|
||||
| Q5 | Surface | **(a) /inbox only — v1.** | As recommended. Email + entity-detail badge are Phase 2. |
|
||||
| Q6 | Requester actions | **Different model: the counter is a NEW pending approval_request row, not an "edit + resubmit" CTA on the requester side.** | Differs from (a). m's reframing: instead of routing back to the requester to act on, the suggestion IS the next request. Original requester sees the old row as `changes_requested` (status pill "Abgelehnt mit Vorschlag" or similar). Original requester then sees the NEW row in /inbox like any pending — and **may approve it themselves**, because they are no longer the row's requested_by (the suggesting approver is). Everyone else eligible sees it too. Cleaner workflow, removes the "edit-and-resubmit CTA" from the requester role entirely. |
|
||||
| Q7 | Notifications | **(b) Notify all eligible approvers + the original requester for the NEW pending row.** | Consistent with Q6. The counter is a fresh `pending` request, so the existing Submit*-notification audience applies. The original requester needs the ping because they're now an eligible approver of the counter — no special-case path. |
|
||||
| Q8 | Audit shape | **(a) New event_type `*_approval_changes_suggested` per entity.** | As recommended. The new row also emits a normal `*_approval_requested` event, so the Verlauf chronology naturally captures the chain. |
|
||||
|
||||
The decisions above lock the design. §3 has been rewritten to reflect them; §2 (open questions) is retained as the historical record of what was open before the decisions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — what's already in the code (verified 2026-05-19)
|
||||
|
||||
- **State machine** in `internal/services/approval_service.go`:
|
||||
- `paliad.approval_requests.status` CHECK is already `('pending', 'approved', 'rejected', 'revoked', 'superseded')` — the `superseded` value is defined as a Go constant `RequestStatusSuperseded` but never written by the live service (reserved).
|
||||
- `paliad.{deadlines,appointments}.approval_status` CHECK is `('approved', 'pending', 'legacy')` — three values only.
|
||||
- Shared kernel `decide(requestID, callerID, finalStatus, note)` powers Approve / Reject / Revoke. Approve invokes `applyApproved`; Reject + Revoke invoke `applyRevert` (restores entity from `pre_image`).
|
||||
- Self-approval blocked at 3 layers: `canApprove` Go gate, `approval_requests_no_self_approval` DB CHECK, deadlock-check excludes requester from pool.
|
||||
- **Handlers** in `internal/handlers/approvals.go`:
|
||||
- `POST /api/approval-requests/{id}/approve`
|
||||
- `POST /api/approval-requests/{id}/reject`
|
||||
- `POST /api/approval-requests/{id}/revoke`
|
||||
- `GET /api/approval-requests/{id}` — single hydrated request
|
||||
- **Per-viewer flags** (t-paliad-202, shipped): every row carries `viewer_can_approve` + `viewer_is_requester` resolved server-side so the UI can grey out buttons the server would reject. Server still enforces — the flags are a UX hint.
|
||||
- **Frontend**:
|
||||
- `frontend/src/client/inbox.ts` wires three buttons per pending row (approve/reject/revoke). Reject opens `window.prompt()` for the note; approve+revoke don't.
|
||||
- `frontend/src/client/views/shape-list.ts` (row_action="approve") stamps the row with action buttons + diff + `decision_note` display if present.
|
||||
- **Audit**: event types `*_approval_requested`, `*_approval_approved`, `*_approval_rejected`, `*_approval_revoked` emitted to `paliad.project_events` (one per entity_type prefix).
|
||||
- **Decision note**: `paliad.approval_requests.decision_note text` — a single free-text column, last-write-wins. Already populated on Reject (Approve also accepts an optional note).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design questions (the open list — see §6 for answered)
|
||||
|
||||
Pre-recommendations from inventor. m will pick via AskUserQuestion.
|
||||
|
||||
### State machine
|
||||
|
||||
**Q1 — Where does "suggest changes" sit on the lifecycle?**
|
||||
- **(a) New status `changes_requested` (RECOMMENDED).** The approval_requests row transitions pending → changes_requested. Sibling of approved/rejected/revoked/superseded. The row is terminal in that status; a re-submit creates a fresh row (linked via `previous_request_id`).
|
||||
- (b) Reuse `rejected` with `is_revisable=true` flag. Cheap, but conflates two semantically distinct outcomes ("we'll never want this" vs. "tweak X and try again").
|
||||
- (c) Auto-revoke the current row, mark the entity for edit, requester creates a new approval row when ready. Reuses existing plumbing — but loses the approver's note as a first-class thing (it'd just be a comment on the project_events row).
|
||||
- (d) Other (you'll tell us).
|
||||
|
||||
Recommend (a) — keeps the audit lifecycle clear, gives us a clean place to hang the suggestion note, and is the smallest schema change (one new value in a CHECK constraint).
|
||||
|
||||
**Q2 — What happens to the entity (deadline/appointment) while in "changes requested"?**
|
||||
- **(a) Entity reverts to pre_image — same as Reject (RECOMMENDED).** approval_status flips back to `approved`. The requester edits the entity in the normal flow; saving fires a fresh `Submit*` cycle.
|
||||
- (b) Entity stays at `approval_status=pending` carrying the proposed values; requester edits "in place" through a new "amend the pending request" endpoint that mutates the same approval_request row + entity fields.
|
||||
- (c) Entity goes to a new `approval_status=draft` (would require a new value on the entity-level CHECK + UI work to handle a third entity state).
|
||||
|
||||
Recommend (a) — minimum schema change, reuses every existing path (entity edit, Submit*, applyRevert, project_events emission). The trade-off is one extra approval_requests row per cycle; we link via `previous_request_id` so the chain stays inspectable.
|
||||
|
||||
**Q3 — Can the approver suggest changes multiple times (across a chain)?**
|
||||
- **(a) Yes, across chained rows (RECOMMENDED).** Each row is terminal after suggest-changes; the requester resubmits → new pending row → approver can suggest changes again. Chain depth unbounded.
|
||||
- (b) No — one chance per entity-lifecycle; if the requester comes back, the only options are approve or reject (the suggest-changes button is hidden for the second submission).
|
||||
|
||||
Recommend (a) — bounded by the requester's patience, not by the system. Multi-round review is the norm in legal-doc workflows.
|
||||
|
||||
**Q4 — Note shape on the suggestion**
|
||||
- **(a) Free-text — reuse `decision_note` (RECOMMENDED).** Same column the existing Reject path already populates. Last-write-wins per row (but rows are terminal after suggest-changes, so there's no real "last write").
|
||||
- (b) Thread of notes — new `paliad.approval_notes` table, ordered, multi-author. Lets the requester respond inline, the approver clarify, etc.
|
||||
- (c) Structured per-field suggestions (`[{"field": "due_date", "current": "...", "suggested": "..."}]`) — a "diff-style" view.
|
||||
|
||||
Recommend (a) — matches the existing Reject UX, no new schema. (b) is right if the team wants to discuss; (c) is over-engineered for v1.
|
||||
|
||||
### UX
|
||||
|
||||
**Q5 — Where does the requester see the suggestion?**
|
||||
- **(a) /inbox under `a_role=self_requested` (RECOMMENDED for v1).** Same surface they already use to see rejected. New status pill "Änderungen vorgeschlagen" + the note + a CTA "Bearbeiten und erneut einreichen".
|
||||
- (b) A new badge on the entity's detail page (e.g. on the deadline detail page itself).
|
||||
- (c) Email + push notification.
|
||||
- (d) All of the above.
|
||||
|
||||
Recommend (a) for v1. Email reminder is a natural Phase-2 add-on (it'd reuse the existing reminder-mail plumbing). The entity-detail badge is nice but the user is already seeing the row in /inbox.
|
||||
|
||||
**Q6 — What action(s) does the requester have on a `changes_requested` row?**
|
||||
- **(a) Edit and resubmit (RECOMMENDED).** Primary action. Opens the entity's edit form pre-populated with the original `payload`. Saving fires `Submit*` → new pending request with `previous_request_id` linking back.
|
||||
- (b) Withdraw (= dismiss the row from inbox, no DB change). Mostly UI-only — the row is already terminal; "withdraw" would just be a "mark as not-pursuing" toggle.
|
||||
- (c) Both.
|
||||
|
||||
Recommend (a). The row is already terminal once status=`changes_requested`; the requester either acts on the suggestion (a) or lets the row sit in their inbox history (no action needed). Adding a "dismiss" button is a UI nice-to-have but doesn't change the data model; can defer.
|
||||
|
||||
### Notifications
|
||||
|
||||
**Q7 — Who gets notified when "suggest changes" fires?**
|
||||
- **(a) Just the requester (RECOMMENDED for v1).** Email-reminder path is reused: requester gets a mail "X hat Änderungen vorgeschlagen für …" with the note inline + a link to /inbox.
|
||||
- (b) Requester + any other potential approvers (they need to know the request is closed, not pending).
|
||||
- (c) Requester + approval-policy-defined watchers (would require a new `approval_policies.watchers` column).
|
||||
|
||||
Recommend (a). The request is terminal so other approvers don't need a "this is now your problem" ping — they wouldn't have anything to act on. They see it in /inbox under "Alle sichtbaren" anyway if curious.
|
||||
|
||||
### Audit
|
||||
|
||||
**Q8 — Audit row shape on `project_events`**
|
||||
- **(a) New event_type `*_approval_changes_suggested` per entity (RECOMMENDED).** Parallel to the existing 4 (requested/approved/rejected/revoked). Two new event types: `deadline_approval_changes_suggested`, `appointment_approval_changes_suggested`. Note text goes in metadata.
|
||||
- (b) Bundle with the resubmission — single composite event "approved-with-revisions" when the chain eventually approves.
|
||||
|
||||
Recommend (a). Each transition gets its own event row — that's how the existing audit chain already works (one event per state change). It also gives the Verlauf timeline a row to render the approver's note.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation sketch (decisions-locked, see §0a)
|
||||
|
||||
### 3.1 Migration `103_approval_suggest_changes.up.sql`
|
||||
|
||||
```sql
|
||||
-- 1. Extend approval_requests.status CHECK to allow 'changes_requested'.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD CONSTRAINT approval_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'));
|
||||
|
||||
-- 2. Add counter_payload — the approver's edited values, becomes the
|
||||
-- `payload` of the NEW pending row spawned in the same tx as the
|
||||
-- suggest-changes call. Stored on the OLD (now changes_requested) row
|
||||
-- too so the audit chain can show "approver edited X, Y, Z" without
|
||||
-- joining to the next row.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN counter_payload jsonb NULL;
|
||||
|
||||
-- 3. Add previous_request_id FK so the new row links back to its origin.
|
||||
ALTER TABLE paliad.approval_requests
|
||||
ADD COLUMN previous_request_id uuid NULL
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX approval_requests_previous_idx
|
||||
ON paliad.approval_requests (previous_request_id)
|
||||
WHERE previous_request_id IS NOT NULL;
|
||||
```
|
||||
|
||||
`.down.sql`: drop the index + columns, restore the original CHECK (would reject existing `changes_requested` rows — that's normal for a breaking-change down).
|
||||
|
||||
### 3.2 Service layer
|
||||
|
||||
`SuggestChanges` is the only new public method on `ApprovalService`. It runs in **one transaction** and does five things:
|
||||
|
||||
```go
|
||||
const RequestStatusChangesRequested = "changes_requested"
|
||||
|
||||
var ErrSuggestionRequiresChange = errors.New("suggestion_requires_change")
|
||||
|
||||
// SuggestChanges closes the pending request as `changes_requested`,
|
||||
// reverts the entity, then immediately inserts a new pending
|
||||
// approval_request authored by the caller carrying `counterPayload` as
|
||||
// its new payload. The new row enters the standard pending flow — anyone
|
||||
// eligible (including the original requester) can approve, reject,
|
||||
// suggest-changes-again, etc.
|
||||
//
|
||||
// Authorization: caller satisfies canApprove on the OLD row (same gate
|
||||
// as Approve / Reject). For the NEW row, the caller is the requested_by
|
||||
// — self-approval is blocked by the standard 3-layer guard. Deadlock
|
||||
// check (qualified-approver-exists-other-than-caller) runs on the new
|
||||
// row to avoid spawning an unapprovable request.
|
||||
//
|
||||
// counterPayload must differ from the old row's payload OR a non-empty
|
||||
// note must be present. A no-op suggest (same values, no note) is
|
||||
// indistinguishable from "I have no opinion" and gets rejected with
|
||||
// ErrSuggestionRequiresChange.
|
||||
func (s *ApprovalService) SuggestChanges(
|
||||
ctx context.Context,
|
||||
requestID, callerID uuid.UUID,
|
||||
counterPayload []byte, // jsonb-marshaled
|
||||
note string,
|
||||
) (newRequestID *uuid.UUID, err error) {
|
||||
// 1. Begin tx, lock old row, validate status=pending + canApprove.
|
||||
// 2. Validate: counterPayload differs from old payload OR note != "".
|
||||
// 3. Update old row: status='changes_requested', decided_by=callerID,
|
||||
// decision_note=note, counter_payload=counterPayload.
|
||||
// 4. applyRevert on the entity (uses old row's pre_image).
|
||||
// 5. Deadlock-check on the new row's required_role + projectID,
|
||||
// excluding callerID.
|
||||
// 6. INSERT new approval_requests row: requested_by=callerID,
|
||||
// pre_image=<entity-state-as-just-reverted> (= old.pre_image),
|
||||
// payload=counterPayload, required_role=old.required_role,
|
||||
// lifecycle_event=old.lifecycle_event, entity_type=old.entity_type,
|
||||
// entity_id=old.entity_id, status='pending',
|
||||
// previous_request_id=requestID.
|
||||
// 7. Re-apply the new payload to the entity (write-then-approve):
|
||||
// apply the counter_payload's field updates + mark
|
||||
// approval_status='pending' + pending_request_id=newRequestID.
|
||||
// 8. Emit *_approval_changes_suggested project_events row
|
||||
// (metadata: note, counter_payload diff vs original).
|
||||
// 9. Emit *_approval_requested project_events row for the new
|
||||
// request (same shape Submit* normally emits).
|
||||
// 10. Commit.
|
||||
}
|
||||
```
|
||||
|
||||
Steps 6 + 7 reuse the existing `Submit*` plumbing structurally — the cleanest implementation factors out an "insert approval row + apply payload to entity" helper that both `Submit*` and `SuggestChanges` call. **decide()** does not need to know about `changes_requested` because suggest-changes is not a decision-kernel transition — it's its own end-to-end action.
|
||||
|
||||
### 3.3 HTTP layer
|
||||
|
||||
```
|
||||
POST /api/approval-requests/{id}/suggest-changes
|
||||
Body: {
|
||||
"counter_payload": { ...same shape as Submit*'s payload... },
|
||||
"note": "free-text explanation, optional iff counter_payload differs from original"
|
||||
}
|
||||
Returns: 200 { "new_request_id": "uuid" }
|
||||
Errors:
|
||||
400 "suggestion_requires_change" — counter_payload == old payload AND note empty
|
||||
400 "invalid_counter_payload" — schema validation failure
|
||||
403 "self_approval_blocked" — caller == old row's requested_by
|
||||
403 "not_authorized" — caller doesn't satisfy canApprove
|
||||
404 — request not found / not visible
|
||||
409 "request_not_pending" — old row already decided
|
||||
409 "no_qualified_approver" — deadlock on the new row (only caller is eligible)
|
||||
```
|
||||
|
||||
Register in `internal/handlers/handlers.go` alongside the existing three:
|
||||
|
||||
```go
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
```
|
||||
|
||||
### 3.4 Frontend
|
||||
|
||||
`frontend/src/client/views/shape-list.ts` — extend the pending-row action group to four buttons:
|
||||
|
||||
```ts
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
```
|
||||
|
||||
The `action` union type gains `"suggest_changes"`. Disabled-reason logic is identical to approve/reject (`viewer_can_approve` gate). i18n: `approvals.action.suggest_changes` → DE "Änderungen vorschlagen" / EN "Suggest changes".
|
||||
|
||||
`frontend/src/client/inbox.ts` — clicking the suggest-changes button opens a **modal**, not a `window.prompt` (the existing reject prompt is OK because reject only needs a note; suggest-changes needs an editable form). The modal:
|
||||
- Renders the same fields the entity edit form would show, pre-populated from `detail.payload` (the requester's proposed values).
|
||||
- Adds a free-text "Vorschlagskommentar" textarea at the bottom (the note).
|
||||
- On submit: POST `/api/approval-requests/{id}/suggest-changes` with `{counter_payload: {...editedFields}, note}`.
|
||||
- On success: refresh the bar — the old row flips to `changes_requested`, the new row appears as `pending`.
|
||||
|
||||
Where the modal's field-editor lives: a new `client/components/approval-edit-modal.ts` that takes `entity_type` + `payload` + `pre_image` and returns the edited payload. For v1 it can be a thin wrapper over the existing entity-edit form components (Frist date picker, Termin start/end pickers). Don't build a generic field-editor framework — just deadlines + appointments, hard-coded fields per entity_type.
|
||||
|
||||
**Status pill for `changes_requested`** — i18n keys + colour:
|
||||
- `approvals.status.changes_requested` → DE "Abgelehnt mit Vorschlag" / EN "Declined with changes"
|
||||
- Reuse the existing `approval-pill--historic` style; no new colour token needed for v1.
|
||||
|
||||
**The "Edit and resubmit" CTA on the requester's row is NOT needed** (m's Q6 reframing) — the requester just sees the new pending row in /inbox, same as any other.
|
||||
|
||||
### 3.5 Inbox filter
|
||||
|
||||
The /inbox `approval_status` filter chip cluster gains `changes_requested`. The `self_requested` viewer-role default already includes terminal statuses, so the original requester sees their `changes_requested` row without changing the default filter.
|
||||
|
||||
### 3.6 Linkage from old row to new row in /inbox
|
||||
|
||||
When showing a `changes_requested` row in /inbox, add a small "→ Neuer Vorschlag von {approver}" link below the note that scrolls / filters to the new pending row (it'll be visible to anyone eligible, including the original requester). The new row has `previous_request_id` pointing at the old one — so the API response for the old row can hydrate `next_request_id` (computed: `SELECT id FROM approval_requests WHERE previous_request_id = $1 LIMIT 1`).
|
||||
|
||||
### 3.7 Email notification (Phase 2 — defer until v1 ships)
|
||||
|
||||
The new row triggers the existing `*_approval_requested` notification path (whatever that is for Submit*) — same audience, same template. No new code. The old row's transition to `changes_requested` doesn't need its own mail; the new-row mail already tells the audience "X suggested changes to your earlier submission" through the body.
|
||||
|
||||
Out of scope for v1: a bespoke "your submission was declined with a counter-proposal" email aimed at the original requester. The new-row mail covers it functionally.
|
||||
|
||||
---
|
||||
|
||||
## 4. Slice plan
|
||||
|
||||
Three reviewable slices, each one PR. Combined scope is small/medium.
|
||||
|
||||
1. **Slice A — backend.** Migration 103 (CHECK extension + `counter_payload jsonb` + `previous_request_id` FK + index) + `SuggestChanges` service method + HTTP handler + service tests (happy path, no-op-suggestion guard, deadlock on new row, self-approval block, request_not_pending). Migration is non-blocking on Postgres; safe for live deploy.
|
||||
2. **Slice B — frontend.** 4th button on /inbox + the edit modal (deadline-fields variant + appointment-fields variant) + status pill `changes_requested` ("Abgelehnt mit Vorschlag") + i18n keys (DE + EN) + the "→ Neuer Vorschlag" link from old row to new row. End-to-end browser smoke test via Playwright.
|
||||
3. **Slice C — Verlauf integration.** Make sure the `*_approval_changes_suggested` event renders on the project / deadline / appointment Verlauf timeline alongside the existing 4 approval event types. May or may not need code change depending on how generic the Verlauf row renderer is — likely just an i18n key + an icon mapping.
|
||||
|
||||
Don't ship a chain-traversal UI in v1. The `previous_request_id` FK is captured so the data is there; surfacing the full chain history (n hops back) is a Phase-2 polish.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risks / open considerations
|
||||
|
||||
- **Chain depth runaway.** Nothing stops an "I keep suggesting / they keep counter-suggesting" loop. Same risk as comment threads on GitHub PRs. Out of scope to cap; the social pressure (each round is a 4-Augen action with a name attached) is the natural brake.
|
||||
- **Concurrent suggestions on the same pending row.** Two approvers click "suggest changes" at the same time? The existing `getRequestForUpdate` row-lock serialises them; the second caller gets `ErrRequestNotPending` (the first already flipped it). Same guarantee as Approve/Reject today.
|
||||
- **Deadlock on the new row.** If the suggesting approver is the only qualified approver other than the original requester, the new row's deadlock check returns "no qualified approver" — because the original requester IS now eligible (they're no longer the requested_by), but might not have a high-enough role. The check needs to recognise: caller's pool = "anyone other than the new requester who can canApprove". Original requester counts if they hit the required-role bar. This is just the existing deadlock predicate run against the new (requester, role) tuple; no special-case logic. Surfaced as `409 "no_qualified_approver"` to the suggesting approver, with the standard global_admin override path still available.
|
||||
- **Counter-payload schema validation.** Server must validate `counter_payload` against the same schema as a normal `Submit*` for that entity_type + lifecycle_event. Otherwise a malicious approver could write garbage values via the suggestion path that wouldn't fly through `Submit*`. Reuse the existing payload-schema validator from the entity services; don't write a parallel.
|
||||
- **No-op suggestion guard.** Approver clicks suggest-changes but doesn't actually edit anything AND leaves the note empty? Server rejects with `ErrSuggestionRequiresChange`. UI guards too (the submit button stays disabled until either the form is dirty OR the note has text).
|
||||
- **Migration safety.** Non-blocking. Adding a value to a CHECK constraint is a metadata-only change; adding a NULLable column + a NULLable FK is also metadata-only.
|
||||
- **What about a structured per-field suggestion (Q4c)?** The `counter_payload` jsonb IS structured — each entity_type has fixed fields. There's no need for a separate "{field, current, suggested}" shape because the diff is computable from `pre_image → counter_payload` on the new row.
|
||||
- **What about thread-of-notes (Q4b)?** Implicit in the chain — each row's `decision_note` is one "note" by one author; following `previous_request_id` backwards reconstructs the full back-and-forth. A future "thread view" UI is layered on top of this without schema change.
|
||||
|
||||
---
|
||||
|
||||
## 6. m's decisions
|
||||
|
||||
See §0a (decisions table) — filled in after the AskUserQuestion phase on 2026-05-19.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope for this design
|
||||
|
||||
- Email + push notifications (Phase 2; see §3.7).
|
||||
- Structured per-field suggestion shape (Phase 2 enhancement).
|
||||
- Approval-policy `watchers` column for notification fan-out.
|
||||
- "Dismiss this row from my inbox" UI toggle (UX-only, not a data-model change).
|
||||
- Cross-entity suggest-changes (e.g. project, party). Same as the original approval scope — deadlines + appointments only.
|
||||
|
||||
597
docs/design-caldav-multi-calendar-2026-05-19.md
Normal file
597
docs/design-caldav-multi-calendar-2026-05-19.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# CalDAV multi-calendar sync — design
|
||||
|
||||
**Task:** t-paliad-212
|
||||
**Inventor:** leibniz (2026-05-19)
|
||||
**Branch:** mai/leibniz/inventor-caldav-multi
|
||||
**Status:** READY FOR REVIEW — m's decisions on the §8 open questions captured in the addendum below (2026-05-19).
|
||||
|
||||
---
|
||||
|
||||
## §0 — One-paragraph summary
|
||||
|
||||
Paliad's CalDAV sync today is a single-target push: every user has one
|
||||
`paliad.user_caldav_config` row, and every Appointment they can see gets
|
||||
PUT into that one calendar. m wants users to pick their own organization —
|
||||
one cal with everything, one cal per project (or per client / litigation /
|
||||
patent / case), or any hybrid. This design splits the model in two:
|
||||
**credentials stay per user** (one CalDAV server, one auth blob) and
|
||||
**bindings become first-class rows** (a join table `paliad.user_calendar_bindings`
|
||||
that points an Appointment-filter scope at a specific `calendar_path`).
|
||||
Push/pull state migrates from scalar `appointments.caldav_uid`/`caldav_etag`
|
||||
columns to a per-(appointment, binding) join table
|
||||
`paliad.appointment_caldav_targets`, so the same Appointment can live in
|
||||
N external calendars at once. The 60-second per-user sync goroutine survives
|
||||
unchanged in shape; inside it the inner loop iterates bindings instead of
|
||||
hard-coding `cfg.CalendarPath`. Sliced for safe rollout: Slice 1 introduces
|
||||
the new tables behind a backfill that auto-creates one binding per
|
||||
existing config row (zero behaviour change); Slice 2 ships the
|
||||
binding-picker UI; Slice 3 wires scope-aware filtering (one cal per project).
|
||||
Bidirectional sync stays exactly as it works today (last-write-wins on ETag,
|
||||
Paliad-owned UIDs only) — multi-calendar does not change the conflict
|
||||
model.
|
||||
|
||||
---
|
||||
|
||||
## §1 — What's already built (verified live, 2026-05-19)
|
||||
|
||||
Verified against the codebase, not the project's CLAUDE.md.
|
||||
|
||||
- **Schema** — `paliad.user_caldav_config` is one row per user with
|
||||
`(user_id PK, url, username, password_encrypted bytea, calendar_path,
|
||||
enabled, last_sync_at, last_sync_error, created_at, updated_at)`. The
|
||||
scalar `calendar_path` is the only handle on which external calendar
|
||||
receives events. Per direct `information_schema` query.
|
||||
- **Appointment binding** — `paliad.appointments` carries scalar
|
||||
`caldav_uid text` and `caldav_etag text` (nullable). Set once after a
|
||||
successful PUT via `AppointmentService.SetCalDAVMeta`. This is the
|
||||
single-target assumption baked into the row itself.
|
||||
- **Sync engine** — `internal/services/caldav_service.go:298–502`. One
|
||||
goroutine per enabled user, 60s ticker, `runSyncOnce` → `syncOnce` →
|
||||
`pushAll` (`AppointmentService.AllForUser` × `cli.PutEvent`) +
|
||||
`pullAll` (`cli.PropfindCalendar` → `cli.GetEvent` → reconcile by UID).
|
||||
`AllForUser` returns *every* personal-or-visible-project appointment
|
||||
for the user; today they all funnel into the single `calendar_path`.
|
||||
- **UID convention** — `paliad-appointment-<uuid>@paliad.de`
|
||||
(`caldav_ical.go:31–34`). Foreign UIDs are intentionally skipped on
|
||||
pull (`caldav_service.go:436–442`).
|
||||
- **Hooks** — `OnAppointmentCreated/Updated/Deleted` push directly to
|
||||
the configured `cfg.CalendarPath` on a 30s-timeout background goroutine
|
||||
so user requests don't block (`caldav_service.go:510–558`).
|
||||
- **Approval flow (t-138)** — project-attached appointments may be
|
||||
`approval_status = 'pending'`. CalDAV push already runs after approval
|
||||
in `AppointmentService.Update` paths; `ApplyRemoteUpdate` from a remote
|
||||
edit currently bypasses the approval gate. That's a pre-existing hole
|
||||
flagged here only because multi-calendar makes "which calendar's edit
|
||||
wins" more visible — fix belongs in t-138 follow-ups, not in this
|
||||
design.
|
||||
- **CalDAV verbs supported** — PUT / DELETE / GET / PROPFIND (depth 0
|
||||
and 1). No MKCALENDAR, no REPORT, no calendar-multiget. Tested
|
||||
against Nextcloud, Radicale, Baikal, mailcow SOGo per
|
||||
`caldav_client.go:22–24`.
|
||||
|
||||
**What is _not_ baked in and is therefore free to extend:**
|
||||
|
||||
- The 60s ticker is per-*user*, not per-*calendar*. Adding bindings does
|
||||
not multiply tickers.
|
||||
- `cfg.CalendarPath` is referenced in exactly two places (`pushAll`,
|
||||
`pullAll`) plus the three hooks. Replacing it with a binding loop is
|
||||
a contained edit.
|
||||
- Credentials are server-scoped, not calendar-scoped — every binding
|
||||
for the same user shares the existing decrypted credential, so the
|
||||
encryption layer (`caldav_crypto.go`) is untouched.
|
||||
|
||||
---
|
||||
|
||||
## §2 — Per-provider calendar-count limits (verified 2026-05-19)
|
||||
|
||||
Real numbers, from current docs, so the design knows its envelope.
|
||||
|
||||
| Provider | Per-account / per-user limit | Source |
|
||||
|---|---|---|
|
||||
| **iCloud** | **100** calendars + reminder-lists combined | [Apple Support 103188](https://support.apple.com/en-us/103188) |
|
||||
| **Google Calendar** | **~100 owned** (soft recommendation, post-Nov-2025 ownership model) | [Workspace Updates 2026-01](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html), [usecarly.com summary](https://www.usecarly.com/blog/how-many-calendars-google-account/) |
|
||||
| **Fastmail** | **No documented cap on calendars.** 100 000 events/user. | [Fastmail account-limits page](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) |
|
||||
| **Nextcloud** | **30 per user** default; admin-configurable, `-1` = unlimited. Rate limit: 10 calendar-creations/hour. | [Nextcloud admin manual — Calendar](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) |
|
||||
| **Radicale / Baikal / mailcow SOGo** | No published per-account cap (file-system / DB bound). | server defaults |
|
||||
|
||||
**Implications for the design:**
|
||||
|
||||
- "One calendar per project" is comfortably within all providers'
|
||||
envelopes for typical HLC caseloads. A senior PA who tracks 40
|
||||
litigations would land 40+ calendars, still inside iCloud's 100 and
|
||||
Nextcloud's default 30 (would need an admin bump on Nextcloud — flag
|
||||
in onboarding).
|
||||
- "One calendar per case" can blow past Nextcloud's default 30 fast and
|
||||
is a real risk on iCloud at the 60+ mark when combined with the
|
||||
user's existing personal calendars + reminder lists. We should
|
||||
**soft-cap** scope choices at the UI layer (warn at 20 bindings, hard
|
||||
block at 80) rather than discover the limit by 5xx on PUT.
|
||||
- Google Calendar's CalDAV endpoint does **not** support `MKCALENDAR`
|
||||
reliably — calendars must be pre-created in the Google UI. iCloud,
|
||||
Fastmail, Nextcloud, Radicale, Baikal, SOGo all accept `MKCALENDAR`.
|
||||
So the "auto-create a calendar per project" affordance is provider-
|
||||
dependent and must degrade gracefully ("we couldn't create it for
|
||||
you — please make `Project X` in your calendar app and paste its
|
||||
URL").
|
||||
|
||||
---
|
||||
|
||||
## §3 — Proposed data model
|
||||
|
||||
Three schema changes, no destructive migrations. The scalar
|
||||
`appointments.caldav_uid` / `caldav_etag` columns survive as a
|
||||
denormalised "default-binding" pointer through Slice 1 and 2; Slice 4
|
||||
drops them after telemetry confirms no path still reads them.
|
||||
|
||||
### §3.1 New table: `paliad.user_calendar_bindings`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.user_calendar_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
calendar_path text NOT NULL, -- absolute URL or path under user_caldav_config.url
|
||||
display_name text NOT NULL DEFAULT '', -- the label discovered via PROPFIND <displayname/>; what we show in the UI
|
||||
|
||||
scope_kind text NOT NULL, -- 'all_visible' | 'personal_only' | 'project' | 'client' | 'litigation' | 'patent' | 'case'
|
||||
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, -- NULL for 'all_visible' / 'personal_only'
|
||||
include_personal boolean NOT NULL DEFAULT false, -- only meaningful when scope_kind <> 'all_visible'/'personal_only'
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_error text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, calendar_path), -- can't bind one calendar twice for the same user
|
||||
UNIQUE (user_id, scope_kind, scope_id), -- one binding per scope per user — but a project can also be covered by 'all_visible'
|
||||
CHECK ((scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
|
||||
OR (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX user_calendar_bindings_user_idx ON paliad.user_calendar_bindings(user_id) WHERE enabled;
|
||||
-- RLS: row visible/writable only when auth.uid() = user_id (mirrors user_caldav_config).
|
||||
```
|
||||
|
||||
**Why per-scope unique but not per-appointment unique:** an Appointment in
|
||||
project P is allowed to land in both the user's `all_visible` calendar
|
||||
AND their `project=P` calendar — that's the explicit "master + per-project"
|
||||
hybrid m asked about. What we forbid is two different `project=P` bindings
|
||||
for the same user, which would have no useful semantics.
|
||||
|
||||
**`scope_kind = 'personal_only'`** is a separate scope from `'all_visible'`
|
||||
because the existing pushAll already covers both personal and visible-project
|
||||
appointments; users may want a "personal only" calendar that does *not*
|
||||
get the noisy team events. Without this, every binding either includes
|
||||
personal events or doesn't, and there's no way to say "the master
|
||||
calendar = everything except personal".
|
||||
|
||||
### §3.2 New table: `paliad.appointment_caldav_targets`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.appointment_caldav_targets (
|
||||
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
|
||||
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
|
||||
caldav_uid text NOT NULL, -- still 'paliad-appointment-<uuid>@paliad.de' — same for all bindings of one appointment
|
||||
caldav_etag text NOT NULL,
|
||||
last_pushed_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (appointment_id, binding_id)
|
||||
);
|
||||
CREATE INDEX appointment_caldav_targets_binding_idx ON paliad.appointment_caldav_targets(binding_id);
|
||||
-- RLS: visible/writable when the underlying binding's user_id = auth.uid().
|
||||
```
|
||||
|
||||
**UID stays per-appointment, not per-binding.** That keeps the iCal UID
|
||||
canonical (still `paliad-appointment-<uuid>@paliad.de`), so when a user
|
||||
removes a binding and re-adds it later, the same UID rebinds without
|
||||
spurious duplicates. The `.ics` filename in the calendar — `<uid>.ics`
|
||||
— is also identical across bindings, which means the same UUID
|
||||
shows up in different calendars on the same server but never collides
|
||||
because they're under different `calendar_path` collections.
|
||||
|
||||
### §3.3 Row examples for the four common organisations
|
||||
|
||||
| Organisation | Rows in `user_calendar_bindings` |
|
||||
|---|---|
|
||||
| **A — one cal, everything** | 1 row: `scope_kind='all_visible'`, `calendar_path='/cal/work'` |
|
||||
| **B — one cal per project** | N rows, all `scope_kind='project'`, distinct `(scope_id, calendar_path)` |
|
||||
| **C — master + per-project hybrid** | 1 row `scope_kind='all_visible'` + N rows `scope_kind='project'`. Each project event appears in both. |
|
||||
| **D — personal split from work** | 1 row `scope_kind='personal_only'` → `/cal/personal` + 1 row `scope_kind='all_visible'` (which will include the same personal events, so the user will more commonly pair `personal_only` with a `scope_kind='client'` per-client work view instead). |
|
||||
|
||||
### §3.4 What stays unchanged
|
||||
|
||||
- `paliad.user_caldav_config` — still holds the server URL, username,
|
||||
encrypted password, and a per-user `enabled` flag. The existing
|
||||
`calendar_path` column becomes a hint for the **default binding** we
|
||||
auto-create on migration and is no longer read by sync logic after
|
||||
Slice 1 ships. We keep it nullable-on-read for forwards-compat then
|
||||
drop in Slice 4.
|
||||
- `paliad.caldav_sync_log` — still per-user; sync entries gain a
|
||||
`binding_id` column (nullable for legacy rows) so the UI can show
|
||||
per-calendar last-sync state.
|
||||
- iCal serialisation (`caldav_ical.go`) — unchanged. Same VEVENT
|
||||
formatter feeds every binding.
|
||||
- AES-GCM credential encryption (`caldav_crypto.go`) — unchanged.
|
||||
|
||||
---
|
||||
|
||||
## §4 — Sync engine implications
|
||||
|
||||
The shape of the per-user goroutine stays. The body of `syncOnce`
|
||||
moves from "push to one path / pull from one path" to "for each
|
||||
enabled binding, push the scope-filtered slice / pull from that path".
|
||||
|
||||
### §4.1 Push fan-out
|
||||
|
||||
```go
|
||||
// pseudocode for the new pushAll body
|
||||
bindings := s.bindings.ListEnabled(ctx, userID) // 1..N rows
|
||||
for _, b := range bindings {
|
||||
appts := s.appointments.ForBinding(ctx, userID, b) // scope-filtered
|
||||
for _, a := range appts {
|
||||
body := formatAppointment(&a)
|
||||
etag, err := cli.PutEvent(ctx, b.CalendarPath, terminUID(a.ID), body)
|
||||
if err != nil { continue } // best-effort, per-binding error
|
||||
s.targets.Upsert(ctx, a.ID, b.ID, terminUID(a.ID), etag)
|
||||
}
|
||||
// Remove events from this calendar that no longer belong to the scope.
|
||||
for _, stale := range s.targets.DanglingForBinding(ctx, b.ID, currentIDs(appts)) {
|
||||
cli.DeleteEvent(ctx, b.CalendarPath, stale.CalDAVUID)
|
||||
s.targets.Delete(ctx, stale.AppointmentID, b.ID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ForBinding(userID, b)` is the scope filter:
|
||||
|
||||
- `all_visible` → existing `AllForUser(userID)`
|
||||
- `personal_only` → appointments with `project_id IS NULL AND created_by = userID`
|
||||
- `project` → appointments where `project_id = scope_id` AND visible to user
|
||||
- `client` / `litigation` / `patent` / `case` → appointments where the
|
||||
ancestor at the relevant hierarchy level = `scope_id` AND visible to user
|
||||
- when `include_personal = true`, union with personal events on top of the above (only for non-`all_visible`/`personal_only` scopes)
|
||||
|
||||
This reuses the existing `can_see_project()` predicate (per project
|
||||
CLAUDE.md, team-based RLS), so visibility shrinkage on a project unshare
|
||||
falls out naturally: next push sees the appointment is no longer in
|
||||
`ForBinding(...)`, sees a dangling target row, issues `DeleteEvent`.
|
||||
|
||||
### §4.2 Pull reconciliation
|
||||
|
||||
Each binding has its own pull pass against `b.CalendarPath`. The
|
||||
matching key is still `caldav_uid` — same UID across all bindings, so
|
||||
`appointments.FindByCalDAVUID(uid)` resolves the local row. The
|
||||
**ETag check is per-target row** now, not per-appointment: a remote
|
||||
edit in calendar X bumps the etag in `appointment_caldav_targets` for
|
||||
binding X only. The local Appointment is updated once (last-write-wins
|
||||
on Appointment.updated_at), the next push tick re-syncs the other
|
||||
bindings with the new payload (they see their stored etag is older
|
||||
than the appointment's `updated_at` and re-PUT).
|
||||
|
||||
**One subtle change:** the foreign-UID skip (`extractAppointmentID == ""`)
|
||||
still applies per-binding pull. That preserves the v1 "Paliad owns its
|
||||
UIDs" property — multi-calendar does not open the door to importing
|
||||
events the user creates in their calendar app. (If/when that becomes
|
||||
in-scope, it's a separate t-paliad-* design.)
|
||||
|
||||
### §4.3 Hooks (instant push)
|
||||
|
||||
`OnAppointmentCreated/Updated/Deleted` fan out across all the user's
|
||||
enabled bindings that match the appointment's scope. Same 30s-timeout
|
||||
background goroutine. The user-facing request still returns
|
||||
immediately; the failure mode is identical (best-effort per binding,
|
||||
logged on `slog.Warn`).
|
||||
|
||||
### §4.4 Bandwidth & rate limits
|
||||
|
||||
- Per user per tick: **N bindings × 1 PROPFIND + per-event GETs**.
|
||||
The pull GET is the dominant cost; a 50-binding user with 20 events
|
||||
per calendar is ~1 000 GETs/min, which is fine over HTTP/1.1 to a
|
||||
decent CalDAV server but **does** put us inside iCloud's
|
||||
~throttle-friendly band and risks Google's quota model.
|
||||
- Mitigation: switch pull to **`REPORT` `calendar-multiget`** so each
|
||||
binding's events come back in one round-trip. That's a single
|
||||
iteration on `caldav_client.go` (the same multistatus parser
|
||||
already handles the body) and pays for itself the moment a user
|
||||
has >10 events per binding. We deliberately deferred this in
|
||||
Phase F (one calendar, low volume) — multi-calendar makes it
|
||||
table-stakes. Plan to land it in **Slice 2** alongside the picker.
|
||||
- Rate limiting on the Paliad side: keep the 60s ticker, but stagger
|
||||
per-binding pulls so we never fire N concurrent PROPFINDs against
|
||||
the same provider. Sequential per binding is fine; we already do
|
||||
this implicitly with the per-user goroutine.
|
||||
|
||||
### §4.5 Server-side cleanup on binding delete
|
||||
|
||||
User deletes a binding → service:
|
||||
|
||||
1. Lists every (appointment, binding) target row for that binding.
|
||||
2. Issues `DELETE` per `.ics` on the remote calendar (best effort).
|
||||
3. Deletes the target rows.
|
||||
4. Deletes the binding row (or relies on `ON DELETE CASCADE` from
|
||||
target FK — cleaner to delete remotely first, then drop the row,
|
||||
so a half-failed cleanup leaves rows we can retry on next tick).
|
||||
|
||||
A "leave events behind in the external calendar" toggle is a real
|
||||
ask (users sometimes archive bindings without wanting their calendar
|
||||
app to suddenly empty). Plumb it as `binding.cleanup_on_delete bool`
|
||||
in Slice 2 if there's demand; default `true` (delete).
|
||||
|
||||
---
|
||||
|
||||
## §5 — Bidirectional vs one-way
|
||||
|
||||
**Recommendation: stay bidirectional, identical to today's semantics,
|
||||
per-binding.** Reasons:
|
||||
|
||||
1. **m's stated workflow expects round-trip.** Drag a deadline in
|
||||
Outlook → Paliad sees the new date → approval flow triggers
|
||||
(t-138). One-way push breaks that. Multi-calendar doesn't change
|
||||
this expectation; if anything, it strengthens it (the user picked
|
||||
the project-cal binding *because* they intend to edit there).
|
||||
2. **The conflict model is already in place.** Last-write-wins on
|
||||
ETag, foreign-UID skip, `LogConflict` audit append. Multi-calendar
|
||||
adds one new question: "if the user edits the same event in two
|
||||
different bindings between ticks, which wins?" Answer: the one
|
||||
that lands first in our pull pass. Bindings are iterated in
|
||||
`created_at` order so the behaviour is deterministic, and the
|
||||
second edit gets overwritten on the next tick when we re-push the
|
||||
resolved appointment to it. Acceptable trade-off; would only show
|
||||
up if a user actually edits the same event in two of their own
|
||||
calendars within 60s, which is vanishingly rare.
|
||||
3. **Approval-flow integration is unchanged.** Pending-approval
|
||||
events have the `[PENDING APPROVAL]` marker baked into the iCal
|
||||
summary by `caldav_ical.go:76+`. That marker survives multi-binding
|
||||
fan-out untouched; an external edit on a pending event still has
|
||||
the pre-existing bypass-the-gate hole (flagged §1, not in scope).
|
||||
|
||||
**Tee-up for m's call:** if multi-calendar is the wrong moment to
|
||||
keep bidirectional (e.g. because per-project calendars are about
|
||||
**read-only visibility for partners**, not editing), we'd add a
|
||||
`binding.read_only bool` column and skip the pull pass for that
|
||||
binding. Cheap to add now or later. **I recommend defaulting
|
||||
`read_only = false` (bidirectional like today) and only making it
|
||||
optional if m's first session with the UI surfaces the need.**
|
||||
|
||||
---
|
||||
|
||||
## §6 — User-facing config model
|
||||
|
||||
Surface on `/einstellungen/caldav` (already exists for Phase F creds).
|
||||
Two sections, in this order:
|
||||
|
||||
1. **Server** (existing) — URL, username, password, "test connection".
|
||||
Unchanged.
|
||||
2. **Calendars** (new) — list of bindings as cards / rows. For each:
|
||||
`display_name`, `calendar_path`, `scope_kind` chip (master /
|
||||
personal / project / …), `enabled` toggle, last-sync status, action
|
||||
buttons "Edit scope" / "Remove".
|
||||
3. **Add a calendar** — flow:
|
||||
- **a)** click "Add". Modal opens. We do a `PROPFIND
|
||||
<calendar-home-set>` against the user's server to discover their
|
||||
existing calendars; show as a picker. (RFC 6638 / 4791 calendar
|
||||
home set discovery — supported by iCloud, Fastmail, Nextcloud,
|
||||
Radicale, Baikal, SOGo. Google CalDAV does not expose this
|
||||
reliably; for Google users we degrade to a manual path entry box.)
|
||||
- **b)** user picks an existing calendar, or chooses "Create new
|
||||
calendar". Create-new attempts `MKCALENDAR` (works on iCloud,
|
||||
Fastmail, Nextcloud, Radicale, Baikal, SOGo; fails on Google →
|
||||
friendly error with copy-paste instruction).
|
||||
- **c)** user picks the **scope**: a radio between "Everything I can
|
||||
see", "Personal only", "One project", and (later) "One client /
|
||||
litigation / patent / case". Project picker uses the existing
|
||||
`/api/projects?…` autocomplete.
|
||||
- **d)** "Save" → POST `/api/caldav-bindings`. The next 60s tick
|
||||
starts pushing into the new calendar; the UI shows "Initial
|
||||
sync running…" with a live last-sync indicator (already polled
|
||||
by the existing `caldav-config` page).
|
||||
|
||||
4. **Quick-add affordances** (Slice 3 polish, not v1):
|
||||
- On a project's `/projects/<id>` page: "Open in calendar app" link
|
||||
if a binding already exists for that project, "Pin to a new
|
||||
calendar" if none does (deep-links to the Add-a-calendar modal
|
||||
pre-filled).
|
||||
- Bulk action "Create one calendar per active litigation" on
|
||||
`/einstellungen/caldav` (requires `MKCALENDAR` support; gated
|
||||
behind a server-capability probe at first PROPFIND).
|
||||
|
||||
5. **Soft limits in the UI:**
|
||||
- At **20 bindings**: yellow info banner "Most users keep ≤ 20
|
||||
calendars; review your list before adding more."
|
||||
- At **80 bindings**: red error, block adding new (we don't know
|
||||
the user's provider for sure; 80 is a safe ceiling for iCloud
|
||||
and Nextcloud-default).
|
||||
- Provider hint surfaced under the Server form: parsed from the
|
||||
URL host, with a "your provider's documented limit" line —
|
||||
pure courtesy, not enforced.
|
||||
|
||||
### §6.1 What the API contract looks like
|
||||
|
||||
| Verb + Path | Body / Returns | Notes |
|
||||
|---|---|---|
|
||||
| `GET /api/caldav-bindings` | array of binding rows + sync status | replaces having to interpret `user_caldav_config.calendar_path` |
|
||||
| `POST /api/caldav-bindings` | `{calendar_path, display_name, scope_kind, scope_id?, include_personal?}` → created binding | triggers immediate sync goroutine wake-up |
|
||||
| `PATCH /api/caldav-bindings/{id}` | partial; toggle `enabled` or change `scope_*` | re-runs `pushAll` for this binding |
|
||||
| `DELETE /api/caldav-bindings/{id}` | — | deletes external events first, then row |
|
||||
| `GET /api/caldav-discover` | array of `{href, displayname}` from server `<calendar-home-set>` | populates the picker; cached 5 min |
|
||||
| `POST /api/caldav-mkcalendar` | `{display_name, color?}` → `{calendar_path}` | issues `MKCALENDAR`; returns 501 on Google |
|
||||
|
||||
`GET /api/caldav-config` still works (back-compat for the server-creds
|
||||
section); its `calendar_path` field is documented as "deprecated, see
|
||||
/api/caldav-bindings".
|
||||
|
||||
---
|
||||
|
||||
## §7 — Slice plan
|
||||
|
||||
Tracer-bullet slices so each is independently shippable, safe to
|
||||
revert, and gives the user something they can see.
|
||||
|
||||
**Slice 1 — Schema + backfill (no UI change).**
|
||||
- Migration: create `user_calendar_bindings`, `appointment_caldav_targets`.
|
||||
- Backfill: for every existing `user_caldav_config` row, insert one
|
||||
`bindings` row `(user_id, calendar_path, display_name='', scope_kind='all_visible', enabled)`.
|
||||
For every Appointment with non-null `caldav_uid`, insert one
|
||||
`appointment_caldav_targets` row pointing at the user's new default
|
||||
binding.
|
||||
- Refactor `CalDAVService.syncOnce` / `pushAll` / `pullAll` to drive
|
||||
off bindings (loop of length 1 per existing user). Behaviour
|
||||
observably identical: same calendars, same events, same logs.
|
||||
- `appointments.caldav_uid` / `caldav_etag` columns still exist and
|
||||
are written for compatibility (treat them as denormalised pointers
|
||||
to the default binding's target row). UI unchanged.
|
||||
- **Exit criterion:** existing users see no change in their calendar;
|
||||
`caldav_sync_log.binding_id` is populated for all new rows; manually
|
||||
inserted second binding via SQL syncs correctly end-to-end on a
|
||||
staging account.
|
||||
|
||||
**Slice 2 — Binding-picker UI + multi-binding support.**
|
||||
- `/api/caldav-bindings` CRUD + `/api/caldav-discover` (PROPFIND
|
||||
`calendar-home-set`) + `/api/caldav-mkcalendar`.
|
||||
- New "Calendars" section on `/einstellungen/caldav` with the modal
|
||||
flow from §6.
|
||||
- **Land `REPORT calendar-multiget` pull** alongside (per §4.4).
|
||||
Required, not optional, for the bandwidth profile multi-binding
|
||||
introduces.
|
||||
- Scope kinds enabled in v1: `all_visible`, `personal_only`, `project`.
|
||||
Hierarchy scopes (`client`, `litigation`, `patent`, `case`) parked
|
||||
for Slice 3.
|
||||
- **Exit criterion:** m can pin a second calendar via the UI on
|
||||
staging; events for project X appear only in the X-bound calendar
|
||||
if his master binding is disabled, and in both if it's enabled.
|
||||
|
||||
**Slice 3 — Hierarchy scopes + project-page quick-adds.**
|
||||
- Enable `scope_kind ∈ {client, litigation, patent, case}` — pure
|
||||
filter-predicate change in `ForBinding(...)` using the existing
|
||||
project-tree walker.
|
||||
- "Pin to a new calendar" button on `/projects/<id>` and on the
|
||||
/einstellungen page.
|
||||
- Bulk "calendar-per-active-litigation" provisioner (with
|
||||
`MKCALENDAR` capability probe).
|
||||
- **Exit criterion:** real HLC PA can set up "one cal per
|
||||
litigation" in <5 min on first try without inventor help.
|
||||
|
||||
**Slice 4 — Polish + cleanup.**
|
||||
- Drop `appointments.caldav_uid` / `caldav_etag` after instrumentation
|
||||
shows zero readers outside `CalDAVService` (`grep` + a one-week
|
||||
query-log audit on the read replica).
|
||||
- Soft-limit banners (20 / 80).
|
||||
- `binding.read_only` and `binding.cleanup_on_delete` toggles if
|
||||
asked for by then.
|
||||
- **Exit criterion:** schema is final; no legacy paths remain in
|
||||
`caldav_service.go`.
|
||||
|
||||
**(Out of scope across all four slices:** foreign-UID import, custom
|
||||
event types per binding, per-binding colour mapping, MKCALENDAR for
|
||||
Google. These are easy to add later if the data says so.)
|
||||
|
||||
---
|
||||
|
||||
## §8 — Open questions for m
|
||||
|
||||
1. **Bidirectional default for new bindings: yes/no?** I recommend
|
||||
**yes** (matches today's single-cal behaviour and the round-trip
|
||||
workflow expectation). A `read_only` per-binding flag is cheap to
|
||||
add later if a real use case shows up. Decide now → Slice 1; decide
|
||||
later → Slice 4.
|
||||
2. **`personal_only` scope — keep or drop?** It's useful for users
|
||||
who want a "noisy team master + clean personal" split, but it's
|
||||
redundant for users who only use the master calendar. I'd keep
|
||||
it; trivial to remove if m disagrees.
|
||||
3. **`MKCALENDAR` (auto-create calendar) — ship in Slice 2 or defer
|
||||
to Slice 3?** Shipping it in Slice 2 means we need the
|
||||
capability-probe + Google-degrade UX up-front. Deferring means
|
||||
Slice 2 users have to pre-create the calendar in their app and
|
||||
paste the URL — workable but clunky. Default plan: **Slice 2,
|
||||
with a clean Google-degrade message**.
|
||||
4. **Soft cap numbers (20 / 80) — sensible?** Picked from §2
|
||||
provider limits + "most paliad users will pick 1–5". m may
|
||||
want different numbers — easy to tune.
|
||||
5. **`/admin/caldav-bindings` view for support debugging?** Not in
|
||||
the slice plan; useful if a user calls confused about which
|
||||
calendar holds which event. Add if m wants it.
|
||||
6. **Approval-flow + remote-edit gap (§1, the bypass) — fix scope?**
|
||||
Pre-existing in single-cal Phase F. Multi-cal makes it more
|
||||
visible. Should this be a follow-up under t-138, or folded into
|
||||
Slice 3? I'd file as a separate task.
|
||||
|
||||
---
|
||||
|
||||
## §9 — Why this is the right shape
|
||||
|
||||
- **Single CalDAV server per user, N bindings.** Matches every real
|
||||
provider's auth model (one auth blob covers all the user's
|
||||
calendars) and keeps `caldav_crypto.go` and `user_caldav_config`
|
||||
untouched.
|
||||
- **Binding scope is a row, not a static config.** Users compose
|
||||
the organisation they want without us guessing; defaults (one
|
||||
master binding on migration) preserve current behaviour.
|
||||
- **UID stays per-appointment.** Means an event re-binding (move
|
||||
from project-cal to master-cal) is just shuffling target rows,
|
||||
not minting new UIDs. Re-importing into the same calendar later
|
||||
rebinds cleanly.
|
||||
- **Sync engine shape is unchanged.** Same per-user goroutine, same
|
||||
60s tick, same hooks. The blast radius of multi-binding is one
|
||||
inner loop, gated behind a feature that backfills to a no-op for
|
||||
every existing user.
|
||||
- **Slices give m a vertical demo at each step.** Slice 1 is
|
||||
invisible-but-shippable; Slice 2 is the first user-facing change
|
||||
("you can pin a second calendar"); Slice 3 is "now organise by
|
||||
project tree"; Slice 4 is cleanup.
|
||||
- **No new external dependencies.** Same hand-rolled CalDAV client.
|
||||
Adds one new verb (`MKCALENDAR`) and one new report
|
||||
(`calendar-multiget`) — both small, both already half-tested
|
||||
against `caldav_client.go`'s patterns.
|
||||
|
||||
---
|
||||
|
||||
## §10 — Sources
|
||||
|
||||
- [Apple Support — Limits for iCloud Contacts, Calendars, Reminders, Bookmarks, and Maps](https://support.apple.com/en-us/103188) — iCloud 100 combined calendars + reminder lists.
|
||||
- [Google Workspace Updates — Automatic addition of owned secondary calendars, Jan 2026](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html) — Google ~100 owned recommendation.
|
||||
- [Fastmail — Account limits](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) — 100k events/user, no documented calendar count cap.
|
||||
- [Nextcloud admin manual — Calendar / CalDAV](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) — default 30, configurable, 10/hr rate limit.
|
||||
- Live verification against `internal/services/caldav_*.go` and `paliad.user_caldav_config` / `paliad.appointments` schema on the youpc Supabase instance.
|
||||
|
||||
---
|
||||
|
||||
## Addendum — m's decisions (2026-05-19)
|
||||
|
||||
Walked through §8.1–§8.6 with m via AskUserQuestion. Decisions are
|
||||
locked in for the coder shift; revisit only on Slice-3 feedback.
|
||||
|
||||
| Q | Decision | Implication for the slice plan |
|
||||
|---|---|---|
|
||||
| **§8.1 — Bidirectional default** | **Yes — bidirectional by default** | No `read_only` flag in Slice 1–3. Multi-cal inherits Phase F's last-write-wins / foreign-UID-skip semantics unchanged. Per-binding `read_only` only added later if a real use case shows up. |
|
||||
| **§8.2 — `personal_only` scope** | **Keep — first-class scope** | Ships in Slice 2 as one of the picker's radio options (`Everything I can see` / `Personal only` / `One project`). One enum value, one `ForBinding()` branch. |
|
||||
| **§8.3 — MKCALENDAR timing** | **Slice 2 with Google-degrade UX** | Slice 2 includes `POST /api/caldav-mkcalendar` + capability probe. Google users get a friendly "create the calendar in your Google UI, paste the URL" fallback. iCloud / Fastmail / Nextcloud / Radicale / Baikal / SOGo get one-click "Create new calendar". |
|
||||
| **§8.4 — Soft caps** | **No caps in v1, add later if data warrants** | Drop the 20-warn / 80-block UI guards from §6. Instrument `count(*)` on `user_calendar_bindings` per user as a Slice 2 telemetry add. Revisit if/when real distributions land. |
|
||||
| **§8.5 — `/admin/caldav-bindings` view** | **Don't ship in v1** | Stays out of the slice plan. Support debugging goes via Supabase SQL until a real ticket lands. Frees Slice 4 polish for the legacy-column drop only. |
|
||||
| **§8.6 — Approval-flow remote-edit gap** | **Separate task under t-138** | Out of scope for all four multi-cal slices. File the gap as a new `t-paliad-*` follow-up under t-138 so multi-cal stays clean and reverter-friendly. Pre-existing hole, surfaced not fixed. |
|
||||
|
||||
### Net effect on §7 slice plan
|
||||
|
||||
- **Slice 1** unchanged — schema + backfill, behaviour-equivalent.
|
||||
- **Slice 2** = picker UI + `REPORT calendar-multiget` + **MKCALENDAR
|
||||
with capability probe + Google-degrade message** + binding-count
|
||||
telemetry. No `read_only` flag, no soft caps, no admin view.
|
||||
Scopes enabled: `all_visible`, `personal_only`, `project`.
|
||||
- **Slice 3** = hierarchy scopes (`client` / `litigation` / `patent` / `case`)
|
||||
+ per-project quick-adds. **No** approval-gap fix folded in.
|
||||
- **Slice 4** = drop legacy `appointments.caldav_uid` / `caldav_etag`.
|
||||
Soft-cap banners only if Slice 2 telemetry says we need them.
|
||||
|
||||
### Net effect on §3 schema
|
||||
|
||||
No change. `user_calendar_bindings` still ships with the full
|
||||
`scope_kind` enum (including `personal_only`). `appointment_caldav_targets`
|
||||
unchanged. No `read_only` column in v1.
|
||||
|
||||
### Follow-ups to file as separate tasks
|
||||
|
||||
1. **`t-paliad-*` (under t-138):** approval-flow + CalDAV remote-edit
|
||||
gap. `ApplyRemoteUpdate` bypasses the approval gate when an external
|
||||
client edits a pending-approval event. Pre-existing in single-cal
|
||||
Phase F. Owner: t-138 maintainer.
|
||||
2. **(maybe) `t-paliad-*`:** soft-cap UI if Slice 2 telemetry shows
|
||||
any user near the iCloud-100 / Nextcloud-30 envelope. Not pre-filed
|
||||
— only opens if data warrants.
|
||||
448
docs/design-calendar-view-align-2026-05-20.md
Normal file
448
docs/design-calendar-view-align-2026-05-20.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# Design: Align calendar-view rendering between Events/Termine and Custom Views
|
||||
|
||||
**Task:** t-paliad-224 — m/paliad#55
|
||||
**Author:** bohr (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Status:** ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch.
|
||||
**Branch:** `mai/bohr/calendar-view-align`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (verified against live source 2026-05-20)
|
||||
|
||||
m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two:
|
||||
|
||||
| | A — Events tab | B — Standalone | C — Custom Views |
|
||||
|---|---|---|---|
|
||||
| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` |
|
||||
| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) |
|
||||
| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) |
|
||||
| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) |
|
||||
| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage` — `internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` |
|
||||
|
||||
**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context.
|
||||
|
||||
The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. m's intent (as I read it)
|
||||
|
||||
> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!"
|
||||
|
||||
The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is:
|
||||
|
||||
1. **One calendar component**, mounted from both the events-page surface and the custom-views surface.
|
||||
2. **Identical visual output** when the same items land in either surface.
|
||||
3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go.
|
||||
4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body.
|
||||
|
||||
The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3.
|
||||
|
||||
---
|
||||
|
||||
## 2. What actually diverges today
|
||||
|
||||
Side-by-side after reading all three implementations (cited line numbers above):
|
||||
|
||||
| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) |
|
||||
|---|---|---|---|
|
||||
| Views offered | month only | month only | month + week + day |
|
||||
| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` |
|
||||
| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" |
|
||||
| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded** — `views-calendar-pill--{deadline|appointment|project_event|approval_request}` |
|
||||
| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) |
|
||||
| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal |
|
||||
| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) |
|
||||
| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view |
|
||||
| Toolbar | inline ‹ month-label › + Heute button | identical | view-switcher chips (M/W/D) + ‹ range-label › + (in day/week) "Zurück zum Monat" link |
|
||||
| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) |
|
||||
| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) |
|
||||
| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) |
|
||||
| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) |
|
||||
| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup |
|
||||
| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` |
|
||||
|
||||
The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem.
|
||||
|
||||
CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away.
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended design (TL;DR)
|
||||
|
||||
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|
||||
|---|---|---|
|
||||
| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. |
|
||||
| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. |
|
||||
| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. |
|
||||
| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type` → `kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. |
|
||||
| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. |
|
||||
| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. |
|
||||
| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. |
|
||||
| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. |
|
||||
|
||||
**Net code change (estimated by file):**
|
||||
|
||||
- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines.
|
||||
- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state).
|
||||
- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block).
|
||||
- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types).
|
||||
- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim.
|
||||
- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`).
|
||||
|
||||
Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture sketch
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ frontend/src/client/ │
|
||||
│ calendar/ │
|
||||
│ mount-calendar.ts ★ │ ← new shared module
|
||||
│ types.ts (CalendarItem)│
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌────────────────────────┼─────────────────────────┐
|
||||
│ │ │
|
||||
client/events.ts (Kalender tab) client/views/ │
|
||||
│ shape-calendar.ts │
|
||||
│ (thin wrapper) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ client/views.ts │
|
||||
│ paintRows(…, "calendar") │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
Data flows:
|
||||
A: /events → fetch /api/events?type=…&status=… → EventListItem[]
|
||||
→ toCalendarItem(items) → CalendarItem[]
|
||||
→ mountCalendar(host, items, opts)
|
||||
|
||||
C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[]
|
||||
→ toCalendarItem(rows) (noop-ish: rename ‘type’→‘kind’ already done)
|
||||
→ renderCalendarShape() → mountCalendar(host, items, opts)
|
||||
```
|
||||
|
||||
### 4.1 The shared module (`mount-calendar.ts`)
|
||||
|
||||
```ts
|
||||
// frontend/src/client/calendar/mount-calendar.ts
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
|
||||
export type CalendarKind =
|
||||
| "deadline" | "appointment" | "project_event" | "approval_request";
|
||||
|
||||
export interface CalendarItem {
|
||||
kind: CalendarKind;
|
||||
id: string;
|
||||
title: string;
|
||||
event_date: string; // ISO-8601; first 10 chars are yyyy-mm-dd
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
}
|
||||
|
||||
export interface CalendarOpts {
|
||||
defaultView?: "month" | "week" | "day";
|
||||
/** If true, calendar reads/writes ?cal_view + ?cal_date (or the prefixed
|
||||
* equivalents); if false, state is in-memory only (use for embedded
|
||||
* calendars where URL state belongs to the host page). */
|
||||
urlState?: boolean;
|
||||
/** Optional prefix for URL params (default: empty). Set if more than
|
||||
* one calendar might live on the same URL. */
|
||||
urlPrefix?: string;
|
||||
/** Optional override: how to render a row's href. Default uses the
|
||||
* kind→/deadlines|/appointments|/inbox|/projects routing the existing
|
||||
* shape-calendar.ts ships with. */
|
||||
hrefFor?: (item: CalendarItem) => string;
|
||||
}
|
||||
|
||||
export interface CalendarHandle {
|
||||
/** Re-render with a new item set (e.g. after a filter change in /events). */
|
||||
update(items: CalendarItem[]): void;
|
||||
/** Tear down listeners + clear host. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function mountCalendar(
|
||||
host: HTMLElement,
|
||||
items: CalendarItem[],
|
||||
opts?: CalendarOpts,
|
||||
): CalendarHandle;
|
||||
```
|
||||
|
||||
Internals lifted verbatim from `shape-calendar.ts` (toolbar, renderMonth/Week/Day, renderPill, renderRowAnchor, bucketByDate, filterByDay, startOfWeek, shift, isToday, isoDate, formatRangeLabel, formatWeekHeader, readView/Anchor, writeURL). Two tweaks:
|
||||
|
||||
- `readView`/`readAnchor`/`writeURL` accept the `urlPrefix` so embedded calendars on `/events?…&` don't clobber other pages' `?cal_view`.
|
||||
- `urlState: false` skips the URL read/write entirely — initial state comes from `opts.defaultView` and "today".
|
||||
|
||||
### 4.2 `shape-calendar.ts` (after refactor)
|
||||
|
||||
```ts
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
|
||||
|
||||
export function renderCalendarShape(
|
||||
host: HTMLElement, rows: ViewRow[], render: RenderSpec,
|
||||
): void {
|
||||
const items: CalendarItem[] = rows.map(r => ({
|
||||
kind: r.kind,
|
||||
id: r.id, title: r.title,
|
||||
event_date: r.event_date,
|
||||
project_id: r.project_id,
|
||||
project_title: r.project_title,
|
||||
project_reference: r.project_reference,
|
||||
}));
|
||||
mountCalendar(host, items, {
|
||||
defaultView: render.calendar?.default_view ?? "month",
|
||||
urlState: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `client/events.ts` (calendar arm only)
|
||||
|
||||
```ts
|
||||
// near the top
|
||||
import { mountCalendar, type CalendarItem, type CalendarHandle } from "./calendar/mount-calendar";
|
||||
|
||||
// state
|
||||
let calendar: CalendarHandle | null = null;
|
||||
|
||||
// inside applyView() when switching to calendar view:
|
||||
function ensureCalendarMounted(host: HTMLElement, items: CalendarItem[]) {
|
||||
if (calendar) { calendar.update(items); return; }
|
||||
calendar = mountCalendar(host, items, { urlState: false, defaultView: "month" });
|
||||
}
|
||||
|
||||
// inside applyView() when switching AWAY from calendar:
|
||||
function teardownCalendar() {
|
||||
if (calendar) { calendar.destroy(); calendar = null; }
|
||||
}
|
||||
|
||||
function toCalendarItem(it: EventListItem): CalendarItem {
|
||||
return {
|
||||
kind: it.type as CalendarKind, // type "deadline" | "appointment"
|
||||
id: it.id, title: it.title,
|
||||
event_date: itemDateISO(it) + "T00:00:00",
|
||||
project_id: it.project_id,
|
||||
project_title: it.project_title,
|
||||
project_reference: it.project_reference,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`urlState: false` for /events because the page already owns its own URL contract (`?type=`, `?status=`, etc.) and a second calendar deep-link param set would compete with future events-page state. (See §11 Q3 — this is a defaultable preference, not a hard constraint.)
|
||||
|
||||
### 4.4 Standalone calendar redirects
|
||||
|
||||
```go
|
||||
// internal/handlers/deadlines_pages.go
|
||||
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// internal/handlers/appointments_pages.go
|
||||
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
```
|
||||
|
||||
The `view=calendar` query string is a **new** events-page URL contract — needs a one-line addition to `client/events.ts:readURLState()` (which already reads `type`, `status`) to honour `view`. Today the view is in-memory only; pinning it to URL is a free side-benefit of this refactor (and lets the redirects land users on the calendar, not on the cards view).
|
||||
|
||||
Build pipeline: delete entries `frontend/build.ts:258`, `261`, `387`, `390` (the two standalone calendar bundles + HTML writes). `paliadin-context.ts:96,100` keep their URL matches — the 301 fires server-side, so the client only ever sees `/events?type=…&view=calendar` (which already maps to a paliadin context).
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual + interaction parity audit
|
||||
|
||||
Walking m's brief checklist against the proposed end-state (assuming the user is on /events Kalender tab after this refactor):
|
||||
|
||||
| Brief item | Today (A) | After refactor | Matches /views? |
|
||||
|---|---|---|---|
|
||||
| Event tile shape | dot | **pill with text** | ✓ |
|
||||
| Color | mixed (urgency + single appointment colour) | **kind-coded** (deadline / appointment / project_event / approval_request) | ✓ |
|
||||
| Click behaviour (navigate to detail) | modal popup → anchor | **direct anchor on pill** (no modal) | ✓ |
|
||||
| Today highlight | accent circle on day-num | **border ring on entire cell + box-shadow** | ✓ |
|
||||
| Weekday header | static TSX divs | **rendered inline in the JS grid** | ✓ |
|
||||
| Date-range / project / type filter shape | same `EventListItem[]` post-adapter | identical adapter feeds same `CalendarItem[]` shape | ✓ shared loader contract |
|
||||
|
||||
Two surfaces still differ after the refactor — and that's by design:
|
||||
|
||||
1. **/events** still has its three view chips above the calendar (Karten / Liste / Kalender) because the events page is multi-shape at the outer level. /views also has its outer shape chips (Liste / Karten / Kalender / Timeline). Both surfaces' shape chips look identical (`agenda-chip-row`).
|
||||
2. **/events** keeps the events-page-level filters (type chip, status select, project select, event-type/appointment-type filters) above the calendar; /views shows its filter-bar (filter-spec-driven axes) instead. Both surfaces' filter chrome is governed by the page, not the calendar — the calendar component itself is the same DOM either way.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mobile parity
|
||||
|
||||
`shape-calendar.ts` today does a mobile fallback at <600px (`mountCalendar` would carry this behaviour over). The fallback appends a single `<p>` notice — "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" or equivalent (i18n key `views.calendar.mobile_fallback`). Cells still render and are responsive (the existing CSS uses CSS-grid + 1fr columns).
|
||||
|
||||
After this refactor:
|
||||
|
||||
- /events Kalender tab: gets the **same** notice + a contextual hint suggesting "Wechsle zu Karten oder Liste" (the events-page shape chips). One new i18n key, OR reuse the existing `views.calendar.mobile_fallback` and accept that it mentions "Listenansicht" generically.
|
||||
- /views Kalender shape: behaviour unchanged from today.
|
||||
|
||||
Mobile audit boxes ticked:
|
||||
|
||||
| | Today A | Today B | Today C | After |
|
||||
|---|---|---|---|---|
|
||||
| Cell shrinks on narrow viewport | ✓ (min-height 64px) | ✓ | partial (cells stay 80px) | ✓ (carry the C behaviour, plus the @media min-height shrink ported) |
|
||||
| Touch target size on pills | n/a (dots, not tappable) | n/a | OK (8px+ at 1x) — but verify on a real phone during coder smoke | OK |
|
||||
| Modal vs drill-down | modal (small viewports lose layout) | modal | drill-down (changes URL — natural back button) | drill-down across both surfaces |
|
||||
| Sidebar collision | sidebar collapses to bottom nav under 768px (existing behaviour) | identical | identical | identical |
|
||||
|
||||
One coder-time TODO: verify the drill-down day-view is comfortable on mobile (it's a vertical list, should be fine, but worth one Playwright screenshot during smoke).
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests + smoke
|
||||
|
||||
Existing test coverage relevant to this refactor:
|
||||
|
||||
- `frontend/src/client/views/shape-timeline-cv.test.ts` — sibling of shape-calendar, no calendar-specific tests today. Add `frontend/src/client/calendar/mount-calendar.test.ts` for the extracted module.
|
||||
- No Go tests touch handler dispatch for `/deadlines/calendar` or `/appointments/calendar` specifically (verified by grep).
|
||||
- `internal/services/render_spec_test.go` covers `CalendarConfig.validate()` — unchanged.
|
||||
|
||||
New test plan:
|
||||
|
||||
1. **`mount-calendar.test.ts` (new)** — table-driven:
|
||||
- Empty `items[]` → month view renders 7-column grid + no pills + (for /views) per-day "no entries" only in day/week views.
|
||||
- `items[]` with mixed kinds → pills get the correct `views-calendar-pill--{kind}` class.
|
||||
- `?cal_view=week` → week column grid renders.
|
||||
- Today bucket flagged with `--today` class on the correct cell.
|
||||
- `+N` overflow renders when items per day > MAX_PILLS_PER_MONTH_CELL (3).
|
||||
- `update(items)` after first mount swaps content without leaking listeners (assert no double-fire on month-nav click).
|
||||
2. **`client/events.ts`** — light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away calls `destroy()`. No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment.
|
||||
3. **Smoke (manual, with `bun run build` + dev server)**:
|
||||
- /events Kalender tab loads, shows pills, click pill navigates to detail.
|
||||
- Day-num click → day view (URL changes if urlState is on for /events per Q3).
|
||||
- /views/{slug} with `render_spec.shape=calendar` (need a saved view or temporary system view to exercise) still loads identical pills + drill-down.
|
||||
- /deadlines/calendar → 301 → /events?type=deadline&view=calendar lands on Kalender tab.
|
||||
- /appointments/calendar → 301 → /events?type=appointment&view=calendar lands on Kalender tab.
|
||||
- DE + EN language toggle on both surfaces.
|
||||
- Light + dark theme on both.
|
||||
4. **Build gate**: `go build ./... && go test ./internal/... && cd frontend && bun run build` must all be clean (per task brief).
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Custom Views users have saved views with `shape=calendar` and rely on the current week/day behaviour | low (shape-calendar is the canonical, only behaviour I'm changing about it is making `urlState` opt-in) | The refactor is structural — same toolbar, same drill-down, same URL params for /views. `urlState=true` stays the default for that surface. |
|
||||
| `paliadin-context.ts` keys (`deadlines.calendar`, `appointments.calendar`) become unreachable after redirects | low | The 301 fires before the client sees the URL; new URL maps to existing `events` context. If we want to preserve the labels, add `events?type=…&view=calendar` matchers in paliadin-context (one if-branch each) — recommend doing this in the same coder PR for tidiness. |
|
||||
| Subtype colouring loss is a feature regression for someone who used /appointments/calendar's legend | low | The page is unreachable from the UI; nobody reaches it without a bookmark. Q4 below confirms with m. |
|
||||
| Events-page calendar `urlState: false` means refresh loses the Kalender chip selection | medium (today: same — calendar is in-memory either way) | Either accept (status quo) or extend events.ts URL state to include `view` (~3 lines). Q3 below. |
|
||||
| /events fetch is unfiltered by date (loads everything); on a busy team Kalender may load slow | medium (existing behaviour) | Not addressed by this refactor. Filed as follow-up in §10. Filter spec / /api/views path solves it but is out of scope here. |
|
||||
| The 301 redirect to `/events?type=…&view=calendar` requires events.ts to honour `view=calendar` from the URL | hard requirement | Must include this in the coder PR. ~3 lines in `readURLState()`. |
|
||||
|
||||
---
|
||||
|
||||
## 9. What stays "out of scope" (consistent with the issue body)
|
||||
|
||||
- New calendar UX: drag-to-create, week-resize, hover-preview, multi-day event spans.
|
||||
- Performance: switching `/events` to a date-window-bounded fetch (today it loads everything and filters client-side).
|
||||
- A unified events↔views landing (e.g. /events as a Saved View). Discussed in `design-events-unification-2026-05-04.md` and `design-data-display-model-2026-05-06.md`; deliberately not folded in here.
|
||||
- /agenda surface. It's a timeline-grouped feed, not a calendar grid — separate conversation if m wants to converge it.
|
||||
- Subtype dot colouring (deferred per §3 trade-off row).
|
||||
|
||||
---
|
||||
|
||||
## 10. Follow-ups (file as separate issues after this lands)
|
||||
|
||||
1. **Date-windowed loading for /events Kalender.** Pass `?from=…&to=…` to `/api/events` matched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already accepts `from`/`to` per `internal/handlers/events.go`. Small.
|
||||
2. **Per-shape config: subtype colouring.** Add `CalendarConfig.subtype_colors` (bool, default false). Surface a `--subtype-{value}` modifier on the pill so the appointment-type colour key can come back per-view, if a user asks.
|
||||
3. **Multi-day event spans.** Most events are single-day; deadlines are point-in-time. But appointments have `end_at`. Today neither A nor C surfaces span-rendering. Defer until requested.
|
||||
4. **/agenda convergence.** /agenda is a different visual (day-grouped feed), but the data shape is the same `EventListItem`. If m wants /agenda to disappear (it's a sibling overview entry today per `design-events-unification-2026-05-04.md`), consider folding it into /events as a fourth shape ("feed" / "agenda"). Out of this design's scope.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for head (NO AskUserQuestion — answered via mai instruct)
|
||||
|
||||
> The role brief disables `AskUserQuestion` for this task. Each question below has a defaulted answer marked **(R)**; head/m can confirm or override via `mai instruct head`. After head replies, decisions land in §12.
|
||||
|
||||
**Q1 — Canonical renderer.** Adopt `shape-calendar.ts` as the canon, fold A into it (§3 sketch), and retire the two standalone routes B as 301-redirects to `/events?type=…&view=calendar`?
|
||||
- **(R) Yes** — covers m's intent ("pick the canonical one — likely the Custom Views renderer"). Net code goes down, no schema changes.
|
||||
- Alternative: keep the standalone routes as standalone pages but make them call `mountCalendar` internally — adds nothing for users (page is unreachable), wastes a build target each.
|
||||
- *(answer: yes / keep-standalone / something-else)*
|
||||
|
||||
**Q2 — Events-page Kalender tab: drill-down vs modal-popup.** Today /events Kalender opens a modal listing the day's items. After the refactor, clicking a day-num drills into the day view (changes view chip, same URL component, but the page swaps to a day-list). Drop the modal entirely?
|
||||
- **(R) Drop the modal** — matches /views behaviour, gives a real day-view (not just a list of links), and removes one popup-management code path.
|
||||
- Alternative: keep the modal on /events only (parity break — defeats the point of the issue).
|
||||
- *(answer: drop / keep)*
|
||||
|
||||
**Q3 — URL state for the /events calendar.** Should the /events Kalender persist its view (month/week/day) and date in the URL via `?cal_view=…&cal_date=…` (matching /views)?
|
||||
- **(R) Yes, persist** — refresh-stable, shareable, ~3 lines in `readURLState()`. /views does it. Cost is owning the param contract on /events.
|
||||
- Alternative: in-memory only — today's behaviour. Keeps /events URL surface minimal.
|
||||
- *(answer: persist / in-memory)*
|
||||
|
||||
**Q4 — Subtype dot colouring on appointments.** The orphaned /appointments/calendar today colours dots by appointment_type (Verhandlung / Besprechung / Beratung / Fristverhandlung) with a legend strip. After the refactor pills are kind-coded only (deadline vs appointment vs …). Drop subtype colouring?
|
||||
- **(R) Drop now, file as follow-up** (§10.2) — page is UI-unreachable today; nobody will notice; can come back as a `CalendarConfig.subtype_colors` flag if/when requested.
|
||||
- Alternative: preserve subtype colouring on /events Kalender tab as well, with a fresh legend matching the new pill colours.
|
||||
- *(answer: drop / preserve)*
|
||||
|
||||
**Q5 — Mobile fallback text.** /views Kalender shows a notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" (key `views.calendar.mobile_fallback`). Reuse the same key on /events, or add an /events-specific key recommending the events-page "Karten" or "Liste" shape?
|
||||
- **(R) Reuse the existing key** — generic phrasing covers both surfaces; both have Karten/Liste alternatives.
|
||||
- Alternative: dedicated key per surface — clearer copy but more strings to maintain.
|
||||
- *(answer: reuse / dedicated)*
|
||||
|
||||
**Q6 — Test approach for the extracted module.** Add `mount-calendar.test.ts` with the seven listed cases (§7.1), OR also add a Playwright smoke that drives the new flow end-to-end through both surfaces?
|
||||
- **(R) Unit tests + manual smoke gauntlet** — matches the codebase's existing test layout (most client/* tests are unit-level; Playwright is reserved for fewer flows). Manual smoke per §7.3 is the brief's bar.
|
||||
- Alternative: unit + Playwright.
|
||||
- *(answer: unit-only / unit-plus-playwright)*
|
||||
|
||||
**Q7 — Sequencing across PRs.** One PR (extract + adopt + retire + CSS prune) or three (extract, then adopt+retire, then CSS prune)?
|
||||
- **(R) One PR** — refactors that don't bisect well are worse split (each intermediate state has unused exports / dead code paths / orphaned CSS classes for a few hours). The diff is reviewable in one read because it's mostly moves + deletes.
|
||||
- Alternative: three PRs — easier rollback at each step, but you'd have to land #2 before m sees any UI alignment, which loses the point.
|
||||
- *(answer: one-pr / three-pr)*
|
||||
|
||||
**Q8 — When (if at all) to delete /events `events.calendar.empty` i18n key.** Replaced by `cal.day.no_entries` in the new flow. Drop now or leave as a dead key in `i18n-keys.ts` for one release?
|
||||
- **(R) Drop now** — i18n-keys.ts is the source of truth; dead keys aren't enforced at compile time but they're a slow-rotting maintenance tax. /events' new calendar surface doesn't render an "empty month" message any more (per-day "no entries" is the only empty state, matching /views).
|
||||
- Alternative: leave for one release as a soft-deprecate.
|
||||
- *(answer: drop / leave)*
|
||||
|
||||
---
|
||||
|
||||
## 12. m's decisions (2026-05-20, via head msg #2087)
|
||||
|
||||
Head accepted all 8 (R) defaults in one round-trip ("Design accepted in
|
||||
full — all 8 (R) defaults stand"). Recorded verbatim below; each entry
|
||||
is the (R) pick from §11.
|
||||
|
||||
- **Q1 — Canonical renderer:** Yes. Canonicalise on `shape-calendar.ts`; fold A into it via extracted `mountCalendar()`; retire B as 301 redirects to `/events?type=…&view=calendar`.
|
||||
- **Q2 — Drill-down vs modal:** Drop the modal on /events. Day-num/+N click drills into the day view, matching /views.
|
||||
- **Q3 — URL state on /events:** Persist. /events Kalender reads/writes `?cal_view=…&cal_date=…` like /views does. Adds `view=calendar` to `client/events.ts:readURLState()` so refreshes/redirects land on the Kalender tab.
|
||||
- **Q4 — Subtype dot colouring:** Drop now. Filed as follow-up §10.2. Pills are kind-coded only after the refactor (deadline / appointment / project_event / approval_request).
|
||||
- **Q5 — Mobile fallback text:** Reuse the existing `views.calendar.mobile_fallback` key on /events as well — generic phrasing covers both surfaces.
|
||||
- **Q6 — Test approach:** Unit tests (`mount-calendar.test.ts`) + manual smoke gauntlet (§7.3). No Playwright on this refactor.
|
||||
- **Q7 — Sequencing:** One PR. Extract + adopt + retire + CSS prune land together on `mai/bohr/calendar-view-align`.
|
||||
- **Q8 — Empty-state i18n key:** Drop dead keys now (`events.calendar.empty`, `appointments.kalender.*`, `deadlines.kalender.*`, appointment-type legend keys not used elsewhere).
|
||||
|
||||
---
|
||||
|
||||
## 13. Coder hand-off (after m's go on §11)
|
||||
|
||||
Once §12 is filled in, the coder shift can proceed in this order:
|
||||
|
||||
1. Create `frontend/src/client/calendar/mount-calendar.ts` + `frontend/src/client/calendar/mount-calendar.test.ts`. Lift the shape-calendar internals; add `update`/`destroy` to the returned handle; pipe `urlState` + `urlPrefix` through.
|
||||
2. Update `frontend/src/client/views/shape-calendar.ts` to delegate to `mountCalendar` (≈30 lines after the lift).
|
||||
3. Update `frontend/src/client/events.ts`: import `mountCalendar`, replace `renderCalendar`/`openCalPopup` and nav handlers with a `mountCalendar(host, items, { urlState: <per Q3>, defaultView: "month" })` call inside the existing `applyView()` branch. Add the `view=calendar` URL state handling per Q3.
|
||||
4. Update `frontend/src/events.tsx`: strip the `events-calendar-wrap` inline DOM (toolbar + grid + modal). The empty container `<div id="events-shape-calendar" />` plus a wrapper class is enough — `mountCalendar` builds the DOM.
|
||||
5. Delete `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts`.
|
||||
6. Update `frontend/build.ts`: remove the `*-calendar.ts` entry-point lines (≈250s) and the `*-calendar.html` writes (≈387s).
|
||||
7. Update `internal/handlers/deadlines_pages.go` + `internal/handlers/appointments_pages.go`: turn the two calendar handlers into 301 redirects to `/events?type=…&view=calendar`.
|
||||
8. Update `frontend/src/styles/global.css`: delete `.frist-cal-*`, `.termin-cal-*`, `.events-cal-dot-appointment`, the 700px-media tweak (lines ~7464-7620, ~8019-8023, ~8680-8700, ~11519-11533). Sanity-check no other consumer (already verified via grep — none).
|
||||
9. Update i18n: drop `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend keys only — keep type values used elsewhere), `events.calendar.empty` per Q8. Make sure `cal.view.*`, `cal.day.no_entries`, `cal.day.back_to_month`, `cal.day.open_day`, `views.calendar.mobile_fallback` (or a new events-specific key per Q5) all exist DE + EN — most already do.
|
||||
10. `paliadin-context.ts`: optional one-line addition to map `events?view=calendar` to the new context label.
|
||||
11. Run `go build ./... && go test ./internal/... && cd frontend && bun run build`.
|
||||
12. Manual smoke per §7.3.
|
||||
13. Commit. `mai report completed` with SHA per task brief.
|
||||
|
||||
Estimated coder shift: one PR per Q7 (R).
|
||||
|
||||
---
|
||||
603
docs/design-paliad-data-export-2026-05-19.md
Normal file
603
docs/design-paliad-data-export-2026-05-19.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# Paliad data export — Excel-first, scoped (org / project-subtree / personal)
|
||||
|
||||
Design: archimedes (inventor), 2026-05-19.
|
||||
Task: **t-paliad-214**.
|
||||
Branch: `mai/archimedes/inventor-excel-data`.
|
||||
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §11 open questions.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (live state, 2026-05-19)
|
||||
|
||||
Verified directly against the youpc Postgres `paliad` schema rather than against memory or older design docs.
|
||||
|
||||
**Migration tracker.** Latest applied is `100_ccr_visible_rule`; next is **101**.
|
||||
|
||||
**Row counts (org-wide today):**
|
||||
|
||||
| table | rows |
|
||||
|------------------------|-----:|
|
||||
| users | 47 |
|
||||
| projects | 11 |
|
||||
| deadlines | 26 |
|
||||
| appointments | 5 |
|
||||
| parties | 0 |
|
||||
| notes | 4 |
|
||||
| documents | 0 |
|
||||
| project_events (audit) | 93 |
|
||||
| project_teams | 3 |
|
||||
| approval_requests | 8 |
|
||||
| approval_policies | 160 |
|
||||
| checklist_instances | 4 |
|
||||
| deadline_rules | 254 |
|
||||
| user_views | 2 |
|
||||
| partner_units | 11 |
|
||||
|
||||
A full org export today is **< 600 rows of user content** plus reference data — synchronous streamed download is plausible for every scope. We design for an order-of-magnitude head-room.
|
||||
|
||||
**Auth.** Passwords live in Supabase Auth (separate `auth` schema, not `paliad`). The `paliad.users` table has **no `password_hash` column** — so the "don't export credentials" rule from the brief is enforced by absence, not by a column-deny list. Good.
|
||||
|
||||
**Visibility.** Row-level via `paliad.can_see_project(project_id)` (subtree-aware through ltree path). Already used as the predicate that gates every list endpoint. We reuse it for the **personal** and **project** scopes; the **org** scope bypasses it under `global_admin`.
|
||||
|
||||
**Documents.** Table exists, 0 rows. Phase H (AI Frist-Extraktion) is deferred per m's 2026-04-16 call. No `ANTHROPIC_API_KEY` on Dokploy. Therefore **this design does not concern itself with binary attachments** — only with the metadata row when documents start landing.
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
Two motivations, both load-bearing:
|
||||
|
||||
1. **Safety / backup.** A workbook on disk is a portable artifact independent of the running app. If paliad.de is down, a partner needs the matter file. If the Dokploy compose corrupts, IT needs a recent dump. If a deadline gets accidentally deleted, we want a recoverable snapshot.
|
||||
|
||||
2. **No lock-in.** A team or an entire org choosing to leave paliad must be able to walk away with their entire dataset in a format anyone can open. We promise this in writing as a trust signal — exactly because the alternative (silently locking customers in) is what we built paliad to *not* be.
|
||||
|
||||
The export is therefore not a "nice analytics feature" — it is **a contractual guarantee that the data is yours**. That framing shapes the design: completeness > convenience, portability > polish, every export auditable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope definitions (precise)
|
||||
|
||||
Three scopes. The boundary is **what the caller is allowed to see**, joined with **what makes the artifact interpretable standalone**.
|
||||
|
||||
### 2.1 `org` scope
|
||||
|
||||
**Caller:** `global_role='global_admin'` only. There is no firm-admin role distinct from global_admin in paliad today (see §4).
|
||||
|
||||
**Content:** literally everything in the `paliad` schema that is user content or reference data the workbook needs to be readable. Specifically:
|
||||
|
||||
| sheet | source table(s) | notes |
|
||||
|------------------------|-------------------------------------------------------------------|-------|
|
||||
| `projects` | `paliad.projects` (all rows) | Full project tree including soft-deleted (status='deleted' / 'closed' if any). |
|
||||
| `project_teams` | `paliad.project_teams` | profession + responsibility (post-t-148). |
|
||||
| `project_partner_units`| `paliad.project_partner_units` | Derivation grants. |
|
||||
| `deadlines` | `paliad.deadlines` | Including completed, cancelled. |
|
||||
| `appointments` | `paliad.appointments` | Including completed. |
|
||||
| `parties` | `paliad.parties` | All client / opposing-party data. |
|
||||
| `notes` | `paliad.notes` | All four polymorphic targets resolved into the `target_kind`/`target_id` columns. |
|
||||
| `documents` | `paliad.documents` metadata (file_path, file_size, mime_type, ai_extracted) | Binaries excluded (open Q1). |
|
||||
| `audit_events` | `paliad.project_events` | Full audit trail per project. |
|
||||
| `approval_requests` | `paliad.approval_requests` | Including completed / rejected, with `requester_kind` + `agent_turn_id`. |
|
||||
| `approval_policies` | `paliad.approval_policies` | Both project-scoped and partner-unit-defaults. |
|
||||
| `policy_audit_log` | `paliad.policy_audit_log` | Source #5 of the audit union. |
|
||||
| `partner_units` | `paliad.partner_units` | Org chart. |
|
||||
| `partner_unit_members` | `paliad.partner_unit_members` | Including unit_role. |
|
||||
| `partner_unit_events` | `paliad.partner_unit_events` | Org-chart audit. |
|
||||
| `checklist_instances` | `paliad.checklist_instances` | Per-project completion state. |
|
||||
| `invitations` | `paliad.invitations` (status, role, expires_at) | Without raw tokens (open Q7). |
|
||||
| `users` | `paliad.users` (id, email, display_name, office, profession, …) | Excludes `email_preferences` jsonb only if it carries channel secrets — none do today, but checked at export time. |
|
||||
| `user_views` | `paliad.user_views` | Saved filters / custom layouts. |
|
||||
| `user_card_layouts` | `paliad.user_card_layouts` | Project-card layouts. |
|
||||
| `user_pinned_projects` | `paliad.user_pinned_projects` | Per-user pins. |
|
||||
| `user_caldav_config` | `paliad.user_caldav_config` **without** the ciphertext column | URL + calendar IDs + last_sync; passwords NEVER exported. |
|
||||
| `reminder_log` | `paliad.reminder_log` | Outbound digest history. |
|
||||
| `caldav_sync_log` | `paliad.caldav_sync_log` | Per-user sync runs. |
|
||||
| `paliadin_turns` | `paliad.paliadin_turns` | **Excluded by default** in org export (privacy — see §6) — admins opt in per Q5. |
|
||||
| `email_broadcasts` | `paliad.email_broadcasts` | Outbound broadcast history. |
|
||||
| `email_templates` + `_versions` | both | Custom firm templates. |
|
||||
| **reference (read-only):** | `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_category_concepts`, `trigger_events`, `holidays`, `courts`, `countries` | One sheet per table, prefixed `ref__`. Embedded so the workbook is interpretable without paliad context. |
|
||||
| **deferred audit (admin opt-in):** | `deadline_rule_audit`, `policy_audit_log`, `partner_unit_events`, `caldav_sync_log`, `paliadin_turns` | Behaviour per Q5/Q6. |
|
||||
|
||||
**Excluded unconditionally:**
|
||||
- `auth.*` (Supabase Auth schema — not ours; the user can request their auth record from Supabase directly).
|
||||
- `paliad_schema_migrations` (operational, no business meaning).
|
||||
- `*_pre_NNN` shadow / pre-migration backup tables (rows are duplicates; the live table is canonical).
|
||||
- Any future `*_secret` / `*_token` columns (see §6 deny-list mechanism).
|
||||
|
||||
**Edge cases:**
|
||||
- **Soft-deleted rows:** paliad currently has no soft-delete columns (`deleted_at` etc.). When that lands, the org export includes them by default with a `deleted_at` column populated. Until then, this is a no-op.
|
||||
- **Archived projects:** `projects.status` can be `'closed'` or future `'archived'` — export includes them (the whole point of backup is recoverability of closed matters).
|
||||
- **Counterclaims:** `projects.counterclaim_of` is a self-FK. Export carries the column as-is; the relationship is reconstructable via the `id` column.
|
||||
|
||||
### 2.2 `project` scope
|
||||
|
||||
**Caller:** any team member of the project who passes the §4 profession-tier gate.
|
||||
|
||||
**Content:** one project + **all descendants** along the ltree path. The descendant walk is `WHERE path <@ root.path` (subtree-inclusive of root). Every entity gets filtered through `WHERE project_id IN (subtree_ids)`.
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
- `projects` (root + descendants, one row each)
|
||||
- `project_teams` (membership for those projects)
|
||||
- `project_partner_units` (derivation attachments)
|
||||
- `deadlines`, `appointments`, `parties`, `notes`, `documents` (metadata), `audit_events`, `approval_requests`, `checklist_instances` — all scoped to subtree
|
||||
- **users sheet — restricted columns:** only `id, email, display_name, office, profession` for users referenced by any FK in the export (created_by, assigned, etc.). Don't dump all 47 users when you only need 4. (Avoids accidental org-chart leak in a project-scope export shared externally.)
|
||||
- **reference data:** `ref__proceeding_types`, `ref__event_types`, `ref__deadline_rules`, `ref__deadline_concepts`, `ref__courts`, `ref__countries`, `ref__holidays`. Same as org but a smaller universe is acceptable too — the v1 ships the full reference tables for simplicity (every row count is ≤ 300; size is moot).
|
||||
- **Cross-project references** (e.g., a party referenced by a project outside the subtree): out of scope by the predicate. The export carries the foreign UUID so a re-import or merge could re-link, but the foreign row itself is not in the workbook. Edge case is rare — `counterclaim_of` is the only known cross-project pointer today.
|
||||
|
||||
**Edge cases:**
|
||||
- **Partner-unit data:** `partner_units` is org-wide; project export carries only the unit ids attached via `project_partner_units`. The unit name + membership are loaded into the workbook on `partner_units` and `partner_unit_members` sheets (filtered to the attached units only).
|
||||
- **Policies:** `approval_policies` rows include both project-scoped (the project + ancestors) **and** partner-unit-defaults attached to this project. Same MAX-of-sources logic as runtime.
|
||||
- **Audit:** `project_events` for the subtree + (admin opt-in only) `deadline_rule_audit` rows whose rule was used by any deadline in the subtree. Default off — these are firm-wide curation logs and don't belong in a per-project handoff.
|
||||
|
||||
### 2.3 `personal` scope
|
||||
|
||||
**Semantics:** "everything I can see right now in paliad, framed as my data."
|
||||
|
||||
That definition resolves the ambiguity in the brief: personal scope is **not** "rows where I am `created_by`" — that misses everything I see by being on a team. It is **the RLS-visible projection of the schema for caller=me**, plus a handful of explicitly-personal sidecars (caldav config, my pins, my views).
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
| sheet | rows |
|
||||
|---|---|
|
||||
| `projects` | `WHERE paliad.can_see_project(id)` for the caller. |
|
||||
| `project_teams` | Rows where `user_id = me` OR the row's project is in my visible set. |
|
||||
| `deadlines` | Same project-visibility filter. |
|
||||
| `appointments` | Same. |
|
||||
| `parties`, `notes`, `documents` metadata, `audit_events`, `checklist_instances` | Same. |
|
||||
| `approval_requests` | Rows where `requested_by = me` OR `decided_by = me` OR project ∈ visible set. |
|
||||
| `me` (single-row sheet) | Caller's `users` row (id, email, display_name, office, profession, reminder_*, lang, escalation_contact_id). |
|
||||
| `my_caldav_config` | The caller's `user_caldav_config` row **without** the encrypted password column — sync URL, calendar IDs, last_sync_at. |
|
||||
| `my_views` | Caller's `user_views` rows. |
|
||||
| `my_pinned_projects` | Caller's `user_pinned_projects` rows. |
|
||||
| `my_card_layouts` | Caller's `user_card_layouts` rows. |
|
||||
| `my_paliadin_turns` | Caller's `paliadin_turns` rows (currently restricted to `PaliadinOwnerEmail` = m, so this sheet is empty for everyone else). Sensitive: AI prompts + responses. **Default on for personal scope** — it's literally the caller's data. |
|
||||
| `users_referenced` | Restricted: id + display_name + email for users referenced as FKs in the export. |
|
||||
| reference tables | Same set as project scope. |
|
||||
|
||||
**Edge cases:**
|
||||
- **Caller leaves a team:** the export reflects the moment-in-time visibility. A `generated_at` timestamp in the workbook header (`__meta` sheet) anchors this.
|
||||
- **Caller is a global_admin:** their personal export is the entire org (because their visible set = all projects). This is by design — but we surface a banner ("Sie sehen alles, weil Sie global_admin sind. Ein org-scope-Export wäre identisch.") so they don't get confused thinking the personal-scope endpoint is broken.
|
||||
- **Caller has no team memberships:** export contains the empty workbook + the `me` row + their caldav config + views/pins. Still useful — they can save their preferences.
|
||||
|
||||
### 2.4 Common columns across all scopes
|
||||
|
||||
Every export workbook contains a `__meta` sheet:
|
||||
|
||||
```
|
||||
schema_version: 1
|
||||
firm_name: HLC # from internal/branding.Name
|
||||
scope: org | project | personal
|
||||
scope_root_id: uuid or NULL # the project id for project-scope, NULL otherwise
|
||||
generated_at: 2026-05-19T14:23:00Z
|
||||
generated_by_user: <uuid> <email> # the caller
|
||||
generated_by_label: archimedes / m / ... # display_name
|
||||
row_counts: JSON {"projects": 11, ...}
|
||||
paliad_version: <git sha at server build>
|
||||
notes: free-form, e.g., "documents binaries excluded by design"
|
||||
```
|
||||
|
||||
This pins provenance + reproducibility + diffability.
|
||||
|
||||
---
|
||||
|
||||
## 3. Format choices
|
||||
|
||||
### 3.1 xlsx as the primary format
|
||||
|
||||
**Library: `github.com/xuri/excelize/v2`.** De-facto Go xlsx library, pure-Go (no cgo, no external libreoffice), MIT, streaming writer for large workbooks, broad format-feature support (number formats, freeze panes, hyperlinks, sheet hide). The streaming writer (`NewStreamWriter`) is what we use — it writes rows one at a time without holding the whole sheet in memory. At 11-projects scale this is unnecessary; at 11k-projects scale it's essential, so we set the pattern now.
|
||||
|
||||
**Why not the alternatives:**
|
||||
- `tealeg/xlsx` — older, unmaintained, no streaming.
|
||||
- `qax-os/excelize` — same project as xuri/excelize (the github org renamed); xuri is the upstream.
|
||||
- `360EntSecGroup-Skylar/excelize` — defunct fork.
|
||||
|
||||
**Workbook structure:** one **sheet per entity type**, *never* a mixed-type sheet with conditional columns. Reasons:
|
||||
- Excel users sort + filter by column; a column that means "deadline due_date" on row 4 and "appointment start_at" on row 12 is unusable.
|
||||
- The "self-describing" promise (no-lock-in) is satisfied by a workbook where every sheet is a flat table with stable column headers, not by a polymorphic blob.
|
||||
- Cross-sheet relationships are represented by **UUIDs in foreign-key columns** + a `__lookup` sheet pairing UUID → display label (project title, user email) for the workbook's lifetime. This makes the workbook self-joining in Power Query / pivot tables.
|
||||
|
||||
**Sheet conventions:**
|
||||
- Sheet names use `snake_case` matching SQL table names (`deadlines`, not `Fristen`). Reference tables prefixed `ref__`. Personal sidecars prefixed `my_`. Meta sheet `__meta`. The `__lookup` sheet sits last.
|
||||
- Row 1 = column headers; frozen.
|
||||
- Column 1 of every entity sheet is `id` (uuid).
|
||||
- Dates: ISO 8601 UTC for timestamptz; `YYYY-MM-DD` for `date`. Always as Excel strings (not Excel date types) — Excel-date interpretation differs by locale (DE: `Tag.Monat.Jahr`, EN: `Month/Day/Year`) and silently corrupts on round-trip. A pinned ISO string is unambiguous and re-importable. Open Q4 covers whether to *also* mirror to native Excel dates for human convenience.
|
||||
- Booleans: literal `TRUE` / `FALSE` strings, same reason.
|
||||
- `jsonb` columns: serialised as compact JSON one-liners in the cell. Cell type = string. Power Query can `Json.Document` them.
|
||||
- Arrays (e.g., `additional_offices text[]`): semicolon-joined string. Excel's CSV-array convention is the comma but our office codes use commas; semicolon avoids the collision.
|
||||
- `text[uuid[]]` paths (the projects.path ltree): exported as the canonical dotted-uuid string.
|
||||
|
||||
**Encoding:** UTF-8 always. Excelize handles the xlsx packaging which is unicode-native. Umlaute round-trip correctly (verified pattern with tesla's CSV export in t-paliad-177).
|
||||
|
||||
### 3.2 CSV + JSON siblings
|
||||
|
||||
Per the no-lock-in promise, **xlsx is not enough on its own** — Excel is a proprietary format owned by Microsoft, and a workbook is opaque without a tool that understands it. For genuine portability we also produce:
|
||||
|
||||
- **CSV:** one file per entity sheet (no reference sheets — those go as JSON), UTF-8 with BOM (`\xEF\xBB\xBF`) for Excel-DE compat, RFC 4180 quoting, headers row 1. Identical column shape to the xlsx sheet.
|
||||
- **JSON:** a single `paliad-export.json` per scope, top-level `{"meta": {...}, "tables": {"projects": [...], "deadlines": [...], ...}}`. Easiest for programmatic re-ingest. Reference tables included.
|
||||
|
||||
**Delivery shape:** all three formats live inside one `.zip` per export:
|
||||
```
|
||||
paliad-export-<scope>-<timestamp>.zip
|
||||
├── README.txt # human-readable: what this is, how to read it
|
||||
├── paliad-export.xlsx # canonical workbook
|
||||
├── paliad-export.json # JSON twin (machine-readable)
|
||||
├── csv/
|
||||
│ ├── projects.csv
|
||||
│ ├── deadlines.csv
|
||||
│ ├── ...
|
||||
│ └── ref/
|
||||
│ ├── proceeding_types.csv
|
||||
│ └── ...
|
||||
└── __meta.json # standalone meta (same content as __meta sheet)
|
||||
```
|
||||
|
||||
The `.zip` is the artifact users download. Default content is "all three" — there's no UI knob to pick (open Q1: should there be? Inventor pick = no, zip-only).
|
||||
|
||||
**Filename convention:**
|
||||
```
|
||||
paliad-export-{scope}-{timestamp}.zip
|
||||
scope = org | project-<root-short> | personal
|
||||
timestamp = YYYY-MM-DDTHHMMZ # UTC, no colons (Windows-safe)
|
||||
```
|
||||
Examples: `paliad-export-org-2026-05-19T1423Z.zip`, `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`, `paliad-export-personal-2026-05-19T1423Z.zip`. The project-short is `slugify(root.title)` capped 40 chars.
|
||||
|
||||
**Determinism (Q6 question).** Two exports of the same scope at the same row state must produce **byte-identical** workbooks. xlsx is internally a zip of XML — file order in the zip is significant; excelize's default zip writer is non-deterministic. We can make this deterministic by sorting the file list before writing. JSON: keys sorted alphabetically. CSV: rows ordered by `id ASC` (stable). The only inherently non-deterministic field is `generated_at`; we externalise it to the filename and the `__meta` sheet, but the rest of the workbook is byte-stable. **Inventor pick: yes, deterministic.** Lets users diff exports and prove "nothing changed between Tuesday and Thursday."
|
||||
|
||||
### 3.3 Future-proofing — schema_version
|
||||
|
||||
`__meta.schema_version = 1`. When we add columns (e.g., projects.archived_at lands), we bump to 2 and note the additions in a `docs/export-schema-changelog.md`. Importers (us in the future, or a re-importer at a different firm) read schema_version first.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authorization model
|
||||
|
||||
**Tightly mirrored to existing paliad role surfaces.** No new roles introduced.
|
||||
|
||||
| Scope | Required auth |
|
||||
|---|---|
|
||||
| `org` | `paliad.users.global_role = 'global_admin'`. Same gate as `/admin/*` pages (`auth.RequireAdminFunc` in `handlers.go:417`). |
|
||||
| `project` | Caller must (a) pass `can_see_project(root_id)`, AND (b) have effective project profession ≥ **associate** on the root. The associate floor mirrors the conservative seed in `approval_policies` (t-154); paralegals + PA can see data but not extract it. m-tunable per Q2. |
|
||||
| `personal` | Any authenticated user. No additional gate. |
|
||||
|
||||
**Profession ladder check** for project scope uses the existing `DerivationService.EffectiveProjectRole` (t-139 phase 2) — direct membership > ancestor > derived via partner-unit. Same surface that gates approvals; same surface gates extracts.
|
||||
|
||||
**Audit row written on every export run.** A new event_type into `paliad.project_events` for project-scope (so it appears on the project's Verlauf), `partner_unit_events` for org-scope (so it appears on the partner-unit audit log of the firm-admin's home unit), and `policy_audit_log` is too narrow — we likely want a **new** audit table for org-wide actions, OR we widen `project_events` to allow `project_id = NULL` org-wide rows. **Inventor pick: new table `paliad.system_audit_log`** — clean separation, integrates into the existing 5-source AuditService union as source #6. Migration 101 adds it.
|
||||
|
||||
`system_audit_log` columns:
|
||||
```sql
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_type text NOT NULL, -- 'data_export'
|
||||
actor_id uuid REFERENCES paliad.users(id),
|
||||
actor_email text NOT NULL, -- captured at write time, survives user deletion
|
||||
scope text NOT NULL, -- 'org' | 'project' | 'personal'
|
||||
scope_root uuid, -- project_id for project scope, NULL otherwise
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"formats":["xlsx","json","csv"], "row_counts":{...}, "file_size_bytes":12345, "filename":"..."}
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
```
|
||||
|
||||
The audit row is written **before** the export runs (so failed exports are still recorded) and **updated** with `file_size_bytes` + final `row_counts` on success. Failure case: separate `event_type='data_export_failed'` row with the error string in metadata. **The audit chain is the trust signal** — m sees who exfiltrated what, when, and how much.
|
||||
|
||||
**Headers on the response:**
|
||||
- `Content-Disposition: attachment; filename="paliad-export-<scope>-<ts>.zip"`
|
||||
- `X-Paliad-Export-Audit-Id: <system_audit_log.id>` — so an automated client can reference the audit row.
|
||||
|
||||
---
|
||||
|
||||
## 5. Trigger model
|
||||
|
||||
Three trigger surfaces:
|
||||
|
||||
### 5.1 On-demand button
|
||||
|
||||
- **Personal:** `/settings` → "Daten exportieren" card → button. POST `/api/me/export` → 200 with `Content-Type: application/zip`. Synchronous.
|
||||
- **Project:** `/projects/{id}` → settings/cog menu → "Daten dieses Projekts exportieren". POST `/api/projects/{id}/export` → 200 zip. Synchronous. Includes a "Inkl. Unterprojekte" toggle hint (it's always subtree-inclusive — the toggle is purely informational, no off switch).
|
||||
- **Org:** `/admin/data-export` (new page, card on `/admin`) → "Org-Export erstellen" button. POST `/api/admin/export/org` → **async** by default (see §6.1). Returns 202 + `job_id`. UI polls `/api/admin/export/org/jobs/{id}` for status.
|
||||
|
||||
**Why org is async even at today's scale:** the principle isn't "is it slow now" — it's "the trigger model should not change as the firm grows." If the partner with the firm-wide button gets a different UX from the associate with the project button, we'd retrofit later. Sync at 600 rows works fine; the wrapping is `goroutine + channel + Server-Sent Events for live progress`, no new infra needed. See §6.1.
|
||||
|
||||
### 5.2 Scheduled exports
|
||||
|
||||
**Inventor pick — defer to slice 4.** Out of v1 scope. The reasoning: scheduling sits on storage + delivery + retention, all of which are *also* deferred to slice 3+. Building the scheduler before we know how + where the artifact lives is premature.
|
||||
|
||||
When it lands (slice 4), the model is:
|
||||
- A new `paliad.scheduled_exports` table: `(id, scope, scope_root_id, owner_user_id, cadence, last_run_at, next_run_at, delivery)` where `delivery` is `{kind: 'email-link' | 'caldav-style-webdav', config: jsonb}`.
|
||||
- A daily cron (mai cron or a `time.Ticker` goroutine) checks `next_run_at < now()`, runs the export, posts the link via the configured delivery channel.
|
||||
- Cadence: weekly + monthly + on-status-change (e.g., "export when project closes" — a webhook from `projects.status` triggers).
|
||||
|
||||
For now (slice 1-2), users can right-click the on-demand button and bookmark the URL — that's the **only** scheduled-export-y thing we offer, and it's intentional: get the manual flow rock-solid before adding cadence.
|
||||
|
||||
### 5.3 API endpoint
|
||||
|
||||
Same endpoints as §5.1, callable directly with the standard cookie / bearer auth. We don't add a separate "API key" surface in v1 — paliad doesn't have personal access tokens today. If a user wants to script their personal export weekly, they can use cookie auth from `m/paliad` automation; that's enough until power-user volume justifies a real PAT surface.
|
||||
|
||||
For machine ergonomics: the `/api/...export` endpoints accept `?format=zip` (default), `?format=xlsx`, `?format=json`, `?format=csv-zip` query params. Only `zip` is documented; the others are internal but reachable for automation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Storage + delivery
|
||||
|
||||
### 6.1 Synchronous vs async — per-scope picks
|
||||
|
||||
**Personal, project:** **Synchronous, streamed.** The handler holds the HTTP connection open, writes the zip directly to `http.ResponseWriter`. For 1MB-class exports (today's reality at every scale up to thousands of rows per entity) this is the right call — no persistence, nothing to garbage-collect, nothing leaking onto disk. Excelize's `NewStreamWriter` flushes rows as they're written so RAM stays bounded.
|
||||
|
||||
**Org:** **Asynchronous, in-process queue, on-disk artifact.**
|
||||
- Submit (`POST /api/admin/export/org`) writes a `system_audit_log` row with status `pending` and dispatches a goroutine.
|
||||
- The goroutine writes the zip to `/var/lib/paliad/exports/{audit_id}.zip` (configurable via `PALIAD_EXPORT_DIR`; on Dokploy this is a mounted volume).
|
||||
- The goroutine updates the audit row's metadata with progress, then status `done` with `file_size_bytes` on success.
|
||||
- The user polls `GET /api/admin/export/org/jobs/{audit_id}` (SSE or simple JSON) — when ready, a download link `GET /api/admin/export/org/jobs/{audit_id}/download` serves the file.
|
||||
- Download deletes the file by default (one-shot link), or keeps it per Q3.
|
||||
|
||||
**Why not S3-style bucket?** Paliad already has a `documents` table that *will* need a binary store, eventually. Coupling export storage to that future store is right — but the future store doesn't exist yet, and we don't want to provision MinIO on mlake purely for exports. **Inventor pick: local disk in `PALIAD_EXPORT_DIR`** until/unless we provision a real object store; at that point the export storage moves there transparently.
|
||||
|
||||
### 6.2 Retention (Q3)
|
||||
|
||||
**Inventor pick: 7 days, then auto-delete.** Justifications:
|
||||
1. Exports contain sensitive client data — minimising the retention window minimises blast radius if the Dokploy host is compromised.
|
||||
2. 7 days covers a holiday-week round-trip ("I exported Friday, want to look at it Monday next week, missed the day-1 link").
|
||||
3. The audit row in `system_audit_log` persists forever — you can always tell that an export happened, even after the artifact is deleted.
|
||||
|
||||
A cleanup goroutine runs daily, lists `system_audit_log` rows older than 7 days with non-NULL `file_path`, deletes the file, sets `metadata.deleted_at`. Audit row stays.
|
||||
|
||||
The `PALIAD_EXPORT_RETENTION_DAYS` env var is the knob (default `7`). m-tunable per firm.
|
||||
|
||||
### 6.3 PII / GDPR
|
||||
|
||||
This is where the design gets serious.
|
||||
|
||||
**At-rest encryption.** Files in `PALIAD_EXPORT_DIR` are plaintext on the Dokploy volume. The volume itself is encrypted at the host layer (Hostinger VPS disk encryption). We **do not** layer additional file-level encryption on the artifact — that would require a per-user key, key escrow, key rotation, all of which is over-engineered for a 7-day-retention exfil where the link is single-use behind cookie auth. The disk encryption + 7-day TTL + audit log is the trust boundary.
|
||||
|
||||
**In-transit encryption.** TLS via Dokploy + Traefik — paliad.de is Let's Encrypt-served. No raw HTTP path.
|
||||
|
||||
**Download authentication.** The download link `/api/admin/export/org/jobs/{audit_id}/download` requires the same cookie auth as the submit. No public signed URLs in v1 (deferred per Q8). When we add scheduled exports + email delivery (slice 4), we'll need expiring signed URLs — that design is captured then, not now.
|
||||
|
||||
**Data-subject requests.** A user invoking `/api/me/export` is, in effect, performing a self-serve GDPR Art. 15 data-portability request. Audit row records the request. If the firm receives a *third-party* DSR ("export the data my client Mr. Müller asked for"), a global_admin can run a project-scope export filtered to projects involving that client; this is a manual workflow we don't automate in v1 (open Q9).
|
||||
|
||||
**Right-to-erasure.** Out of scope. Erasure is a write path; export is read-only. They share no code.
|
||||
|
||||
**External sharing of export files.** A user who downloads an export and emails it to an external party has done so on their own authority and outside paliad's protection. We don't watermark the file (debated and rejected: watermarking introduces non-determinism, breaks diffability, and gives false security — anyone reading the zip can strip metadata). What we *do* document in the embedded `README.txt`:
|
||||
|
||||
> Diese Datei enthält möglicherweise vertrauliche Mandantsdaten. Sie wurde
|
||||
> erzeugt am {generated_at} durch {actor_email} aus Paliad ({firm_name}).
|
||||
> Die Weitergabe an Dritte erfolgt in eigener Verantwortung des Empfängers.
|
||||
|
||||
A simple "you broke the seal" notice is what we offer. It's a contract, not a control.
|
||||
|
||||
**PII column deny-list.** Hard-coded in `internal/services/export_service.go`:
|
||||
- `paliad.users.password_hash` — doesn't exist, but the deny-list is the safety net if it ever does.
|
||||
- `paliad.user_caldav_config.encrypted_password` — explicit drop.
|
||||
- Any column whose name matches `(?i)secret|token|password|api[_-]?key|private[_-]?key` — caught at column-discovery time, errors loudly into `system_audit_log.metadata.warnings`.
|
||||
- `paliadin_turns.assistant_response` — present in personal export of caller's own data; **off** in org export by default (m's call per Q5).
|
||||
|
||||
### 6.4 GDPR-completeness note
|
||||
|
||||
The export of one user's personal scope is **a partial Art. 15 disclosure** — it contains what's *in paliad's* control. Other systems (Supabase Auth row, mlake logs, CalDAV provider) are out of paliad's scope and not in the export. The embedded README states this explicitly so the user knows the workbook is the paliad-side answer, not a complete personal-data dump from "the firm."
|
||||
|
||||
---
|
||||
|
||||
## 7. Slice plan
|
||||
|
||||
Tracer-bullet shipping. Each slice is independently shippable and reviewable. The first slice closes the no-lock-in promise for the smallest, lowest-risk scope; later slices widen.
|
||||
|
||||
### Slice 1 — personal export, synchronous, xlsx + JSON
|
||||
|
||||
- Adds `excelize/v2` to `go.mod`.
|
||||
- New `internal/services/export_service.go` with the column-discovery + writer plumbing for xlsx + JSON.
|
||||
- New `internal/handlers/export.go` with `POST /api/me/export`.
|
||||
- New `/settings` UI: "Daten exportieren" card + button.
|
||||
- Migration 101: `paliad.system_audit_log` + `AuditService.ListEntries` 6th union branch.
|
||||
- i18n keys (`settings.export.*`, `__meta.*`).
|
||||
- Tests: `export_service_test.go` covers xlsx structure (one row each kind), JSON shape, PII deny-list.
|
||||
|
||||
Ships the no-lock-in promise for every user immediately. ~600-800 LoC + ~25 i18n keys.
|
||||
|
||||
### Slice 2 — project export, synchronous, xlsx + JSON + CSV-zip
|
||||
|
||||
- Generalises the export_service to scope-aware queries (the visibility predicate gets injected per scope).
|
||||
- New `POST /api/projects/{id}/export`, gated by §4.
|
||||
- Adds CSV writer alongside xlsx + JSON; bundles all three into `.zip`.
|
||||
- Project-detail UI gets the export menu entry.
|
||||
- README.txt template embedded.
|
||||
- Tests + e2e (Playwright) on the project page button.
|
||||
|
||||
~800-1000 LoC. The CSV path generalises the xlsx column-discovery so the marginal cost is low. After this slice, two of three scopes are shipped and synchronous serves both.
|
||||
|
||||
### Slice 3 — org export, async with job tracking
|
||||
|
||||
- Adds the goroutine + on-disk artifact path + `PALIAD_EXPORT_DIR` env.
|
||||
- `POST /api/admin/export/org` + job status + download endpoints.
|
||||
- New `/admin/data-export` page (card on `/admin/`).
|
||||
- Cleanup goroutine (daily, deletes artifacts > `PALIAD_EXPORT_RETENTION_DAYS`).
|
||||
- Refactor: extract the now-common "writeExportToWriter" core from the synchronous path so async re-uses it.
|
||||
|
||||
~600-800 LoC. After this slice, all three scopes ship + audit trail is complete.
|
||||
|
||||
### Slice 4 — scheduled exports (deferred, not v1)
|
||||
|
||||
Designed in §5.2; building deferred until at least 2 firms ask. The contract surface is the `scheduled_exports` table + cadence + delivery channel.
|
||||
|
||||
### Slice 5 — API ergonomics (deferred)
|
||||
|
||||
Personal Access Tokens (the "I want to cron my own export" surface). Until there's a customer, we don't build the PAT issuer + revocation + audit.
|
||||
|
||||
### Slice 6 — GDPR DSR helpers (deferred)
|
||||
|
||||
A `/admin/data-subject-request` workflow to assemble a per-natural-person export across projects. Built on Slice 1-3 primitives; not blocked by them.
|
||||
|
||||
### Slice 7 — document binary inclusion (deferred until documents have rows)
|
||||
|
||||
When the `documents` table starts holding real files, the export adds a `documents/` subdir in the zip with the actual files, keyed by filename = `{document_id}.{ext}`. The metadata sheet links by id. Adds ~150 LoC + an env var for the file backend.
|
||||
|
||||
**Critical-path slices for v1: 1 + 2 + 3.** Everything after is layered, optional, m-prioritised when there's a real customer pull.
|
||||
|
||||
---
|
||||
|
||||
## 8. Trade-offs flagged
|
||||
|
||||
1. **xlsx-first means we own the `excelize` dependency forever.** Mitigation: excelize is the canonical Go xlsx — replacing it would be a multi-thousand-LoC migration, but the upstream is healthy (MIT, 17k+ stars, monthly releases). Acceptable lock-in.
|
||||
|
||||
2. **Determinism (sorted file order, sorted JSON keys, row-id-ordered CSV) is implementation discipline, not a library default.** Test that breaks if any future change introduces non-determinism is essential (helps reviewers + prevents regressions).
|
||||
|
||||
3. **Synchronous personal + project means a runaway export can block a request goroutine for seconds.** At today's data shape this is sub-second. Watchdog: a 30s context deadline on synchronous exports; over that, return 503 with "export too large — contact admin for async." Triggers slice 3 → slice 4 of the user's mental model.
|
||||
|
||||
4. **Per-scope endpoints triplicate similar code paths.** Mitigated by the shared `ExportSpec` struct + scope-aware predicate injection. Read carefully in code review — this is the place subtle scope leaks creep in.
|
||||
|
||||
5. **JSON twin is genuinely redundant for human users.** It's there for the no-lock-in promise (a Python script can re-ingest without Excel). The cost is one extra file in the zip + one extra serialisation pass. Acceptable.
|
||||
|
||||
6. **No diff tooling — yet.** Determinism enables `diff -r` between two extracted zips, but no in-app surface. Slice 4+ may layer "show me what changed between Monday's and Friday's export" once exports are scheduled and stored.
|
||||
|
||||
7. **`paliadin_turns` privacy default.** Currently restricted to `PaliadinOwnerEmail` so the table is empty for every other user. Personal export carries them by default ("your AI history"); org export by default does NOT (admin opt-in via `?include=paliadin_turns`). When Paliadin opens past owner-only (post-API cutover), revisit.
|
||||
|
||||
8. **Reference-data inclusion bloats every export.** 254 deadline_rules + 102 trigger_events + 56 concepts + … = ~1000 reference rows in every workbook regardless of scope. At zip-compressed sizes this is < 100KB and worth the standalone-interpretability. If the workbook gets too large later, ship reference data as a separate "paliad-reference-snapshot.zip" once + reference it from each export's README.
|
||||
|
||||
9. **Org export volume at firm-scale.** A 10k-project firm has ~50k deadlines and ~200k audit events. Even at 200 bytes/row average that's < 100MB — comfortable for the async path with 4GB Dokploy RAM. Threshold concerns kick in at 1M+ rows, which is firm-class-of-100-attorneys territory. Designed for, not blocked on.
|
||||
|
||||
10. **Audit-log explosion.** A nightly cron + 47 users self-exporting = 50 audit rows / day. At a year that's 18k rows. Still trivial. No retention on the audit chain (the artifact retention does NOT touch audit-log retention — the audit chain is the trust signal, see §4).
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended implementer
|
||||
|
||||
**Single PR, layered slices 1 → 2 → 3 as separate commits.** No DB-heavy migrations; the only schema add is `system_audit_log` (one table, one trigger if any). The hard work is in the writer abstraction.
|
||||
|
||||
- **Slice 1:** pattern-fluent Sonnet coder. ~600-800 LoC, mostly bookkeeping. Tests pin the shape.
|
||||
- **Slice 2:** same hands as slice 1 (continuity matters here — the writer abstraction is set in slice 1 and the project scope generalises it).
|
||||
- **Slice 3:** same hands again. The async path is its own subsystem but the writer is unchanged.
|
||||
|
||||
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
|
||||
**NOT m** — this is a coder task end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## 10. Inventor → coder transition (GATED per project CLAUDE.md)
|
||||
|
||||
Per `.claude/CLAUDE.md`: design phase ends here. No code touches the tree from inventor. Head's `mai-head` skill gates the coder shift after m's go on §11 open questions.
|
||||
|
||||
When approved, the coder shift opens on `mai/<coder-name>/data-export-slice-1` (fresh branch off main, NOT off the design branch — design doc commit is the only artifact this branch carries forward via cherry-pick).
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for m
|
||||
|
||||
The brief lists 8 candidate questions. After live-state verification I've collapsed + sharpened to 9, each with an inventor pick + reasoning. Will be asked sequentially via AskUserQuestion (paliad dogma — no `## §X.Y` markdown dump on m, per t-paliad-154 lesson).
|
||||
|
||||
### Q1 — Bundle xlsx + CSV + JSON in one zip, or let user pick format?
|
||||
|
||||
**Inventor pick: bundle all three in one zip, no UI knob.**
|
||||
|
||||
Reasoning: the no-lock-in promise *requires* the JSON twin (Excel-independent re-ingest); the xlsx is the human-readable default; CSV is the universal lingua franca. Picking only one breaks the promise for some user. Bundle size at today's scale is < 1MB; even at firm-scale it's well under the email-attachment limit. The cost of a checkbox UI is more than the cost of three extra files.
|
||||
|
||||
Alternative: offer `?format=xlsx-only|json-only|csv-only` query params for the API surface, default to bundle. Documented in README only. We do this in v1 anyway since multi-format is what generates the zip in the first place.
|
||||
|
||||
### Q2 — Project-scope profession floor: associate (inventor pick) or member?
|
||||
|
||||
**Inventor pick: associate floor.**
|
||||
|
||||
A project export carries party names, addresses, decision-history, draft strategy notes. That's "I can write a paper for the partner" data, not "I can see the deadline calendar" data. Member is the bare-visibility tier (you got added to the team). Export is exfiltration — needs the next tier up.
|
||||
|
||||
Alternative: gate by `responsibility ∈ {lead, member}` (no profession floor, only the project-team responsibility check). Cleaner architecturally — separates the "can see" axis from the "can extract" axis using the same fields. Less restrictive in practice.
|
||||
|
||||
Worth choosing now because the gate text in the audit row mentions the tier.
|
||||
|
||||
### Q3 — Org-export artifact retention: 7 days (pick) or 30 / 90?
|
||||
|
||||
**Inventor pick: 7 days.**
|
||||
|
||||
Default conservative. m-tunable per firm via env var.
|
||||
|
||||
### Q4 — Excel dates: ISO strings only (pick) or also a mirrored native-Excel-date column?
|
||||
|
||||
**Inventor pick: ISO strings only.**
|
||||
|
||||
Native Excel dates are locale-poisoned (DE vs EN epoch interpretation flips, round-trip corruption when re-saved). ISO is the universal answer. Power users who want a sortable native-date column can derive it once in their workbook — but the canonical export stays unambiguous.
|
||||
|
||||
### Q5 — `paliadin_turns` in org export: opt-in only (pick), or include by default?
|
||||
|
||||
**Inventor pick: opt-in via `?include=paliadin_turns` query.**
|
||||
|
||||
Today it's m-only data (`PaliadinOwnerEmail` gate), so the privacy stakes are low — but the *moment* Paliadin opens beyond owner-only, the AI conversation history per user is the most sensitive personal data we carry. Setting the off-by-default precedent now means we don't accidentally start dumping it later.
|
||||
|
||||
### Q6 — Deterministic byte-for-byte exports: yes (pick) or accept timestamp drift in zip metadata?
|
||||
|
||||
**Inventor pick: yes, deterministic.**
|
||||
|
||||
Lets users diff exports across time. Cost: ~50 lines of `sort.Strings` + a custom zip writer with stable ordering. Worth it.
|
||||
|
||||
### Q7 — Invitation tokens in org export: drop them entirely (pick) or include as hash?
|
||||
|
||||
**Inventor pick: drop entirely.**
|
||||
|
||||
Tokens grant signup access. Including them in a backup creates a vulnerability surface — an exfiltrated backup could be used to sign up as someone-else with their pending invite. Hashing doesn't help because the hash is what the URL contains. The invitation **row** (recipient, role, expiry, sent_at) is in the export; the token is not. If you need to re-issue, you do so from paliad's invite UI.
|
||||
|
||||
### Q8 — Public signed-URL downloads (for scheduled/email delivery): yes / not in v1 (pick)?
|
||||
|
||||
**Inventor pick: not in v1.**
|
||||
|
||||
Defer to slice 4. v1's download is cookie-authenticated only. Signed URLs are useful when the recipient is asynchronously notified (email link), which is the scheduled-export model — and that whole subsystem ships later.
|
||||
|
||||
### Q9 — GDPR Art. 15 DSR helper UI: not in v1 (pick)?
|
||||
|
||||
**Inventor pick: not in v1.**
|
||||
|
||||
A global_admin can already assemble a DSR manually using project-scope exports filtered by client. v1 ships the primitives; v2 ships the workflow.
|
||||
|
||||
### Closing question for m: implementer
|
||||
|
||||
> Recommend pattern-fluent Sonnet for all three slices, same hands across (continuity matters for the writer abstraction). Specific name = your call.
|
||||
|
||||
---
|
||||
|
||||
## 12. m's decisions (addendum, 2026-05-19)
|
||||
|
||||
m walked the §11 questions live via AskUserQuestion. Results below — these supersede the inventor picks where they differ.
|
||||
|
||||
- **Q1 — Bundle format:** Bundle xlsx + JSON + CSV in one `.zip` per export. ✓ matches pick.
|
||||
- **Q2 — Project-scope floor:** **Any team member** (`responsibility ∈ {lead, member}`). ⚠ **Deviation** from associate-floor pick — m chose the looser axis-split gate. **Implementation update for §4:** project-scope auth becomes `(a) can_see_project(root_id) AND (b) caller is on project_teams for the root with responsibility ∈ {lead, member}`. The DerivationService profession check is dropped from the export gate; observers + externals + derived-only members still cannot extract. `system_audit_log.metadata` records the responsibility value the caller held at export time.
|
||||
- **Q3 — Org-export retention:** **90 days**. ⚠ **Deviation** from 7-day pick. **Implementation update for §6.2:** `PALIAD_EXPORT_RETENTION_DAYS` default flips from `7` to `90`. The cleanup goroutine still runs daily; the threshold is just longer. Audit row unaffected (still persists forever).
|
||||
- **Q4 — Date format:** ISO 8601 strings only. ✓ matches pick.
|
||||
- **Q5 — paliadin_turns in org export:** **Never include in org export.** ⚠ **Tighter** than opt-in pick. **Implementation update for §2.1 + §6.3:** the `paliadin_turns` row drops from the org-scope sheet table entirely — no `?include=paliadin_turns` query param. Personal scope still carries the caller's own paliadin_turns (it's literally their data). The hard exclusion is enforced in `export_service.go`'s scope-aware sheet registry, not just in column-discovery, so a future schema addition can't accidentally re-include it.
|
||||
- **Q6 — Deterministic exports:** Yes. ✓ matches pick. (m answered freeform "1" alongside the batching request — first option = deterministic.)
|
||||
- **Q7 — Invitation tokens:** Drop entirely. ✓ matches pick.
|
||||
- **Q8 — Signed URLs in v1:** Not in v1. ✓ matches pick.
|
||||
- **Q9 — GDPR DSR helper UI in v1:** Not in v1. ✓ matches pick.
|
||||
|
||||
**Net effect on slice plan:** unchanged shape, three modifications:
|
||||
- Slice 2 gate logic uses `project_teams.responsibility` only (no profession lookup).
|
||||
- Slice 3 default retention is 90 days (one env-var value change).
|
||||
- Slice 1 + 3 sheet registry omits `paliadin_turns` from org scope entirely.
|
||||
|
||||
No other slice deltas. v1 still ships slices 1+2+3.
|
||||
|
||||
**Coder shift gating:** head still gates the implementation handoff; m's decisions here close §11 but don't auto-trigger coder work.
|
||||
|
||||
---
|
||||
|
||||
## 13. Adjacent / out-of-scope
|
||||
|
||||
- **Import path** — explicitly out per brief. A round-trip "export then re-import" is appealing but is its own design (rebinding UUIDs, conflict resolution, schema_version migrations). Don't conflate.
|
||||
- **Postgres replacement** — the Excel workbook is a *backup* + *portability artifact*, not a data-model alternative. Postgres stays canonical.
|
||||
- **t-paliad-212 (leibniz, CalDAV multi-calendar):** personal export already carries the caller's caldav config (minus ciphertext). When leibniz designs multi-calendar, the personal export's `my_caldav_config` sheet becomes a list rather than a single row — handled by column-discovery automatically. No design conflict; flagged for confirmation when leibniz's design lands.
|
||||
- **t-paliad-213 (mendel, test strategy):** export service warrants pure-function tests for column discovery, deny-list, scope predicate, plus one e2e (Playwright) per scope endpoint. Slice tests pin the contract; mendel's overall strategy decides framework choice.
|
||||
|
||||
---
|
||||
|
||||
## 14. References
|
||||
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
---
|
||||
|
||||
**END OF DESIGN. Status: READY FOR REVIEW.**
|
||||
|
||||
Inventor parks until m's go/no-go on §11. No code touches the tree from this branch.
|
||||
582
docs/design-paliad-test-strategy-2026-05-19.md
Normal file
582
docs/design-paliad-test-strategy-2026-05-19.md
Normal file
@@ -0,0 +1,582 @@
|
||||
# Design — Paliad Test Strategy (production-grade)
|
||||
|
||||
**Author:** mendel (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Task:** t-paliad-213
|
||||
**Branch:** `mai/mendel/inventor-test-strategy`
|
||||
**Status:** DESIGN READY FOR REVIEW. No test files / Make targets / CI configs touched. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Paliad has accidental test discipline today: 59 `_test.go` files / 323 test functions in Go (≈45 % of services tested, ≈12 % of handlers tested) and 4 frontend test files for 90+ client modules (≈4 %). There is no committed end-to-end suite and no CI — every smoke pass is human-driven via the manual reports in `tests/`. The `mig 098` prod crash-loop, the `t-paliad-036` triple-bug after the German→English rename, and a long tail of UX regressions (deadline-done modal, calendar column drift) would all have been caught by a 10-test boot-and-click smoke pass.
|
||||
|
||||
This design proposes a six-layer test pyramid with a concrete tool per layer (stdlib `testing` + bun's built-in `bun:test` + `playwright` for E2E — nothing third-party we don't already use). It pins three lessons paliad has paid for in commits:
|
||||
|
||||
1. **No mocks at the service↔DB boundary.** Live-DB tests against a per-developer Postgres are the floor; in-memory mocks for `paliad.*` would have hidden every rename-after-DROP-CASCADE bug. Project preference is already in this direction (27/44 service tests are live-DB-gated); we double down rather than reverse.
|
||||
2. **Migrations must dry-run before they merge.** Every recent prod-down (mig 098, mig 020-after-rename, mig 099 audit_reason gap) was a migration that compiled, passed `go test ./...` (which skips without `TEST_DATABASE_URL`), and broke on first apply against the real schema. A `make verify-migrations` target that does BEGIN/apply/ROLLBACK in CI fixes the entire failure mode.
|
||||
3. **Browser-shaped bugs need a browser.** The fristenrechner cascade, shape-timeline render, calendar grid, inline paliadin widget — these are JS state machines. Bun's stdlib `bun:test` covers the pure parser/codec code; Playwright covers the auth-gated DOM. Don't try to substitute one for the other.
|
||||
|
||||
Six slices roll the strategy out as tracer-bullet PRs, each independently shippable. Slice 1 (migration dry-run harness) and Slice 4 (Playwright golden-path smoke) buy the most outage-prevention per LoC; the rest is widening proven patterns.
|
||||
|
||||
Six open questions for m at §6. Most surface a coverage-vs-cost trade-off — the picks that need m's call before any code lands are CI infrastructure choice (Q2), per-PR run-time budget (Q1), and live-DB-vs-dockerised Postgres (Q3).
|
||||
|
||||
---
|
||||
|
||||
## 1. Audit — what exists today
|
||||
|
||||
Counts taken on `mai/mendel/inventor-test-strategy` @ HEAD (2026-05-19, 100 migrations applied).
|
||||
|
||||
### 1.1 Go test inventory
|
||||
|
||||
| Package | Source files | Test files | Test functions | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `internal/services` | 56 | 44 | ~200 | 26 live-DB-gated (`TEST_DATABASE_URL`), 18 pure-Go. 24 services have **no test file at all** — see §1.4. |
|
||||
| `internal/handlers` | 59 | 7 | ~30 | Only auth-domain check, search, audit-parse, approval-error-mapping, redirects, verfahrensablauf-redirect, chart-404 covered. **53 handlers have no test file.** |
|
||||
| `internal/auth` | small | 2 | ~10 | Session middleware + require-admin. |
|
||||
| `internal/branding` | small | 1 | small | Firm-name override. |
|
||||
| `internal/offices` | small | 1 | small | Office enum. |
|
||||
| `internal/changelog` | small | 1 | small | Pure parser. |
|
||||
| `internal/calc` | small | 1 | small | Fees / fee tables. |
|
||||
| `cmd/server` | 1 | 1 | small | `main_paliadin_backend_test.go` covers env-gate selection. |
|
||||
| **Total** | **133** | **58** | **323** | |
|
||||
|
||||
`go test ./...` runs all 58 files. Without `TEST_DATABASE_URL` set, 27 of them silently skip their live-DB cases — the suite still passes, but coverage of mutation paths drops to near zero.
|
||||
|
||||
### 1.2 Frontend test inventory
|
||||
|
||||
| Path | Test files | Tested |
|
||||
|---|---|---|
|
||||
| `frontend/src/client/filter-bar/url-codec.test.ts` | 1 | FilterBar URL codec round-trip. |
|
||||
| `frontend/src/client/views/format.test.ts` | 1 | Date/time formatters (regression for t-paliad-153). |
|
||||
| `frontend/src/client/views/shape-timeline-chart.test.ts` | 1 | Chart layout pure function. |
|
||||
| `frontend/src/client/views/shape-timeline-cv.test.ts` | 1 | Continuous-view shape layout. |
|
||||
| **Total** | **4** | Out of ~90 client modules (`frontend/src/client/*.ts`). |
|
||||
|
||||
All four use bun's built-in `bun:test` (no extra dep). No DOM/jsdom tests. No Playwright. No `bun test` script in `package.json` (`bun run build` is the only script).
|
||||
|
||||
### 1.3 End-to-end / smoke
|
||||
|
||||
- `tests/smoke-2026-04-25.md`, `tests/smoke-auth-2026-04-25.md`, `tests/smoke-auth-2026-04-26-cleanup.md` — human-written reports with screenshots committed under `tests/screenshots-*`. No code. No re-runnable script.
|
||||
- `mai-tester` skill uses Playwright for ad-hoc runs; nothing committed.
|
||||
- No `e2e/`, no `.gitea/workflows/`, no `.github/workflows/`, no `Makefile`.
|
||||
|
||||
### 1.4 Critical service paths with no test file
|
||||
|
||||
These are `internal/services/*.go` for which no `*_test.go` sibling exists:
|
||||
|
||||
| Service | Risk class | Why it matters |
|
||||
|---|---|---|
|
||||
| `caldav_service.go`, `caldav_client.go`, `caldav_crypto.go`, `caldav_ical.go` | High | Per-user push/pull goroutines + AES-GCM at rest. One pure parser test (`caldav_ical_timeline_test.go`) exists but the service + crypto + WebDAV client are blind. |
|
||||
| `agenda_service.go` | High | Dashboard agenda query; reused by `/agenda` page. Exercised transitively by visibility tests but no direct test. |
|
||||
| `dashboard_service.go` | High | Traffic-light + summary counts. Same story — transitively covered via visibility, no direct test. |
|
||||
| `derivation_service.go` | Medium | Project-tree derivation (the new t-paliad-194-era subtree machinery). |
|
||||
| `team_service.go` | Medium | Team membership / inheritance. |
|
||||
| `partner_unit_service.go` | Medium | Dezernat replacement (t-paliad-070). |
|
||||
| `party_service.go`, `note_service.go`, `link_service.go`, `checklist_instance_service.go` | Medium | All do project-scoped CRUD with the same RLS+audit pattern that `t-paliad-036` proved easy to break. |
|
||||
| `appointment_service.go` | High | Hot — every calendar mutation. Exercised through approval tests but has no own test file. |
|
||||
| `view_service.go` | Medium | Powers the substrate (`/views/*`). |
|
||||
| `paliadin_jwt.go` | Medium | Per-turn JWT mint for the aichat path (`t-paliad-194`). No call sites in tests today. |
|
||||
| `markdown.go` | Low | Glossary + checklist content render. |
|
||||
|
||||
### 1.5 Handlers with no test file
|
||||
|
||||
53 of 59. Notably: **`auth.go` itself** (login / logout / session creation), **`projects.go`** (the most-mutated entity), **`deadlines.go` / `appointments.go`** (writes), **`paliadin.go` / `paliadin_suggest.go`** (m-only routes — never click-tested), **`fristenrechner.go` / `fristenrechner_search.go` / `fristenrechner_event_categories.go`** (the cascade users live in), **`dashboard.go` / `agenda.go`** (landing), **`onboarding.go` / `onboarding_gate.go`** (every new user's first three minutes), **`invite.go`** (rate-limited write path). The currently-tested handlers (search, audit-parse, approval error mapping, etc.) are the cheap pure-Go ones; every handler that touches the DB is untested at handler level.
|
||||
|
||||
### 1.6 Live-DB test scaffold — is it sound?
|
||||
|
||||
The pattern (read from `internal/services/visibility_test.go`):
|
||||
|
||||
```go
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") }
|
||||
if err := db.ApplyMigrations(url); err != nil { t.Fatalf(...) }
|
||||
pool, _ := sqlx.Connect("postgres", url)
|
||||
defer pool.Close()
|
||||
// per-test seed + cleanup via DELETE + defer cleanup()
|
||||
```
|
||||
|
||||
Verdict: **sound, but has rough edges that need addressing before we widen.**
|
||||
|
||||
- ✅ Migrations apply at test startup against the test DB — catches every "you forgot to add a CHECK" / "you reference a column that doesn't exist" before a real-DB-touching test runs.
|
||||
- ✅ Per-test cleanup via `DELETE FROM ... WHERE id IN ($1,...)` is explicit and idempotent.
|
||||
- ✅ The `paliad.paliad_schema_migrations` tracker collision noted in memory `0b900afa…` is a pre-existing issue, not introduced by this design.
|
||||
- ⚠️ Cleanup-via-DELETE is fragile: a test that creates a row referenced by FK from another table needs to remember to clean both. A few existing tests (see `audit_service_test.go`) already chain 5+ DELETEs.
|
||||
- ⚠️ Tests can't run in parallel against the same `TEST_DATABASE_URL` because they share schema state. `go test ./...` defaults to `-parallel` per-package; same-package tests with overlapping cleanup IDs can interfere.
|
||||
- ⚠️ No CI today actually exercises `TEST_DATABASE_URL` — so every live-DB test is effectively run only on the author's laptop or not at all. Half the value is paid-for but unbilled.
|
||||
|
||||
### 1.7 Migration tooling
|
||||
|
||||
- `internal/db/migrate.go` embeds `migrations/*.sql` and applies on server boot via `golang-migrate/v4` with the `paliad_schema_migrations` tracker in `public` schema.
|
||||
- 100 migrations on disk (`001` → `100`).
|
||||
- **No dry-run gate today.** A bad migration breaks `paliad.de` at boot (Dokploy crash-loops the container). Recent prod incidents: mig 098 (submission code rename), mig 099 (with_po flag drop missed audit_reason gap), mig 020 (function rename without body rewrite — see memory `49a05cfa…`).
|
||||
- `down.sql` exists for every migration but no test ever exercises it.
|
||||
|
||||
### 1.8 CI / deploy loop
|
||||
|
||||
- No CI. Push-to-main → Gitea webhook → Dokploy auto-builds the Dockerfile and replaces the container. The Dockerfile runs `bun run build` then `go build`. **Neither `go test` nor `bun test` runs in the build pipeline.**
|
||||
- Pre-commit hooks: none in repo. Each worker runs `go build / go vet / go test / bun run build` by convention (see memories — every shipped task report ends with "build hygiene held").
|
||||
|
||||
---
|
||||
|
||||
## 2. Test pyramid — recommended shape
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ E2E (Playwright)│ ~10 flows
|
||||
│ L6 │
|
||||
└─────────────────┘
|
||||
┌─────────────────────────┐
|
||||
│ Handler integration │ ~30 routes
|
||||
│ L5 (httptest + real DB)│
|
||||
└─────────────────────────┘
|
||||
┌──────────────────────────────────┐
|
||||
│ Service-layer (live DB) │ ~60 tests
|
||||
│ L4 (BEGIN/ROLLBACK harness) │
|
||||
└──────────────────────────────────┘
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Frontend DOM / cascade (bun:test+jsdom) │ ~15 modules
|
||||
│ L3 │
|
||||
└──────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Frontend unit (bun:test pure TS) │ ~30 modules
|
||||
│ L2 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Go unit (stdlib testing, table-driven, pure functions) │ ~150 tests
|
||||
│ L1 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Migration dry-run (make verify-migrations) │ 100 mig
|
||||
│ L0 — gate on every PR │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer 0 — Migration dry-run
|
||||
|
||||
**What:** Every `*.up.sql` in `internal/db/migrations/` is applied inside a single `BEGIN ... ROLLBACK` transaction against a scratch Postgres, in numeric order. The harness asserts each statement succeeds *and* asserts no statement leaves the schema in a `paliad_schema_migrations.dirty=true` state. A second pass applies all up-migrations end-to-end (no rollback) and then re-applies the latest up-migration to assert idempotency (every paliad migration since `t-paliad-070` has been written to be idempotent — this enforces it).
|
||||
|
||||
**Tool:** stdlib `testing` package, no third-party. Pattern: `internal/db/migrate_test.go` with a `TestMigrations_DryRun` driven from `TEST_DATABASE_URL`. A `make verify-migrations` target wraps it.
|
||||
|
||||
**Why this layer matters most:** Every recent prod-down was a migration. Catching them on a CI run before merge is the highest-leverage test investment paliad can make. Cost: one ~100-line Go file + one Postgres in CI.
|
||||
|
||||
**Coverage target:** 100 % of `*.up.sql` files. Hard gate on PR — no exceptions.
|
||||
|
||||
### Layer 1 — Go unit (pure)
|
||||
|
||||
**What:** `go test ./...` against pure functions — formatters, parsers, validators, calculators, fee tables, deadline calculators, projection lookahead clamping, codec round-trips. No DB, no HTTP.
|
||||
|
||||
**Tool:** stdlib `testing`. Table-driven `cases := []struct{...}{...}` style is already the house pattern (see `auth_test.go` / `projection_anchor_test.go`). **Do not introduce testify or any matcher library** — the current code reads cleanly without one, and 323 existing test functions don't need a rename pass.
|
||||
|
||||
**What's already there:** 19 pure-Go test files (calculator, mapping, codec, holiday, fees, etc.). Density is good; targeted infill rather than re-architecture.
|
||||
|
||||
**Coverage target:** Every pure function in `internal/services/`, `internal/handlers/`, `internal/calc/`, `internal/changelog/`. Aim for "every branch in a decision table has at least one test row." Don't chase % — chase "the obvious edge that would burn a coworker".
|
||||
|
||||
### Layer 2 — Frontend unit (pure)
|
||||
|
||||
**What:** `bun test` against pure TS modules — URL codecs (`filter-bar/url-codec`), formatters, parsers, i18n key correctness (every `data-i18n` attribute used in TSX has a key in `i18n.ts`), view-spec parsers, projection-row mapping helpers.
|
||||
|
||||
**Tool:** `bun:test` (built into bun, no install). Already in use in 4 files — extend the same pattern. Add `bun test` to `package.json` `scripts`.
|
||||
|
||||
**What to add:**
|
||||
- i18n key audit (every `t("foo.bar")` and `data-i18n="foo.bar"` resolves in both `de` and `en`).
|
||||
- `filter-bar/` types + render helpers (paliad has shipped 4 FilterBar slices; coverage is one codec test).
|
||||
- `paliadin-context.ts` route table + entity extraction (the `[ctx …]` envelope is a stable contract paliadin's SKILL.md depends on; any drift here is a silent failure).
|
||||
- `paliadin-starters.ts` registry — every route maps to ≥1 starter; every starter is bilingual.
|
||||
- View-spec parsers in `views/`.
|
||||
|
||||
**Coverage target:** Every pure TS module in `frontend/src/client/`. Pages (TSX renderers) are E2E concern, not unit concern.
|
||||
|
||||
### Layer 3 — Frontend DOM (cascade / jsdom)
|
||||
|
||||
**What:** `bun test` with jsdom global, exercising the interactive cascade modules — the fristenrechner cascade builder, the shape-timeline render, the FilterBar UI (chips, panels), the calendar grid, the inline Paliadin widget message stream, the inbox-row click handler, the dashboard activity item navigation.
|
||||
|
||||
These modules contain enough state that pure-function tests miss real bugs (e.g. the t-paliad-098 `.entity-table` row-cursor lie was a CSS+DOM bug; t-paliad-099's modal close was a DOM-event bug; t-paliad-103's `::before` overlay click-swallow was a DOM bug).
|
||||
|
||||
**Tool:** bun + `happy-dom` is the lighter choice; if it can't handle event ordering, fall back to `jsdom`. Both are ESM-clean and bun-friendly. **Pick one and stick with it — running both means twice the dependency surface.** Default pick: `happy-dom` (smaller, paliad doesn't need legacy IE semantics).
|
||||
|
||||
**Pattern:** import the cascade module, build a minimal DOM (`document.body.innerHTML = …`), dispatch synthetic events, assert resulting state. Reuses the production renderers — no test-only fakes.
|
||||
|
||||
**Coverage target:** ~15 modules. Specifically:
|
||||
- `client/filter-bar/index.ts` chip render + active-state.
|
||||
- `client/fristenrechner.ts` cascade — most complex JS in the codebase; depend chains light up every UPC bug we know.
|
||||
- `client/shape-timeline.ts` lane mode + track mode (envelope wire shape brittle to refactor).
|
||||
- `client/projects-detail.ts` row click + Verlauf render.
|
||||
- `client/paliadin-widget.ts` + `paliadin-context.ts` interaction.
|
||||
- `client/inbox.ts` row-action click routing.
|
||||
- `client/dashboard.ts` activity-item nav.
|
||||
- `client/deadlines-calendar.ts` / `appointments-calendar.ts` column layout (the calendar-column-drift bug class).
|
||||
|
||||
Not unit tests; not E2E. They are the missing middle.
|
||||
|
||||
### Layer 4 — Service-layer (live DB)
|
||||
|
||||
**What:** Go service methods against a real Postgres, using the existing `TEST_DATABASE_URL` pattern. Two improvements:
|
||||
|
||||
1. **Replace per-test DELETE cleanup with a per-test transaction harness** — open a transaction, run the test inside it, ROLLBACK. Faster, isolating, no cleanup forgotten. Already viable because the service layer accepts `*sqlx.DB`-or-tx-shaped interfaces in many places; needs a small `internal/services/internal/testdb` package that exposes `WithTx(t *testing.T, fn func(*sqlx.Tx))`. Migration is mechanical, can happen alongside infill.
|
||||
|
||||
*Caveat:* some service methods open their own transactions internally (`approval_service.submit` is one). Those keep DELETE cleanup; the tx harness is a default, not a mandate.
|
||||
|
||||
2. **Make `TEST_DATABASE_URL` mandatory in CI.** Today these tests are skipped on every machine that doesn't `export TEST_DATABASE_URL=…` — i.e. they don't run on autoatic pipelines because there's no pipeline. Once CI exists (§3.5), it becomes a required env var.
|
||||
|
||||
**Tool:** stdlib `testing` + `sqlx` (already in `go.mod`). **No mocks at the service↔DB boundary.** This is m's hardest line — see global CLAUDE.md memory pattern and `t-paliad-036` (the bug that masked two other bugs would have been caught instantly by a real-DB test).
|
||||
|
||||
**Where to invest first:** Approval (already heavy), Projection (already heavy), Fristenrechner (already heavy), DeadlineService Create/Update/Complete/Delete with `pending_request_id` interplay, AppointmentService same, ProjectService visibility predicate, CalDAV push (the four CalDAV `*.go` files have zero direct test).
|
||||
|
||||
**Coverage target:** Every service method that mutates the DB has at least one happy-path live-DB test. RLS predicate (`visibilityPredicatePositional`) has one test per role (global_admin, member, non-member).
|
||||
|
||||
### Layer 5 — Handler integration (httptest + real DB)
|
||||
|
||||
**What:** Spin a real `services.DBService`, mount the protected mux, drive `httptest.NewRequest` + `ServeHTTP` against it. Auth via a fake session cookie produced by a `testauth.Login(t, userID)` helper that mints the same Supabase JWT shape `auth.UserIDFromContext` expects.
|
||||
|
||||
**Why:** The 53 untested handlers are where the request shape ↔ service interaction lives. Examples that would have caught real bugs:
|
||||
- `t-paliad-036`'s "`/projects/{id}` 404 while `/api/projects/{id}` 200" mismatch — a 5-line handler test would have failed before the migration ran.
|
||||
- mig 020's three-stacked bug — a handler test that POSTs a deadline and asserts a 200 + read-back row would have failed at submit-time, not boot-time.
|
||||
- The audit-log query timezone bug — handler test asserts the JSON contains the expected `event_date`.
|
||||
|
||||
**Tool:** stdlib `net/http/httptest`. **No new framework.** Pattern: handler tests live next to the handler file (`internal/handlers/deadlines_test.go` next to `deadlines.go`).
|
||||
|
||||
**Coverage target:** Every handler that gates a state-changing route — `POST/PATCH/DELETE` flavour. Plus `GET` handlers that compose a non-trivial query (dashboard, agenda, search, audit-log).
|
||||
|
||||
### Layer 6 — End-to-end (Playwright)
|
||||
|
||||
**What:** A small Playwright suite (~10 flows) committed at `e2e/` with a `bun run e2e` entry. Targets a local `./paliad` against a scratch Postgres (the same `TEST_DATABASE_URL`). Each test logs in, drives the UI through one user journey, asserts visible state.
|
||||
|
||||
**Why ~10 not 100:** Per-PR budget caps at ~2 min total (§6 Q1). Playwright tests are the most expensive minute-per-confidence in this stack; they pay for themselves on the *golden path* and nothing else. The deep-coverage layer is L5; E2E is *"is the app still alive end to end?"*.
|
||||
|
||||
**Tool:** `playwright` (npm; bun installs cleanly). No third-party test runner — Playwright ships its own. Tests live in `e2e/*.spec.ts`. **Not bun:test.** Playwright's runner is purpose-built for browser-driving and integrates with their tracing — don't fight it.
|
||||
|
||||
**Cap:** 10 flows. If a new test wants in, an existing one must drop out (or we have a real reason to widen). This is the cheapest discipline available: it forces the suite to remain a smoke pass, not a regression-test dumping ground.
|
||||
|
||||
**Coverage target:** See §4.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tooling — concrete picks per layer
|
||||
|
||||
| Layer | Tool | Already in deps? | Install? |
|
||||
|---|---|---|---|
|
||||
| L0 — migration dry-run | stdlib `testing` + `migrate/v4` | yes | no |
|
||||
| L1 — Go unit | stdlib `testing` | yes | no |
|
||||
| L2 — Frontend unit | `bun:test` | yes (built into bun) | no |
|
||||
| L3 — Frontend DOM | `bun:test` + `happy-dom` | bun yes, happy-dom **new** | `bun add -d happy-dom` (one dep, ~200 KB) |
|
||||
| L4 — Service live-DB | stdlib + sqlx | yes | no |
|
||||
| L5 — Handler integration | stdlib `net/http/httptest` + sqlx | yes | no |
|
||||
| L6 — E2E | `@playwright/test` | **new** | `bun add -d @playwright/test` + `npx playwright install chromium` |
|
||||
|
||||
Net new deps: **2** (happy-dom + playwright). Both are mainstream, both have small surface area, both align with bun's ecosystem.
|
||||
|
||||
Explicit rejects:
|
||||
- ❌ **testify** — current tests read cleanly with stdlib; adding it forces a rename pass nobody wants.
|
||||
- ❌ **vitest** — bun's built-in test runner is faster and the tests are already in `bun:test` shape.
|
||||
- ❌ **dockertest / testcontainers-go** — m's preference is real-DB tests against the existing Postgres; spinning ephemeral Docker Postgres per package run adds latency and surface area for marginal isolation gain. See Q3.
|
||||
- ❌ **sqlmock / gomock for DB** — banned by §0 lesson 1.
|
||||
- ❌ **cypress** — Playwright is the better tool today, and the team's existing skill (`/mai-tester`) already uses it.
|
||||
|
||||
### 3.1 Per-PR run-time budget
|
||||
|
||||
Target (subject to m's call in Q1): **≤ 90 s for the gating tier (L0+L1+L2+L4 subset+L5 happy-path)**, ≤ 4 min for the full suite (add L3+L4 full+L6). The gating tier blocks merge; the full suite blocks deploy.
|
||||
|
||||
Indicative times (estimated, validate when slice 1 lands):
|
||||
|
||||
| Tier | Layers | Est. time | Blocks |
|
||||
|---|---|---|---|
|
||||
| **Gate (every PR)** | L0 + L1 + L2 + L5 happy-path + L4 critical | 60–90 s | merge |
|
||||
| **Full (every merge to main)** | + L4 full + L3 + L6 | 3–4 min | deploy |
|
||||
|
||||
### 3.2 CI — proposal, not commitment
|
||||
|
||||
paliad has no CI today. Two routes:
|
||||
|
||||
- **Gitea Actions** (m's stack already runs `mgit.msbls.de`). Self-hosted; same auth model as the rest of mAi. Adds a `.gitea/workflows/test.yml`. Postgres comes from a service container.
|
||||
- **Stay click-deploy.** No CI. Workers run tests locally; Dokploy auto-deploys on green-main convention.
|
||||
|
||||
Recommendation: **Gitea Actions for the gate tier only** (L0 + L1 + L2), driven by a single short workflow. The L3-L6 expansion can be a follow-up once the gate tier proves stable. Deferred to Q2 for m's call.
|
||||
|
||||
### 3.3 Test DB — live YouPC vs ephemeral
|
||||
|
||||
The `paliad` schema lives on the shared YouPC Postgres (port 11833). Three options:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **Per-developer separate DB on YouPC** (`TEST_DATABASE_URL` per laptop) | Closest to prod; existing pattern. | Cleanup discipline matters; cross-developer contention possible. |
|
||||
| **Ephemeral docker postgres per CI run** | Full isolation; parallel-safe; reset for free. | New infra; ~5 s container startup per CI invocation. |
|
||||
| **Dedicated test DB on a paliad-only Postgres** | Isolated; cheap. | New infra to maintain. |
|
||||
|
||||
Recommendation: **option 1 for developers (no-op change), option 2 for CI** (Gitea Actions postgres service container). Deferred to Q3 for m's call.
|
||||
|
||||
### 3.4 Coverage targets
|
||||
|
||||
Don't gate on percentage. Gate on critical-path coverage (§4). Add `go test -coverprofile=` output to CI for visibility, not as a merge gate. Coverage % gating produces tests-for-tests'-sake; we want the tests that catch the bugs we've shipped.
|
||||
|
||||
---
|
||||
|
||||
## 4. Critical journeys — what MUST be covered
|
||||
|
||||
These are the golden-path flows. Anything not on this list is L1-L5 territory, not L6. The list is intentionally short; if it grows beyond 10, we are doing E2E wrong.
|
||||
|
||||
| # | Flow | Why it's critical | Layer mix |
|
||||
|---|---|---|---|
|
||||
| 1 | **Login → dashboard renders → traffic-light counts match** | Every user does this every day; broken auth = paliad is offline. | L6 (Playwright) + L5 handler (auth.go) |
|
||||
| 2 | **Create project (Client → Litigation → Patent → Case)** | Hierarchy with team inheritance — the data model's spine. | L6 + L5 + L4 (project_service) |
|
||||
| 3 | **Submit deadline → routes to /inbox → approver approves → state flips** | The 4-eye flow (t-paliad-138). Most-mutated paliad surface. | L6 + L5 (deadlines, approvals) + L4 (approval_service) |
|
||||
| 4 | **Fristenrechner: pick proceeding → cascade fires → result shows** | The platform's flagship interactive tool. JS cascade. | L6 + L3 (fristenrechner cascade) + L4 (fristenrechner) |
|
||||
| 5 | **SmartTimeline: anchor a projected row → predecessor-missing-error handled** | Recent Slice-2 work (t-paliad-173 / #31). High-touch surface. | L6 + L3 (shape-timeline) + L4 (projection_service) |
|
||||
| 6 | **CalDAV sync: PUT a Termin → external client sees it, edits there → pull reconciles** | Owned-event semantics + foreign-UID skip rule from Phase F. Untested today. | L4 (caldav_service push/pull) — gated on Q3 (live YouPC vs ephemeral) |
|
||||
| 7 | **Paliadin chat: anon visit hits 404; m's session opens widget; turn renders** | Owner-gated `/paliadin` is the only m-only surface. Quiet failures here are silent. | L6 (smoke) + L5 (paliadin_suggest) + L4 (paliadin / aichat_paliadin) |
|
||||
| 8 | **/admin/rules: filter → edit one rule → lifecycle transition → audit log row** | Rules drive the cascade; bad edits break every user's fristenrechner. | L6 + L5 (admin_rules) + L4 (rule_editor_service) |
|
||||
| 9 | **Onboarding: new user with allowed email → onboarding form → first project membership** | The new-user funnel; gateOnboarded middleware traps. | L6 + L5 (onboarding, invite) |
|
||||
| 10 | **Migration boot smoke: spin paliad against an empty DB → server binds 8080** | Catches every mig-N crash-loop. | L0 (migration dry-run) + L4 boot-smoke variant |
|
||||
|
||||
Picks 1, 3, 4 and 10 are the highest-value-per-cost — they cover the routes most regressions land on (auth, mutation, cascade, boot).
|
||||
|
||||
---
|
||||
|
||||
## 5. Slice plan — tracer-bullet roll-out
|
||||
|
||||
Each slice is a shippable PR with a concrete deliverable, in order of expected outage-prevention payoff. Sized for a single coder shift unless flagged. No slice depends on a later one being merged. Hour estimates intentionally omitted (per global CLAUDE.md).
|
||||
|
||||
### Slice 1 — Migration dry-run harness + boot smoke (highest leverage)
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-1-migrations`
|
||||
|
||||
**Deliverable:**
|
||||
- `internal/db/migrate_test.go` — `TestMigrations_DryRun` (per-mig BEGIN/ROLLBACK), `TestMigrations_EndToEnd` (full apply, then re-apply latest to assert idempotency), `TestMigrations_Down` (apply N→0).
|
||||
- `Makefile` with `make verify-migrations` (the gate target), `make test` (run everything), `make test-go`, `make test-frontend`.
|
||||
- `cmd/server/main_paliadin_backend_test.go` already exists; extend with a `TestMain_BindsHTTPAfterMigrate` that boots the full server against `TEST_DATABASE_URL`, asserts `:8080` is listening, then shuts down. Catches the mig-098-class crash-loop in a single test.
|
||||
- README section: how to set `TEST_DATABASE_URL` locally.
|
||||
|
||||
**Catches:** Every mig-98-class crash-loop; every drop-cascade-with-stale-policy-name regression (t-paliad-036).
|
||||
|
||||
### Slice 2 — Service-layer infill: critical mutators
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-2-services`
|
||||
|
||||
**Deliverable:**
|
||||
- Test files for the three highest-impact untested services:
|
||||
- `internal/services/agenda_service_test.go` (live-DB, dashboard agenda query)
|
||||
- `internal/services/dashboard_service_test.go` (traffic-light counts)
|
||||
- `internal/services/team_service_test.go` (membership + inheritance — RLS-load-bearing)
|
||||
- Tighten existing `approval_service_test.go` + `deadline_service_test.go` coverage of the create/update/complete/delete × pending-request matrix where there are demonstrable gaps.
|
||||
- Add `internal/services/internal/testdb/withtx.go` — the per-test tx harness (optional adoption; existing tests stay).
|
||||
|
||||
**Catches:** RLS regressions, approval interplay regressions, dashboard count drift after schema renames.
|
||||
|
||||
### Slice 3 — Frontend bun:test setup + L2 infill
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-3-frontend-unit`
|
||||
|
||||
**Deliverable:**
|
||||
- `frontend/package.json` `scripts.test = "bun test"`.
|
||||
- New tests under `frontend/src/client/`:
|
||||
- `paliadin-context.test.ts` (route table, entity extraction, selection truncation).
|
||||
- `paliadin-starters.test.ts` (every route ≥1 starter, every starter bilingual).
|
||||
- `filter-bar/index.test.ts` (chip render + active state — pure DOM-less helpers).
|
||||
- i18n key audit: `frontend/scripts/i18n-audit.test.ts` parses every `data-i18n="…"` from `dist/` HTML and every `t("…")` call from `src/`, asserts both `de` and `en` resolve. Runs as part of `bun test`.
|
||||
- `make test-frontend` wires `cd frontend && bun test`.
|
||||
|
||||
**Catches:** i18n drift (untranslated key shipped to user), context-envelope contract drift (paliadin SKILL.md depends on it), starter-registry regressions.
|
||||
|
||||
### Slice 4 — Playwright golden-path smoke
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-4-e2e`
|
||||
|
||||
**Deliverable:**
|
||||
- `e2e/` directory at repo root.
|
||||
- `playwright.config.ts` pointing at `http://localhost:8080` (paliad started by the test, not assumed).
|
||||
- Five Playwright `*.spec.ts` files covering critical journeys 1, 3, 4, 7, 9 from §4.
|
||||
- `make e2e` target that:
|
||||
1. starts paliad against `TEST_DATABASE_URL`,
|
||||
2. waits for `:8080` to be live,
|
||||
3. runs `npx playwright test`,
|
||||
4. tears the server down.
|
||||
- `bun add -d @playwright/test` + `npx playwright install chromium`.
|
||||
|
||||
**Catches:** Auth regressions, deadline-mutation regressions, fristenrechner cascade regressions, owner-gated /paliadin leaks, onboarding-gate misbehaviour.
|
||||
|
||||
### Slice 5 — Handler integration tests for the 5 most-touched routes
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-5-handlers`
|
||||
|
||||
**Deliverable:**
|
||||
- `internal/handlers/auth_test.go` extended with `TestLogin_HappyPath` + `TestLogout_ClearsCookie` (real DB).
|
||||
- `internal/handlers/projects_test.go` — `TestProjectsCreate` (POST 200, row inserted, audit emitted), `TestProjectsGetByID_RespectsVisibility` (404 for non-member).
|
||||
- `internal/handlers/deadlines_test.go` — `TestDeadlinesCreate_TriggersApproval` (verifies pending pill).
|
||||
- `internal/handlers/appointments_test.go` — same shape.
|
||||
- `internal/handlers/paliadin_test.go` — `TestPaliadinPage_404ForNonOwner`, `TestPaliadinPage_200ForOwner`.
|
||||
- Shared `internal/handlers/testauth/testauth.go` — mints a session cookie for `userID` so handler tests don't reinvent auth seeding.
|
||||
|
||||
**Catches:** Handler ↔ service wiring drift, visibility-predicate handler-side bugs (t-paliad-036 bug 2 was exactly this), owner-gate bypass.
|
||||
|
||||
### Slice 6 — Frontend L3 (DOM) cascade tests
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-6-frontend-dom`
|
||||
|
||||
**Deliverable:**
|
||||
- `bun add -d happy-dom`.
|
||||
- DOM-driven tests for the three most-touched cascades:
|
||||
- `client/fristenrechner.test.ts` (cascade activate → row appears → date-set fires fetch).
|
||||
- `client/shape-timeline.test.ts` (lane render, track render, projected-row click).
|
||||
- `client/filter-bar/index.test.ts` (chip click toggles state, URL params update).
|
||||
|
||||
**Catches:** The whole class of "the function exists and is unit-tested but the cascade in the browser doesn't fire it" bugs. This is the layer that catches t-paliad-098 / 099 / 102 / 103.
|
||||
|
||||
### Slice 7 — CI wiring (deferred — Q2 dependent)
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-7-ci` (gated on m's Q2 pick)
|
||||
|
||||
**Deliverable:**
|
||||
- `.gitea/workflows/test.yml` (or stay click-deploy if m picks that).
|
||||
- Gate tier runs on every PR; full suite runs on merge to main.
|
||||
- Postgres service container provides `TEST_DATABASE_URL`.
|
||||
- Slack/Gotify ping on red main.
|
||||
|
||||
**Catches:** Drift between "tests pass on my laptop" and prod reality.
|
||||
|
||||
### Slice 8 — Coverage reporting + dashboard (lowest priority)
|
||||
|
||||
**Branch:** `mai/<coder>/test-strategy-slice-8-coverage`
|
||||
|
||||
**Deliverable:**
|
||||
- `go test -coverprofile=` aggregated into a single `coverage.html`.
|
||||
- Bun's coverage output similarly.
|
||||
- A `docs/coverage.md` index updated by CI.
|
||||
- **Not a merge gate.** Visibility only.
|
||||
|
||||
**Catches:** Slow drift; nice-to-have once the floor is in.
|
||||
|
||||
### Slice order rationale
|
||||
|
||||
1, 4, 5 are the highest outage-prevention per LoC: migration dry-run kills crash-loops, E2E kills regressions, handler tests kill wiring drift. 2, 3, 6 widen the floor; 7-8 are infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions for m
|
||||
|
||||
These need m's call before any coder shift starts (or before specific slices start, where noted).
|
||||
|
||||
### Q1 — Per-PR test-run budget
|
||||
|
||||
How long is acceptable to wait on the gate tier before merge?
|
||||
|
||||
- 30 s — only L0 + L1 (no L2+ on the gate).
|
||||
- **60–90 s (recommended)** — L0 + L1 + L2 + L5 happy-path + L4 critical.
|
||||
- 2 min — add L3 + L4 full.
|
||||
- 4+ min — add L6 (E2E on gate).
|
||||
|
||||
The pick determines whether E2E gates merge or only deploy.
|
||||
|
||||
### Q2 — CI infrastructure
|
||||
|
||||
- **Gitea Actions** (self-hosted, gate tier only, recommended) — minimal new infra; aligns with m's existing stack.
|
||||
- **Stay click-deploy** — workers run tests locally; merge discipline enforced by convention. Today's reality; we keep it.
|
||||
- **Both:** start with click-deploy, add Gitea Actions in Slice 7 once gate tier proves stable.
|
||||
|
||||
### Q3 — Live-DB vs ephemeral docker Postgres for tests
|
||||
|
||||
- **Per-developer YouPC DB (current pattern)** — closest to prod; existing tests work unchanged.
|
||||
- **Ephemeral docker postgres in CI, YouPC for devs (recommended hybrid)** — keeps local-dev simple, gives CI deterministic isolation.
|
||||
- **YouPC everywhere** — simplest, but parallel CI runs would contend.
|
||||
|
||||
### Q4 — Coverage targets — % or critical-path?
|
||||
|
||||
- **Critical-path only (recommended)** — §4's 10 flows + every state-mutating service method has a test. No % gate.
|
||||
- **% gate** — set a floor (e.g. 60 % lines, 50 % branches) and refuse merges below it.
|
||||
- **Both** — critical-path is mandatory, % is informational.
|
||||
|
||||
m's prior preference (memory pattern: "tests that catch real bugs > coverage theatre") points at critical-path-only. Confirming.
|
||||
|
||||
### Q5 — Which slices land before paliad is "production-grade"?
|
||||
|
||||
paliad is already live at `paliad.de` and being used by HLC colleagues. "Production-grade" here means "next time someone ships, we don't go down."
|
||||
|
||||
Picks:
|
||||
- **Slices 1 + 4 + 5 are the production-grade floor (recommended).** Migration dry-run + golden-path E2E + handler integration tests cover the failure modes that hit prod since the rebrand.
|
||||
- Add Slice 2 + 3 + 6 as widening passes, on their own cadence.
|
||||
- Slice 7-8 are nice-to-haves.
|
||||
|
||||
Confirming the floor pick — and whether m wants all three to land before any new feature work, or whether they roll out alongside.
|
||||
|
||||
### Q6 — Who owns each slice?
|
||||
|
||||
Recommendation: rotate coder slots so the same person isn't on every slice. Suggested assignment (head can override):
|
||||
|
||||
| Slice | Profile fit |
|
||||
|---|---|
|
||||
| 1 — migrations | Backend-heavy coder (knuth, gauss, cronus). |
|
||||
| 2 — service infill | Backend-heavy coder; whoever owns approval/projection. |
|
||||
| 3 — frontend unit | Frontend-heavy coder. |
|
||||
| 4 — Playwright E2E | Cross-stack coder; ideally one familiar with `/mai-tester`. |
|
||||
| 5 — handler integration | Backend coder. |
|
||||
| 6 — frontend DOM | Frontend coder (same person as 3 makes sense). |
|
||||
|
||||
Inventor does **not** decide assignments; head + m do.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope (explicit)
|
||||
|
||||
- **No rewrite of any existing test.** The 323 existing test functions stay. New tests use the new patterns; old tests are migrated only when their files are touched for unrelated reasons.
|
||||
- **No third-party framework where stdlib + bun:test suffice** (testify, vitest, etc. — see §3).
|
||||
- **No mocks at the service↔DB boundary.** This is the lock-in. Mocks lie; the live-DB tests we already have are paliad's most useful safety net.
|
||||
- **No new feature work in this strategy.** The doc proposes infra; feature scope is unchanged.
|
||||
- **No retirement of the `tests/smoke-*.md` human-written reports.** Those are great for one-shot regression hunts; they coexist with the automated suite.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation notes for the eventual coder
|
||||
|
||||
(For whichever coder picks up a slice. Not exhaustive.)
|
||||
|
||||
- **Test-name collisions in Go's flat package namespace bite when a service grows N implementations.** Memory note from `t-paliad-194` already records this. Prefix tests with the service name (e.g. `TestAichatPaliadin_RunTurn_…` not `TestRunTurn_…`).
|
||||
- **`httptest.NewRequest` does not URL-encode** — use `url.QueryEscape` for any `?q=…` argument. Memory note from `t-paliad-026`.
|
||||
- **sqlx v1.4.0 `Named` parser strips one colon from `::uuid[]`** — known pitfall, repro lives at `internal/services/project_service.go`. Use `CAST(... AS uuid[])` in new query strings.
|
||||
- **Live-DB cleanup must DELETE FKs first.** Order matters (auth.users last). Look at `audit_service_test.go` for the chain pattern.
|
||||
- **`paliad.paliad_schema_migrations` tracker collision** is documented but unresolved. Slice 1 should add a `make reset-test-db` target that drops both `public.paliad_schema_migrations` *and* `paliad.paliad_schema_migrations` to keep developers unblocked.
|
||||
- **`bun:test` matchers are Jest-compatible** — `expect().toEqual()`, `expect().toHaveBeenCalled()`, etc. No deps needed.
|
||||
- **happy-dom does not implement** every DOM method (notably some `<dialog>` semantics). If a cascade test fails on something missing, jsdom is the escape hatch.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decision summary — pick list for m
|
||||
|
||||
| # | Question | Inventor recommends |
|
||||
|---|---|---|
|
||||
| Q1 | Per-PR budget | 60–90 s gate, 3–4 min full |
|
||||
| Q2 | CI infra | Gitea Actions, gate tier only |
|
||||
| Q3 | Test DB | YouPC for devs, ephemeral docker for CI |
|
||||
| Q4 | Coverage target | Critical-path only, no % gate |
|
||||
| Q5 | Production-grade floor | Slices 1 + 4 + 5 before new feature work |
|
||||
| Q6 | Slice ownership | Rotate per profile; head decides |
|
||||
|
||||
If m's calls match inventor's, the implementer's brief writes itself: Slice 1 first, then 4 + 5 in parallel, then 2/3/6 as widening passes.
|
||||
|
||||
---
|
||||
|
||||
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift starts.
|
||||
|
||||
---
|
||||
|
||||
## 10. m's decisions (2026-05-19, locked)
|
||||
|
||||
Walked through §6 with m via the AskUserQuestion interview (per head's 2026-05-19 workflow rule: inventor questions are resolved before parking, not after). Six picks locked, all matching inventor's recommendation.
|
||||
|
||||
| # | Question | m's answer | Effect on plan |
|
||||
|---|---|---|---|
|
||||
| Q1 | Per-PR test-run budget | **Inventor's call** (m deferred). Pick: **60–90 s gate, 3–4 min full.** | Gate tier = L0 + L1 + L2 + L5 happy-path + L4 critical. L6 E2E gates deploy, not merge. |
|
||||
| Q2 | CI infrastructure | **Gitea Actions, gate tier only.** | Slice 7 adds `.gitea/workflows/test.yml` running the gate tier; full suite stays on merge-to-main. |
|
||||
| Q3 | Test DB topology | **YouPC for devs + ephemeral docker for CI.** | Local dev unchanged. Slice 7 wires Postgres service container in Gitea Actions. |
|
||||
| Q4 | Coverage target | **Critical-path only, no % gate.** | §4's 10 flows + every state-mutating service method gets a test. Coverage % output is informational in Slice 8, never a merge gate. |
|
||||
| Q5 | Production-grade floor | **Slices 1 + 4 + 5 before new feature work.** | These three land before any new paliad feature gets a coder shift. Slices 2, 3, 6 widen the floor on their own cadence. Slices 7-8 are nice-to-haves. |
|
||||
| Q6 | Slice ownership | **Head decides + rotate per profile.** | Backend slices (1, 2, 5) → backend-heavy coder. Frontend slices (3, 6) → frontend-heavy coder. E2E (4) → cross-stack. Head picks at dispatch time. |
|
||||
|
||||
**Implementer brief (post-m-decisions):**
|
||||
|
||||
1. **Slice 1 starts first** — migration dry-run harness + `make verify-migrations` + boot-smoke variant of `cmd/server/main_paliadin_backend_test.go`. Backend-heavy coder.
|
||||
2. **Slice 4 + Slice 5 in parallel** once Slice 1 is merged — Playwright golden-path (cross-stack coder, 5 specs) and handler integration (backend coder, auth/projects/deadlines/appointments/paliadin).
|
||||
3. Slice 7 (Gitea Actions wiring) follows once Slice 1 gate tier is proven locally.
|
||||
4. Slices 2, 3, 6 enter rotation alongside feature work — not blocking.
|
||||
5. Slice 8 (coverage reporting) lowest priority.
|
||||
|
||||
**Status:** DESIGN APPROVED — awaiting head's dispatch of Slice 1 coder shift.
|
||||
686
docs/design-project-metadata-rework-2026-05-20.md
Normal file
686
docs/design-project-metadata-rework-2026-05-20.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# Project metadata rework — Client Role + auto-derived project codes
|
||||
|
||||
Status: design, ready for head review (2026-05-20)
|
||||
Task: t-paliad-222
|
||||
Issues: m/paliad#47 (Client Role) + m/paliad#50 (project codes)
|
||||
Branch: `mai/kepler/inventorcoder-project`
|
||||
|
||||
Pairs two related changes because both touch `paliad.projects` schema, the
|
||||
project form, and downstream consumers (Fristenrechner Determinator,
|
||||
submission templates, Verlauf, picker / breadcrumb surfaces). One design,
|
||||
two migrations, one coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §1 Scope & non-goals
|
||||
|
||||
In scope:
|
||||
|
||||
- Drop "Wir vertreten" entirely on `type='client'`, `'litigation'`, `'patent'`.
|
||||
- Rename to "Client Role" / "Mandantenrolle" on `type='case'` with new
|
||||
option set (Active / Reactive / Third Party / Other).
|
||||
- Widen `paliad.projects.our_side` CHECK to the new sub-role values; drop
|
||||
`'court'` and `'both'`; backfill existing rows to NULL.
|
||||
- Add `paliad.projects.opponent_code text` on `type='litigation'` rows
|
||||
(segment source for project codes).
|
||||
- New Go helper `services.BuildProjectCode(ctx, projectID) (string, error)`
|
||||
that walks the ancestor chain via the existing ltree `path` and assembles
|
||||
the dotted code. Custom `paliad.projects.reference` on the project itself
|
||||
wins.
|
||||
- Wire the helper into project header, breadcrumb, picker labels, the
|
||||
submission-template variable bag (`{{project.code}}`), and the Excel
|
||||
export `__meta` sheet.
|
||||
|
||||
Out of scope (handled separately or dropped):
|
||||
|
||||
- Reshaping `paliad.parties` (per-party role rows are unchanged).
|
||||
- New analytics / reports breaking out sub-roles.
|
||||
- Bulk-renaming user-facing copy that says "Klägerseite" /
|
||||
"Beklagtenseite" outside the project form.
|
||||
- Reverse lookup (project by code) — already works via `reference`.
|
||||
- Audit-history for who changed an override and when — not requested.
|
||||
- Bulk regeneration of existing `reference` strings — manual entries stay
|
||||
intact; auto-derive only fills empty slots.
|
||||
- Renaming the `our_side` DB column — see §2.2 / Q1.
|
||||
|
||||
---
|
||||
|
||||
## §2 Issue #47 — Client Role rework
|
||||
|
||||
### §2.1 Current state (verified 2026-05-20)
|
||||
|
||||
- Column: `paliad.projects.our_side text`, CHECK constraint
|
||||
`projects_our_side_check` allows `('claimant','defendant','court','both',NULL)`
|
||||
(mig 072).
|
||||
- Live data audit (`SELECT our_side, count(*) FROM paliad.projects
|
||||
GROUP BY our_side`): **all 12 rows are NULL**. Zero rows on
|
||||
`'court'` or `'both'` — backfill is a no-op. The migration is risk-free
|
||||
on the current dataset.
|
||||
- Form: rendered for every project type by
|
||||
`frontend/src/components/ProjectFormFields.tsx:156-168` (one
|
||||
`<select id="project-our-side">` with five static `<option>`s, no
|
||||
conditional render).
|
||||
- Downstream consumers (verified by grep on `our_side` /
|
||||
`OurSide` in `internal/` and `frontend/src/`):
|
||||
- `frontend/src/client/fristenrechner.ts:2187,2734,3754-3776` —
|
||||
Determinator Slice 3c, `ourSideToPerspective()` maps
|
||||
`claimant → claimant`, `defendant → defendant`, anything else
|
||||
(incl. `'court'`, `'both'`, NULL) → `null` (chip free-pick).
|
||||
- `internal/services/submission_vars.go:276-278,390-418` —
|
||||
`{{project.our_side_de}}` / `_en` legal-prose forms. `ourSideDE` /
|
||||
`ourSideEN` switch on the 4 enum values.
|
||||
- `internal/services/project_service.go:1083-1104` —
|
||||
`our_side_changed` project-event row on writes.
|
||||
- `internal/services/project_service.go:1228,1372,1955-` — CCR
|
||||
counterclaim child default-inverts `our_side`; `nullableOurSide()`
|
||||
and `isValidOurSide()` (`project_service.go:1915`) gate writes.
|
||||
|
||||
### §2.2 Decisions
|
||||
|
||||
**Q1 — Rename column `our_side → client_role`?**
|
||||
**Pick: NO. Keep `our_side`.** Renaming forces churn in eleven Go files,
|
||||
the Determinator client bundle (`fristenrechner.ts` type literal +
|
||||
`ourSideToPerspective`), all submission-template tests
|
||||
(`submission_render_test.go:275`), the project-event title key
|
||||
(`event.title.our_side_changed`), and every `{{project.our_side*}}` template
|
||||
that exists in the wild on user systems. The label is purely UI; the column
|
||||
name is internal. Future grep stays clean because the new label
|
||||
("Client Role") and the column (`our_side`) describe the same concept from
|
||||
different perspectives ("which side the firm represents" =
|
||||
"what role the client plays"). Keeping the column avoids a 200-line
|
||||
mechanical rename with non-trivial risk for zero functional gain. The
|
||||
i18n keys *do* rename (`projects.field.our_side` → `projects.field.client_role`)
|
||||
so user-facing copy stays clean.
|
||||
|
||||
**Q2 — Sub-role granularity (7 distinct values vs 3 groups)?**
|
||||
**Pick: 7 sub-roles** — `claimant, defendant, applicant, appellant,
|
||||
respondent, third_party, other`. Lawyers care about the specific
|
||||
procedural posture; Applicant ≠ Claimant in some UPC contexts (e.g. PI
|
||||
applications use "Applicant"). Group-level aggregation is trivial at
|
||||
display time (`switch role { case claimant, applicant, appellant:
|
||||
return "Active" }`). Storing the group only would be a lossy choice we
|
||||
cannot reconstruct from.
|
||||
|
||||
**Q3 — Project types where the field is visible?**
|
||||
**Pick: ONLY `type='case'`.** m's wording is unambiguous ("only plays a
|
||||
role in case projects — and even there the question should be 'Client
|
||||
Role'"). Hide on `client`, `litigation`, `patent`, and the generic
|
||||
`project` type. The client-level "industry / country" block stays as is
|
||||
(those are client-attributes, not procedural roles). The form already
|
||||
has `projekt-fields-case` conditional render (`ProjectFormFields.tsx:143`)
|
||||
— moving the role select into that block is a 4-line change.
|
||||
|
||||
**Q4 — Existing `'court'` / `'both'` row backfill?**
|
||||
**Pick: backfill to NULL** in the same migration that widens the CHECK.
|
||||
Zero rows in production (verified 2026-05-20), so the backfill is a
|
||||
no-op today; it's there for safety if any test fixture or
|
||||
not-yet-deployed instance has them. No audit-event emission for the
|
||||
backfill (it's schema cleanup, not user action).
|
||||
|
||||
**Q5 — Determinator perspective mapping for new sub-roles?**
|
||||
**Pick: Active group → `claimant`, Reactive group → `defendant`, Third
|
||||
Party / Other → `null` (chip free-pick).** Concretely:
|
||||
|
||||
- `claimant`, `applicant`, `appellant` → perspective `'claimant'`
|
||||
- `defendant`, `respondent` → perspective `'defendant'`
|
||||
- `third_party`, `other`, NULL → perspective `null`
|
||||
|
||||
This keeps the Determinator's existing claimant-rule / defendant-rule
|
||||
filter logic unchanged; only `ourSideToPerspective()`'s switch widens.
|
||||
|
||||
**Q6 — Submission template `_de` / `_en` prose for new sub-roles?**
|
||||
|
||||
| value | `_de` (Nominativ) | `_en` |
|
||||
|---------------|-------------------------------|---------------|
|
||||
| `claimant` | Klägerin | Claimant |
|
||||
| `defendant` | Beklagte | Defendant |
|
||||
| `applicant` | Antragstellerin | Applicant |
|
||||
| `appellant` | Berufungsklägerin | Appellant |
|
||||
| `respondent` | Antragsgegnerin | Respondent |
|
||||
| `third_party` | Streithelferin | Third Party |
|
||||
| `other` | sonstige Verfahrensbeteiligte | other party |
|
||||
|
||||
Existing `'court'`/`'both'` switch arms get deleted (no live rows; if a
|
||||
stale `our_side='court'` slipped through somehow, the function returns
|
||||
`""` — same fallback as today for unknown values).
|
||||
|
||||
### §2.3 Migration `112_client_role_rework`
|
||||
|
||||
```sql
|
||||
-- 112_client_role_rework.up.sql (renumbered 2026-05-20 — mig 110 was claimed by m/paliad#51, mig 111 by m/paliad#48)
|
||||
-- t-paliad-222 / m/paliad#47.
|
||||
-- Widens projects.our_side CHECK to seven sub-role values and drops
|
||||
-- the legacy 'court' / 'both' entries. Backfill is a no-op on the
|
||||
-- current dataset (verified 2026-05-20: all 12 rows are NULL), but
|
||||
-- runs defensively in case any test fixture / staging instance still
|
||||
-- carries the old values.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Backfill any 'court' / 'both' rows to NULL. Idempotent.
|
||||
UPDATE paliad.projects
|
||||
SET our_side = NULL
|
||||
WHERE our_side IN ('court', 'both');
|
||||
|
||||
-- 2. Drop the old CHECK, add the widened one. Both are idempotent
|
||||
-- against partially-applied state.
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL OR our_side IN (
|
||||
'claimant', 'defendant',
|
||||
'applicant', 'appellant',
|
||||
'respondent',
|
||||
'third_party', 'other'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this case project (renamed in '
|
||||
'the UI to "Client Role" — t-paliad-222 / m/paliad#47). Allowed '
|
||||
'sub-roles, grouped at display time: Active (claimant, applicant, '
|
||||
'appellant); Reactive (defendant, respondent); Third Party / Other '
|
||||
'(third_party, other). NULL = unknown. Hidden in the form on '
|
||||
'non-case project types. Drives the Fristenrechner Determinator '
|
||||
'perspective chip (Active→claimant, Reactive→defendant, else null).';
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
The down migration restores the original 4-value CHECK and, for
|
||||
defensive symmetry, backfills any new sub-role values to NULL (so the
|
||||
schema is internally consistent when stepped down).
|
||||
|
||||
### §2.4 Frontend changes
|
||||
|
||||
`frontend/src/components/ProjectFormFields.tsx`:
|
||||
|
||||
1. Move the `<div className="form-field">` containing
|
||||
`#project-our-side` from the always-visible block (line 156) into
|
||||
the `projekt-fields-case` block (after the court / case-number
|
||||
row).
|
||||
2. Rename label `data-i18n="projects.field.our_side"` →
|
||||
`projects.field.client_role`.
|
||||
3. Replace the five flat `<option>`s with three `<optgroup>`s + the
|
||||
seven new options + an "Unbekannt" empty option.
|
||||
4. Update the hint text to mention the Determinator group mapping
|
||||
(Active/Reactive).
|
||||
|
||||
`frontend/src/client/i18n.ts` — add new keys (DE + EN):
|
||||
|
||||
```
|
||||
projects.field.client_role → "Mandantenrolle" / "Client Role"
|
||||
projects.field.client_role.hint → "..."
|
||||
projects.field.client_role.unset → "Unbekannt" / "Unknown"
|
||||
projects.field.client_role.group.active → "Aktiv (wir greifen an)" / "Active (we initiate)"
|
||||
projects.field.client_role.group.reactive → "Reaktiv (wir verteidigen)" / "Reactive (we defend)"
|
||||
projects.field.client_role.group.other → "Dritte / Sonstige" / "Third Party / Other"
|
||||
projects.field.client_role.claimant → "Klägerseite" / "Claimant"
|
||||
projects.field.client_role.applicant → "Antragsteller" / "Applicant"
|
||||
projects.field.client_role.appellant → "Berufungsführer" / "Appellant"
|
||||
projects.field.client_role.defendant → "Beklagtenseite" / "Defendant"
|
||||
projects.field.client_role.respondent → "Antragsgegner" / "Respondent"
|
||||
projects.field.client_role.third_party → "Streithelfer / Dritter" / "Third Party"
|
||||
projects.field.client_role.other → "Sonstige Beteiligte" / "Other party"
|
||||
```
|
||||
|
||||
The legacy `projects.field.our_side.*` keys stay deprecated-but-present
|
||||
for one release so any cached browser bundle keeps rendering. They get
|
||||
deleted in a follow-up housekeeping shift once the rollout is confirmed.
|
||||
|
||||
`frontend/src/client/project-form.ts:182-230` — adjust the payload
|
||||
read/write to only include `our_side` when the field is in the DOM
|
||||
(non-case forms no longer emit it). The current code does
|
||||
`if (v) payload.our_side = v` which already handles the "field absent"
|
||||
case gracefully (osSel becomes `null`, no payload key set).
|
||||
|
||||
`frontend/src/client/fristenrechner.ts:3754-3776` —
|
||||
`ourSideToPerspective` switch widens:
|
||||
|
||||
```ts
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
switch (os) {
|
||||
case "claimant":
|
||||
case "applicant":
|
||||
case "appellant":
|
||||
return "claimant";
|
||||
case "defendant":
|
||||
case "respondent":
|
||||
return "defendant";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`frontend/src/projects-detail.tsx` Verlauf — the `our_side_changed`
|
||||
event description currently renders the raw enum. Update the renderer
|
||||
to use a label lookup so "Mandant: Beklagte → Antragsteller" reads
|
||||
correctly. Same `event.title.our_side_changed` key stays (the *title*
|
||||
is "Vertretene Seite geändert" / "Represented side changed", which is
|
||||
still accurate semantically).
|
||||
|
||||
### §2.5 Backend changes
|
||||
|
||||
`internal/services/project_service.go:1915` — `isValidOurSide()` widens
|
||||
its allowlist:
|
||||
|
||||
```go
|
||||
case "", "claimant", "defendant",
|
||||
"applicant", "appellant",
|
||||
"respondent",
|
||||
"third_party", "other":
|
||||
return nil
|
||||
```
|
||||
|
||||
`internal/services/project_service.go:1372` —
|
||||
`derivedCounterclaimOurSide()` (CCR flip logic): widen the flip map to
|
||||
mirror the Determinator grouping:
|
||||
|
||||
- claimant ↔ defendant (current behaviour)
|
||||
- applicant ↔ respondent
|
||||
- appellant → defendant (CCR against an appellant is rare; pick
|
||||
the most-likely procedural posture; can be overridden by
|
||||
explicit `flip_our_side=false`)
|
||||
- third_party / other / NULL → keep as-is (no flip)
|
||||
|
||||
`internal/services/submission_vars.go:391-418` — `ourSideDE` /
|
||||
`ourSideEN` switch arms add the five new values per the table in
|
||||
§2.2 Q6. `'court'` and `'both'` arms get deleted.
|
||||
|
||||
`internal/services/project_service.go:1083-1104` — `our_side_changed`
|
||||
audit emission unchanged (it just records old → new on the column).
|
||||
|
||||
`frontend/build.ts` — no change; bundling already picks up
|
||||
`projects.field.client_role.*` i18n keys via `i18n-keys.ts` regeneration.
|
||||
|
||||
`frontend/src/i18n-keys.ts` — regenerate via existing scripted path
|
||||
(adds the new keys, keeps the legacy ones as deprecated entries until
|
||||
the housekeeping pass).
|
||||
|
||||
### §2.6 Tests
|
||||
|
||||
- `internal/services/submission_render_test.go:275` —
|
||||
`TestOurSideTranslations` widens the table to cover the 7 new values
|
||||
in both DE and EN.
|
||||
- `internal/services/projection_service_unit_test.go:319` —
|
||||
`TestDerivedCounterclaimOurSide` widens to cover the new flip map.
|
||||
- New: `TestProjectFormHidesOurSideForNonCase` — unit test on the
|
||||
project-form payload reader confirms `our_side` is silently dropped
|
||||
when the form renders for a non-case project type.
|
||||
|
||||
### §2.7 Acceptance (issue #47)
|
||||
|
||||
- [x] Creating a project of `type='client'`, `'litigation'`, `'patent'`,
|
||||
`'project'` does **not** show the field.
|
||||
- [x] Creating a project of `type='case'` shows the field labelled
|
||||
"Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups
|
||||
and seven options.
|
||||
- [x] Existing `'court'` / `'both'` rows (none in prod, but defensive)
|
||||
are migrated to NULL.
|
||||
- [x] Submission templates referencing `{{project.our_side_de}}` /
|
||||
`_en` render coherent prose for the five new values.
|
||||
- [x] Determinator perspective chip pre-fills correctly from each
|
||||
sub-role (Active→claimant, Reactive→defendant, Other→null).
|
||||
- [x] CCR counterclaim flip yields a sensible child role for the new
|
||||
sub-roles.
|
||||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||||
build` clean.
|
||||
|
||||
---
|
||||
|
||||
## §3 Issue #50 — Auto-derived project codes
|
||||
|
||||
### §3.1 Current state (verified 2026-05-20)
|
||||
|
||||
- `paliad.projects.reference text` exists and is informally used (live
|
||||
values: `EXMPL` on a client, `L-2026-001` on a litigation, `C-UPC-0001`
|
||||
on a case, `P-EP1111222` on a patent). No format enforcement.
|
||||
- `paliad.projects.path ltree` is maintained by a Postgres trigger
|
||||
(`projects.path` joined UUIDs root-to-self). Walking ancestors in Go
|
||||
is straightforward: `SELECT * FROM paliad.projects WHERE path @>
|
||||
$1::ltree ORDER BY nlevel(path)`.
|
||||
- No `opponent` field exists anywhere. Opponent text lives only inside
|
||||
the litigation `title` (e.g. "Siemens AG ./. Huawei Technologies").
|
||||
- `paliad.proceeding_types.code` is dot-separated:
|
||||
`upc.inf.cfi`, `upc.rev.cfi`, `de.inf.lg`, `upc.apl.merits`, etc.
|
||||
Splitting on `.` and upper-casing yields `INF`, `REV`, `LG`,
|
||||
`APL.MERITS`. Suitable as the case segment.
|
||||
- `paliad.projects.court text` is free-text on cases (live values:
|
||||
`UPC`, `UPC CoA`, `LG München I`). Not normalised; use the
|
||||
proceeding_type code instead — it carries the same info structurally.
|
||||
|
||||
### §3.2 Decisions
|
||||
|
||||
**Q1 — Litigation opponent source: new column or regex on title?**
|
||||
**Pick: new column `paliad.projects.opponent_code text` on litigation
|
||||
rows.** Regex on title is brittle ("./.", "v.", "vs", "—", varying
|
||||
order) and the user already knows the short code at creation time. New
|
||||
field with explicit validation (slug-cased, max 16 chars) is clean and
|
||||
takes one form field + one migration. Title stays as the human-readable
|
||||
caption; `opponent_code` is the machine-readable segment source.
|
||||
NULL → segment skipped silently.
|
||||
|
||||
**Q2 — Patent segment: always last 3, or last-N variable?**
|
||||
**Pick: last 3 digits when the digit-stream is ≥ 4 digits long; full
|
||||
digit-stream when shorter.** m's example (`EP3456789 → 789`) is 7
|
||||
digits last-3 = 789 ✓. UPC publication numbers (10+ digits) collapse to
|
||||
their last 3 just fine — uniqueness inside the same litigation tree is
|
||||
near-certain because the same litigation tree won't hold two patents
|
||||
sharing the same last-3. If it ever does, the user can set a custom
|
||||
`reference` (Q5). No need for last-4 / last-N logic.
|
||||
|
||||
The patent-number regex extracts the digit-stream from any common
|
||||
format (`EP1234567`, `EP 1 234 567`, `EP1234567A1`, `WO2020/123456A1`):
|
||||
strip non-digits, take last 3 (or whole if shorter), upper-cased.
|
||||
|
||||
**Q3 — Case segment from `proceeding_types.code`?**
|
||||
**Pick: take `proceeding_types.code` (e.g. `upc.inf.cfi`), split on `.`,
|
||||
drop the leading jurisdiction segment, uppercase the rest, join with
|
||||
`.`.** Examples:
|
||||
|
||||
- `upc.inf.cfi` → `INF.CFI`
|
||||
- `upc.rev.cfi` → `REV.CFI`
|
||||
- `upc.pi.cfi` → `PI.CFI`
|
||||
- `upc.apl.merits` → `APL.MERITS`
|
||||
- `de.inf.lg` → `INF.LG`
|
||||
- `de.inf.olg` → `INF.OLG` (appeal instance → segment already
|
||||
encodes "OLG", so we get the appeal level for free; no separate
|
||||
instance segment needed)
|
||||
|
||||
The jurisdiction is dropped because the parent client/patent already
|
||||
implies the jurisdiction context. If the user wants explicit
|
||||
jurisdiction in the code, custom `reference` wins.
|
||||
|
||||
If `proceeding_type_id` is NULL on the case, segment is omitted
|
||||
silently. No fallback to `court` text — that's free-text and noisy.
|
||||
|
||||
**Q4 — Override semantics: wholesale or per-segment?**
|
||||
**Pick: wholesale.** When `paliad.projects.reference` is non-empty on
|
||||
the project the helper is asked about, that string is returned
|
||||
verbatim — no auto-derivation, no string-concatenation, no merging.
|
||||
Per-segment override doubles the implementation complexity for a UX
|
||||
nobody asked for. Users who want partial overrides set the
|
||||
`reference` on the relevant ancestor and let the rest auto-derive
|
||||
naturally.
|
||||
|
||||
**Q5 — Where the user types the override?**
|
||||
**Pick: existing `paliad.projects.reference` field.** Already there,
|
||||
already labelled "Interne Referenz (optional)", already used by users.
|
||||
Adding a second "project_code_override" alongside `reference` would
|
||||
confuse the form. The hint text gets a small addendum: "Leer lassen
|
||||
für automatischen Code aus dem Projekt-Baum."
|
||||
|
||||
**Q6 — Collision handling (two cases derive to the same code)?**
|
||||
**Pick: advisory in v1; no disambiguator.** Codes are display-only
|
||||
(not a primary key, not a unique constraint). Real-world collisions
|
||||
inside the same litigation tree are vanishingly rare; if they happen,
|
||||
the user notices in the picker and sets a custom `reference` on one.
|
||||
Adding `-N` suffixes silently would mask a data issue the user should
|
||||
see. A future surface could flag duplicates as a project-detail warning,
|
||||
but it's not in v1.
|
||||
|
||||
**Q7 (new) — Helper signature and call site?**
|
||||
**Pick: `ProjectService.BuildProjectCode(ctx context.Context, projectID
|
||||
uuid.UUID) (string, error)`.** Lives on the existing ProjectService
|
||||
(it needs DB access for the ancestor walk). Internally builds segments
|
||||
with a small `projectCodeSegment(p Project) string` pure function per
|
||||
type that's table-test-friendly. The helper is called from the
|
||||
projection layer when a project gets serialised for the API
|
||||
(adds a `code` field to the JSON), so every surface — header,
|
||||
breadcrumb, picker, dashboard tile, Excel export — gets the code for
|
||||
free without each surface re-walking the tree. Pricier than a
|
||||
display-time call but eliminates N+1 walks in list views.
|
||||
|
||||
**Q8 (new) — Cache strategy?**
|
||||
**Pick: no cache in v1.** Each ancestor walk is one indexed lookup
|
||||
on `paliad.projects(path)`. With 12 projects in prod and order-of-100s
|
||||
in any plausible firm-scale future, this is microsecond-cheap. If
|
||||
profiling later shows it as a hotspot in list views (which fetch many
|
||||
projects), introduce a materialised view
|
||||
`paliad.projects_derived_codes(project_id, derived_code)` refreshed by
|
||||
trigger on `projects` writes. Don't pre-optimise.
|
||||
|
||||
### §3.3 Migration `113_projects_opponent_code`
|
||||
|
||||
```sql
|
||||
-- 113_projects_opponent_code.up.sql (renumbered 2026-05-20)
|
||||
-- t-paliad-222 / m/paliad#50.
|
||||
-- Add an opponent-code field on litigation projects. Used as the
|
||||
-- middle segment when assembling auto-derived project codes from the
|
||||
-- ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). NULL = segment is
|
||||
-- skipped silently. No backfill — existing litigation rows simply
|
||||
-- yield codes without an opponent segment until the user sets one.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS opponent_code text;
|
||||
|
||||
-- Slug-shape gate: uppercase letters, digits, dashes, max 16 chars.
|
||||
-- Matches the style of m's example "OPNT". Keeps the auto-code clean.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_opponent_code_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_opponent_code_check
|
||||
CHECK (opponent_code IS NULL
|
||||
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
|
||||
AND type = 'litigation'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.opponent_code IS
|
||||
'Short slug for the opposing party on a litigation project '
|
||||
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
|
||||
'middle segment when BuildProjectCode walks the ancestor tree to '
|
||||
'assemble a dotted project code (t-paliad-222 / m/paliad#50). '
|
||||
'NULL = segment skipped silently.';
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
The down migration drops the constraint then the column.
|
||||
|
||||
### §3.4 Go helper
|
||||
|
||||
New file `internal/services/project_code.go`:
|
||||
|
||||
```go
|
||||
// Package-level function (not a method) so it can be called from any
|
||||
// service that already has a *sqlx.DB. ProjectService has a thin
|
||||
// wrapper that calls into this.
|
||||
//
|
||||
// BuildProjectCode assembles the dotted ancestor code for projectID
|
||||
// from the existing paliad.projects.path ltree. If the target row's
|
||||
// reference column is non-empty, it wins outright (no derivation).
|
||||
// Missing ancestor segments are skipped silently — there is no
|
||||
// "unknown" placeholder.
|
||||
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error)
|
||||
|
||||
// projectCodeSegment is the per-type segment derivation. Pure, table-
|
||||
// test friendly, never touches the DB.
|
||||
//
|
||||
// client → opts.PreferShortReference (reference if set, else slug(title))
|
||||
// litigation → opts.PreferShortReference (opponent_code if set, else "")
|
||||
// patent → last 3 digits of patent_number (full digits if <4)
|
||||
// case → uppercase tail of proceeding_types.code (jurisdiction segment dropped)
|
||||
// project → "" (generic projects don't contribute a segment)
|
||||
//
|
||||
// proceedingCode is only needed for case rows; the caller resolves
|
||||
// it via a single join (or a cached small lookup) before calling.
|
||||
func projectCodeSegment(p models.Project, proceedingCode string) string
|
||||
```
|
||||
|
||||
Sanitisation helpers live alongside as unexported funcs:
|
||||
|
||||
- `sanitizeClientShort(s string) string` — uppercase, strip diacritics
|
||||
via `golang.org/x/text/unicode/norm` + filter, replace non-alnum
|
||||
with `-`, trim, cap at 8 chars. Already similar to what
|
||||
`internal/util/slug` does for the global slug helper.
|
||||
- `patentLast3(s string) string` — strip non-digits, take last 3
|
||||
characters (or the whole digit-stream when shorter); uppercase.
|
||||
Empty → "".
|
||||
- `proceedingTail(code string) string` — split on `.`, drop element 0
|
||||
(jurisdiction), uppercase + join the rest. `""` → `""`.
|
||||
|
||||
`BuildProjectCode` SQL is a single round-trip:
|
||||
|
||||
```sql
|
||||
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
|
||||
p.patent_number, p.proceeding_type_id,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.projects p
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
|
||||
ORDER BY nlevel(p.path);
|
||||
```
|
||||
|
||||
It returns the chain root-to-target. The function:
|
||||
|
||||
1. If the last row (the target) has non-empty `reference` → return it
|
||||
verbatim. Done.
|
||||
2. Otherwise walk the chain top-to-bottom, call `projectCodeSegment`
|
||||
on each row, skip empty segments, join with `.`, return.
|
||||
|
||||
### §3.5 Wiring into surfaces
|
||||
|
||||
- `internal/services/project_service.go` projection — add a `Code`
|
||||
string field to the read-side struct and populate it in the single
|
||||
fetch path. For list endpoints, do **one** ancestor-chain query per
|
||||
page (CTE that groups by target id) rather than N+1.
|
||||
- `internal/services/submission_vars.go:277` — add
|
||||
`bag["project.code"] = derefString(p.Code)` so submission templates
|
||||
can reference `{{project.code}}`.
|
||||
- `frontend/src/components/ProjectHeader.tsx` (current header
|
||||
component on `/projects/{id}`) — render `code` next to the title
|
||||
(small monospace badge) if non-empty.
|
||||
- `frontend/src/components/Breadcrumb*.tsx` — when rendering the
|
||||
trail, use `project.code` as the trailing badge per segment if the
|
||||
caller asks for it (opt-in to avoid breaking other consumers).
|
||||
- `frontend/src/client/project-form.ts` and any project-picker
|
||||
typeahead — show `code · title` in the dropdown labels when `code`
|
||||
is non-empty.
|
||||
- Excel `__meta` sheet — add a `Project Code` row (already enumerates
|
||||
project metadata).
|
||||
|
||||
The "copy reference" affordance in the header gets a second line: if
|
||||
both `reference` (user override) and the auto-derived code differ, both
|
||||
are visible (override above, derived below, smaller).
|
||||
|
||||
### §3.6 Tests
|
||||
|
||||
- `TestProjectCodeSegment` (table) — every project type × multiple
|
||||
shapes (with/without reference, NULL ancestors, patent_number
|
||||
formats, proceeding codes with 1/2/3 segments).
|
||||
- `TestBuildProjectCodeFullChain` — fixture tree
|
||||
Client → Litigation → Patent → Case yields `EXMPL.OPNT.567.INF.CFI`.
|
||||
- `TestBuildProjectCodeRespectsOverride` — non-empty `reference` wins
|
||||
outright.
|
||||
- `TestBuildProjectCodeMissingAncestors` — case directly under client
|
||||
(no litigation, no patent) yields `EXMPL.INF.CFI`.
|
||||
- `TestBuildProjectCodeCollisionDoesNotDisambiguate` — two sibling
|
||||
cases with identical derived codes both return the same string (v1
|
||||
contract per Q6).
|
||||
- Migration sanity test (existing harness in
|
||||
`internal/db/migrations_test.go` if present) — up → down → up.
|
||||
|
||||
### §3.7 Acceptance (issue #50)
|
||||
|
||||
- [x] `BuildProjectCode` returns `EXMPL.OPNT.567.INF.CFI` for the
|
||||
reference tree (Client EXMPL → Litigation OPNT → Patent
|
||||
EP1234567 → Case `upc.inf.cfi`).
|
||||
- [x] Setting `projects.reference = 'CUSTOM-CODE'` on the case
|
||||
returns `CUSTOM-CODE` verbatim.
|
||||
- [x] Missing ancestor segments are skipped silently
|
||||
(no `..` collapses, no "?" placeholder).
|
||||
- [x] `{{project.code}}` resolves in submission templates.
|
||||
- [x] Project header, breadcrumb, picker, Excel `__meta` all show the
|
||||
code when set/derived.
|
||||
- [x] Litigation form has a new "Opponent Code" field (DE:
|
||||
"Gegner-Kürzel") with the slug pattern validation. Hidden on
|
||||
non-litigation types.
|
||||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||||
build` clean.
|
||||
|
||||
---
|
||||
|
||||
## §4 Open questions for the head
|
||||
|
||||
(Head: default to the §2.2 / §3.2 "Pick" recommendations unless something
|
||||
material pushes back. Coder shift only after head signs off.)
|
||||
|
||||
1. **§2.2 Q1** — Keep column name `our_side`? (Recommend YES; rename
|
||||
touches 11+ Go files + bundled-template wire format for zero gain.)
|
||||
2. **§2.2 Q2** — Store 7 sub-roles? (Recommend YES; group-only is
|
||||
lossy.)
|
||||
3. **§2.2 Q3** — Hide the field on `litigation` and `patent` too, not
|
||||
just on `client`? (Recommend YES per m's "only on case projects".)
|
||||
4. **§2.2 Q6** — German prose forms use feminine grammatical gender
|
||||
(Klägerin, Beklagte) per the existing translation table? Or
|
||||
masculine / neutral? (Recommend feminine to match existing
|
||||
`ourSideDE` — keeps consistency with already-rendered templates.)
|
||||
5. **§3.2 Q1** — Add a dedicated `opponent_code` column on
|
||||
litigations? (Recommend YES; regex-on-title is brittle.)
|
||||
6. **§3.2 Q2** — Patent segment = last 3 digits (variable for
|
||||
<4-digit numbers)? (Recommend YES, matches m's example.)
|
||||
7. **§3.2 Q3** — Case segment drops the jurisdiction prefix from
|
||||
`proceeding_types.code` (so `upc.inf.cfi` → `INF.CFI`, not
|
||||
`UPC.INF.CFI`)? (Recommend YES — jurisdiction is implied by the
|
||||
ancestor client/patent context.)
|
||||
8. **§3.2 Q7** — `BuildProjectCode` populates a `code` field on every
|
||||
projected Project JSON (not lazy per-render)? (Recommend YES;
|
||||
simpler consumers, one DB round-trip per list page.)
|
||||
9. **§3.2 Q8** — No cache / materialised view in v1? (Recommend YES;
|
||||
profile later if list views get slow.)
|
||||
|
||||
---
|
||||
|
||||
## §5 Implementation order (coder phase)
|
||||
|
||||
1. **Mig 112** (client role widen + backfill) → mig 113 (opponent_code).
|
||||
*Renumbered twice on 2026-05-20 — mig 110 claimed by m/paliad#51 project_type_other; mig 111 claimed by m/paliad#48 project_admin_and_select; boltzmann's gap-tolerant runner hard-fails on collisions so this is a strict rebump.*
|
||||
Run `ls internal/db/migrations/ | tail` first to verify slot
|
||||
availability (boltzmann's gap-tolerant runner means 110 is fine
|
||||
even if 109 was the last applied).
|
||||
2. **Backend** — `isValidOurSide`, `ourSideDE/EN`,
|
||||
`derivedCounterclaimOurSide`, new `project_code.go` package
|
||||
+ ProjectService wiring + projection `Code` field.
|
||||
3. **Frontend** — `ProjectFormFields.tsx` (conditional render + new
|
||||
options + opponent_code field on litigation block), `i18n.ts` keys,
|
||||
`fristenrechner.ts` `ourSideToPerspective` widen, header /
|
||||
breadcrumb / picker code-badge wiring.
|
||||
4. **Tests** — pinning tests above; `go test ./internal/...` clean.
|
||||
5. **Build verification** — `go build && cd frontend && bun run build`
|
||||
clean.
|
||||
6. **Commit per slice** — three commits (migration + backend, frontend,
|
||||
tests) keep review tractable.
|
||||
|
||||
---
|
||||
|
||||
## §6 Risks & rollback
|
||||
|
||||
- **Submission templates in the wild.** Users may have downloaded /
|
||||
customised submission templates that still reference
|
||||
`{{project.our_side_de}}` for `our_side='court'` or `'both'`. After
|
||||
this change those values are unreachable, so the template arm
|
||||
returns `""`. Already the fallback behaviour for unknown values;
|
||||
no breakage, just an empty render. Mention in release notes.
|
||||
- **Browser cache.** Users with a stale bundle still see the old
|
||||
"Wir vertreten" form for one cache-bust cycle. The legacy i18n keys
|
||||
stay until housekeeping (§2.4), so labels still resolve.
|
||||
- **Migration down path.** Stepping down from 110 restores the old
|
||||
4-value CHECK; new sub-role rows would violate it. The down
|
||||
migration backfills new sub-roles → NULL to stay consistent.
|
||||
- **Per-tree opponent_code uniqueness.** Two litigations under the
|
||||
same client with the same `opponent_code` would derive identical
|
||||
case codes. Per Q6 we accept this; users see it in the picker and
|
||||
customise `reference` if it bothers them.
|
||||
- **No new env vars, no Dokploy compose change** — both changes are
|
||||
pure code + schema; deploy is the existing main-push → webhook →
|
||||
Dokploy auto-redeploy path.
|
||||
784
docs/design-submission-generator-2026-05-19.md
Normal file
784
docs/design-submission-generator-2026-05-19.md
Normal file
@@ -0,0 +1,784 @@
|
||||
# Design — Submission generator (t-paliad-215)
|
||||
|
||||
**Author:** copernicus (inventor)
|
||||
**Date:** 2026-05-19
|
||||
**Issue:** m/paliad (task t-paliad-215, no Gitea issue filed yet)
|
||||
**Branch:** `mai/copernicus/inventor-submission`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Each row in `paliad.deadline_rules` represents a SUBMISSION — a filing,
|
||||
hearing, or decision inside a proceeding (`submission_code` shape
|
||||
`de.inf.lg.erwidg`, `upc.inf.cfi.soc`, …). The submission generator
|
||||
takes a project + a submission_code, pulls a `.docx` template from
|
||||
Gitea, merges in project variables (party names, court, case number,
|
||||
patent number, our_side, deadline date, legal_source citation, firm
|
||||
header), and streams the result to the browser as a download.
|
||||
|
||||
- **Scope (locked by m):** template-render to `.docx`. No LLM in v1.
|
||||
- **Template registry (locked):** Gitea — same proxy pattern as the
|
||||
existing HL Patents Style `.dotm` in `internal/handlers/files.go`.
|
||||
- **Output (locked):** direct download, NO server-side binary
|
||||
persistence. One audit row per generation; the bytes themselves are
|
||||
regenerable from inputs on demand.
|
||||
- **Lookup (locked):** fallback chain — firm-specific override →
|
||||
base for the exact `submission_code` → generic for the proceeding
|
||||
family → ultra-generic skeleton.
|
||||
- **Slice 1 (locked):** one template, end-to-end, on one project.
|
||||
Pick `de.inf.lg.erwidg` (Klageerwiderung) as the proof template.
|
||||
- **AI-drafted body:** explicitly OUT of scope for this task. Lives
|
||||
in §11 as a follow-up sketch only.
|
||||
|
||||
This design is read-only. No code, no migrations, no schema
|
||||
additions. Implementation gate is m's go/no-go on this doc.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-19)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not
|
||||
against CLAUDE.md or memory. Where a claim load-bears the design, it
|
||||
was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Migration tracker at **102** (next is 103) | `ls internal/db/migrations/` — `102_system_audit_log` is the latest applied. |
|
||||
| `paliad.documents` table exists, is empty, no code writes to it yet | `SELECT COUNT(*) FROM paliad.documents` → 0 rows. Columns: `id, title, doc_type, file_path NULLABLE, file_size, mime_type, ai_extracted jsonb, uploaded_by, created_at, updated_at, project_id NOT NULL`. `grep` shows only `export_service.go` (audit-export only) and a comment in `render_spec.go`. No `document_service.go`, no `/api/documents` handler. |
|
||||
| `paliad.deadline_rules` carries the submission corpus | 254 total rows, 158 unique `submission_code`s, 214 `published`. Per-row fields used by the generator: `name`, `name_en`, `submission_code`, `primary_party` (claimant/defendant/court/both), `event_type` (filing/hearing/decision), `legal_source` (e.g. `DE.ZPO.276.1`, `UPC.RoP.23.1`), `is_bilateral`. |
|
||||
| Slice 1 target row exists in published state | `SELECT … WHERE submission_code='de.inf.lg.erwidg'` → `{name:"Klageerwiderung", name_en:"Statement of Defence", primary_party:"defendant", legal_source:"DE.ZPO.276.1"}`. |
|
||||
| Project rows carry all variables we need to merge | `paliad.projects` has `case_number, court, patent_number, filing_date, grant_date, our_side, instance_level, proceeding_type_id, title, reference, client_number, matter_number`. |
|
||||
| Party rows carry party variables | `paliad.parties` has `name, role, representative, contact_info jsonb` and is project-scoped via `project_id`. |
|
||||
| The HL Patents Style proxy pattern is reusable | `internal/handlers/files.go`: `fileRegistry` map → Gitea raw URL + SHA-based cache + 5-min refresh check + binary download response with `Content-Disposition`. Cache is in-process (`sync.Mutex` over a `map[string]*cacheEntry`). Single web replica today (`docker-compose.yml`), so in-process cache is fine. |
|
||||
| Email templates already use `{{.VarName}}` placeholders + a "variable contract" sidebar pattern | `internal/services/email_template_variables.go` — `EmailTemplateVariable{Name, Type, Description, SampleDE, SampleEN}` rendered in `/admin/email-templates`. Submission generator can copy this contract pattern. |
|
||||
| Audit infrastructure landed in mig 102 | `paliad.system_audit_log(id, event_type, actor_id, actor_email, scope, scope_root, metadata jsonb, created_at, updated_at)` — submission_generated events slot straight in. |
|
||||
| Branding source is `internal/branding.Name` | Default `"HLC"`, overridable via `FIRM_NAME`. Inlined into client bundles by `frontend/build.ts`. Submission templates honour this via the `{{firm.name}}` placeholder. |
|
||||
| `paliad.can_see_project(project_id)` is the canonical visibility predicate | mig 055; `internal/services/visibility.go` mirrors it. Generator gates on this; no new auth surface. |
|
||||
| Paliadin runs on the aichat backend (mRiver) with persona system | `internal/services/aichat_paliadin.go` + `personas.yaml` in `m/mAi/internal/aichat/persona/`. Owner-gated to `PaliadinOwnerEmail = matthias.siebels@hoganlovells.com`. A future AI-drafted body would be a new persona, not a new Go service. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material for this design.
|
||||
`docs/project-status.md` still lists "Phase H AI Frist-Extraktion
|
||||
deferred" — this design does NOT revive Phase H (different surface;
|
||||
this is template merge, not document understanding).
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-19)
|
||||
|
||||
Locked via AskUserQuestion before drafting the rest of the design.
|
||||
|
||||
| # | Question | m's pick | Inventor recommended? |
|
||||
|---|---|---|---|
|
||||
| Q1 | Generator scope (template / AI-draft / brief / other) | **Template-render to `.docx`** | ✅ yes |
|
||||
| Q2 | Template registry (Gitea / paliad DB / hybrid) | **Gitea** | ✅ yes |
|
||||
| Q3 | Output flow (download-only / persist binary / attach to Frist) | **Direct download, no server-side binary** | ✅ yes |
|
||||
| Q4 | Mapping (fallback chain / 1:1 / 1:N user picks) | **Fallback chain** | ✅ yes |
|
||||
| Q5 | Slice 1 scope (1 template / 3–5 templates / full corpus / skeleton-only) | **One template, end-to-end on one project** (`de.inf.lg.erwidg` Klageerwiderung) | ✅ yes |
|
||||
|
||||
Inventor-defaulted (not asked because there's a clear right answer or
|
||||
because the question is implementation-level, not architecture-level):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Variable engine | `{{path.dot.notation}}` placeholders in the .docx body, replaced via a Go library that handles run-fragmentation | Matches the existing email-template `{{.Var}}` shape lawyers already see in `/admin/email-templates`. See §6. |
|
||||
| D2 | Authorization | Project-team visibility only (`paliad.can_see_project`) + audit row | Matches every other write surface in paliad. No profession floor (generation is read-only on source data and produces a draft, not a binding action). |
|
||||
| D3 | Naming convention | `{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx`, slashes → underscores, FIRM_NAME-aware | Mirrors how lawyers name files manually. See §7. |
|
||||
| D4 | Missing-variable behaviour | Render `[KEIN WERT: {field}]` / `[NO VALUE: {field}]` marker inline | Lets the lawyer see the gap in Word, fix in paliad, regenerate. Better than 400ing. |
|
||||
| D5 | Editor surface | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo) | A paliad uploader UI is Phase 2 affordance if Gitea round-trip is painful. |
|
||||
| D6 | AI-drafted body | OUT of scope for this task | §11 sketches the natural follow-up shape (new aichat persona) but does not commit to it. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail page │
|
||||
│ ├─ "Submissions" panel (or button row) │
|
||||
│ │ [Generate Klageerwiderung] [Generate Klageerhebung] [...] │
|
||||
│ │ Each button enabled iff a template exists for that │
|
||||
│ │ submission_code AND user passes paliad.can_see_project. │
|
||||
│ └─ Click → POST /api/projects/{id}/submissions/{code}/generate │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submissions.go (NEW) │
|
||||
│ 1. Auth: UserIDFromContext + can_see_project gate │
|
||||
│ 2. Load deadline_rule by submission_code │
|
||||
│ 3. Resolve template via fallback chain (TemplateRegistry) │
|
||||
│ 4. Build variable bag (services/submission_vars.go) │
|
||||
│ 5. Render via SubmissionRenderer (services/submission_render.go) │
|
||||
│ 6. Write paliad.documents audit row (NO file_path) │
|
||||
│ 7. Write paliad.system_audit_log entry (event_type= │
|
||||
│ 'submission.generated') │
|
||||
│ 8. Stream .docx bytes with Content-Disposition: attachment │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│ (template fetch)
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ TemplateRegistry (services/submission_templates.go) — NEW │
|
||||
│ • In-process cache (same shape as handlers/files.go cacheEntry) │
|
||||
│ • Lookup path: │
|
||||
│ (1) templates/{FIRM_NAME}/{submission_code}.docx │
|
||||
│ (2) templates/_base/{submission_code}.docx │
|
||||
│ (3) templates/_base/{proceeding_family}.docx (e.g. upc.inf.cfi) │
|
||||
│ (4) templates/_base/_skeleton.docx │
|
||||
│ • Fetched from m/mWorkRepo via Gitea raw URL │
|
||||
│ • 5-min SHA refresh check (identical pattern to files.go) │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Gitea: m/mWorkRepo
|
||||
templates/HLC/de.inf.lg.erwidg.docx
|
||||
templates/_base/de.inf.lg.erwidg.docx
|
||||
templates/_base/de.inf.lg.docx
|
||||
templates/_base/_skeleton.docx
|
||||
```
|
||||
|
||||
**No new tables.** `paliad.documents` already exists; we write audit
|
||||
rows there but leave `file_path` NULL. The fallback chain uses
|
||||
filesystem-style paths inside the existing Gitea repo; no
|
||||
`submission_templates` table needed for Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Slice 1 — what ships first
|
||||
|
||||
Locked by Q5: **one template, end-to-end, on one project.**
|
||||
|
||||
### 4.1 Target submission
|
||||
|
||||
**`de.inf.lg.erwidg`** — Klageerwiderung (DE Verletzungs-LG).
|
||||
Reasoning:
|
||||
|
||||
- High-frequency submission in patent practice; lawyers draft these
|
||||
often enough that the tool earns its keep on day 1.
|
||||
- `primary_party='defendant'` — exercises the our_side variable.
|
||||
- `legal_source='DE.ZPO.276.1'` — exercises citation injection.
|
||||
- Pure-DE (no UPC complexity); easier first template for HLC's
|
||||
Munich/Düsseldorf practice to author and review.
|
||||
- Klageerhebung (`de.inf.lg.klage`) is an obvious alternative; either
|
||||
works. m can flip the target in his decision review if Klageerhebung
|
||||
is the better proof case.
|
||||
|
||||
### 4.2 Surfaces in Slice 1
|
||||
|
||||
- **Project detail page** — new "Submissions" panel listing every
|
||||
submission_code from the project's `proceeding_type` (via existing
|
||||
`DeadlineRuleService`) with a `[Generieren]` button per row. Button
|
||||
is enabled iff a template resolves AND `event_type='filing'` (no
|
||||
`[Generieren]` on hearings/decisions — those don't have submissions).
|
||||
- **Project detail API** — `GET /api/projects/{id}/submissions` returns
|
||||
the list of (submission_code, name, has_template) so the frontend
|
||||
can render enabled/disabled state.
|
||||
- **Generate endpoint** — `POST /api/projects/{id}/submissions/{code}/generate`
|
||||
returns `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
||||
with `Content-Disposition: attachment; filename="..."`.
|
||||
|
||||
Slice 1 does NOT add:
|
||||
|
||||
- A `/admin/submission-templates` editor (Gitea is the editor).
|
||||
- A Frist-detail "Generate" button (project-detail only in Slice 1;
|
||||
Frist-level surface is a Slice 2 affordance).
|
||||
- A "Submissions" tab as a dedicated page (project-detail panel only).
|
||||
- Per-firm overrides beyond `templates/HLC/...` (the fallback chain is
|
||||
WIRED but the only override directory exercised in Slice 1 is HLC).
|
||||
- The variable-contract sidebar UI (mirrors email-template editor) —
|
||||
the contract is documented in §6 as code constants, surfaced as a
|
||||
Slice 2 admin affordance.
|
||||
|
||||
### 4.3 Slice 1 LoC estimate (informational, no time estimate)
|
||||
|
||||
| File | Approx |
|
||||
|---|---|
|
||||
| `internal/handlers/submissions.go` (NEW) | 180 |
|
||||
| `internal/services/submission_templates.go` (NEW — registry + Gitea proxy, reuses files.go cache idea) | 200 |
|
||||
| `internal/services/submission_vars.go` (NEW — variable bag builder) | 220 |
|
||||
| `internal/services/submission_render.go` (NEW — docx merge engine wrapper) | 120 |
|
||||
| `internal/services/submission_render_test.go` (placeholder coverage + missing-var marker) | 180 |
|
||||
| `frontend/src/components/SubmissionsPanel.tsx` (NEW) | 80 |
|
||||
| `frontend/src/client/submissions.ts` (NEW — fetch + download) | 60 |
|
||||
| Wiring in `cmd/server/main.go` + `internal/handlers/handlers.go` | 30 |
|
||||
| i18n keys (`submissions.*`) DE+EN | 20 |
|
||||
| **Total** | **~1090 LoC** |
|
||||
|
||||
Plus: ONE `.docx` template authored by HLC at
|
||||
`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`, lawyer-reviewed
|
||||
before Slice 1 closes.
|
||||
|
||||
---
|
||||
|
||||
## §5 Template registry (Gitea-backed)
|
||||
|
||||
### 5.1 Gitea layout
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ └── de.inf.lg.erwidg.docx # Slice 1 ships THIS file
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # (Phase 2+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx # (Phase 2+)
|
||||
│ └── _skeleton.docx # ultra-generic fallback
|
||||
└── README.md # placeholder reference for authors
|
||||
```
|
||||
|
||||
Naming convention is the submission_code with a `.docx` suffix.
|
||||
Proceeding-family fallback is the submission_code's first two
|
||||
dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`).
|
||||
|
||||
### 5.2 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go
|
||||
func (r *TemplateRegistry) Resolve(ctx context.Context, code string) (Template, error) {
|
||||
firm := branding.Name // "HLC", or whatever FIRM_NAME is
|
||||
family := familyOf(code) // "de.inf.lg" from "de.inf.lg.erwidg"
|
||||
candidates := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", firm, code),
|
||||
fmt.Sprintf("templates/_base/%s.docx", code),
|
||||
fmt.Sprintf("templates/_base/%s.docx", family),
|
||||
"templates/_base/_skeleton.docx",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if tmpl, ok := r.fetch(ctx, path); ok {
|
||||
return tmpl, nil
|
||||
}
|
||||
}
|
||||
return Template{}, ErrNoTemplate
|
||||
}
|
||||
```
|
||||
|
||||
`fetch` does the same SHA-cache dance `handlers/files.go` already
|
||||
does, scoped to the templates subtree.
|
||||
|
||||
### 5.3 Gitea auth
|
||||
|
||||
Reuses `GITEA_TOKEN` env var that already exists for the HL Patents
|
||||
Style proxy. `m/mWorkRepo` is the same repo, same access token. No
|
||||
new secret to configure.
|
||||
|
||||
### 5.4 What happens when no template resolves
|
||||
|
||||
The fallback chain ends at `_skeleton.docx`. The skeleton is an
|
||||
intentionally bare-bones .docx (firm letterhead + party block + court
|
||||
address + case number + signature stub) that ships as part of the
|
||||
initial template set. In practice every Generate request resolves to
|
||||
something — but if even the skeleton 404s (misconfigured repo), the
|
||||
generator returns `503` with a clear error, the SubmissionsPanel
|
||||
button surfaces "Vorlagen-Repository nicht erreichbar".
|
||||
|
||||
---
|
||||
|
||||
## §6 Variable interpolation
|
||||
|
||||
### 6.1 Engine
|
||||
|
||||
Plain text replacement of `{{path.dot.notation}}` placeholders in the
|
||||
.docx body. Whitespace inside braces is trimmed
|
||||
(`{{ project.case_number }}` ≡ `{{project.case_number}}`).
|
||||
|
||||
Implementation: a Go library that handles Word's run-fragmentation
|
||||
correctly (Word may split `{{project.case_number}}` across multiple
|
||||
`<w:r>` runs during editing; naive find/replace breaks). Candidates:
|
||||
|
||||
- **`github.com/lukasjarosch/go-docx`** (~2k stars, MIT, pure Go,
|
||||
maintained). Handles run-merging before replacement. **Inventor
|
||||
recommendation.**
|
||||
- `github.com/nguyenthenguyen/docx` — older, less active.
|
||||
- Custom in-house implementation — ~200 LoC for a minimal robust
|
||||
replacer that walks the document XML and merges runs that fall
|
||||
inside a `{{…}}` span. Fallback if the library doesn't pan out.
|
||||
|
||||
Slice 1: try `lukasjarosch/go-docx` first; if it has dealbreaker bugs
|
||||
(e.g. blows up on Word's autocorrect runs), fall back to the in-house
|
||||
~200 LoC walker. The library choice is an implementation detail; the
|
||||
placeholder syntax stays the same either way.
|
||||
|
||||
### 6.2 Variable contract (v1 placeholder set)
|
||||
|
||||
```
|
||||
{{firm.name}} — HLC (or whatever FIRM_NAME is)
|
||||
{{firm.signature_block}} — Phase 2; v1 renders empty string
|
||||
|
||||
{{today}} — 2026-05-19 (ISO)
|
||||
{{today.long_de}} — "19. Mai 2026"
|
||||
{{today.long_en}} — "19 May 2026"
|
||||
|
||||
{{user.display_name}} — "Maria Schmidt"
|
||||
{{user.email}} — "maria.schmidt@hlc.com"
|
||||
{{user.office}} — "Munich"
|
||||
|
||||
{{project.title}} — paliad.projects.title
|
||||
{{project.reference}} — paliad.projects.reference
|
||||
{{project.case_number}} — paliad.projects.case_number
|
||||
{{project.court}} — paliad.projects.court
|
||||
{{project.patent_number}} — paliad.projects.patent_number
|
||||
{{project.filing_date}} — ISO date
|
||||
{{project.grant_date}} — ISO date
|
||||
{{project.our_side}} — "claimant" | "defendant"
|
||||
{{project.our_side_de}} — "Klägerin" | "Beklagte"
|
||||
{{project.instance_level}} — "lg" | "olg" | "bgh" | ...
|
||||
{{project.proceeding.code}} — e.g. "de.inf.lg"
|
||||
{{project.proceeding.name}} — Verletzungsklage am Landgericht
|
||||
{{project.client_number}} — paliad.projects.client_number
|
||||
{{project.matter_number}} — paliad.projects.matter_number
|
||||
|
||||
{{parties.claimant.name}} — first paliad.parties row with role='claimant'
|
||||
{{parties.claimant.representative}} — paliad.parties.representative
|
||||
{{parties.defendant.name}} — first row with role='defendant'
|
||||
{{parties.defendant.representative}} — paliad.parties.representative
|
||||
{{parties.other.name}} — first row with role NOT IN ('claimant','defendant') — court, intervener, etc.
|
||||
|
||||
{{rule.submission_code}} — "de.inf.lg.erwidg"
|
||||
{{rule.name}} — "Klageerwiderung"
|
||||
{{rule.name_en}} — "Statement of Defence"
|
||||
{{rule.legal_source}} — "DE.ZPO.276.1"
|
||||
{{rule.legal_source_pretty}} — "§ 276 Abs. 1 ZPO"
|
||||
{{rule.primary_party}} — "defendant"
|
||||
{{rule.event_type}} — "filing"
|
||||
|
||||
{{deadline.due_date}} — date of the next pending deadline for this rule on this project
|
||||
{{deadline.due_date_long_de}} — "26. Juni 2026"
|
||||
{{deadline.computed_from}} — anchor description (e.g. "Klageerhebung am 12.05.2026 +6 Wochen")
|
||||
```
|
||||
|
||||
Per-firm extensions (e.g. `{{firm.signature_block}}` filled from a
|
||||
table) are Phase 2.
|
||||
|
||||
### 6.3 Variable bag construction
|
||||
|
||||
`services/submission_vars.go` builds a flat `map[string]string`
|
||||
keyed by the dotted-path placeholders above. One pass over:
|
||||
|
||||
1. `branding.Name` for `{{firm.*}}`
|
||||
2. `time.Now()` (with `Europe/Berlin` locale for the long forms) for
|
||||
`{{today.*}}`
|
||||
3. `userService.GetByID()` for `{{user.*}}`
|
||||
4. `projectService.GetByID()` for `{{project.*}}`
|
||||
5. `partyService.ListByProject()` for `{{parties.*}}`
|
||||
6. `deadlineRuleService.GetByCode()` for `{{rule.*}}`
|
||||
7. `deadlineService.NextByRuleOnProject()` for `{{deadline.*}}`
|
||||
|
||||
Missing values render as `[KEIN WERT: {dotted.path}]` (DE) or
|
||||
`[NO VALUE: {dotted.path}]` (EN) based on user locale. This is by
|
||||
design — the lawyer sees the gap in Word, fixes it (either in Word
|
||||
or in paliad and regenerates), rather than getting a 400 with a list
|
||||
of missing fields they then have to chase.
|
||||
|
||||
### 6.4 Pretty-printing the legal_source
|
||||
|
||||
`legal_source` in the rule corpus is shorthand
|
||||
(`DE.ZPO.276.1`, `UPC.RoP.23.1`). Lawyers don't want that in a brief;
|
||||
they want `§ 276 Abs. 1 ZPO` or `Rule 23.1 RoP UPC`.
|
||||
|
||||
Slice 1 ships a small pretty-printer (`legalSourcePretty`) that knows
|
||||
the families we currently use:
|
||||
|
||||
| Prefix | Pretty form (DE) | Pretty form (EN) |
|
||||
|---|---|---|
|
||||
| `DE.ZPO.<§>.<Abs>` | `§ <§> Abs. <Abs> ZPO` | `Section <§>(<Abs>) ZPO` |
|
||||
| `DE.ZPO.<§>` | `§ <§> ZPO` | `Section <§> ZPO` |
|
||||
| `UPC.RoP.<Rule>.<Sub>` | `Regel <Rule>.<Sub> VerfO UPC` | `Rule <Rule>.<Sub> RoP UPC` |
|
||||
| `UPC.RoP.<Rule>` | `Regel <Rule> VerfO UPC` | `Rule <Rule> RoP UPC` |
|
||||
| `DE.PatG.<§>` | `§ <§> PatG` | `Section <§> PatG` |
|
||||
| `EPC.<Art>` | `Art. <Art> EPÜ` | `Art. <Art> EPC` |
|
||||
| (unknown) | original string | original string |
|
||||
|
||||
Unrecognised prefixes pass through unchanged (better than an
|
||||
incorrect prettification). The function is pure and unit-tested.
|
||||
|
||||
---
|
||||
|
||||
## §7 File naming
|
||||
|
||||
Generated file name:
|
||||
|
||||
```
|
||||
{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
|
||||
```
|
||||
|
||||
Concrete example for the Slice 1 happy path:
|
||||
|
||||
```
|
||||
Klageerwiderung-2 O 123_25-2026-05-19.docx
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `rule.name` honours user locale (`Klageerwiderung` for DE,
|
||||
`Statement of Defence` for EN).
|
||||
- `project.case_number` slash/backslash → underscore (Word file name
|
||||
hygiene), other characters preserved.
|
||||
- Date is ISO at server-local (`Europe/Berlin`) date.
|
||||
- If `project.case_number` is empty → fall back to a short hash of
|
||||
`project_id` (8 hex chars) so the file still has a stable identifier
|
||||
the lawyer can rename without losing track.
|
||||
|
||||
---
|
||||
|
||||
## §8 Authorization
|
||||
|
||||
- **Visibility gate:** `paliad.can_see_project(project_id)` — anyone
|
||||
who can see the project can generate. Matches every other write
|
||||
surface on the project. The endpoint inlines the predicate;
|
||||
unauthorised callers get 404 (not 403, to avoid project
|
||||
enumeration).
|
||||
- **No profession floor.** A paralegal can generate a draft of a
|
||||
Klageerwiderung; the draft is a Word doc that needs the associate's
|
||||
approval downstream (in Word, on the document itself). Adding an
|
||||
approval gate on generation would slow the workflow without
|
||||
preventing anything that paliad's existing approval system doesn't
|
||||
already cover at the substantive-act layer.
|
||||
- **Owner gate (Paliadin) does NOT apply.** This is the
|
||||
submission-template engine, not Paliadin. All paliad users get the
|
||||
feature once a template exists for the proceeding their project is
|
||||
in.
|
||||
|
||||
---
|
||||
|
||||
## §9 Audit trail
|
||||
|
||||
Two records per generation:
|
||||
|
||||
### 9.1 `paliad.documents` row (audit-only, no binary)
|
||||
|
||||
```sql
|
||||
INSERT INTO paliad.documents (id, title, doc_type, file_path, file_size,
|
||||
mime_type, ai_extracted, uploaded_by,
|
||||
project_id)
|
||||
VALUES (gen_random_uuid(),
|
||||
'{rule.name} (generiert {YYYY-MM-DD})',
|
||||
'generated_submission', -- new doc_type value
|
||||
NULL, -- no on-disk path
|
||||
NULL, -- no file size (binary not persisted)
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
jsonb_build_object(
|
||||
'submission_code', $1,
|
||||
'template_path', $2, -- the gitea path we resolved
|
||||
'template_sha', $3, -- pinned SHA from the cache fetch
|
||||
'firm', $4),
|
||||
$user_id,
|
||||
$project_id);
|
||||
```
|
||||
|
||||
- `doc_type='generated_submission'` is a new value; no CHECK constraint
|
||||
on doc_type today so this is additive.
|
||||
- `file_path NULL` is the marker that says "regenerate from inputs on
|
||||
demand". The /api/projects/{id}/documents listing UI (Phase 2) will
|
||||
surface a `[Erneut generieren]` action for these rows.
|
||||
- `ai_extracted` jsonb is repurposed for generation provenance
|
||||
(template SHA, firm at time of generation). Naming is unfortunate
|
||||
but the column shape fits; renaming the column is out of scope for
|
||||
this task.
|
||||
|
||||
### 9.2 `paliad.system_audit_log` row
|
||||
|
||||
```sql
|
||||
INSERT INTO paliad.system_audit_log (event_type, actor_id, actor_email,
|
||||
scope, scope_root, metadata)
|
||||
VALUES ('submission.generated',
|
||||
$user_id,
|
||||
$user_email,
|
||||
'project',
|
||||
$project_id::text,
|
||||
jsonb_build_object(
|
||||
'submission_code', $1,
|
||||
'template_path', $2,
|
||||
'template_sha', $3,
|
||||
'document_id', $document_id,
|
||||
'firm', $4));
|
||||
```
|
||||
|
||||
Mirrors the existing `system_audit_log` event_type convention
|
||||
(`*.created`, `*.updated`, etc., from t-paliad-214).
|
||||
|
||||
### 9.3 Verlauf entry (project event)
|
||||
|
||||
`paliad.project_events` gets a row with `event_type='submission_generated'`
|
||||
and `timeline_kind='custom_milestone'` so the generation surfaces in
|
||||
SmartTimeline's audit-log toggle and on the project's Verlauf list.
|
||||
This is the user-visible footprint; the `system_audit_log` entry is
|
||||
the admin-visible audit footprint.
|
||||
|
||||
---
|
||||
|
||||
## §10 Frontend surface
|
||||
|
||||
### 10.1 Slice 1 — SubmissionsPanel on project detail
|
||||
|
||||
A new panel below the existing Verlauf / Deadlines panels on
|
||||
`/projects/{id}`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Schriftsätze │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Klageerhebung [— Vorlage fehlt] │
|
||||
│ Klageerwiderung [Generieren ↓] │
|
||||
│ Replik [— Vorlage fehlt] │
|
||||
│ Duplik [— Vorlage fehlt] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Filter: only `event_type='filing'` rules from the project's
|
||||
`proceeding_type` are listed. Hearings and decisions don't have
|
||||
submissions.
|
||||
- Per-row state: `has_template` returned by
|
||||
`GET /api/projects/{id}/submissions`. Disabled buttons show the
|
||||
"Vorlage fehlt" hint (German default, English in EN locale).
|
||||
- Click `[Generieren ↓]` → POST → browser triggers download.
|
||||
- `aria-busy="true"` on the panel while a generation is in flight
|
||||
(cheap, but lawyers feel slow networks).
|
||||
|
||||
### 10.2 Out of scope for Slice 1
|
||||
|
||||
- A standalone `/submissions` index page.
|
||||
- A Frist-detail "Generate" button.
|
||||
- A picker for template variants (1:N) — locked to fallback chain
|
||||
(Q4), which is 1:1 from the user's perspective.
|
||||
- An "edit project, then regenerate" loop on the same UI.
|
||||
|
||||
---
|
||||
|
||||
## §11 AI-drafted body (deferred — sketch only)
|
||||
|
||||
NOT in scope for t-paliad-215. Documented here so the next inventor
|
||||
picking up the "AI Klageerwiderung body" task has a clear starting
|
||||
shape.
|
||||
|
||||
The natural fit: a new aichat persona (e.g. `paliadin-draft`) on
|
||||
mRiver, parallel to the existing `paliadin` persona.
|
||||
|
||||
```
|
||||
{{ai.draft_body}} # placeholder in the template
|
||||
|
||||
→ generator detects {{ai.*}} placeholders in the template
|
||||
→ POSTs to aichat with persona=paliadin-draft + context:
|
||||
- project state (variables already built)
|
||||
- relevant project notes (paliad.notes)
|
||||
- the deadline_rule corpus (rule + family)
|
||||
- HL Patents Style guide chunks (RAG, eventually)
|
||||
→ aichat returns Markdown body
|
||||
→ generator injects into the .docx as one or more <w:p> paragraphs
|
||||
(Word-friendly Markdown → docx mapping needed; substantive
|
||||
formatting question for that follow-up)
|
||||
```
|
||||
|
||||
Open shape questions for that follow-up (NOT for this design):
|
||||
|
||||
- One persona per submission type, or one persona that branches on
|
||||
`submission_code` in its system prompt?
|
||||
- Owner gate (m only) like current paliadin, or open to all
|
||||
authenticated users?
|
||||
- Approval gate before the AI body lands in the .docx?
|
||||
- Cost accounting per generation?
|
||||
- Where does the prose context come from (notes / uploaded patent
|
||||
spec / prior pleadings)?
|
||||
|
||||
Re-uses, when that task fires:
|
||||
|
||||
- This task's template engine, variable contract, fallback chain,
|
||||
audit trail — all unchanged.
|
||||
- Just a new placeholder family (`{{ai.*}}`) + a new aichat persona +
|
||||
a new admin gate.
|
||||
|
||||
---
|
||||
|
||||
## §12 Slice plan beyond Slice 1
|
||||
|
||||
| Slice | Scope |
|
||||
|---|---|
|
||||
| 1 | One template (`de.inf.lg.erwidg`), engine + fallback chain + audit + SubmissionsPanel on project detail. THIS DESIGN. |
|
||||
| 2 | 3–5 more templates (Klageerhebung, SoC `upc.inf.cfi.soc`, SoD `upc.inf.cfi.sod`, Berufungsbegründung `de.inf.olg.begruendung`). Template authoring effort, no new architecture. |
|
||||
| 3 | Variable-contract sidebar in a new `/admin/submission-templates` page (mirrors `/admin/email-templates` shape). Shows what placeholders exist, with samples. Does NOT add an uploader UI — Gitea remains the editor. |
|
||||
| 4 | Per-firm override directory exercised (first non-HLC firm onboarded). |
|
||||
| 5 | Frist-detail "Generate" button + paliad.documents.deadline_id FK (mig 103+) for per-Frist draft history. |
|
||||
| 6 | (Separate task) AI-drafted body via Paliadin persona — see §11. |
|
||||
| 7 | (Future) Paliad UI uploader as alternative to Gitea, if the round-trip is friction. |
|
||||
|
||||
Slices 2–5 are roadmap markers, not commitments — m decides cadence.
|
||||
|
||||
---
|
||||
|
||||
## §13 Trade-offs flagged
|
||||
|
||||
1. **No binary persistence is a deliberate retention choice.** If a
|
||||
lawyer regenerates after the project state changes (party renamed,
|
||||
case_number corrected), the "regenerated" doc differs from the
|
||||
"original generated" doc. This is a feature, not a bug — the source
|
||||
of truth is paliad's project state, and the .docx is a derivative.
|
||||
But the lawyer needs to be aware: there is no "what did I generate
|
||||
last Thursday" recovery without re-saving locally. The
|
||||
`paliad.documents` audit row records WHAT was generated (template
|
||||
SHA + project state hash, optionally), but not the bytes.
|
||||
|
||||
2. **Gitea round-trip for template edits is friction.** Template
|
||||
authors edit `.docx` in Word, save, drag to Gitea web UI (or push
|
||||
from a local clone). The 5-min SHA cache means edits surface
|
||||
within 5 minutes (or instantly via `POST /api/files/refresh` —
|
||||
already wired for the HL Patents Style template). If lawyers
|
||||
complain, Phase 7 adds an in-paliad uploader. Until then, Gitea is
|
||||
the editor.
|
||||
|
||||
3. **Variable contract changes are coordinated edits.** Adding a new
|
||||
`{{project.*}}` placeholder needs both a code change (var bag) AND
|
||||
template edits (templates won't auto-discover new placeholders).
|
||||
The variable-contract sidebar (Slice 3) is the mitigation —
|
||||
template authors see what's available without reading the Go code.
|
||||
|
||||
4. **`lukasjarosch/go-docx` library risk.** ~2k stars, MIT, maintained
|
||||
— but it's a third-party dep we haven't used before. Fallback is
|
||||
the in-house ~200-LoC walker. The placeholder syntax doesn't change
|
||||
either way; Slice 1 can swap engines without touching templates or
|
||||
callers.
|
||||
|
||||
5. **`paliad.documents.ai_extracted` is repurposed for generation
|
||||
provenance.** Slightly ugly naming because the column was added for
|
||||
Phase H (AI Frist-Extraktion), which never shipped. Renaming the
|
||||
column to something like `metadata` is out of scope for this task
|
||||
but should be folded into the migration that lands when Phase 5
|
||||
adds `deadline_id`.
|
||||
|
||||
6. **`paliad.parties.role='claimant'`** — multiple claimants on a
|
||||
project (multi-party suit) → Slice 1 picks the first row. v1
|
||||
shortcut. Templates needing multi-claimant blocks become Phase 2
|
||||
work (with a `{{#each parties.claimants}}` shape on top of
|
||||
`lukasjarosch/go-docx`'s loop support).
|
||||
|
||||
7. **No Word-side `MERGEFIELD` support.** Lawyers who insert Word
|
||||
merge fields (via Insert → Quick Parts → Field) instead of typing
|
||||
`{{…}}` will get untouched MERGEFIELD codes in the rendered output.
|
||||
Decision: standardise on `{{…}}` syntax (cheap to type, visible
|
||||
in the template, predictable). Document this in the `templates/
|
||||
README.md`.
|
||||
|
||||
8. **No template versioning UI.** Gitea provides git history; that's
|
||||
the canonical version trail. Bumping to "use template X as of
|
||||
commit Y" for an old project is a manual git-checkout-and-pin
|
||||
exercise. Phase 2+ if anyone asks; not today.
|
||||
|
||||
---
|
||||
|
||||
## §14 Open follow-ups (NOT blocking)
|
||||
|
||||
These items are NOT m-decisions; they're follow-ups for the coder
|
||||
shift or future inventor passes:
|
||||
|
||||
- **Template authoring effort.** Slice 1 needs HLC to author/review
|
||||
the actual Klageerwiderung template. That's a legal-review task that
|
||||
can run in parallel with the engine code (template uploaded last
|
||||
before the slice ships). Coordinate with m on who reviews.
|
||||
- **English version of `legalSourcePretty`.** Pretty-printer table in
|
||||
§6.4 needs an EN column for every prefix — populated from existing
|
||||
glossary entries where possible.
|
||||
- **i18n key sweep.** `submissions.*` namespace; ~20 keys for Slice 1
|
||||
(panel title, button labels, "Vorlage fehlt" hints, error messages
|
||||
for 503/404/422).
|
||||
- **README for template authors.** A `templates/README.md` in
|
||||
m/mWorkRepo listing the available placeholders + naming convention
|
||||
+ a screenshot of a working template. Coder ships this alongside
|
||||
Slice 1.
|
||||
- **CLAUDE.md update.** Add a "Submission templates" section
|
||||
documenting the Gitea proxy, placeholder syntax, and the
|
||||
`submission.generated` audit event_type.
|
||||
- **Cleanup task for `ai_extracted` naming.** Issue + Phase 5 mig.
|
||||
|
||||
---
|
||||
|
||||
## §15 What this design does NOT do
|
||||
|
||||
To set the scope boundary cleanly:
|
||||
|
||||
- ❌ Generate PDFs.
|
||||
- ❌ Generate emails or any non-.docx format.
|
||||
- ❌ Edit `.docx` files inside paliad (no in-browser Word editor).
|
||||
- ❌ Upload .docx to NetDocuments or any external DMS.
|
||||
- ❌ Translate templates DE↔EN automatically.
|
||||
- ❌ Validate the generated draft against any legal rule.
|
||||
- ❌ Sign, certify, or notarise the output.
|
||||
- ❌ Send the draft to court / e-filing.
|
||||
- ❌ AI-draft any prose. (See §11.)
|
||||
- ❌ Provide a paliad-UI template editor. (Gitea is the editor.)
|
||||
- ❌ Persist generated .docx bytes server-side. (Audit row only.)
|
||||
- ❌ Add a new database table. (`paliad.documents` is enough for v1.)
|
||||
- ❌ Require a database migration. (Slice 1 is migration-free.)
|
||||
|
||||
Each of these is a defensible future-scope item; none belong in
|
||||
Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## §16 Recommended implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. The substrate is well-trodden:
|
||||
|
||||
- Gitea proxy + cache: `internal/handlers/files.go` is the template
|
||||
to lift.
|
||||
- Variable contract pattern: `internal/services/email_template_variables.go`
|
||||
is the template to mirror (different surface, identical shape).
|
||||
- Visibility gate: `internal/services/visibility.go` +
|
||||
`paliad.can_see_project()` — standard everywhere.
|
||||
- Audit insert: `paliad.system_audit_log` (mig 102) + `paliad.documents`
|
||||
(existing table, first writer).
|
||||
- Frontend SubmissionsPanel: stock TSX + client/.ts pattern, same shape
|
||||
as the existing CardLayout / EventsList panels.
|
||||
|
||||
The only novel piece is the docx merge library integration — that's a
|
||||
~200 LoC isolated module the coder can prototype on a sample .docx
|
||||
before wiring into the project flow.
|
||||
|
||||
NOT cronus per project memory directive.
|
||||
|
||||
---
|
||||
|
||||
## §17 Acceptance criteria for Slice 1
|
||||
|
||||
The coder considers Slice 1 done when:
|
||||
|
||||
1. Pushing a `.docx` to `m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`
|
||||
and visiting any project with `proceeding_type=de.inf.lg` surfaces
|
||||
a `[Generieren]` Klageerwiderung button.
|
||||
2. Clicking it downloads a `.docx` named per §7 with all §6.2
|
||||
placeholders resolved (or `[KEIN WERT: …]` markers for genuinely
|
||||
missing project fields).
|
||||
3. Opening the downloaded .docx in Word renders cleanly (no run
|
||||
fragmentation artefacts, no broken styles).
|
||||
4. A row appears in `paliad.documents` with `doc_type='generated_submission'`,
|
||||
`file_path=NULL`, and `ai_extracted` jsonb carrying the template
|
||||
path + SHA.
|
||||
5. A row appears in `paliad.system_audit_log` with `event_type='submission.generated'`.
|
||||
6. A row appears in `paliad.project_events` with
|
||||
`event_type='submission_generated'` and shows up in the project's
|
||||
Verlauf / SmartTimeline.
|
||||
7. Calling the endpoint without project visibility returns 404.
|
||||
8. Calling the endpoint with no template anywhere in the fallback
|
||||
chain returns 503 with a clear error.
|
||||
9. Unit tests cover: placeholder rendering happy path, missing-var
|
||||
marker, fallback chain (all 4 levels), file naming, slash
|
||||
sanitization, legalSourcePretty for every prefix in §6.4.
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build`
|
||||
all clean.
|
||||
11. Manual test on the live database (test admin
|
||||
`tester@hlc.de` per memory) against a project with a real
|
||||
`de.inf.lg` proceeding succeeds end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## §18 Approval gate
|
||||
|
||||
Per inventor SKILL.md and project CLAUDE.md: this design needs m's
|
||||
go/no-go before any coder is hired. After m approves:
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder`
|
||||
with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice 1, opens a
|
||||
PR (no self-merge — maria's gate).
|
||||
- Phase 11 (AI-drafted body) is a SEPARATE task — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
668
docs/design-submission-page-2026-05-22.md
Normal file
668
docs/design-submission-page-2026-05-22.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-22
|
||||
**Issue:** m/paliad (mai task t-paliad-238)
|
||||
**Branch:** `mai/cronus/inventor-dedicated`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
|
||||
|
||||
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
|
||||
|
||||
1. Picks (or creates) a named **draft** for one (project, submission_code).
|
||||
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
|
||||
3. Sees a read-only preview pane showing the merged document body as HTML.
|
||||
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
|
||||
|
||||
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
|
||||
|
||||
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
|
||||
|
||||
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
|
||||
|
||||
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-22)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
|
||||
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
|
||||
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123` — `Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
|
||||
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID` → `can_see_project`. |
|
||||
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
|
||||
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/` → `…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
|
||||
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
|
||||
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
|
||||
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
|
||||
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
|
||||
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
|
||||
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52` — `PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
|
||||
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-22)
|
||||
|
||||
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
|
||||
|
||||
| # | Question (from task brief) | Default adopted | Source |
|
||||
|---|---|---|---|
|
||||
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
|
||||
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
|
||||
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
|
||||
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
|
||||
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
|
||||
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
|
||||
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
|
||||
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export` → `.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
|
||||
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
|
||||
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
|
||||
|
||||
Inventor-defaulted (not in the (R) matrix; clear right answer):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
|
||||
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
|
||||
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
|
||||
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
|
||||
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
|
||||
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
|
||||
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
|
||||
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail (existing) — /projects/{id} with #tab-submissions │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Schriftsätze tab │ │
|
||||
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
|
||||
│ │ "Bearbeiten" deep-link │ "Generieren" = │
|
||||
│ │ │ existing one-click │
|
||||
│ ▼ │ format-only export │
|
||||
└───────────┼───────────────────────────────────────────┼──────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
|
||||
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
|
||||
│ │
|
||||
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
|
||||
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
|
||||
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
|
||||
│ │ ───────────────────────────────── │ │ overrides] │ │
|
||||
│ │ project.case_number │ │ │ │
|
||||
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
|
||||
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
|
||||
│ │ BMW AG [override?] │ │ durch … │ │
|
||||
│ │ deadline.due_date │ │ │ │
|
||||
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
|
||||
│ │ ... │ │ │ │
|
||||
│ │ │ │ [KEIN WERT: project.our_side] │ │
|
||||
│ │ [✎ Bearbeiten] inline │ │ │ │
|
||||
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
└──────────────────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submission_drafts.go (NEW) │
|
||||
│ 1. Auth: ProjectService.GetByID → can_see_project │
|
||||
│ 2. Load draft row (RLS via project visibility) │
|
||||
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
|
||||
│ 4. Apply draft.overrides on top of bag │
|
||||
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
|
||||
│ (Slice A: skips registry, fetches the universal .dotm directly) │
|
||||
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
|
||||
│ 7. Audit: system_audit_log + project_events │
|
||||
│ 8. Stream .docx with Content-Disposition: attachment │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
|
||||
|
||||
---
|
||||
|
||||
## §4 Schema (`paliad.submission_drafts`)
|
||||
|
||||
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
|
||||
|
||||
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
|
||||
-- empty value = "don't override, use bag"
|
||||
-- present key = "use this verbatim"
|
||||
|
||||
last_exported_at timestamptz, -- NULL until first export
|
||||
last_exported_sha text, -- template SHA at last export (audit aid)
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY submission_drafts_visible
|
||||
ON paliad.submission_drafts
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
|
||||
CREATE TRIGGER submission_drafts_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
```
|
||||
|
||||
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
|
||||
|
||||
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not** — `system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
|
||||
|
||||
### 4.1 RLS read-vs-write
|
||||
|
||||
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
|
||||
|
||||
### 4.2 Down migration
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
```
|
||||
|
||||
No data loss concern at design time — feature ships without legacy drafts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Service layer
|
||||
|
||||
### 5.1 Resurrect from git (no new code)
|
||||
|
||||
```
|
||||
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
|
||||
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
|
||||
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
|
||||
strip macros from the universal .dotm before the merge step runs.
|
||||
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
|
||||
NOT wired in Slice A (universal .dotm only); wired in Slice B.
|
||||
```
|
||||
|
||||
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
|
||||
|
||||
### 5.2 New service — `SubmissionDraftService`
|
||||
|
||||
```go
|
||||
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
|
||||
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
Overrides PlaceholderMap // jsonb → map[string]string
|
||||
LastExportedAt *time.Time
|
||||
LastExportedSHA *string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
|
||||
// Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
|
||||
|
||||
// Get returns a single draft by ID, gated on project visibility.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
|
||||
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
|
||||
|
||||
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
|
||||
|
||||
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
|
||||
```
|
||||
|
||||
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
|
||||
|
||||
### 5.3 Wiring
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (additions, no replacements)
|
||||
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
|
||||
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
|
||||
// Slice B only:
|
||||
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
|
||||
```
|
||||
|
||||
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
|
||||
|
||||
---
|
||||
|
||||
## §6 UI surface
|
||||
|
||||
### 6.1 Page layout
|
||||
|
||||
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
|
||||
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
|
||||
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
|
||||
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
|
||||
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
|
||||
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
|
||||
│ │ 80333 München │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ Variablen │ In der Sache │
|
||||
│ firm.name HLC │ │
|
||||
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
|
||||
│ project.court LG München I [✎] │ — Klägerin — │
|
||||
│ parties.claimant.name BMW AG [✎] │ │
|
||||
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
|
||||
│ parties.defendant.representative │ │
|
||||
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
|
||||
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
|
||||
│ rule.legal_source_pretty │ │
|
||||
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
|
||||
│ … │ │
|
||||
│ │ [KEIN WERT: project.our_side] │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ [Entwurf umbenennen] [Entwurf löschen] │ │
|
||||
└────────────────────────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sidebar grouping (top-to-bottom, locale-aware labels):
|
||||
|
||||
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
|
||||
2. **Mandanten & Parteien** (parties.*)
|
||||
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
|
||||
4. **Frist** (deadline.* — due_date, computed_from)
|
||||
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
|
||||
|
||||
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
|
||||
|
||||
### 6.2 Preview pane
|
||||
|
||||
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
|
||||
|
||||
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
|
||||
|
||||
### 6.3 Routing + handlers
|
||||
|
||||
```
|
||||
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
|
||||
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
|
||||
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
|
||||
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
|
||||
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
|
||||
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
|
||||
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||
```
|
||||
|
||||
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
|
||||
|
||||
### 6.4 Schriftsätze tab — additive change
|
||||
|
||||
```diff
|
||||
- // Each row: [Generieren ↓]
|
||||
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
|
||||
```
|
||||
|
||||
`[Bearbeiten ↗]` → `window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
|
||||
|
||||
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
|
||||
|
||||
---
|
||||
|
||||
## §7 Variable contract (v1 placeholder set)
|
||||
|
||||
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
|
||||
|
||||
```
|
||||
firm.name — branding.Name (HLC or FIRM_NAME override)
|
||||
firm.signature_block — empty in v1 (Phase 2 affordance)
|
||||
|
||||
today — 2026-05-22 (ISO, Europe/Berlin)
|
||||
today.iso — ISO short
|
||||
today.long_de — "22. Mai 2026"
|
||||
today.long_en — "22 May 2026"
|
||||
|
||||
user.display_name — paliad.users.display_name
|
||||
user.email — paliad.users.email
|
||||
user.office — paliad.users.office
|
||||
|
||||
project.title — paliad.projects.title
|
||||
project.reference — paliad.projects.reference
|
||||
project.case_number — paliad.projects.case_number
|
||||
project.court — paliad.projects.court
|
||||
project.patent_number — DE/inline form "EP 1 234 567 B1"
|
||||
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
|
||||
project.filing_date — ISO date
|
||||
project.grant_date — ISO date
|
||||
project.our_side — claimant | defendant
|
||||
project.our_side_de — "Klägerin" | "Beklagte"
|
||||
project.our_side_en — "Claimant" | "Defendant"
|
||||
project.instance_level — lg | olg | bgh | cfi | …
|
||||
project.client_number — paliad.projects.client_number
|
||||
project.matter_number — paliad.projects.matter_number
|
||||
project.proceeding.code — e.g. "de.inf.lg"
|
||||
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
|
||||
project.proceeding.name_de — explicit DE
|
||||
project.proceeding.name_en — explicit EN
|
||||
|
||||
parties.claimant.name — first paliad.parties row with role='claimant'
|
||||
parties.claimant.representative
|
||||
parties.defendant.name — first row with role='defendant'
|
||||
parties.defendant.representative
|
||||
parties.other.name — first non-claimant/defendant row
|
||||
parties.other.representative
|
||||
|
||||
rule.submission_code — "de.inf.lg.erwidg"
|
||||
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
|
||||
rule.name_de
|
||||
rule.name_en
|
||||
rule.legal_source — "DE.ZPO.276.1"
|
||||
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
|
||||
rule.primary_party — claimant | defendant | court | both
|
||||
rule.event_type — filing | hearing | decision
|
||||
|
||||
deadline.due_date — ISO of next pending deadline for this rule on this project
|
||||
deadline.due_date_long_de — "12. Juni 2026"
|
||||
deadline.due_date_long_en — "12 June 2026"
|
||||
deadline.original_due_date — ISO if extended
|
||||
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
|
||||
deadline.title — deadline.title
|
||||
deadline.source — "rule" | "manual" | …
|
||||
```
|
||||
|
||||
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
|
||||
|
||||
### 7.1 Override semantics
|
||||
|
||||
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
|
||||
|
||||
```go
|
||||
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
|
||||
for k, v := range draft.Overrides { // lawyer's edits
|
||||
if v == "" {
|
||||
delete(bag, k) // empty override means "force missing marker"
|
||||
} else {
|
||||
bag[k] = v // non-empty override replaces
|
||||
}
|
||||
}
|
||||
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
|
||||
```
|
||||
|
||||
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
|
||||
|
||||
---
|
||||
|
||||
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
|
||||
|
||||
### 8.1 Slice A — universal .dotm only
|
||||
|
||||
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
|
||||
|
||||
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
|
||||
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
|
||||
|
||||
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
|
||||
|
||||
### 8.2 Slice B — fallback-chain registry
|
||||
|
||||
Layout reproduced from the 2026-05-19 design §5.1:
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo, already proxied)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
|
||||
│ ├── de.inf.lg.klage.docx # Slice B addition
|
||||
│ ├── upc.inf.cfi.soc.docx # Slice B addition
|
||||
│ └── upc.inf.cfi.sod.docx # Slice B addition
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx
|
||||
│ ├── _skeleton.docx # ultra-generic fallback
|
||||
│ └── _universal.docx # the v1 Slice A "any code" template
|
||||
└── README.md # placeholder reference for template authors
|
||||
```
|
||||
|
||||
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
|
||||
|
||||
### 8.3 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go (resurrected from 3677c81)
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, "templates/_base/_skeleton.docx")
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
|
||||
|
||||
### 8.4 No template at all — Slice A vs Slice B
|
||||
|
||||
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
|
||||
|
||||
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
|
||||
|
||||
### 8.5 Template authoring task lands outside this design
|
||||
|
||||
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
|
||||
|
||||
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
|
||||
|
||||
---
|
||||
|
||||
## §9 Slice plan
|
||||
|
||||
### Slice A — schema + new page + variables-only export against universal .docx
|
||||
|
||||
Ships the editor end-to-end with one template.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
|
||||
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
|
||||
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
|
||||
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
|
||||
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
|
||||
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
|
||||
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
|
||||
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
|
||||
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
|
||||
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
|
||||
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
|
||||
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
|
||||
2. Sidebar renders ~30 placeholders, pre-filled from project state.
|
||||
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
|
||||
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
|
||||
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
|
||||
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
|
||||
7. `project_events` row appears on export and surfaces in Verlauf.
|
||||
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
|
||||
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
|
||||
|
||||
### Slice B — per-submission_code templates + fallback chain
|
||||
|
||||
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
|
||||
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
|
||||
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
|
||||
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
|
||||
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
|
||||
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
|
||||
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
|
||||
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
|
||||
|
||||
### Slice C — toggleable passages
|
||||
|
||||
Lawyer can include/exclude boilerplate sections before export.
|
||||
|
||||
| Deliverable | Notes |
|
||||
|---|---|
|
||||
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb` — `{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
|
||||
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
|
||||
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
|
||||
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
|
||||
|
||||
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
|
||||
|
||||
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
|
||||
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
|
||||
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
|
||||
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
|
||||
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
|
||||
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
|
||||
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
|
||||
- Citation insertion from the sources system. Waits for the sources surface m parked.
|
||||
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
|
||||
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
|
||||
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
|
||||
|
||||
---
|
||||
|
||||
## §11 Material picks escalated to head
|
||||
|
||||
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
|
||||
|
||||
### Q-E1 — Template authoring effort
|
||||
|
||||
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
|
||||
|
||||
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
|
||||
|
||||
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
|
||||
|
||||
### Q-E2 — `paliad.documents` row on export
|
||||
|
||||
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
|
||||
|
||||
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
|
||||
|
||||
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
|
||||
|
||||
### Q-E3 — Preview render — server or client?
|
||||
|
||||
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
|
||||
|
||||
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
|
||||
|
||||
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
|
||||
|
||||
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
|
||||
|
||||
### Q-E4 — Inter-user draft visibility
|
||||
|
||||
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
|
||||
|
||||
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
|
||||
|
||||
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
|
||||
|
||||
---
|
||||
|
||||
## §12 Implementation notes
|
||||
|
||||
For the coder, not for head.
|
||||
|
||||
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
|
||||
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
|
||||
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
|
||||
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
|
||||
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
|
||||
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
|
||||
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
|
||||
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
|
||||
|
||||
---
|
||||
|
||||
## §13 Acceptance gate
|
||||
|
||||
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
|
||||
- Slices B and C are SEPARATE tasks — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
918
docs/design-user-checklists-2026-05-20.md
Normal file
918
docs/design-user-checklists-2026-05-20.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# User-authored checklists: authoring, sharing, admin-promotion
|
||||
|
||||
**Task:** t-paliad-225 — Gitea m/paliad#61
|
||||
**Inventor:** dirac, 2026-05-20
|
||||
**Branch:** `mai/dirac/user-checklists`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
Paliad ships a curated catalog of UPC / DE / EPA checklists today
|
||||
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
|
||||
on Akten and check items off; per-instance state lives in
|
||||
`paliad.checklist_instances` and is gated by the parent project's
|
||||
team-based visibility.
|
||||
|
||||
m wants three new capabilities (m 2026-05-20 14:14):
|
||||
|
||||
1. **User-authored templates** — any non-`global_admin` can create a
|
||||
checklist template they own (title, sections, items, references).
|
||||
2. **Sharing** — author shares with specific colleagues, an Office, a
|
||||
Dezernat (partner-unit), a project team, or the whole firm.
|
||||
3. **Admin promotion to global** — `global_admin` promotes an authored
|
||||
template into the firm-wide catalog so it appears alongside the
|
||||
curated UPC/DE/EPA templates for every user.
|
||||
|
||||
This design covers all three across three sequential slices.
|
||||
|
||||
## 2. Premises verified live (load-bearing findings)
|
||||
|
||||
The Gitea issue body says "Add `owner_id uuid NULL` to
|
||||
`paliad.checklists`". That table **does not exist**. Verifying against
|
||||
the live DB and the code corrected several premises:
|
||||
|
||||
- **`paliad.checklists` does NOT exist as a DB table.** Templates today
|
||||
are pure Go data in `internal/checklists/templates.go` (6 entries,
|
||||
~310 lines), served by `internal/handlers/checklists.go` via
|
||||
`checklists.Summaries()` and `checklists.Find(slug)`. The DB has
|
||||
`paliad.checklist_instances` (per-user state) and
|
||||
`paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
|
||||
design has to introduce `paliad.checklists` from scratch.
|
||||
|
||||
- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
|
||||
validity is enforced in `ChecklistInstanceService.Create` against the
|
||||
static Go registry. This is what lets the design keep the static
|
||||
catalog as one source of truth and add the DB catalog as a parallel
|
||||
source: instance creation just resolves the slug against the merged
|
||||
view and snapshots the template body.
|
||||
|
||||
- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
|
||||
on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
|
||||
109 user_dashboard_layouts, 110 project_type_other, 111
|
||||
project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
|
||||
today). At inventor time the next free slot is **112**. The coder
|
||||
MUST re-verify with `ls internal/db/migrations/ | tail` at shift
|
||||
start — the slot can drift if other branches merge first.
|
||||
|
||||
- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
|
||||
migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
|
||||
STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
|
||||
branches on global_admin shortcut + project_teams responsibility =
|
||||
'admin'. **Used by this design** to gate the "Make global" button (we
|
||||
reuse the global_admin shortcut, not the project-admin branch — see
|
||||
§4.4) and as the precedent for any new STABLE SECURITY DEFINER
|
||||
predicates we add.
|
||||
|
||||
- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
|
||||
Columns: `event_type` (free-text), `actor_id`, `actor_email`,
|
||||
`scope` ∈ {org, project, personal}, `scope_root uuid`,
|
||||
`metadata jsonb`. RLS: self-read for the actor +
|
||||
global_admin read-all. **Pattern to follow:** insert event row at
|
||||
state transition (see `ExportService.WriteAuditRow` in
|
||||
`internal/services/export_service.go:1120` for the canonical shape).
|
||||
|
||||
- **`paliad.project_events`** is the project-timeline audit sink and is
|
||||
already wired for checklist instance lifecycle events
|
||||
(`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
|
||||
`_deleted`). We do NOT need to invent a new event_type for instance
|
||||
events; we'll add a few `_snapshot_taken` / template-level events to
|
||||
`system_audit_log` and keep instance events on `project_events`.
|
||||
|
||||
- **`paliad.users.office`** is `text` (CHECK against the office key
|
||||
list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
|
||||
hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
|
||||
have `additional_offices text[]`. Both are first-class columns; no
|
||||
separate `offices` table.
|
||||
|
||||
- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
|
||||
timestamps) is the Dezernat / practice-group table. Membership lives
|
||||
in `paliad.partner_unit_members`. Projects attach via
|
||||
`paliad.project_partner_units` (with derivation flags). All three
|
||||
are referenceable from a share recipient.
|
||||
|
||||
- **`paliad.users.global_role`** is `text`; values include
|
||||
`'global_admin'`. Used for the firm-wide promote/demote authority.
|
||||
|
||||
- **`paliad.project_teams`** (mig 111 just added) carries
|
||||
`responsibility` ∈ {admin, lead, member, observer, external}. We
|
||||
reuse `can_see_project` (visibility) for share-to-project recipients,
|
||||
NOT `effective_project_admin`. The semantic of "share with a project
|
||||
team" is "anyone on the matter sees it", not "anyone who can edit
|
||||
membership sees it".
|
||||
|
||||
- **No precedent for entity-level sharing in paliad.** The personal-
|
||||
sidecar tables (`user_views`, `user_dashboard_layouts`,
|
||||
`user_pinned_projects`, `user_card_layouts`) are owner-only with no
|
||||
share columns. Existing visibility predicates
|
||||
(`paliad.can_see_project`) walk the project tree, not arbitrary
|
||||
entities. This design introduces the first multi-axis share pattern
|
||||
in the codebase (§3.2).
|
||||
|
||||
## 3. Architecture: hybrid templates + share table
|
||||
|
||||
### 3.1 Two template sources, one read layer
|
||||
|
||||
**KEEP** the static Go template registry as the firm's curated catalog.
|
||||
It's version-controlled, code-reviewed, immutable at runtime, and the
|
||||
right substrate for legally-curated content (RoP citations, EPC rule
|
||||
references). Migrating those into DB rows would lose the git review
|
||||
trail for content that requires lawyer eyes.
|
||||
|
||||
**ADD** `paliad.checklists` as the DB catalog for user-authored content.
|
||||
Same Template shape (slug, titles, regime, court, groups[], items[])
|
||||
but stored as JSONB so the schema doesn't have to chase content
|
||||
evolution.
|
||||
|
||||
A `ChecklistCatalogService` unifies the two at read time:
|
||||
- `ListVisible(user)` → static templates ∪ DB rows the user can see
|
||||
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
|
||||
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
|
||||
rejected if they collide with a static slug).
|
||||
|
||||
Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
|
||||
their JSON shape — they just delegate to the catalog service instead of
|
||||
the bare static registry.
|
||||
|
||||
### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred
|
||||
|
||||
The task brief asks for a "modular / abstract" solution. I considered a
|
||||
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
|
||||
recipient_*)` table that could later carry shares for views, dashboards,
|
||||
saved searches, project templates, etc.
|
||||
|
||||
**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
|
||||
v1.** Reasons:
|
||||
|
||||
1. There is NO second entity in paliad that requests sharing today —
|
||||
`user_views`, `user_dashboard_layouts`, `user_card_layouts`,
|
||||
`user_pinned_projects` are all explicitly owner-only by design (see
|
||||
migration comments). The "future reuse" is hypothetical.
|
||||
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
|
||||
needs its own deletion trigger. That complexity is real, the
|
||||
reusability gain is not.
|
||||
3. The CORRECT abstraction emerges by extracting *after* the second use
|
||||
case shows up. Right now we don't know whether dashboards want the
|
||||
same recipient axes (user / office / partner-unit / project) or a
|
||||
different set (e.g. dashboards probably want "everyone on a project"
|
||||
not "the whole firm").
|
||||
|
||||
The design IS modular in the sense that the recipient resolution logic
|
||||
(below) is centralized in one SQL predicate (§4.3) which a future
|
||||
polymorphic refactor can lift verbatim.
|
||||
|
||||
If the second entity asks for sharing within ~3 months, refactor to
|
||||
`paliad.entity_shares` as a single-mig follow-up. Until then,
|
||||
`paliad.checklist_shares` keeps the schema honest.
|
||||
|
||||
### 3.3 Visibility states
|
||||
|
||||
`paliad.checklists.visibility text` (CHECK enum):
|
||||
|
||||
| state | who sees | who edits |
|
||||
|-----------|----------------------------------------------------|---------------------|
|
||||
| `private` | owner only | owner |
|
||||
| `shared` | owner + explicit recipients in checklist_shares | owner |
|
||||
| `firm` | owner + every authenticated paliad user | owner |
|
||||
| `global` | owner + every authenticated paliad user + catalog | owner + global_admin|
|
||||
|
||||
`firm` vs `global` distinction:
|
||||
- `firm` = author self-published. Author can flip back to private/shared
|
||||
any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
|
||||
in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
|
||||
- `global` = admin-promoted into the firm catalog. Appears in the main
|
||||
Vorlagen tab alongside the static templates. Author retains edit
|
||||
authority by default; only `global_admin` can demote.
|
||||
|
||||
Demotion target: `global → firm` (preserves visibility for users who
|
||||
already started instances). Author can subsequently narrow further.
|
||||
|
||||
### 3.4 Template snapshot on instance create
|
||||
|
||||
m's brief calls this out as a design decision: when an author edits a
|
||||
template, do existing instances pick up the changes (propagate) or stay
|
||||
on the version they were created from (snapshot)?
|
||||
|
||||
**Pick: snapshot.** Inventor pick (R). Rationale:
|
||||
|
||||
1. **Data integrity.** Instances are working artefacts. A user halfway
|
||||
through a Klageerwiderung instance shouldn't have items disappear or
|
||||
reorder under them because the author edited the template.
|
||||
2. **Audit story.** The completed instance shows exactly what the
|
||||
author saw when they started. Reconstruction without git-blame on
|
||||
the template.
|
||||
3. **Visibility narrowing safe by construction.** If author unshares
|
||||
from a colleague who already has an instance, the instance survives
|
||||
because the snapshot is local.
|
||||
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
|
||||
exceed a few per user per template. Even 10× the row size of today
|
||||
is fine.
|
||||
|
||||
Schema cost: one nullable `template_snapshot jsonb` column on
|
||||
`paliad.checklist_instances`. Backfilled lazily — existing instances
|
||||
keep `NULL`, service falls back to looking the slug up in the catalog;
|
||||
new instances always get a snapshot. Slice C can backfill the column
|
||||
for already-existing rows via a one-off `UPDATE` if we want strict
|
||||
consistency.
|
||||
|
||||
## 4. Schema (migration 112 — verify slot at coder shift)
|
||||
|
||||
Single migration file `internal/db/migrations/112_user_checklists.up.sql`
|
||||
+ matching `.down.sql`. Idempotent throughout
|
||||
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).
|
||||
|
||||
> Slot caveat: at design time, latest disk = 111, live tracker = 106
|
||||
> (mig 107-111 pending deploy). Coder MUST re-verify
|
||||
> `ls internal/db/migrations/ | tail` at shift start. If a higher
|
||||
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
|
||||
> 112), bump to the next free slot.
|
||||
|
||||
### 4.1 `paliad.checklists` — authored template catalog
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.checklists (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
-- Authoring metadata
|
||||
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
regime text NOT NULL DEFAULT 'OTHER', -- UPC | DE | EPA | OTHER
|
||||
court text NOT NULL DEFAULT '',
|
||||
reference text NOT NULL DEFAULT '',
|
||||
deadline text NOT NULL DEFAULT '',
|
||||
lang text NOT NULL DEFAULT 'de', -- 'de' | 'en' — author's primary language
|
||||
-- Body
|
||||
body jsonb NOT NULL, -- { groups: [{ title, items: [{ label, note, rule }] }] }
|
||||
-- Lifecycle
|
||||
visibility text NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
|
||||
promoted_at timestamptz, -- set on transition to 'global'
|
||||
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- Timestamps
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX checklists_owner_idx ON paliad.checklists (owner_id);
|
||||
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
|
||||
CREATE INDEX checklists_regime_idx ON paliad.checklists (regime);
|
||||
```
|
||||
|
||||
**Slug-collision safety net:** application layer validates that the
|
||||
chosen slug doesn't collide with a static template slug. The static
|
||||
list is loaded into a `map[string]bool` at boot. New authored slugs
|
||||
auto-prefixed with `u-` so collisions with static slugs are structurally
|
||||
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).
|
||||
|
||||
### 4.2 `paliad.checklist_shares` — explicit grants
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.checklist_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
|
||||
recipient_kind text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
|
||||
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
recipient_office text,
|
||||
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- XOR check: exactly one recipient_* column populated per kind
|
||||
CONSTRAINT checklist_shares_recipient_xor CHECK (
|
||||
(recipient_kind = 'user' AND recipient_user_id IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'office' AND recipient_office IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'project' AND recipient_project_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Avoid duplicates per recipient
|
||||
CREATE UNIQUE INDEX checklist_shares_user_uniq ON paliad.checklist_shares (checklist_id, recipient_user_id) WHERE recipient_kind = 'user';
|
||||
CREATE UNIQUE INDEX checklist_shares_office_uniq ON paliad.checklist_shares (checklist_id, recipient_office) WHERE recipient_kind = 'office';
|
||||
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
|
||||
CREATE UNIQUE INDEX checklist_shares_project_uniq ON paliad.checklist_shares (checklist_id, recipient_project_id) WHERE recipient_kind = 'project';
|
||||
|
||||
-- Hot-path index for the visibility predicate
|
||||
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
|
||||
```
|
||||
|
||||
### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner can always see
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.owner_id = _user_id
|
||||
)
|
||||
-- 'firm' / 'global' visible to all authenticated users
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.visibility IN ('firm', 'global')
|
||||
)
|
||||
-- Explicit share: user
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = _user_id
|
||||
)
|
||||
-- Explicit share: office (matches user.office OR additional_offices)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
-- Explicit share: partner_unit (caller is a member)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
-- Explicit share: project (caller can see the project via existing predicate)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'project'
|
||||
AND paliad.can_see_project(s.recipient_project_id) -- reuses ltree walk
|
||||
);
|
||||
$$;
|
||||
```
|
||||
|
||||
> Note on `can_see_project` self-reference: that function reads
|
||||
> `auth.uid()` internally — when called from inside another SECURITY
|
||||
> DEFINER body it picks up the caller's uid via search_path inheritance
|
||||
> (same pattern as `effective_project_admin` reuse in mig 111).
|
||||
|
||||
### 4.4 RLS on `paliad.checklists`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: owner OR visible via can_see_checklist
|
||||
CREATE POLICY checklists_select
|
||||
ON paliad.checklists FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_checklist(auth.uid(), id));
|
||||
|
||||
-- INSERT: caller can only create templates owned by themselves
|
||||
CREATE POLICY checklists_insert
|
||||
ON paliad.checklists FOR INSERT TO authenticated
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
|
||||
CREATE POLICY checklists_update
|
||||
ON paliad.checklists FOR UPDATE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin
|
||||
CREATE POLICY checklists_delete
|
||||
ON paliad.checklists FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.5 RLS on `paliad.checklist_shares`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
|
||||
CREATE POLICY checklist_shares_select
|
||||
ON paliad.checklist_shares FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- INSERT: only the checklist owner can grant
|
||||
CREATE POLICY checklist_shares_insert
|
||||
ON paliad.checklist_shares FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
AND granted_by = auth.uid()
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
|
||||
CREATE POLICY checklist_shares_delete
|
||||
ON paliad.checklist_shares FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
```
|
||||
|
||||
### 4.6 `paliad.checklist_instances.template_snapshot jsonb`
|
||||
|
||||
```sql
|
||||
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
|
||||
```
|
||||
|
||||
Existing RLS on `checklist_instances` untouched.
|
||||
|
||||
## 5. Service layer
|
||||
|
||||
### 5.1 `internal/services/checklist_catalog_service.go` (new)
|
||||
|
||||
Unified read facade over static + DB templates.
|
||||
|
||||
```go
|
||||
type ChecklistCatalogService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
type CatalogEntry struct {
|
||||
Slug string // matches checklists.Template.Slug or paliad.checklists.slug
|
||||
Origin string // "static" | "authored"
|
||||
OwnerID *uuid.UUID // nil for static
|
||||
OwnerName string // empty for static
|
||||
Visibility string // "static" | "private" | "shared" | "firm" | "global"
|
||||
Template checklists.Template
|
||||
}
|
||||
|
||||
// ListVisible returns every catalog entry the caller can see.
|
||||
// Static entries are always returned. DB entries pass through RLS.
|
||||
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)
|
||||
|
||||
// Find returns one entry by slug (static lookup first, then DB).
|
||||
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)
|
||||
|
||||
// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
|
||||
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
|
||||
```
|
||||
|
||||
### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)
|
||||
|
||||
CRUD on `paliad.checklists`.
|
||||
|
||||
```go
|
||||
type ChecklistTemplateService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
type CreateTemplateInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Regime string
|
||||
Court string
|
||||
Reference string
|
||||
Deadline string
|
||||
Lang string
|
||||
Body checklists.Template // unmarshalled to body jsonb minus slug/titles/etc
|
||||
}
|
||||
|
||||
func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
|
||||
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
|
||||
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
|
||||
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error // private/firm only
|
||||
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
|
||||
```
|
||||
|
||||
Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
|
||||
suffix (collision retry up to 3x). Validator enforces
|
||||
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
|
||||
`internal/checklists/checklists.go` Templates rejected at write time.
|
||||
|
||||
### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)
|
||||
|
||||
```go
|
||||
type ChecklistShareService struct { db *sqlx.DB }
|
||||
|
||||
type ShareGrantInput struct {
|
||||
RecipientKind string
|
||||
UserID *uuid.UUID
|
||||
Office string
|
||||
PartnerUnitID *uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
}
|
||||
|
||||
func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
|
||||
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
|
||||
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
|
||||
```
|
||||
|
||||
### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)
|
||||
|
||||
`global_admin`-only operations.
|
||||
|
||||
```go
|
||||
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }
|
||||
|
||||
func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
|
||||
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
|
||||
```
|
||||
|
||||
Promote: assert caller.global_role = 'global_admin' → UPDATE visibility =
|
||||
'global', promoted_at = now(), promoted_by = caller → audit row
|
||||
`event_type='checklist.promoted_global'`.
|
||||
|
||||
Demote: assert caller is global_admin → UPDATE visibility = target
|
||||
(default 'firm') → audit row `event_type='checklist.demoted'`.
|
||||
|
||||
### 5.5 Wire instance create to take snapshot
|
||||
|
||||
`ChecklistInstanceService.Create` extends to capture
|
||||
`template_snapshot` at insert time via
|
||||
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
|
||||
(NULL snapshot, fallback path in read layer).
|
||||
|
||||
### 5.6 Endpoints
|
||||
|
||||
| Method | Path | Slice | Purpose |
|
||||
|--------|------|-------|---------|
|
||||
| `GET` | `/api/checklists` | (existing)| Merged catalog list (static + visible DB) |
|
||||
| `GET` | `/api/checklists/{slug}` | (existing)| Single template (static or DB) |
|
||||
| `POST` | `/api/checklists/templates` | A | Create authored template |
|
||||
| `GET` | `/api/checklists/templates/mine` | A | List own authored templates |
|
||||
| `PATCH` | `/api/checklists/templates/{slug}` | A | Edit authored template |
|
||||
| `DELETE` | `/api/checklists/templates/{slug}` | A | Delete authored template |
|
||||
| `PATCH` | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
|
||||
| `GET` | `/api/checklists/templates/{slug}/shares` | B | List grants |
|
||||
| `POST` | `/api/checklists/templates/{slug}/shares` | B | Grant share |
|
||||
| `DELETE` | `/api/checklists/shares/{id}` | B | Revoke share |
|
||||
| `POST` | `/api/admin/checklists/{slug}/promote` | B | Admin promote to global |
|
||||
| `POST` | `/api/admin/checklists/{slug}/demote` | B | Admin demote |
|
||||
| `GET` | `/api/checklists/gallery` | C | Browse all firm + global templates |
|
||||
|
||||
## 6. Instance snapshot lifecycle
|
||||
|
||||
**On Create (`ChecklistInstanceService.Create`):**
|
||||
1. Resolve slug via `catalog.Find(userID, slug)` — enforces visibility.
|
||||
2. `snapshot = catalog.SnapshotBody(userID, slug)` — captures the
|
||||
template body (groups + items) at this moment, as JSONB.
|
||||
3. Insert into `checklist_instances` with
|
||||
`template_snapshot = snapshot`, `template_slug = slug`,
|
||||
`state = '{}'::jsonb`.
|
||||
|
||||
**On Read (`ChecklistInstanceService.GetByID`):**
|
||||
- Return the instance with `template_snapshot` if non-null.
|
||||
- If NULL (legacy row created before mig 112), fall back to
|
||||
`catalog.Find(slug)`. Logged at INFO; not a fatal path.
|
||||
|
||||
**On Template Edit (Slice A):**
|
||||
- Owner edits template via PATCH → DB row mutated → `checklists.updated_at`
|
||||
bumped → no propagation. Existing instances continue rendering their
|
||||
snapshot. New instances pick up the edit.
|
||||
- Audit row `event_type='checklist.edited'`,
|
||||
`metadata={ checklist_id, slug, changes:[...] }`.
|
||||
|
||||
**On Template Delete:**
|
||||
- DB row deleted. Instances that snapshotted survive (snapshot is
|
||||
local). Instances that DIDN'T snapshot (NULL) gracefully degrade —
|
||||
service detects "template not found in catalog" and returns the
|
||||
instance with a sentinel "template withdrawn" body (renders a small
|
||||
banner client-side; checkboxes still work because `state` is the
|
||||
source of truth, not the template).
|
||||
|
||||
**On Visibility Narrow (firm → shared → private):**
|
||||
- Existing instances unaffected (snapshot is local; visibility check is
|
||||
on the template, not instance).
|
||||
- New instance attempts fail with `ErrNotVisible` (the user can no
|
||||
longer see the template to instantiate it).
|
||||
|
||||
## 7. Frontend (concise sketch — coder owns the detail)
|
||||
|
||||
### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"
|
||||
|
||||
Add a third tab between "Vorlagen" and "Vorhandene Instanzen":
|
||||
|
||||
```
|
||||
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
|
||||
```
|
||||
|
||||
- **Vorlagen** (existing): static catalog + global-promoted DB
|
||||
templates, grouped by Regime, filter pills (UPC/DE/EPA).
|
||||
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
|
||||
Vorlage" CTA. Each card shows title, description, visibility chip,
|
||||
Aktions-Buttons (Bearbeiten / Teilen / Löschen).
|
||||
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
|
||||
optionally render an "📌 Snapshot" badge when `template_snapshot` is
|
||||
non-null (Slice A backfill marker).
|
||||
|
||||
Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
|
||||
templates not yet promoted — discovery surface).
|
||||
|
||||
### 7.2 `/checklists/new` (NEW — Slice A)
|
||||
|
||||
Authoring wizard. Three steps:
|
||||
1. Metadata — title, description, regime (UPC/DE/EPA/OTHER), court,
|
||||
reference, deadline.
|
||||
2. Sections + items — repeating editor (group title → items[] of
|
||||
{label, note, rule}).
|
||||
3. Visibility — radio: privat / firm-weit. (Sharing flow comes in
|
||||
Slice B.)
|
||||
|
||||
Save → POST `/api/checklists/templates` → redirect to
|
||||
`/checklists/{slug}` detail.
|
||||
|
||||
### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)
|
||||
|
||||
Same wizard, prefilled. Owner-only (404 otherwise).
|
||||
|
||||
### 7.4 `/checklists/{slug}` detail page
|
||||
|
||||
Existing detail page renders the template (static OR authored).
|
||||
Additions:
|
||||
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
|
||||
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
|
||||
entfernen" button (Slice B).
|
||||
- Provenance line under the title: "Erstellt von <author> · <date>"
|
||||
(only for DB templates).
|
||||
|
||||
### 7.5 Share modal (Slice B)
|
||||
|
||||
Triggered by "Teilen" on owner's detail page. Four pickers stacked:
|
||||
- Kollegen (user-picker, multi-select)
|
||||
- Office (chip-select from `offices.All`)
|
||||
- Dezernat (chip-select from `partner_units`)
|
||||
- Projekt (autocomplete from owner-visible projects)
|
||||
|
||||
Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
|
||||
"firm-weit" greys out the picker (firm-weit doesn't need grants).
|
||||
|
||||
Apply → POST grants individually → audit emits one
|
||||
`event_type='checklist.shared'` per grant with
|
||||
`metadata={ recipient_kind, recipient_id, checklist_id }`.
|
||||
|
||||
### 7.6 i18n keys
|
||||
|
||||
~28 new keys (DE+EN) under `checklisten.authoring.*`,
|
||||
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
|
||||
matches existing `checklisten.tab.*` / `checklisten.instances.*`.
|
||||
|
||||
## 8. Audit events
|
||||
|
||||
Org-scope (`paliad.system_audit_log` via a small new helper
|
||||
`SystemAuditLogService.WriteChecklistEvent`):
|
||||
|
||||
| event_type | actor | metadata keys |
|
||||
|----------------------------------|-------------|----------------------------------------------------|
|
||||
| `checklist.authored` | owner | checklist_id, slug, visibility |
|
||||
| `checklist.edited` | owner | checklist_id, slug, changed_fields[] |
|
||||
| `checklist.visibility_changed` | owner | checklist_id, slug, from, to |
|
||||
| `checklist.shared` | owner | checklist_id, slug, recipient_kind, recipient_id |
|
||||
| `checklist.unshared` | owner | checklist_id, slug, recipient_kind, recipient_id |
|
||||
| `checklist.promoted_global` | global_admin| checklist_id, slug, owner_id |
|
||||
| `checklist.demoted` | global_admin| checklist_id, slug, target_visibility |
|
||||
| `checklist.deleted` | owner OR ga | checklist_id, slug, was_visibility |
|
||||
|
||||
Project-scope (`paliad.project_events` — existing helper
|
||||
`insertProjectEventWithMeta`): existing checklist-instance events
|
||||
unchanged. NO new project_events types for templates — templates are
|
||||
not project-scoped.
|
||||
|
||||
`AuditService.ListEntries` already reads from `system_audit_log` via
|
||||
the UNION ALL branch added in t-paliad-214 — no changes needed there;
|
||||
new event_types surface automatically in the audit log UI.
|
||||
|
||||
## 9. Slice plan
|
||||
|
||||
### Slice A — Foundation (~700 LoC)
|
||||
|
||||
**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
|
||||
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
|
||||
no share table yet; visibility limited to private/firm.
|
||||
|
||||
**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
|
||||
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
|
||||
`SystemAuditLogService.WriteChecklistEvent` helper.
|
||||
|
||||
**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
|
||||
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.
|
||||
|
||||
**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
|
||||
`/checklists/{slug}/edit`, owner controls on detail page.
|
||||
|
||||
**Test pass:** unit tests for slug validation, snapshot capture,
|
||||
visibility predicate (without share rows), audit emit, fallback to
|
||||
catalog when snapshot NULL.
|
||||
|
||||
**No share, no admin promote, no gallery.** Ships immediately useful
|
||||
for solo authoring + firm-wide publishing.
|
||||
|
||||
### Slice B — Sharing + Promotion (~600 LoC)
|
||||
|
||||
**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
|
||||
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
|
||||
sub-enum (Slice A schema already includes 'shared' as valid value —
|
||||
just no grants point at it yet).
|
||||
|
||||
**Service:** `ChecklistShareService`, `ChecklistPromotionService`.
|
||||
|
||||
**Endpoints:** shares endpoints + admin promote/demote.
|
||||
|
||||
**Frontend:** Share modal, "Make global" admin button on detail page,
|
||||
share-grant chip list on detail page (owner-only).
|
||||
|
||||
**Audit:** new event_types (shared, unshared, promoted_global, demoted).
|
||||
|
||||
### Slice C — Discoverability + UX polish (~400 LoC)
|
||||
|
||||
**Gallery page** `/checklists/gallery`: browses every template the user
|
||||
can see that's NOT their own, grouped by Regime / Author / Recency.
|
||||
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.
|
||||
|
||||
**Backfill** existing `checklist_instances` with `template_snapshot`
|
||||
via a one-off migration (mig 114) — pure data move, no schema change.
|
||||
After backfill, the catalog-fallback path can be removed (deferred to
|
||||
Slice D / cleanup).
|
||||
|
||||
**Optional**:
|
||||
- "Vorlage kopieren" action — clone an existing template (static OR
|
||||
authored) into the caller's "Meine Vorlagen" for personal adaptation.
|
||||
- Per-template instance counter ("12 Kollegen haben diese Vorlage
|
||||
benutzt") — surfaced from `checklist_instances` group-by.
|
||||
|
||||
## 10. Trade-offs flagged
|
||||
|
||||
1. **Hybrid catalog (static + DB).** Two sources of truth means two
|
||||
slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
|
||||
reserved-list rejection. Refactoring all static templates into DB
|
||||
loses the git review trail; the hybrid is the right cost.
|
||||
2. **Polymorphism deferred.** A future second sharable entity will need
|
||||
to either copy the `checklist_shares` pattern (cheap but duplicative)
|
||||
or refactor to `entity_shares` (one mig). The refactor is small;
|
||||
premature abstraction now would pay complexity for no current
|
||||
benefit.
|
||||
3. **Snapshot semantics may surprise.** A user who edits their template
|
||||
expecting downstream instances to update will be confused.
|
||||
Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
|
||||
neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
|
||||
detail page that re-snapshots from the current template (preserves
|
||||
the user's checkbox state to the extent items still match).
|
||||
4. **Office membership is set-membership, not hierarchy.** Sharing to
|
||||
"munich" reaches every user with `office='munich'` OR
|
||||
`'munich' = ANY(additional_offices)`. There's no concept of "Munich
|
||||
plus its sub-teams" because offices don't nest in paliad. Fine.
|
||||
5. **Partner-unit membership join is N+1 on the predicate.** Each
|
||||
visibility check touches `partner_unit_members` if any partner-unit
|
||||
share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
|
||||
already exist (per mig 027 lineage); the join is single-row.
|
||||
6. **Share-to-project recipient resolution uses
|
||||
`can_see_project(s.recipient_project_id)`.** That predicate reads
|
||||
`auth.uid()` from the session, so it works correctly inside our
|
||||
SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
|
||||
in `paliad.can_see_project` source — same pattern that
|
||||
`effective_project_admin` uses in mig 111.
|
||||
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
|
||||
Means a global_admin can edit content of any user's template, not
|
||||
just visibility. This is intentional for catalog hygiene
|
||||
(correcting typos, removing inflammatory content) but should be used
|
||||
sparingly and audited. The audit log captures every
|
||||
global_admin-attributed edit via `checklist.edited` with actor_id.
|
||||
8. **Instance snapshot fallback path lives indefinitely.** Existing
|
||||
pre-mig-112 instances stay NULL until Slice C backfills. The
|
||||
fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
|
||||
no hot-path concern — but it's "dead code" once the backfill runs.
|
||||
Acceptable until Slice C.
|
||||
9. **Cascade on owner deletion.** If an authored template's owner is
|
||||
removed (`paliad.users.id` cascades), the template is wiped along
|
||||
with all its shares. Existing instances survive via snapshot. The
|
||||
alternative (transfer ownership to global_admin on user-delete) is
|
||||
more polite but introduces governance questions ("which admin?")
|
||||
that aren't worth Slice A complexity. Flag for Slice C if it bites.
|
||||
10. **Slug uniqueness across origins enforced application-side.**
|
||||
The static catalog is in-memory at boot. If a deploy adds a static
|
||||
slug that collides with an existing DB slug, the deploy boots
|
||||
cleanly but the DB row becomes unreachable via the catalog read
|
||||
layer (static wins on slug lookup). Mitigation: a boot-time
|
||||
integrity check in `cmd/server/main.go` logs WARN if collision
|
||||
detected. Owner can rename their template manually via the edit UI.
|
||||
|
||||
## 11. m's decisions ledger (all defaulted to (R) per task brief)
|
||||
|
||||
Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
|
||||
material." I have not escalated; all picks below default to (R).
|
||||
|
||||
| # | Question | (R) pick |
|
||||
|---|---------------------------------------------------------|-------------------------------------------|
|
||||
| 1 | Storage model for authored templates | Hybrid: keep static catalog + new `paliad.checklists` DB table |
|
||||
| 2 | Instance lifecycle on template edit | **Snapshot** at instance create (NOT propagate) |
|
||||
| 3 | Visibility enum values | `private`, `shared`, `firm`, `global` |
|
||||
| 4 | Share recipients | user, office, partner_unit, project (4 axes) |
|
||||
| 5 | Share-to-project resolution | Reuse `can_see_project` (visibility, not just team rows) |
|
||||
| 6 | Promotion authority | `global_admin` only (no per-project admin promote in v1) |
|
||||
| 7 | Demotion target | `global → firm` (preserves visibility for in-flight instances) |
|
||||
| 8 | Slug strategy | `u-` prefix on authored, application-side collision check vs static |
|
||||
| 9 | Polymorphic share table (`entity_shares`) vs scoped | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
|
||||
| 10| Authoring i18n | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
|
||||
| 11| Audit sink for template lifecycle | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
|
||||
| 12| Slice ordering | A (foundation) → B (share + promote) → C (gallery + backfill) |
|
||||
|
||||
Material escalation list: empty. If m disagrees with any of the above,
|
||||
amend §11 in the next inventor shift; the schema is designed to be
|
||||
forward-compatible with most reversals (e.g. flipping snapshot →
|
||||
propagate is a service-layer change, not a schema change).
|
||||
|
||||
## 12. Acceptance criteria — Slice A
|
||||
|
||||
1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
|
||||
on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
|
||||
`paliad` schema).
|
||||
2. **`/api/checklists` returns merged catalog** — static templates
|
||||
plus DB templates the caller can see (visibility ∈ {firm, global}
|
||||
OR owner = caller).
|
||||
3. **POST `/api/checklists/templates`** creates a row, returns the
|
||||
created template with auto-generated `u-…` slug, emits
|
||||
`checklist.authored` audit row.
|
||||
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
|
||||
fields, rejects 403 from non-owner non-admin, emits
|
||||
`checklist.edited`.
|
||||
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
|
||||
private↔firm; rejects `shared` and `global` in Slice A (those land
|
||||
in Slice B); emits `checklist.visibility_changed`.
|
||||
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
|
||||
existing instances survive via snapshot.
|
||||
7. **Instance create snapshots the template body** —
|
||||
`template_snapshot` non-null on every new instance row.
|
||||
8. **Legacy instances (NULL snapshot) still render** via catalog
|
||||
fallback (covered by a regression test).
|
||||
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
|
||||
CTA navigates to `/checklists/new`; wizard saves successfully.
|
||||
10. **`go build ./... && go vet ./... && go test ./internal/...`
|
||||
clean.** `bun run build` clean (i18n key count incremented by ~20).
|
||||
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
|
||||
template; setting visibility to `firm` makes it visible to a second
|
||||
tester account; deleting the template doesn't break existing
|
||||
instances.
|
||||
|
||||
## 13. Recommended implementer
|
||||
|
||||
Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
|
||||
directive 2026-05-06). Substrate is well-trodden:
|
||||
|
||||
- Migration shape mirrors mig 111 (gauss) for the predicate function +
|
||||
policy replacement pattern.
|
||||
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
|
||||
emit + visibility check.
|
||||
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
|
||||
- Frontend tab pattern mirrors the existing
|
||||
`entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.
|
||||
|
||||
Novel pieces:
|
||||
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
|
||||
prototype before committing to the full slice. Pure function; easy
|
||||
to unit-test.
|
||||
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
|
||||
into a STABLE SECURITY DEFINER function; pattern matches mig 111
|
||||
exactly.
|
||||
|
||||
Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
|
||||
or one branch with three commits — coder's call. Each slice ends with
|
||||
acceptance criteria; head merges between slices for fast feedback.
|
||||
|
||||
## 14. Out of scope (explicitly)
|
||||
|
||||
- Importing checklists from external sources (Notion, Trello, .docx).
|
||||
- Approval-policy gating on checklist edits (admin pre-publish review).
|
||||
- Cross-firm template marketplace.
|
||||
- Translation workflow (de↔en) for authored templates — Slice A
|
||||
ships single-language; if firm appetite shows up post-launch, file
|
||||
a follow-up.
|
||||
- Static-catalog editor UI (the static templates remain code-only).
|
||||
- Versioning UI ("show me the version this instance was created from")
|
||||
— snapshot is captured; surfacing it is Slice C polish.
|
||||
|
||||
---
|
||||
|
||||
**Inventor parked per gate protocol.** No auto-shift to coder. Head
|
||||
decides: same worker as `/mai-coder` with this brief, fresh coder, or
|
||||
rescope. Slice ordering A → B → C is independent enough that the head
|
||||
can also greenlight Slice A alone and re-design B/C after Slice A
|
||||
ships.
|
||||
52
docs/t-paliad-207-followup-scope.md
Normal file
52
docs/t-paliad-207-followup-scope.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# t-paliad-207 follow-up scope — close-out assessment
|
||||
|
||||
**Author:** fermi (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
|
||||
|
||||
---
|
||||
|
||||
## 0. What shipped under t-paliad-207
|
||||
|
||||
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
|
||||
|
||||
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
|
||||
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
|
||||
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
|
||||
4. **mig 100** — `upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
|
||||
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
|
||||
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
|
||||
7. **Notes toggle** — `Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
|
||||
|
||||
Filed two follow-up issues during the session:
|
||||
|
||||
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
|
||||
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
|
||||
|
||||
## 1. Why (A) DONE
|
||||
|
||||
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
|
||||
|
||||
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
|
||||
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
|
||||
|
||||
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
|
||||
|
||||
## 2. Optional tail — would file as discrete issues, not a fermi slice
|
||||
|
||||
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
|
||||
|
||||
| # | Candidate | Size | Already covered? |
|
||||
|---|---|---|---|
|
||||
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
|
||||
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
|
||||
| 3 | **Touch-device fallback for the ⓘ hover hint** — `title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
|
||||
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
|
||||
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
|
||||
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
|
||||
|
||||
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
|
||||
|
||||
## 3. Recommendation
|
||||
|
||||
Close t-paliad-207. Fire fermi. The remaining tail (items 1–6 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.
|
||||
@@ -10,6 +10,7 @@ import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
|
||||
import { renderChecklists } from "./src/checklists";
|
||||
import { renderChecklistsAuthor } from "./src/checklists-author";
|
||||
import { renderChecklistsDetail } from "./src/checklists-detail";
|
||||
import { renderChecklistsInstance } from "./src/checklists-instance";
|
||||
import { renderCourts } from "./src/courts";
|
||||
@@ -17,13 +18,14 @@ import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
|
||||
import { renderAppointmentsNew } from "./src/appointments-new";
|
||||
import { renderAppointmentsDetail } from "./src/appointments-detail";
|
||||
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
|
||||
import { renderSettings } from "./src/settings";
|
||||
import { renderDashboard } from "./src/dashboard";
|
||||
import { renderAgenda } from "./src/agenda";
|
||||
@@ -245,6 +247,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
|
||||
join(import.meta.dir, "src/client/checklists.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-author.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-detail.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-instance.ts"),
|
||||
join(import.meta.dir, "src/client/courts.ts"),
|
||||
@@ -252,13 +255,14 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-new.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-detail.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-calendar.ts"),
|
||||
join(import.meta.dir, "src/client/settings.ts"),
|
||||
join(import.meta.dir, "src/client/dashboard.ts"),
|
||||
join(import.meta.dir, "src/client/agenda.ts"),
|
||||
@@ -370,6 +374,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
|
||||
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
|
||||
await Bun.write(join(DIST, "checklists-author.html"), renderChecklistsAuthor());
|
||||
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
|
||||
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
|
||||
await Bun.write(join(DIST, "courts.html"), renderCourts());
|
||||
@@ -377,6 +382,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
@@ -384,10 +392,8 @@ async function build() {
|
||||
await Bun.write(join(DIST, "events.html"), renderEvents());
|
||||
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
|
||||
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
|
||||
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
|
||||
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
|
||||
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
|
||||
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
|
||||
await Bun.write(join(DIST, "settings.html"), renderSettings());
|
||||
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
|
||||
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
|
||||
|
||||
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
BIN
frontend/public/patentstyle/HL-Patents-Style.dotm
Normal file
Binary file not shown.
126
frontend/public/patentstyle/index.html
Normal file
126
frontend/public/patentstyle/index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HL Patents Style</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #002236;
|
||||
--fg: #e8e8ed;
|
||||
--muted: #8a9aa6;
|
||||
--accent: #bff355;
|
||||
--rule: #0f3a55;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
line-height: 1.55;
|
||||
font-size: 17px;
|
||||
}
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 6rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 .accent { color: var(--accent); }
|
||||
.lead {
|
||||
color: var(--muted);
|
||||
margin: 0 0 3rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
margin: 2.5rem 0 0.75rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
ul { padding-left: 1.25rem; margin: 0.5rem 0 1rem; }
|
||||
li { margin: 0.35rem 0; }
|
||||
p { margin: 0.6rem 0; }
|
||||
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
a:hover { border-bottom-color: var(--accent); }
|
||||
code, kbd {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: #0a2d44;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.download {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
}
|
||||
.download:hover { border-bottom: 0; filter: brightness(1.05); }
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer code { color: var(--muted); background: transparent; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>HL <span class="accent">Patents Style</span></h1>
|
||||
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
|
||||
|
||||
<h2>Was es kann</h2>
|
||||
<ul>
|
||||
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
|
||||
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
|
||||
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
|
||||
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
|
||||
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
|
||||
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Aktualisierungen</h2>
|
||||
<p>Im Ribbon-Tab <em>HL Patent</em> → Gruppe <em>Manage</em> → <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
|
||||
|
||||
<h2>Frische Installation</h2>
|
||||
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
|
||||
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
|
||||
|
||||
<h2>Hilfe & Feedback</h2>
|
||||
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
|
||||
<footer>
|
||||
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> · Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
|
||||
<p id="ver"></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Best-effort: show the currently-served version
|
||||
fetch('version.json', { cache: 'no-cache' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(j => {
|
||||
if (j && j.version) {
|
||||
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
5
frontend/public/patentstyle/version.json
Normal file
5
frontend/public/patentstyle/version.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v0.260518",
|
||||
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
|
||||
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-team-actions">
|
||||
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
|
||||
Konto direkt anlegen
|
||||
</button>
|
||||
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
|
||||
Bestehendes Konto onboarden
|
||||
</button>
|
||||
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
|
||||
Creates BOTH the auth.users row (via Supabase Admin API) and
|
||||
the paliad.users row in one click. New user is visible in
|
||||
dropdowns immediately. */}
|
||||
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
|
||||
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
|
||||
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.
|
||||
</p>
|
||||
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
|
||||
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
|
||||
<input type="text" id="admin-af-name" name="display_name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
|
||||
<select id="admin-af-office" name="office" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
|
||||
<select id="admin-af-profession" name="profession">
|
||||
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
|
||||
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
|
||||
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
|
||||
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
|
||||
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
|
||||
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
|
||||
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
|
||||
<select id="admin-af-lang" name="lang">
|
||||
<option value="de" selected>Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="admin-af-send-welcome" checked />
|
||||
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
|
||||
</label>
|
||||
<div id="admin-af-feedback" className="form-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-team.js"></script>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="appointments.kalender.title">Terminkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/events?type=appointment" />
|
||||
<BottomNav currentPath="/events?type=appointment" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 data-i18n="appointments.kalender.heading">Terminkalender</h1>
|
||||
<p className="tool-subtitle" data-i18n="appointments.kalender.subtitle">
|
||||
Monatsübersicht aller Termine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="fristen-header-actions">
|
||||
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
|
||||
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</button>
|
||||
<h2 id="cal-month-label" className="frist-cal-month-label" />
|
||||
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="Nächster Monat">→</button>
|
||||
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
|
||||
</div>
|
||||
|
||||
<div className="termin-cal-legend">
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-hearing" />
|
||||
<span data-i18n="appointments.type.hearing">Verhandlung</span>
|
||||
</span>
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-meeting" />
|
||||
<span data-i18n="appointments.type.meeting">Besprechung</span>
|
||||
</span>
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-consultation" />
|
||||
<span data-i18n="appointments.type.consultation">Beratung</span>
|
||||
</span>
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-deadline_hearing" />
|
||||
<span data-i18n="appointments.type.deadline_hearing">Fristverhandlung</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar" id="appointment-calendar">
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
|
||||
<div id="appointment-cal-grid" className="frist-cal-grid" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="appointment-cal-empty" style="display:none" data-i18n="appointments.kalender.empty">
|
||||
Keine Termine im ausgewählten Zeitraum.
|
||||
</p>
|
||||
|
||||
<div className="modal-overlay" id="cal-popup" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="cal-popup-date" />
|
||||
<button className="modal-close" id="cal-popup-close" type="button">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="cal-popup-list" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
120
frontend/src/checklists-author.tsx
Normal file
120
frontend/src/checklists-author.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Authoring wizard for paliad.checklists. Both /checklists/new and
|
||||
// /checklists/templates/{slug}/edit serve this same bundle; the client reads
|
||||
// window.location.pathname to decide create vs edit mode.
|
||||
export function renderChecklistsAuthor(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="checklisten.author.title">Vorlage erstellen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/checklists" />
|
||||
<BottomNav currentPath="/checklists" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 id="author-heading" data-i18n="checklisten.author.heading.new">Neue Checklisten-Vorlage</h1>
|
||||
<p className="tool-subtitle" data-i18n="checklisten.author.subtitle">
|
||||
Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="author-form" className="form-stack" autoComplete="off">
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="title" data-i18n="checklisten.author.field.title">Titel</label>
|
||||
<input className="form-input" id="title" name="title" type="text" required maxLength="200" />
|
||||
<p className="form-hint" data-i18n="checklisten.author.field.title.hint">z.B. „UPC SoC — interne Checkliste“.</p>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="description" data-i18n="checklisten.author.field.description">Kurzbeschreibung</label>
|
||||
<textarea className="form-input" id="description" name="description" rows="3" maxLength="2000" />
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid-2">
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="regime" data-i18n="checklisten.author.field.regime">Regime</label>
|
||||
<select className="form-input" id="regime" name="regime">
|
||||
<option value="UPC">UPC</option>
|
||||
<option value="DE">DE</option>
|
||||
<option value="EPA">EPA</option>
|
||||
<option value="OTHER" selected>OTHER</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="lang" data-i18n="checklisten.author.field.lang">Sprache</label>
|
||||
<select className="form-input" id="lang" name="lang">
|
||||
<option value="de" selected>Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid-2">
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="court" data-i18n="checklisten.author.field.court">Gericht / Behörde</label>
|
||||
<input className="form-input" id="court" name="court" type="text" maxLength="200" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="reference" data-i18n="checklisten.author.field.reference">Rechtsgrundlage</label>
|
||||
<input className="form-input" id="reference" name="reference" type="text" maxLength="200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="deadline" data-i18n="checklisten.author.field.deadline">Deadline (optional)</label>
|
||||
<input className="form-input" id="deadline" name="deadline" type="text" maxLength="200" />
|
||||
</div>
|
||||
|
||||
<fieldset className="form-fieldset">
|
||||
<legend data-i18n="checklisten.author.field.visibility">Sichtbarkeit</legend>
|
||||
<label className="form-radio">
|
||||
<input type="radio" name="visibility" value="private" checked />
|
||||
<span><strong data-i18n="checklisten.mine.visibility.private">Privat</strong> — <span data-i18n="checklisten.author.visibility.private.hint">Nur für Sie sichtbar.</span></span>
|
||||
</label>
|
||||
<label className="form-radio">
|
||||
<input type="radio" name="visibility" value="firm" />
|
||||
<span><strong data-i18n="checklisten.mine.visibility.firm">Firmenweit</strong> — <span data-i18n="checklisten.author.visibility.firm.hint">Für alle angemeldeten Kolleginnen und Kollegen sichtbar.</span></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-fieldset">
|
||||
<legend data-i18n="checklisten.author.groups.heading">Sektionen und Punkte</legend>
|
||||
<div id="groups-container" />
|
||||
<button type="button" className="btn btn-secondary" id="add-group" data-i18n="checklisten.author.groups.add">+ Sektion hinzufügen</button>
|
||||
</fieldset>
|
||||
|
||||
<p id="author-error" className="form-error" style="display:none" role="alert" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" id="author-save" data-i18n="checklisten.author.save">Speichern</button>
|
||||
<a className="btn btn-secondary" href="/checklists?tab=mine" data-i18n="checklisten.author.cancel">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-author.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -39,12 +39,28 @@ export function renderChecklistsDetail(): string {
|
||||
<div>
|
||||
<h1 id="checklist-title"> </h1>
|
||||
<p className="tool-subtitle" id="checklist-subtitle"> </p>
|
||||
{/* Provenance line — visible only for authored
|
||||
templates; populated by the client from the
|
||||
catalog response's owner_display_name. */}
|
||||
<p className="checklist-provenance" id="checklist-provenance" style="display:none" />
|
||||
<dl className="checklist-meta" id="checklist-meta" />
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
|
||||
Neue Instanz
|
||||
</button>
|
||||
{/* Owner controls (Slice B) — toggled on by the
|
||||
client once /api/checklists/{slug} returns
|
||||
origin='authored' AND owner_email matches the
|
||||
logged-in user. Kept hidden by default so
|
||||
guests / non-owners never see them. */}
|
||||
<a id="btn-edit-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.edit">Bearbeiten</a>
|
||||
<button type="button" id="btn-share-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.share">Teilen</button>
|
||||
<button type="button" id="btn-delete-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.mine.delete">Löschen</button>
|
||||
{/* global_admin controls — revealed by the client
|
||||
when /api/me reports global_role='global_admin'. */}
|
||||
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
|
||||
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
|
||||
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
|
||||
<span data-i18n="checklisten.feedback.btn">Feedback</span>
|
||||
</button>
|
||||
@@ -122,6 +138,65 @@ export function renderChecklistsDetail(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
|
||||
opens it. Four recipient kinds in a single modal: pick the kind,
|
||||
then the matching entity (user / office / partner_unit / project). */}
|
||||
<div className="modal-overlay" id="share-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
|
||||
<button className="modal-close" id="share-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label data-i18n="checklisten.share.kind">Empfängertyp</label>
|
||||
<div className="filter-pills" id="share-kind-pills">
|
||||
<button type="button" className="filter-pill active" data-kind="user" data-i18n="checklisten.share.kind.user">Kollege</button>
|
||||
<button type="button" className="filter-pill" data-kind="office" data-i18n="checklisten.share.kind.office">Office</button>
|
||||
<button type="button" className="filter-pill" data-kind="partner_unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</button>
|
||||
<button type="button" className="filter-pill" data-kind="project" data-i18n="checklisten.share.kind.project">Projekt</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field share-kind-section" data-kind="user">
|
||||
<label htmlFor="share-user" data-i18n="checklisten.share.kind.user">Kollege</label>
|
||||
<select id="share-user">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field share-kind-section" data-kind="office" style="display:none">
|
||||
<label htmlFor="share-office" data-i18n="checklisten.share.kind.office">Office</label>
|
||||
<select id="share-office">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field share-kind-section" data-kind="partner_unit" style="display:none">
|
||||
<label htmlFor="share-partner-unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</label>
|
||||
<select id="share-partner-unit">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field share-kind-section" data-kind="project" style="display:none">
|
||||
<label htmlFor="share-project" data-i18n="checklisten.share.kind.project">Projekt</label>
|
||||
<select id="share-project">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="share-cancel" data-i18n="checklisten.share.cancel">Abbrechen</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime" id="share-submit" data-i18n="checklisten.share.submit">Freigeben</button>
|
||||
</div>
|
||||
<p className="form-msg" id="share-msg" />
|
||||
|
||||
{/* Existing grants — populated on open from
|
||||
/api/checklists/templates/{slug}/shares. */}
|
||||
<h3 className="share-grants-heading" data-i18n="checklisten.share.grants.heading">Bestehende Freigaben</h3>
|
||||
<ul className="share-grants-list" id="share-grants-list">
|
||||
<li className="entity-events-empty" id="share-grants-empty" data-i18n="checklisten.share.grants.empty">Keine Freigaben.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback modal */}
|
||||
<div className="modal-overlay" id="feedback-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
|
||||
@@ -58,6 +58,10 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
<p className="tool-subtitle" id="instance-template-title"> </p>
|
||||
<dl className="checklist-meta" id="instance-meta" />
|
||||
{/* Slice C: 'template updated since this instance
|
||||
was created' banner. Populated by the client
|
||||
when instance.template_version < template.version. */}
|
||||
<div id="instance-outdated-slot" />
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
|
||||
@@ -118,6 +122,21 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slice C: template-diff modal — opened from the
|
||||
"Änderungen anzeigen" button on the outdated banner. */}
|
||||
<div className="modal-overlay" id="instance-diff-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.instance.diff.title">Geänderte Punkte</h2>
|
||||
<button className="modal-close" id="instance-diff-close" type="button">×</button>
|
||||
</div>
|
||||
<div id="instance-diff-body" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="instance-diff-close-bottom" data-i18n="checklisten.instance.diff.close">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-instance.js"></script>
|
||||
|
||||
@@ -34,6 +34,8 @@ export function renderChecklists(): string {
|
||||
|
||||
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
|
||||
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="mine" href="/checklists?tab=mine" data-i18n="checklisten.tab.mine">Meine Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="gallery" href="/checklists?tab=gallery" data-i18n="checklisten.tab.gallery">Geteilte Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
|
||||
</nav>
|
||||
|
||||
@@ -49,6 +51,36 @@ export function renderChecklists(): string {
|
||||
<div className="checklist-grid" id="checklist-grid" />
|
||||
</section>
|
||||
|
||||
{/* Meine Vorlagen tab — caller's own authored templates */}
|
||||
<section className="entity-tab-panel" id="tab-mine" style="display:none">
|
||||
<div className="tool-actions" style="margin-bottom:1rem">
|
||||
<a href="/checklists/new" className="btn btn-primary" data-i18n="checklisten.mine.new">Neue Vorlage</a>
|
||||
</div>
|
||||
<p className="entity-events-empty" id="checklists-mine-loading" data-i18n="checklisten.mine.loading">Lädt…</p>
|
||||
<p className="entity-events-empty" id="checklists-mine-empty" style="display:none" data-i18n="checklisten.mine.empty">
|
||||
Sie haben noch keine eigene Vorlage angelegt.
|
||||
</p>
|
||||
<div className="checklist-grid" id="checklists-mine-grid" style="display:none" />
|
||||
</section>
|
||||
|
||||
{/* Geteilte Vorlagen tab — discovery surface for templates
|
||||
that aren't owned by the caller (firm-published,
|
||||
globally-promoted, or explicitly shared). Slice C. */}
|
||||
<section className="entity-tab-panel" id="tab-gallery" style="display:none">
|
||||
<div className="checklist-filters" id="checklist-gallery-filters">
|
||||
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
|
||||
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
|
||||
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
|
||||
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
|
||||
<button className="filter-pill" data-regime="OTHER" type="button" data-i18n="checklisten.filter.other">Sonstige</button>
|
||||
</div>
|
||||
<p className="entity-events-empty" id="checklists-gallery-loading" data-i18n="checklisten.mine.loading">Lädt…</p>
|
||||
<p className="entity-events-empty" id="checklists-gallery-empty" style="display:none" data-i18n="checklisten.gallery.empty">
|
||||
Noch keine geteilten Vorlagen sichtbar.
|
||||
</p>
|
||||
<div className="checklist-grid" id="checklists-gallery-grid" style="display:none" />
|
||||
</section>
|
||||
|
||||
{/* Instances tab — every visible instance across templates */}
|
||||
<section className="entity-tab-panel" id="tab-instances" style="display:none">
|
||||
<p className="entity-events-empty" id="checklists-instances-loading" data-i18n="checklisten.instances.all.loading">Lädt…</p>
|
||||
|
||||
@@ -468,11 +468,125 @@ function initInviteButton() {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
|
||||
// the auth.users row (via Supabase Admin API) and the paliad.users row in
|
||||
// one POST. New user appears in dropdowns immediately. Welcome email with
|
||||
// magic-link is sent by default; admin can opt out via the checkbox.
|
||||
function openAddFullModal() {
|
||||
const modal = document.getElementById("admin-add-full-modal")!;
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
|
||||
fb.style.display = "none";
|
||||
emailField.value = "";
|
||||
nameField.value = "";
|
||||
jobTitleField.value = "";
|
||||
profSel.value = "associate";
|
||||
langSel.value = "de";
|
||||
sendWelcome.checked = true;
|
||||
officeSel.innerHTML = officeOptions("munich");
|
||||
|
||||
modal.style.display = "flex";
|
||||
emailField.focus();
|
||||
}
|
||||
|
||||
function closeAddFullModal() {
|
||||
document.getElementById("admin-add-full-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
function initAddFullModal() {
|
||||
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
|
||||
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeAddFullModal();
|
||||
});
|
||||
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
// Pre-fill the display name from the email local-part the first time the
|
||||
// admin tabs out of the email field — mirrors the existing onboard flow.
|
||||
emailField.addEventListener("blur", () => {
|
||||
if (nameField.value || !emailField.value) return;
|
||||
const local = emailField.value.split("@")[0] ?? "";
|
||||
nameField.value = local
|
||||
.split(/[._-]/)
|
||||
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
||||
.join(" ")
|
||||
.trim();
|
||||
});
|
||||
|
||||
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
fb.style.display = "none";
|
||||
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
email: emailField.value.trim().toLowerCase(),
|
||||
display_name: nameField.value.trim(),
|
||||
office: officeSel.value,
|
||||
job_title: jobTitleField.value.trim() || "Associate",
|
||||
profession: profSel.value,
|
||||
lang: langSel.value,
|
||||
send_welcome_mail: sendWelcome.checked,
|
||||
};
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/admin/users/full", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
// Map two friendly cases inline; everything else surfaces the
|
||||
// server message so the admin can act on it.
|
||||
if (resp.status === 503) {
|
||||
fb.textContent = t("admin.team.add_full.error.unavailable")
|
||||
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
|
||||
} else if (resp.status === 409) {
|
||||
fb.textContent = body.error
|
||||
|| (t("admin.team.add_full.error.email_exists")
|
||||
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
|
||||
} else {
|
||||
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
|
||||
}
|
||||
fb.className = "form-msg form-msg-error";
|
||||
fb.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const created = (await resp.json()) as User;
|
||||
users = users.concat(created);
|
||||
closeAddFullModal();
|
||||
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
|
||||
render();
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initDirectAddModal();
|
||||
initAddFullModal();
|
||||
initInviteButton();
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
title: string;
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
appointment_type?: string;
|
||||
project_reference?: string;
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
let allAppointments: Appointment[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const m = String(month + 1).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function loadAppointments() {
|
||||
// Pull a wide window (current month plus a little buffer either side).
|
||||
// We could narrow this, but the user typically navigates ±1-2 months
|
||||
// and the dataset is small.
|
||||
try {
|
||||
const resp = await fetch("/api/appointments");
|
||||
if (resp.ok) allAppointments = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function appointmentsForDate(iso: string): Appointment[] {
|
||||
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
|
||||
}
|
||||
|
||||
function typeClass(t?: string): string {
|
||||
return t ? `termin-type-${t}` : "termin-type-default";
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const items = appointmentsForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
const grid = document.getElementById("appointment-cal-grid")!;
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(viewYear, viewMonth, 1);
|
||||
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
||||
const hasInMonth = allAppointments.some((tt) => {
|
||||
const iso = tt.start_at.slice(0, 10);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
const empty = document.getElementById("appointment-cal-empty")!;
|
||||
empty.style.display = hasInMonth ? "none" : "";
|
||||
}
|
||||
|
||||
function openPopup(iso: string) {
|
||||
const items = appointmentsForDate(iso);
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const dateEl = document.getElementById("cal-popup-date")!;
|
||||
const list = document.getElementById("cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((tt) => {
|
||||
const akteRef = tt.project_id
|
||||
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
|
||||
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
|
||||
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
|
||||
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
|
||||
${akteRef}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function initPopup() {
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const close = document.getElementById("cal-popup-close")!;
|
||||
close.addEventListener("click", () => (popup.style.display = "none"));
|
||||
popup.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-next")!.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-today")!.addEventListener("click", () => {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadAppointments();
|
||||
render();
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -43,6 +47,10 @@ let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
|
||||
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
|
||||
// "Termin bearbeiten" in the withdraw warning modal.
|
||||
let pendingEditMode = false;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -207,10 +215,14 @@ function renderHeader() {
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
|
||||
// withdraw modal, pendingEditMode unfreezes the form so Save can route
|
||||
// to /edit-entity (which keeps the request pending + merges payload).
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const freeze = isPending && !pendingEditMode;
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
.forEach((el) => { el.disabled = freeze; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
// t-paliad-252 — pending-edit mode routes through /edit-entity which
|
||||
// keeps the request pending + merges fields into payload. clear_project
|
||||
// and project_id are NOT in the counter-allowlist (yet) — the requester
|
||||
// can't move projects on a pending request from this surface.
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const editFields = { ...payload };
|
||||
delete editFields.clear_project;
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: editFields }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
// Exit pending-edit mode so the form re-freezes (still pending).
|
||||
pendingEditMode = false;
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -312,12 +357,37 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-252 — withdraw warning modal replaces the old confirm().
|
||||
// Returns:
|
||||
// "edit" → unfreeze the edit form (pending-edit mode); Save will
|
||||
// route through /api/approval-requests/{id}/edit-entity
|
||||
// "withdraw" → destructive: the existing /revoke endpoint
|
||||
// null → user cancelled
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "appointment",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
pendingEditMode = true;
|
||||
if (btn) btn.disabled = false;
|
||||
// renderHeader re-evaluates the freeze and unfreezes the form now
|
||||
// that pendingEditMode is set. Focus the first editable field so the
|
||||
// user can type immediately.
|
||||
renderHeader();
|
||||
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
|
||||
titleEl?.focus();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
// CREATE lifecycle: entity gone → back to the list.
|
||||
window.location.href = "/events?type=appointment";
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7,
|
||||
// retrofitted onto the unified modal primitive in t-paliad-217 Slice D).
|
||||
//
|
||||
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
||||
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
||||
// collects subject + body + (optional) template and posts to
|
||||
// /api/team/broadcast. On success it shows a per-recipient send report
|
||||
// and closes.
|
||||
// and closes after a short delay.
|
||||
//
|
||||
// Per-recipient privacy: each member receives their own envelope. The
|
||||
// modal lists every addressee so the sender knows exactly who will be
|
||||
// mailed; there is no surprise to-line.
|
||||
//
|
||||
// Migration notes (t-paliad-217 Slice D): the shell, ESC, backdrop,
|
||||
// close button, and browser back-button are now owned by openModal().
|
||||
// The body is built imperatively so the submit handler can read form
|
||||
// state from the modal-body element it constructed.
|
||||
|
||||
import { t } from "./i18n";
|
||||
import { openModal } from "./components/modal";
|
||||
|
||||
export interface BroadcastRecipient {
|
||||
user_id: string;
|
||||
@@ -35,6 +42,12 @@ interface EmailTemplateOption {
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
interface BroadcastResult {
|
||||
sent: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const RECIPIENT_CAP = 100;
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -78,69 +91,32 @@ export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing modal? Remove. Avoids stacking on rapid double-click.
|
||||
document.getElementById("broadcast-modal")?.remove();
|
||||
const body = renderBody(args);
|
||||
wireBody(body);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "broadcast-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
document.addEventListener("keydown", function escClose(e) {
|
||||
if (e.key === "Escape") {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", escClose);
|
||||
}
|
||||
});
|
||||
|
||||
// Recipient toggle
|
||||
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown
|
||||
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
|
||||
// Submit
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(form, overlay, args);
|
||||
void openModal<BroadcastResult>({
|
||||
title: t("team.broadcast.title") || "E-Mail an Auswahl",
|
||||
body,
|
||||
size: "lg",
|
||||
primary: {
|
||||
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
|
||||
handler: async (close) => {
|
||||
await onSubmit(body, args, close);
|
||||
},
|
||||
},
|
||||
secondary: { label: t("common.cancel") || "Abbrechen" },
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
root.className = "broadcast-body";
|
||||
const count = args.recipients.length;
|
||||
const previewItems = args.recipients
|
||||
.slice(0, 5)
|
||||
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
||||
.join(", ");
|
||||
const more = count > 5 ? ` +${count - 5}` : "";
|
||||
|
||||
const fullList = args.recipients
|
||||
.map(
|
||||
(r) =>
|
||||
@@ -150,65 +126,89 @@ function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
|
||||
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">×</button>
|
||||
</header>
|
||||
<form data-broadcast-form>
|
||||
<div class="modal-body">
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
|
||||
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
|
||||
</footer>
|
||||
</form>
|
||||
root.innerHTML = `
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
`;
|
||||
return root;
|
||||
}
|
||||
|
||||
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
|
||||
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
function wireBody(body: HTMLElement): void {
|
||||
// Recipient list toggle.
|
||||
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown — populates subject/body from the selected template.
|
||||
const templateSelect = body.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = body.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(
|
||||
body: HTMLElement,
|
||||
args: OpenBroadcastModalArgs,
|
||||
close: (result: BroadcastResult) => void,
|
||||
): Promise<void> {
|
||||
const subject = (body.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const bodyText = (body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = body.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = body.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = body.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
errEl?.classList.add("hidden");
|
||||
okEl?.classList.add("hidden");
|
||||
|
||||
@@ -216,17 +216,15 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
if (!bodyText) {
|
||||
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
|
||||
}
|
||||
|
||||
// The modal primary button lives in the footer (owned by openModal),
|
||||
// not in the body. We surface "sending..." feedback via the in-body
|
||||
// success/error areas; the primary button stays clickable but the
|
||||
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
|
||||
const recipientFilter: Record<string, unknown> = {};
|
||||
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
||||
if (args.projectID) recipientFilter.project_id = args.projectID;
|
||||
@@ -242,7 +240,7 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
body: JSON.stringify({
|
||||
project_id: args.projectID ?? null,
|
||||
subject,
|
||||
body,
|
||||
body: bodyText,
|
||||
template_key: templateKey || undefined,
|
||||
lang,
|
||||
recipient_filter: recipientFilter,
|
||||
@@ -252,13 +250,9 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
||||
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const report = (await res.json()) as { sent: number; failed: number; total: number };
|
||||
const report = (await res.json()) as BroadcastResult;
|
||||
if (okEl) {
|
||||
okEl.classList.remove("hidden");
|
||||
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
||||
@@ -267,17 +261,10 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
||||
.replace("{total}", String(report.total))
|
||||
.replace("{failed}", String(report.failed));
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
|
||||
}
|
||||
setTimeout(() => overlay.remove(), 2500);
|
||||
// Give the sender a moment to see the report, then close.
|
||||
setTimeout(() => close(report), 2500);
|
||||
} catch (e) {
|
||||
showError(errEl, String(e));
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
135
frontend/src/client/calendar/mount-calendar.test.ts
Normal file
135
frontend/src/client/calendar/mount-calendar.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
bucketByDate,
|
||||
filterByDay,
|
||||
isToday,
|
||||
isoDate,
|
||||
shift,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
type CalendarItem,
|
||||
} from "./mount-calendar";
|
||||
|
||||
// Regression tests for t-paliad-224: the calendar bucket / week / shift
|
||||
// helpers underpin both /events Kalender and the Custom Views shape=
|
||||
// calendar. DOM-rendering is covered by manual smoke (frontend tests in
|
||||
// this repo run in plain Node, no jsdom — see verfahrensablauf-core.test
|
||||
// ts comment), so the pure date-math goes here.
|
||||
|
||||
const item = (overrides: Partial<CalendarItem> = {}): CalendarItem => ({
|
||||
kind: "deadline",
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
title: "Klageerwiderung",
|
||||
event_date: "2026-05-08T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isoDate / startOfDay / startOfWeek", () => {
|
||||
test("isoDate pads month + day", () => {
|
||||
expect(isoDate(new Date(2026, 0, 3))).toBe("2026-01-03");
|
||||
expect(isoDate(new Date(2026, 11, 31))).toBe("2026-12-31");
|
||||
});
|
||||
|
||||
test("startOfDay strips time", () => {
|
||||
const d = new Date(2026, 4, 8, 13, 47, 22);
|
||||
const out = startOfDay(d);
|
||||
expect(out.getHours()).toBe(0);
|
||||
expect(out.getMinutes()).toBe(0);
|
||||
expect(out.getSeconds()).toBe(0);
|
||||
expect(isoDate(out)).toBe("2026-05-08");
|
||||
});
|
||||
|
||||
test("startOfWeek snaps to Monday (Mon=0)", () => {
|
||||
// 2026-05-08 was a Friday.
|
||||
const fri = new Date(2026, 4, 8);
|
||||
expect(isoDate(startOfWeek(fri))).toBe("2026-05-04");
|
||||
// Sunday wraps backward to the same Monday, not forward to the next.
|
||||
const sun = new Date(2026, 4, 10);
|
||||
expect(isoDate(startOfWeek(sun))).toBe("2026-05-04");
|
||||
// Monday is its own startOfWeek.
|
||||
const mon = new Date(2026, 4, 4);
|
||||
expect(isoDate(startOfWeek(mon))).toBe("2026-05-04");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift", () => {
|
||||
test("month shift lands on day=1 of the target month", () => {
|
||||
const out = shift(new Date(2026, 4, 15), "month", 1);
|
||||
expect(out.getFullYear()).toBe(2026);
|
||||
expect(out.getMonth()).toBe(5);
|
||||
expect(out.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("month shift wraps year boundary", () => {
|
||||
const out = shift(new Date(2026, 11, 15), "month", 1);
|
||||
expect(out.getFullYear()).toBe(2027);
|
||||
expect(out.getMonth()).toBe(0);
|
||||
expect(out.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("week shift moves seven days", () => {
|
||||
const out = shift(new Date(2026, 4, 8), "week", 1);
|
||||
expect(isoDate(out)).toBe("2026-05-15");
|
||||
});
|
||||
|
||||
test("day shift moves one day", () => {
|
||||
const out = shift(new Date(2026, 4, 8), "day", -1);
|
||||
expect(isoDate(out)).toBe("2026-05-07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketByDate", () => {
|
||||
test("groups items by ISO date and skips items outside the filter", () => {
|
||||
const rows = [
|
||||
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "b", event_date: "2026-05-08T15:30:00Z" }),
|
||||
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
|
||||
// outside the May 2026 filter:
|
||||
item({ id: "x", event_date: "2026-06-01T00:00:00Z" }),
|
||||
// malformed:
|
||||
item({ id: "bad", event_date: "not-a-date" }),
|
||||
];
|
||||
const out = bucketByDate(rows, (d) => d.getMonth() === 4 && d.getFullYear() === 2026);
|
||||
expect(out.size).toBe(2);
|
||||
expect(out.get("2026-05-08")?.map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(out.get("2026-05-09")?.map((r) => r.id)).toEqual(["c"]);
|
||||
expect(out.has("2026-06-01")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByDay", () => {
|
||||
test("returns only items whose calendar day equals the target", () => {
|
||||
const rows = [
|
||||
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "b", event_date: "2026-05-08T23:59:00Z" }),
|
||||
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
|
||||
];
|
||||
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(filterByDay(rows, new Date(2026, 4, 9)).map((r) => r.id)).toEqual(["c"]);
|
||||
expect(filterByDay(rows, new Date(2026, 4, 10))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores malformed dates", () => {
|
||||
const rows = [
|
||||
item({ id: "ok", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "bad", event_date: "not-a-date" }),
|
||||
];
|
||||
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["ok"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToday", () => {
|
||||
test("matches today's calendar day", () => {
|
||||
expect(isToday(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects yesterday + tomorrow", () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
expect(isToday(yesterday)).toBe(false);
|
||||
expect(isToday(tomorrow)).toBe(false);
|
||||
});
|
||||
});
|
||||
579
frontend/src/client/calendar/mount-calendar.ts
Normal file
579
frontend/src/client/calendar/mount-calendar.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
|
||||
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
|
||||
// Lifted from the original shape-calendar.ts so both Custom Views
|
||||
// (shape=calendar) and /events Kalender tab render through the same DOM.
|
||||
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
|
||||
//
|
||||
// Surfaces wire in via mountCalendar(host, items, opts). The returned
|
||||
// handle exposes update(items) for re-render after a filter change and
|
||||
// destroy() for teardown when the host swaps to a different view.
|
||||
|
||||
export type CalendarKind =
|
||||
| "deadline" | "appointment" | "project_event" | "approval_request";
|
||||
|
||||
export interface CalendarItem {
|
||||
kind: CalendarKind;
|
||||
id: string;
|
||||
title: string;
|
||||
/** ISO-8601 timestamp or date string. First 10 chars are read as the
|
||||
* calendar bucket (yyyy-mm-dd). */
|
||||
event_date: string;
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
}
|
||||
|
||||
export type CalendarView = "month" | "week" | "day";
|
||||
|
||||
export interface CalendarOpts {
|
||||
/** Initial view if URL has no override (or urlState is disabled). */
|
||||
defaultView?: CalendarView;
|
||||
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
|
||||
* Surfaces that own their own URL contract pass urlState=false. */
|
||||
urlState?: boolean;
|
||||
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
|
||||
* meaningful when urlState=true. Leave empty for the default
|
||||
* ?cal_view / ?cal_date contract. */
|
||||
urlPrefix?: string;
|
||||
/** Override how a row's href is built. Default routes by kind. */
|
||||
hrefFor?: (item: CalendarItem) => string;
|
||||
}
|
||||
|
||||
export interface CalendarHandle {
|
||||
/** Replace the item set and re-paint at the current view+anchor. */
|
||||
update(items: CalendarItem[]): void;
|
||||
/** Clear host + drop the keep-alive state. After destroy(), the handle
|
||||
* is dead; create a fresh one with mountCalendar(). */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const MAX_PILLS_PER_MONTH_CELL = 3;
|
||||
|
||||
export function mountCalendar(
|
||||
host: HTMLElement,
|
||||
initialItems: CalendarItem[],
|
||||
opts: CalendarOpts = {},
|
||||
): CalendarHandle {
|
||||
let items = initialItems;
|
||||
let view: CalendarView;
|
||||
let anchor: Date;
|
||||
let destroyed = false;
|
||||
|
||||
const urlEnabled = opts.urlState ?? false;
|
||||
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
|
||||
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
|
||||
|
||||
view = urlEnabled
|
||||
? readView(viewParam, opts.defaultView ?? "month")
|
||||
: (opts.defaultView ?? "month");
|
||||
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
|
||||
|
||||
paint();
|
||||
|
||||
return {
|
||||
update(nextItems) {
|
||||
if (destroyed) return;
|
||||
items = nextItems;
|
||||
paint();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
host.innerHTML = "";
|
||||
},
|
||||
};
|
||||
|
||||
// --- paint -----------------------------------------------------------
|
||||
|
||||
function paint(): void {
|
||||
if (destroyed) return;
|
||||
host.innerHTML = "";
|
||||
|
||||
// Mobile fallback notice (<600px). Documented in design-calendar-
|
||||
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
|
||||
// notice just nudges users toward a friendlier view.
|
||||
if (typeof window !== "undefined" && window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
wrap.appendChild(renderToolbar());
|
||||
if (view === "month") {
|
||||
wrap.appendChild(renderMonth());
|
||||
} else if (view === "week") {
|
||||
wrap.appendChild(renderWeek());
|
||||
} else {
|
||||
wrap.appendChild(renderDay());
|
||||
}
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
function setView(nextView: CalendarView, nextAnchor: Date): void {
|
||||
view = nextView;
|
||||
anchor = nextAnchor;
|
||||
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
|
||||
paint();
|
||||
}
|
||||
|
||||
// --- Toolbar ---------------------------------------------------------
|
||||
|
||||
function renderToolbar(): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "views-calendar-toolbar";
|
||||
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "views-calendar-view-switcher agenda-chip-row";
|
||||
switcher.setAttribute("role", "tablist");
|
||||
for (const v of ["month", "week", "day"] as CalendarView[]) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
|
||||
chip.dataset.calView = v;
|
||||
chip.setAttribute("role", "tab");
|
||||
chip.setAttribute("aria-selected", v === view ? "true" : "false");
|
||||
chip.textContent = t(`cal.view.${v}` as I18nKey);
|
||||
chip.addEventListener("click", () => {
|
||||
if (v === view) return;
|
||||
setView(v, anchor);
|
||||
});
|
||||
switcher.appendChild(chip);
|
||||
}
|
||||
bar.appendChild(switcher);
|
||||
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "views-calendar-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
|
||||
prev.textContent = "‹";
|
||||
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
|
||||
nav.appendChild(prev);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "views-calendar-nav-label";
|
||||
label.textContent = formatRangeLabel(view, anchor);
|
||||
nav.appendChild(label);
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
|
||||
next.textContent = "›";
|
||||
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
|
||||
nav.appendChild(next);
|
||||
|
||||
// "Heute" button — jump back to today in the current view. Adds a
|
||||
// recognisable affordance for the /events Kalender users who relied
|
||||
// on the old toolbar's "Heute" button.
|
||||
const today = document.createElement("button");
|
||||
today.type = "button";
|
||||
today.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
today.textContent = t("cal.today");
|
||||
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
|
||||
nav.appendChild(today);
|
||||
|
||||
if (view !== "month") {
|
||||
const backToMonth = document.createElement("button");
|
||||
backToMonth.type = "button";
|
||||
backToMonth.className = "btn-link views-calendar-back-to-month";
|
||||
backToMonth.textContent = t("cal.day.back_to_month");
|
||||
backToMonth.addEventListener("click", () => setView("month", anchor));
|
||||
nav.appendChild(backToMonth);
|
||||
}
|
||||
|
||||
bar.appendChild(nav);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// --- Month -----------------------------------------------------------
|
||||
|
||||
function renderMonth(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const weekdayKeys: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const byDate = bucketByDate(items, (d) =>
|
||||
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
|
||||
);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
|
||||
const dateKey = isoDate(dayDate);
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
|
||||
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
|
||||
|
||||
const dayLabel = document.createElement("button");
|
||||
dayLabel.type = "button";
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(dayNum);
|
||||
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
dayLabel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setView("day", dayDate);
|
||||
});
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
|
||||
for (const row of visible) ul.appendChild(renderPill(row));
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
const moreBtn = document.createElement("button");
|
||||
moreBtn.type = "button";
|
||||
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
|
||||
moreBtn.textContent = `+${dayRows.length - visible.length}`;
|
||||
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
moreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setView("day", dayDate);
|
||||
});
|
||||
more.appendChild(moreBtn);
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
// --- Week ------------------------------------------------------------
|
||||
|
||||
function renderWeek(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-week";
|
||||
|
||||
const weekStart = startOfWeek(anchor);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-week-grid";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
grid.appendChild(renderWeekColumn(day));
|
||||
}
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWeekColumn(day: Date): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const col = document.createElement("div");
|
||||
col.className = "views-calendar-week-column";
|
||||
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-calendar-week-head";
|
||||
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
|
||||
const dow = document.createElement("span");
|
||||
dow.className = "views-calendar-week-dow";
|
||||
dow.textContent = t(weekdayKey);
|
||||
const dnum = document.createElement("span");
|
||||
dnum.className = "views-calendar-week-dnum";
|
||||
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
head.appendChild(dow);
|
||||
head.appendChild(dnum);
|
||||
col.appendChild(head);
|
||||
|
||||
const dayRows = filterByDay(items, day);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-week-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
col.appendChild(empty);
|
||||
return col;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-week-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "week"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
col.appendChild(ul);
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- Day -------------------------------------------------------------
|
||||
|
||||
function renderDay(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-day-wrap";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const dayRows = filterByDay(items, anchor);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-day-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-day-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "day"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// --- Row rendering ---------------------------------------------------
|
||||
|
||||
function renderPill(row: CalendarItem): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
a.href = hrefFor(row);
|
||||
a.textContent = row.title;
|
||||
a.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
a.addEventListener("click", (e) => e.stopPropagation());
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
|
||||
a.href = hrefFor(row);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
|
||||
a.appendChild(dot);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "views-calendar-row-body";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-calendar-row-title";
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(tDyn("views.kind." + row.kind));
|
||||
if (row.project_reference) metaParts.push(row.project_reference);
|
||||
else if (row.project_title) metaParts.push(row.project_title);
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "views-calendar-row-meta";
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
a.appendChild(body);
|
||||
return a;
|
||||
}
|
||||
|
||||
function hrefFor(row: CalendarItem): string {
|
||||
if (opts.hrefFor) return opts.hrefFor(row);
|
||||
return defaultHrefFor(row);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pure helpers (shared, not closure-bound) ----------------------------
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
|
||||
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
|
||||
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
|
||||
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
|
||||
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
|
||||
}
|
||||
|
||||
function defaultHrefFor(row: CalendarItem): string {
|
||||
switch (row.kind) {
|
||||
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
|
||||
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
|
||||
case "approval_request": return `/inbox`;
|
||||
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
|
||||
}
|
||||
}
|
||||
|
||||
export function bucketByDate(
|
||||
rows: CalendarItem[], filter: (d: Date) => boolean,
|
||||
): Map<string, CalendarItem[]> {
|
||||
const out = new Map<string, CalendarItem[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (!filter(d)) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = out.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else out.set(key, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
|
||||
const key = isoDate(day);
|
||||
return rows.filter((r) => {
|
||||
const d = new Date(r.event_date);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
return isoDate(d) === key;
|
||||
});
|
||||
}
|
||||
|
||||
export function startOfWeek(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const offset = (out.getDay() + 6) % 7;
|
||||
out.setDate(out.getDate() - offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
export function shift(d: Date, view: CalendarView, dir: number): Date {
|
||||
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||||
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
|
||||
}
|
||||
|
||||
export function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
export function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatRangeLabel(view: CalendarView, anchor: Date): string {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (view === "month") {
|
||||
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(anchor);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return formatWeekHeader(start, end, lang);
|
||||
}
|
||||
return anchor.toLocaleDateString(lang, {
|
||||
weekday: "short", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekHeader(start: Date, end: Date, lang: string): string {
|
||||
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
function firstAnchor(rows: CalendarItem[]): Date {
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return startOfDay(d);
|
||||
}
|
||||
return startOfDay(new Date());
|
||||
}
|
||||
|
||||
function paramName(prefix: string | undefined, base: string): string {
|
||||
if (!prefix) return base;
|
||||
return `${prefix}_${base}`;
|
||||
}
|
||||
|
||||
function readView(viewParam: string, fallback: CalendarView): CalendarView {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(viewParam);
|
||||
if (raw === "month" || raw === "week" || raw === "day") return raw;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
|
||||
if (typeof window === "undefined") return firstAnchor(rows);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(dateParam);
|
||||
if (raw) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
return firstAnchor(rows);
|
||||
}
|
||||
|
||||
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(viewParam, view);
|
||||
url.searchParams.set(dateParam, isoDate(anchor));
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
365
frontend/src/client/checklists-author.ts
Normal file
365
frontend/src/client/checklists-author.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
// Authoring wizard for paliad.checklists. Serves both /checklists/new
|
||||
// (create) and /checklists/templates/{slug}/edit (edit). The HTML bundle is the
|
||||
// same; this client reads location.pathname to decide which mode to
|
||||
// boot into.
|
||||
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Item {
|
||||
labelDE: string;
|
||||
labelEN: string;
|
||||
noteDE?: string;
|
||||
noteEN?: string;
|
||||
rule?: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
interface Checklist {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
regime: string;
|
||||
court: string;
|
||||
reference: string;
|
||||
deadline: string;
|
||||
lang: string;
|
||||
visibility: string;
|
||||
body: { groups: Group[] };
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function detectMode(): { mode: "create" | "edit"; slug?: string } {
|
||||
const path = window.location.pathname;
|
||||
if (path === "/checklists/new") {
|
||||
return { mode: "create" };
|
||||
}
|
||||
const m = path.match(/^\/checklists\/templates\/([^/]+)\/edit$/);
|
||||
if (m) {
|
||||
return { mode: "edit", slug: m[1] };
|
||||
}
|
||||
return { mode: "create" };
|
||||
}
|
||||
|
||||
let groups: Group[] = [];
|
||||
|
||||
function renderGroups() {
|
||||
const container = document.getElementById("groups-container")!;
|
||||
if (groups.length === 0) {
|
||||
// Seed with a single empty group + item so the user has something
|
||||
// to fill out rather than a blank canvas.
|
||||
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
|
||||
}
|
||||
container.innerHTML = groups.map((g, gi) => {
|
||||
const itemsHTML = g.items.map((it, ii) => {
|
||||
return `<div class="author-item" data-gi="${gi}" data-ii="${ii}">
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.item.label"))}</label>
|
||||
<input class="form-input" data-field="label" value="${escAttr(it.labelDE || "")}" />
|
||||
</div>
|
||||
<div class="form-grid form-grid-2">
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.item.note"))}</label>
|
||||
<input class="form-input" data-field="note" value="${escAttr(it.noteDE || "")}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.item.rule"))}</label>
|
||||
<input class="form-input" data-field="rule" value="${escAttr(it.rule || "")}" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-small btn-danger" data-action="remove-item">${esc(t("checklisten.author.item.remove"))}</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `<div class="author-group" data-gi="${gi}">
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.group.title"))}</label>
|
||||
<input class="form-input" data-field="group-title" value="${escAttr(g.titleDE || "")}" />
|
||||
</div>
|
||||
<div class="author-items">${itemsHTML}</div>
|
||||
<div class="author-group-actions">
|
||||
<button type="button" class="btn btn-small" data-action="add-item">${esc(t("checklisten.author.item.add"))}</button>
|
||||
<button type="button" class="btn btn-small btn-danger" data-action="remove-group">${esc(t("checklisten.author.group.remove"))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// Wire input changes back into the data array.
|
||||
container.querySelectorAll<HTMLInputElement>(".author-group > .form-row input[data-field=group-title]").forEach((input) => {
|
||||
const groupDiv = input.closest<HTMLElement>(".author-group")!;
|
||||
const gi = parseInt(groupDiv.dataset.gi!, 10);
|
||||
input.addEventListener("input", () => {
|
||||
groups[gi].titleDE = input.value;
|
||||
groups[gi].titleEN = input.value; // single-language for Slice A
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLDivElement>(".author-item").forEach((itemDiv) => {
|
||||
const gi = parseInt(itemDiv.dataset.gi!, 10);
|
||||
const ii = parseInt(itemDiv.dataset.ii!, 10);
|
||||
itemDiv.querySelectorAll<HTMLInputElement>("input[data-field]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const field = input.dataset.field!;
|
||||
if (field === "label") {
|
||||
groups[gi].items[ii].labelDE = input.value;
|
||||
groups[gi].items[ii].labelEN = input.value;
|
||||
} else if (field === "note") {
|
||||
groups[gi].items[ii].noteDE = input.value || undefined;
|
||||
groups[gi].items[ii].noteEN = input.value || undefined;
|
||||
} else if (field === "rule") {
|
||||
groups[gi].items[ii].rule = input.value || undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
itemDiv.querySelector<HTMLButtonElement>("button[data-action=remove-item]")!.addEventListener("click", () => {
|
||||
groups[gi].items.splice(ii, 1);
|
||||
if (groups[gi].items.length === 0) {
|
||||
groups[gi].items.push({ labelDE: "", labelEN: "" });
|
||||
}
|
||||
renderGroups();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>("button[data-action=add-item]").forEach((btn) => {
|
||||
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
|
||||
const gi = parseInt(groupDiv.dataset.gi!, 10);
|
||||
btn.addEventListener("click", () => {
|
||||
groups[gi].items.push({ labelDE: "", labelEN: "" });
|
||||
renderGroups();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>("button[data-action=remove-group]").forEach((btn) => {
|
||||
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
|
||||
const gi = parseInt(groupDiv.dataset.gi!, 10);
|
||||
btn.addEventListener("click", () => {
|
||||
groups.splice(gi, 1);
|
||||
renderGroups();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg: string) {
|
||||
const err = document.getElementById("author-error")!;
|
||||
err.textContent = msg;
|
||||
err.style.display = "";
|
||||
err.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
const err = document.getElementById("author-error")!;
|
||||
err.textContent = "";
|
||||
err.style.display = "none";
|
||||
}
|
||||
|
||||
function collectInput() {
|
||||
const title = (document.getElementById("title") as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById("description") as HTMLTextAreaElement).value.trim();
|
||||
const regime = (document.getElementById("regime") as HTMLSelectElement).value;
|
||||
const court = (document.getElementById("court") as HTMLInputElement).value.trim();
|
||||
const reference = (document.getElementById("reference") as HTMLInputElement).value.trim();
|
||||
const deadline = (document.getElementById("deadline") as HTMLInputElement).value.trim();
|
||||
const lang = (document.getElementById("lang") as HTMLSelectElement).value;
|
||||
const visibilityInput = document.querySelector<HTMLInputElement>("input[name=visibility]:checked");
|
||||
const visibility = visibilityInput?.value || "private";
|
||||
return { title, description, regime, court, reference, deadline, lang, visibility };
|
||||
}
|
||||
|
||||
function validateGroups(): boolean {
|
||||
if (groups.length === 0) return false;
|
||||
let totalItems = 0;
|
||||
for (const g of groups) {
|
||||
if (!g.titleDE.trim()) return false;
|
||||
for (const it of g.items) {
|
||||
if (it.labelDE.trim()) totalItems += 1;
|
||||
}
|
||||
}
|
||||
return totalItems > 0;
|
||||
}
|
||||
|
||||
function trimmedGroups(): Group[] {
|
||||
return groups
|
||||
.filter((g) => g.titleDE.trim() && g.items.some((it) => it.labelDE.trim()))
|
||||
.map((g) => ({
|
||||
titleDE: g.titleDE.trim(),
|
||||
titleEN: g.titleEN.trim(),
|
||||
items: g.items
|
||||
.filter((it) => it.labelDE.trim())
|
||||
.map((it) => ({
|
||||
labelDE: it.labelDE.trim(),
|
||||
labelEN: it.labelEN.trim(),
|
||||
noteDE: it.noteDE?.trim() || undefined,
|
||||
noteEN: it.noteEN?.trim() || undefined,
|
||||
rule: it.rule?.trim() || undefined,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadEditTemplate(slug: string) {
|
||||
// Use /api/checklists/{slug} (catalog Find with visibility check) +
|
||||
// the mine list to ensure we have the editable fields. Templates the
|
||||
// caller doesn't own/admin will trip the PATCH gate later.
|
||||
const resp = await fetch(`/api/checklists/templates/mine`);
|
||||
if (!resp.ok) {
|
||||
showError(t("checklisten.author.error.notfound"));
|
||||
return;
|
||||
}
|
||||
const rows: Checklist[] = (await resp.json()) ?? [];
|
||||
const tpl = rows.find((r) => r.slug === slug);
|
||||
if (!tpl) {
|
||||
showError(t("checklisten.author.error.notfound"));
|
||||
return;
|
||||
}
|
||||
(document.getElementById("author-heading")!).textContent = t("checklisten.author.heading.edit");
|
||||
document.title = t("checklisten.author.title.edit");
|
||||
(document.getElementById("title") as HTMLInputElement).value = tpl.title;
|
||||
(document.getElementById("description") as HTMLTextAreaElement).value = tpl.description;
|
||||
(document.getElementById("regime") as HTMLSelectElement).value = tpl.regime;
|
||||
(document.getElementById("court") as HTMLInputElement).value = tpl.court;
|
||||
(document.getElementById("reference") as HTMLInputElement).value = tpl.reference;
|
||||
(document.getElementById("deadline") as HTMLInputElement).value = tpl.deadline;
|
||||
(document.getElementById("lang") as HTMLSelectElement).value = tpl.lang || "de";
|
||||
const visIn = document.querySelector<HTMLInputElement>(`input[name=visibility][value=${tpl.visibility}]`);
|
||||
if (visIn) visIn.checked = true;
|
||||
groups = (tpl.body?.groups || []).map((g) => ({
|
||||
titleDE: g.titleDE || "",
|
||||
titleEN: g.titleEN || g.titleDE || "",
|
||||
items: g.items.map((it) => ({
|
||||
labelDE: it.labelDE || "",
|
||||
labelEN: it.labelEN || it.labelDE || "",
|
||||
noteDE: it.noteDE,
|
||||
noteEN: it.noteEN,
|
||||
rule: it.rule,
|
||||
})),
|
||||
}));
|
||||
if (groups.length === 0) {
|
||||
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
|
||||
}
|
||||
renderGroups();
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
clearError();
|
||||
const input = collectInput();
|
||||
if (!input.title) {
|
||||
showError(t("checklisten.author.error.title"));
|
||||
return;
|
||||
}
|
||||
if (!validateGroups()) {
|
||||
showError(t("checklisten.author.error.no_groups"));
|
||||
return;
|
||||
}
|
||||
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = t("checklisten.author.saving");
|
||||
const body = JSON.stringify({ ...input, body: { groups: trimmedGroups() } });
|
||||
const resp = await fetch("/api/checklists/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = t("checklisten.author.save");
|
||||
if (!resp.ok) {
|
||||
let msg = t("checklisten.author.error.generic");
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) msg = j.error;
|
||||
} catch { /* keep generic */ }
|
||||
showError(msg);
|
||||
return;
|
||||
}
|
||||
const created: Checklist = await resp.json();
|
||||
window.location.href = `/checklists/${encodeURIComponent(created.slug)}`;
|
||||
}
|
||||
|
||||
async function submitEdit(slug: string) {
|
||||
clearError();
|
||||
const input = collectInput();
|
||||
if (!input.title) {
|
||||
showError(t("checklisten.author.error.title"));
|
||||
return;
|
||||
}
|
||||
if (!validateGroups()) {
|
||||
showError(t("checklisten.author.error.no_groups"));
|
||||
return;
|
||||
}
|
||||
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = t("checklisten.author.saving");
|
||||
const patch = {
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
regime: input.regime,
|
||||
court: input.court,
|
||||
reference: input.reference,
|
||||
deadline: input.deadline,
|
||||
body: { groups: trimmedGroups() },
|
||||
};
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
// Visibility lives on its own endpoint so the audit row reflects the
|
||||
// distinct transition. Only call if it actually changed.
|
||||
if (resp.ok && input.visibility) {
|
||||
await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}/visibility`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ visibility: input.visibility }),
|
||||
});
|
||||
}
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = t("checklisten.author.save");
|
||||
if (!resp.ok) {
|
||||
let msg = t("checklisten.author.error.generic");
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) msg = j.error;
|
||||
} catch { /* keep generic */ }
|
||||
showError(msg);
|
||||
return;
|
||||
}
|
||||
window.location.href = `/checklists/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
renderGroups();
|
||||
|
||||
document.getElementById("add-group")!.addEventListener("click", () => {
|
||||
groups.push({ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] });
|
||||
renderGroups();
|
||||
});
|
||||
|
||||
const { mode, slug } = detectMode();
|
||||
|
||||
if (mode === "edit" && slug) {
|
||||
void loadEditTemplate(slug);
|
||||
}
|
||||
|
||||
document.getElementById("author-form")!.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
if (mode === "edit" && slug) {
|
||||
void submitEdit(slug);
|
||||
} else {
|
||||
void submitCreate();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,37 @@ interface Checklist {
|
||||
referenceDE?: string;
|
||||
referenceEN?: string;
|
||||
groups: ChecklistGroup[];
|
||||
// Slice B fields — present on authored entries via the merged
|
||||
// catalog response. 'static' templates don't carry these.
|
||||
origin?: "static" | "authored";
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
global_role?: string;
|
||||
}
|
||||
|
||||
interface UserSummary {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
checklist_id: string;
|
||||
recipient_kind: "user" | "office" | "partner_unit" | "project";
|
||||
recipient_label: string;
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
@@ -371,13 +402,320 @@ function rerenderAll() {
|
||||
renderInstances();
|
||||
}
|
||||
|
||||
// --- Slice B: owner actions + admin promote + share modal ----------------
|
||||
|
||||
let me: Me | null = null;
|
||||
let isOwner = false;
|
||||
let isAdmin = false;
|
||||
let shareUsers: UserSummary[] = [];
|
||||
let sharePartnerUnits: PartnerUnit[] = [];
|
||||
let shareProjects: AkteSummary[] = [];
|
||||
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
|
||||
|
||||
async function loadMe(): Promise<Me | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function templateOriginInfo() {
|
||||
return template as unknown as {
|
||||
origin?: string;
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function applyOwnerControls() {
|
||||
const info = templateOriginInfo();
|
||||
const isAuthored = info?.origin === "authored";
|
||||
const provenance = document.getElementById("checklist-provenance")!;
|
||||
if (isAuthored && info?.owner_display_name) {
|
||||
provenance.style.display = "";
|
||||
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
|
||||
} else {
|
||||
provenance.style.display = "none";
|
||||
}
|
||||
|
||||
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
|
||||
isAdmin = !!(me && me.global_role === "global_admin");
|
||||
const ownerOnly = (id: string, show: boolean) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) (el as HTMLElement).style.display = show ? "" : "none";
|
||||
};
|
||||
if (template) {
|
||||
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
|
||||
"href",
|
||||
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
|
||||
);
|
||||
}
|
||||
ownerOnly("btn-edit-template", isOwner);
|
||||
ownerOnly("btn-share-template", isOwner);
|
||||
ownerOnly("btn-delete-template", isOwner);
|
||||
|
||||
// Admin promote/demote — only when an authored template is visible to
|
||||
// an admin, and only the appropriate one for the current visibility.
|
||||
if (isAuthored && isAdmin) {
|
||||
const isGlobal = info?.visibility === "global";
|
||||
ownerOnly("btn-promote-template", !isGlobal);
|
||||
ownerOnly("btn-demote-template", isGlobal);
|
||||
} else {
|
||||
ownerOnly("btn-promote-template", false);
|
||||
ownerOnly("btn-demote-template", false);
|
||||
}
|
||||
}
|
||||
|
||||
function initOwnerActions() {
|
||||
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
const isEN = getLang() === "en";
|
||||
const title = isEN ? template.titleEN : template.titleDE;
|
||||
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
|
||||
if (!window.confirm(msg)) return;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.delete.error"));
|
||||
return;
|
||||
}
|
||||
window.location.href = "/checklists?tab=mine";
|
||||
});
|
||||
|
||||
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
|
||||
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.promote.error"));
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
|
||||
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: "firm" }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.promote.error"));
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSharePickerData() {
|
||||
// Fire all three lookups in parallel — the share modal needs all of
|
||||
// them but doesn't depend on their order.
|
||||
try {
|
||||
const [usersResp, unitsResp, projectsResp] = await Promise.all([
|
||||
fetch("/api/users"),
|
||||
fetch("/api/partner-units"),
|
||||
fetch("/api/projects"),
|
||||
]);
|
||||
shareUsers = usersResp.ok ? await usersResp.json() : [];
|
||||
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
|
||||
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
|
||||
} catch {
|
||||
/* leave whatever loaded */
|
||||
}
|
||||
populateSharePickerOptions();
|
||||
}
|
||||
|
||||
function populateSharePickerOptions() {
|
||||
const userSel = document.getElementById("share-user") as HTMLSelectElement;
|
||||
if (userSel) {
|
||||
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
shareUsers
|
||||
.slice()
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||
.forEach((u) => {
|
||||
if (me && u.id === me.id) return; // can't share with self
|
||||
const opt = document.createElement("option");
|
||||
opt.value = u.id;
|
||||
opt.textContent = `${u.display_name} (${u.email})`;
|
||||
userSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
|
||||
if (officeSel) {
|
||||
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
|
||||
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
officeKeys.forEach((k) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = k;
|
||||
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
officeSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
|
||||
if (puSel) {
|
||||
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
sharePartnerUnits
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((u) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = u.id;
|
||||
opt.textContent = u.name;
|
||||
puSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const prSel = document.getElementById("share-project") as HTMLSelectElement;
|
||||
if (prSel) {
|
||||
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
shareProjects
|
||||
.slice()
|
||||
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
|
||||
.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.reference || ""} — ${p.title}`;
|
||||
prSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
|
||||
activeShareKind = kind;
|
||||
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
|
||||
p.classList.toggle("active", p.dataset.kind === kind);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
|
||||
s.style.display = s.dataset.kind === kind ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initShareModal() {
|
||||
const modal = document.getElementById("share-modal")!;
|
||||
const msg = document.getElementById("share-msg")!;
|
||||
const close = () => { modal.style.display = "none"; };
|
||||
|
||||
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
switchShareKind("user");
|
||||
modal.style.display = "flex";
|
||||
await loadSharePickerData();
|
||||
await renderGrants();
|
||||
});
|
||||
|
||||
document.getElementById("share-close")?.addEventListener("click", close);
|
||||
document.getElementById("share-cancel")?.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
|
||||
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
|
||||
if (!btn) return;
|
||||
switchShareKind(btn.dataset.kind as typeof activeShareKind);
|
||||
});
|
||||
|
||||
document.getElementById("share-submit")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
|
||||
switch (activeShareKind) {
|
||||
case "user": {
|
||||
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_user_id"] = v;
|
||||
break;
|
||||
}
|
||||
case "office": {
|
||||
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_office"] = v;
|
||||
break;
|
||||
}
|
||||
case "partner_unit": {
|
||||
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_partner_unit_id"] = v;
|
||||
break;
|
||||
}
|
||||
case "project": {
|
||||
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_project_id"] = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let errMsg = t("checklisten.share.error.generic");
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) errMsg = j.error;
|
||||
} catch { /* keep generic */ }
|
||||
msg.textContent = errMsg;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("checklisten.share.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
await renderGrants();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderGrants() {
|
||||
if (!template) return;
|
||||
const list = document.getElementById("share-grants-list")!;
|
||||
const empty = document.getElementById("share-grants-empty")!;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
|
||||
const rows: Share[] = resp.ok ? await resp.json() : [];
|
||||
if (rows.length === 0) {
|
||||
list.innerHTML = "";
|
||||
list.appendChild(empty);
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = rows.map((s) => {
|
||||
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
|
||||
return `<li class="share-grant-row" data-id="${esc(s.id)}">
|
||||
<span class="share-grant-kind">${kindLabel}</span>
|
||||
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
|
||||
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
|
||||
</li>`;
|
||||
}).join("");
|
||||
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
|
||||
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
window.alert(t("checklisten.share.grants.revoke.error"));
|
||||
return;
|
||||
}
|
||||
await renderGrants();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initNewInstance();
|
||||
initFeedback();
|
||||
initOwnerActions();
|
||||
initShareModal();
|
||||
onLangChange(rerenderAll);
|
||||
void loadTemplate();
|
||||
void (async () => {
|
||||
me = await loadMe();
|
||||
await loadTemplate();
|
||||
applyOwnerControls();
|
||||
})();
|
||||
void loadInstances();
|
||||
void loadAkten();
|
||||
});
|
||||
|
||||
@@ -40,6 +40,16 @@ interface Instance {
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Slice C — snapshot of the template body + its version at create time.
|
||||
template_snapshot?: { groups: ChecklistGroup[] } | null;
|
||||
template_version?: number | null;
|
||||
}
|
||||
|
||||
// Slice C — augmented Checklist with origin + version, returned by
|
||||
// /api/checklists/{slug}.
|
||||
interface ChecklistWithMeta extends Checklist {
|
||||
origin?: "static" | "authored";
|
||||
version?: number;
|
||||
}
|
||||
|
||||
let template: Checklist | null = null;
|
||||
@@ -155,6 +165,119 @@ function renderHeader() {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
|
||||
}
|
||||
document.getElementById("instance-meta")!.innerHTML = parts.join("");
|
||||
renderOutdatedBadge();
|
||||
}
|
||||
|
||||
// Slice C — show an "outdated" badge when the live template has a
|
||||
// version > the instance's snapshot version. Both values must be
|
||||
// non-null for the comparison to be meaningful (pre-Slice-C instances
|
||||
// have NULL template_version; static templates always have version=1
|
||||
// and never bump).
|
||||
function renderOutdatedBadge() {
|
||||
const slot = document.getElementById("instance-outdated-slot");
|
||||
if (!slot || !instance || !template) return;
|
||||
const tplMeta = template as ChecklistWithMeta;
|
||||
const instVersion = instance.template_version;
|
||||
const tplVersion = tplMeta.version;
|
||||
if (
|
||||
instVersion == null ||
|
||||
tplVersion == null ||
|
||||
tplMeta.origin !== "authored" ||
|
||||
tplVersion <= instVersion
|
||||
) {
|
||||
slot.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const badge = esc(t("checklisten.instance.outdated.badge"));
|
||||
const note = esc(
|
||||
t("checklisten.instance.outdated.note")
|
||||
.replace("{from}", String(instVersion))
|
||||
.replace("{to}", String(tplVersion)),
|
||||
);
|
||||
const action = esc(t("checklisten.instance.outdated.diff"));
|
||||
slot.innerHTML = `<div class="instance-outdated-banner">
|
||||
<span class="instance-outdated-badge">${badge}</span>
|
||||
<span class="instance-outdated-note">${note}</span>
|
||||
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
|
||||
</div>`;
|
||||
document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal);
|
||||
}
|
||||
|
||||
// Shallow diff between two checklist bodies. Compares item label/note/
|
||||
// rule pairs grouped by section title. Items with the same group title
|
||||
// + same label are matched; differences in note/rule are flagged
|
||||
// 'changed'. Items present only in snapshot are 'removed'; items only
|
||||
// in current are 'added'.
|
||||
function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]):
|
||||
{ added: string[]; removed: string[]; changed: string[] } {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const changed: string[] = [];
|
||||
const oldGroups = snapshot?.groups ?? [];
|
||||
const oldMap: Record<string, ChecklistItem> = {};
|
||||
for (const g of oldGroups) {
|
||||
for (const it of g.items) {
|
||||
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
|
||||
oldMap[key] = it;
|
||||
}
|
||||
}
|
||||
const newMap: Record<string, ChecklistItem> = {};
|
||||
for (const g of current) {
|
||||
for (const it of g.items) {
|
||||
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
|
||||
newMap[key] = it;
|
||||
if (!(key in oldMap)) {
|
||||
added.push(it.labelDE || it.labelEN);
|
||||
} else {
|
||||
const o = oldMap[key];
|
||||
if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") ||
|
||||
(o.rule || "") !== (it.rule || "")) {
|
||||
changed.push(it.labelDE || it.labelEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key in oldMap) {
|
||||
if (!(key in newMap)) {
|
||||
const labelParts = key.split("::");
|
||||
removed.push(labelParts[1] || key);
|
||||
}
|
||||
}
|
||||
return { added, removed, changed };
|
||||
}
|
||||
|
||||
function openDiffModal() {
|
||||
if (!template || !instance) return;
|
||||
const modal = document.getElementById("instance-diff-modal")!;
|
||||
const body = document.getElementById("instance-diff-body")!;
|
||||
const diff = diffBodies(instance.template_snapshot, template.groups);
|
||||
const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
|
||||
if (empty) {
|
||||
body.innerHTML = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
|
||||
} else {
|
||||
const section = (label: string, klass: string, items: string[]) => {
|
||||
if (items.length === 0) return "";
|
||||
return `<section class="instance-diff-section ${klass}">
|
||||
<h3>${esc(label)}</h3>
|
||||
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
|
||||
</section>`;
|
||||
};
|
||||
body.innerHTML = [
|
||||
section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added),
|
||||
section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed),
|
||||
section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed),
|
||||
].join("");
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function initDiffModal() {
|
||||
const modal = document.getElementById("instance-diff-modal");
|
||||
if (!modal) return;
|
||||
const close = () => { modal.style.display = "none"; };
|
||||
document.getElementById("instance-diff-close")?.addEventListener("click", close);
|
||||
document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
@@ -389,6 +512,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initPrint();
|
||||
initRename();
|
||||
initFeedback();
|
||||
initDiffModal();
|
||||
onLangChange(renderAll);
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,26 @@ interface ChecklistSummary {
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
itemCount: number;
|
||||
origin?: "static" | "authored";
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
}
|
||||
|
||||
interface MyChecklist {
|
||||
id: string;
|
||||
slug: string;
|
||||
owner_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
regime: string;
|
||||
court: string;
|
||||
reference: string;
|
||||
deadline: string;
|
||||
lang: string;
|
||||
visibility: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
@@ -26,15 +46,20 @@ interface ChecklistInstance {
|
||||
project_title?: string | null;
|
||||
}
|
||||
|
||||
type TabId = "templates" | "instances";
|
||||
type TabId = "templates" | "mine" | "gallery" | "instances";
|
||||
|
||||
const VALID_TABS: TabId[] = ["templates", "instances"];
|
||||
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
|
||||
|
||||
let allChecklists: ChecklistSummary[] = [];
|
||||
let activeRegime = "all";
|
||||
let galleryRegime = "all";
|
||||
let allInstances: ChecklistInstance[] = [];
|
||||
let templatesBySlug: Record<string, ChecklistSummary> = {};
|
||||
let instancesLoaded = false;
|
||||
let myTemplates: MyChecklist[] = [];
|
||||
let myTemplatesLoaded = false;
|
||||
let galleryLoaded = false;
|
||||
let me: { id: string; email: string } | null = null;
|
||||
let activeTab: TabId = "templates";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -208,7 +233,10 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
el.style.display = el.id === `tab-${tab}` ? "" : "none";
|
||||
});
|
||||
if (opts.pushHistory ?? true) {
|
||||
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
|
||||
let newURL = "/checklists";
|
||||
if (tab === "instances") newURL = "/checklists?tab=instances";
|
||||
if (tab === "mine") newURL = "/checklists?tab=mine";
|
||||
if (tab === "gallery") newURL = "/checklists?tab=gallery";
|
||||
if (window.location.pathname + window.location.search !== newURL) {
|
||||
window.history.replaceState({}, "", newURL);
|
||||
}
|
||||
@@ -216,6 +244,155 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
if (tab === "instances") {
|
||||
void loadInstances();
|
||||
}
|
||||
if (tab === "mine") {
|
||||
void loadMyTemplates();
|
||||
}
|
||||
if (tab === "gallery") {
|
||||
void loadGallery();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGallery(force = false) {
|
||||
if (galleryLoaded && !force) return;
|
||||
galleryLoaded = true;
|
||||
// /api/checklists already returns the merged catalog; the gallery
|
||||
// filter just narrows to non-static + non-owned + non-private.
|
||||
if (allChecklists.length === 0) {
|
||||
await loadTemplates();
|
||||
}
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
function renderGallery() {
|
||||
const loading = document.getElementById("checklists-gallery-loading")!;
|
||||
const empty = document.getElementById("checklists-gallery-empty")!;
|
||||
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
const visible = allChecklists.filter((c) => {
|
||||
if (c.origin !== "authored") return false;
|
||||
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
|
||||
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
empty.style.display = "";
|
||||
grid.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
grid.style.display = "";
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
grid.innerHTML = visible.map((c) => {
|
||||
const title = isEN ? c.titleEN : c.titleDE;
|
||||
const desc = isEN ? c.descriptionEN : c.descriptionDE;
|
||||
const court = isEN ? c.courtEN : c.courtDE;
|
||||
const itemsLabel = isEN ? "items" : "Punkte";
|
||||
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
|
||||
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
|
||||
const authorLine = c.owner_display_name
|
||||
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
|
||||
: "";
|
||||
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
|
||||
<div class="checklist-card-top">
|
||||
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
|
||||
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
|
||||
</div>
|
||||
<h2 class="checklist-card-title">${esc(title)}</h2>
|
||||
<p class="checklist-card-desc">${esc(desc)}</p>
|
||||
<p class="checklist-card-court">${esc(court)}</p>
|
||||
${authorLine}
|
||||
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
|
||||
</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function initGalleryFilters() {
|
||||
const container = document.getElementById("checklist-gallery-filters");
|
||||
if (!container) return;
|
||||
container.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
|
||||
if (!btn) return;
|
||||
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
galleryRegime = btn.dataset.regime ?? "all";
|
||||
renderGallery();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch { /* leave me=null */ }
|
||||
}
|
||||
|
||||
async function loadMyTemplates(force = false) {
|
||||
if (myTemplatesLoaded && !force) return;
|
||||
myTemplatesLoaded = true;
|
||||
const resp = await fetch("/api/checklists/templates/mine");
|
||||
if (!resp.ok) {
|
||||
myTemplates = [];
|
||||
} else {
|
||||
myTemplates = (await resp.json()) ?? [];
|
||||
}
|
||||
renderMyTemplates();
|
||||
}
|
||||
|
||||
function renderMyTemplates() {
|
||||
const loading = document.getElementById("checklists-mine-loading")!;
|
||||
const empty = document.getElementById("checklists-mine-empty")!;
|
||||
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (myTemplates.length === 0) {
|
||||
empty.style.display = "";
|
||||
grid.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
grid.style.display = "";
|
||||
|
||||
grid.innerHTML = myTemplates.map((tpl) => {
|
||||
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
|
||||
const visLabel = esc(t(visKey as never) || tpl.visibility);
|
||||
const titleSafe = esc(tpl.title);
|
||||
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
|
||||
<div class="checklist-card-top">
|
||||
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
|
||||
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
|
||||
</div>
|
||||
<h2 class="checklist-card-title">
|
||||
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
|
||||
</h2>
|
||||
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
|
||||
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
|
||||
<div class="checklist-card-actions">
|
||||
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
|
||||
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">Löschen</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
|
||||
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const slug = btn.dataset.slug!;
|
||||
const title = btn.dataset.title || slug;
|
||||
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
|
||||
if (!window.confirm(msg)) return;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.mine.delete.error"));
|
||||
return;
|
||||
}
|
||||
await loadMyTemplates(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
@@ -234,11 +411,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
initGalleryFilters();
|
||||
initTabs();
|
||||
onLangChange(() => {
|
||||
renderTemplates();
|
||||
if (instancesLoaded) renderInstances();
|
||||
if (myTemplatesLoaded) renderMyTemplates();
|
||||
if (galleryLoaded) renderGallery();
|
||||
});
|
||||
void loadMe();
|
||||
void loadTemplates();
|
||||
showTab(parseTab(), { pushHistory: false });
|
||||
});
|
||||
|
||||
435
frontend/src/client/components/approval-edit-modal.ts
Normal file
435
frontend/src/client/components/approval-edit-modal.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
// t-paliad-216 Slice B (initial) + t-paliad-217 Slice C (rewrite) —
|
||||
// modal for the "Suggest changes" approval action.
|
||||
//
|
||||
// The approver authors a counter-proposal: edits any field on the
|
||||
// underlying deadline / appointment AND/OR leaves a free-text note. On
|
||||
// submit the caller POSTs to /api/approval-requests/{id}/suggest-changes,
|
||||
// which closes the OLD row as `changes_requested` and spawns a NEW pending
|
||||
// row authored by the approver carrying counter_payload as its payload.
|
||||
//
|
||||
// Scope (t-paliad-217 m's Q1 Reading A — 2026-05-20):
|
||||
// - Every editable field on the entity is in the form, not just the
|
||||
// date allowlist that triggers approval (t-paliad-138 §Q4). The
|
||||
// backend's counter-allowlist (buildCounterSetClauses in
|
||||
// approval_service.go) accepts the wider set:
|
||||
// deadline: title, due_date, original_due_date, warning_date,
|
||||
// description, notes, rule_code, event_type_ids
|
||||
// appointment: title, start_at, end_at, description, location,
|
||||
// appointment_type
|
||||
// - Lifecycle restriction: update-only. shape-list.ts hides the
|
||||
// suggest_changes button for create / complete / delete; this modal
|
||||
// refuses to open on them as defence-in-depth.
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) —
|
||||
// the primitive owns ESC, focus, backdrop, close button, browser
|
||||
// back-button, mobile takeover. This module only constructs the body.
|
||||
//
|
||||
// API:
|
||||
// const result = await openApprovalEditModal({
|
||||
// entityType: "deadline",
|
||||
// lifecycleEvent: "update",
|
||||
// payload: {...}, // requester's proposed values (= current entity row)
|
||||
// preImage: {...}, // pre-mutation values (for "vorher" diff hints)
|
||||
// });
|
||||
// if (result) {
|
||||
// // result.counterPayload + result.note ready to POST
|
||||
// } else {
|
||||
// // user cancelled
|
||||
// }
|
||||
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
fetchEventTypes,
|
||||
type PickerHandle,
|
||||
} from "../event-types";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export interface ApprovalEditModalArgs {
|
||||
entityType: "deadline" | "appointment";
|
||||
lifecycleEvent: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
preImage: Record<string, unknown> | null;
|
||||
// Optional context for the read-only context section. The caller can
|
||||
// hydrate these from the row's API response (project_title,
|
||||
// requester_name, requested_at) when available; the modal degrades
|
||||
// gracefully when they're missing.
|
||||
projectTitle?: string;
|
||||
requesterName?: string;
|
||||
requestedAt?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalEditModalResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// FieldSpec — one editable input row. The type determines the <input>
|
||||
// (or <textarea>) shape; getValue / setValue normalise the form-element
|
||||
// value to the server-friendly counter_payload shape.
|
||||
interface FieldSpec {
|
||||
key: string;
|
||||
labelKey: string; // i18n key
|
||||
inputType: "text" | "date" | "datetime-local" | "textarea";
|
||||
// Required = title (NOT NULL on the column). Other fields are nullable;
|
||||
// empty string clears (server's addText helper handles this).
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// Deadline-only fields rendered in the editable section. `rule_code` and
|
||||
// `event_type_ids` are intentionally NOT here — they're bundled into the
|
||||
// dedicated "Verfahrenshandlung" section below the base fields so the
|
||||
// event-type (parent concept) reads before the rule (m/paliad#56).
|
||||
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
|
||||
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
|
||||
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
|
||||
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
|
||||
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
|
||||
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
|
||||
];
|
||||
|
||||
const APPOINTMENT_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "appointments.field.title", inputType: "text", required: true },
|
||||
{ key: "start_at", labelKey: "appointments.field.start", inputType: "datetime-local" },
|
||||
{ key: "end_at", labelKey: "appointments.field.end", inputType: "datetime-local" },
|
||||
{ key: "location", labelKey: "appointments.field.location", inputType: "text" },
|
||||
{ key: "appointment_type", labelKey: "appointments.field.type", inputType: "text" },
|
||||
{ key: "description", labelKey: "appointments.field.description", inputType: "textarea" },
|
||||
];
|
||||
|
||||
export async function openApprovalEditModal(
|
||||
args: ApprovalEditModalArgs,
|
||||
): Promise<ApprovalEditModalResult | null> {
|
||||
if (args.lifecycleEvent !== "update") {
|
||||
window.alert(t("approvals.suggest.unsupported_lifecycle"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
|
||||
const original = (args.payload ?? {}) as Record<string, unknown>;
|
||||
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Build the body element imperatively so we can wire input handlers
|
||||
// before openModal mounts the dialog.
|
||||
const body = document.createElement("div");
|
||||
body.className = "approval-suggest-body";
|
||||
|
||||
body.appendChild(renderIntro());
|
||||
body.appendChild(renderFieldsSection(fields, original, preImage));
|
||||
|
||||
// event_type_ids picker (deadline-only) — async because the picker
|
||||
// needs to fetch the firm's event-type catalogue. We attach a host
|
||||
// element synchronously and populate it once the fetch returns.
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let eventTypePickerLoaded = false;
|
||||
if (args.entityType === "deadline") {
|
||||
const pickerSection = renderEventTypePickerSection(original, preImage);
|
||||
body.appendChild(pickerSection.section);
|
||||
void (async () => {
|
||||
try {
|
||||
await fetchEventTypes();
|
||||
eventTypePicker = attachEventTypePicker(pickerSection.host, {
|
||||
initialIDs: (original.event_type_ids as string[] | undefined) ?? [],
|
||||
});
|
||||
eventTypePickerLoaded = true;
|
||||
} catch (_e) {
|
||||
// Fail-soft: leave the section empty; counter still works
|
||||
// without event_type_ids in the payload.
|
||||
pickerSection.host.textContent = t("approvals.suggest.event_type_picker_unavailable");
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
body.appendChild(renderContextSection(args, original));
|
||||
const noteEl = renderNoteSection();
|
||||
body.appendChild(noteEl.section);
|
||||
|
||||
// Read inputs back at submit time. The same list is what we listen to
|
||||
// for the dirty-state gate.
|
||||
const fieldInputs = Array.from(
|
||||
body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("[data-suggest-field]"),
|
||||
);
|
||||
|
||||
return openModal<ApprovalEditModalResult>({
|
||||
title: `${t("approvals.suggest.modal_title")} — ${t(("approvals.entity." + args.entityType) as never)}`,
|
||||
body,
|
||||
size: "lg",
|
||||
primary: {
|
||||
label: t("approvals.suggest.submit"),
|
||||
handler: (close) => {
|
||||
const result = buildResult(fieldInputs, noteEl.textarea, original, eventTypePicker, eventTypePickerLoaded);
|
||||
if (!result.dirty && !result.note) {
|
||||
// Server enforces too. Client-side guard avoids the 400 round-trip.
|
||||
window.alert(t("approvals.suggest.submit_disabled_hint"));
|
||||
return;
|
||||
}
|
||||
close({
|
||||
counterPayload: result.counterPayload,
|
||||
note: result.note,
|
||||
});
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.suggest.cancel") },
|
||||
});
|
||||
}
|
||||
|
||||
function renderIntro(): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "approval-suggest-intro muted";
|
||||
p.textContent = t("approvals.suggest.intro");
|
||||
return p;
|
||||
}
|
||||
|
||||
function renderFieldsSection(
|
||||
fields: ReadonlyArray<FieldSpec>,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.editable");
|
||||
section.appendChild(h);
|
||||
|
||||
for (const f of fields) {
|
||||
section.appendChild(renderSingleField(f, original, preImage));
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
// Verfahrenshandlung section — bundles the event-type picker and the
|
||||
// rule_code input so the editor reads "what procedural step? which rule
|
||||
// cites it?" instead of two disconnected fields with rule above type
|
||||
// (m/paliad#56). The hint underneath spells out the parent/child
|
||||
// relationship so first-time editors don't read them as peers.
|
||||
function renderEventTypePickerSection(
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): { section: HTMLElement; host: HTMLElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.event_type_rule");
|
||||
section.appendChild(h);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "approval-suggest-event-type-picker";
|
||||
section.appendChild(host);
|
||||
|
||||
// Rule citation — rendered as a sub-field directly beneath the picker so
|
||||
// the visual hierarchy matches the conceptual one (rule is meta on the
|
||||
// event type, not a peer).
|
||||
const ruleField: FieldSpec = {
|
||||
key: "rule_code",
|
||||
labelKey: "approvals.suggest.field.rule_code",
|
||||
inputType: "text",
|
||||
};
|
||||
section.appendChild(renderSingleField(ruleField, original, preImage));
|
||||
|
||||
return { section, host };
|
||||
}
|
||||
|
||||
// renderSingleField builds one labelled input in the same shape as the
|
||||
// fields-section loop. Extracted so the Verfahrenshandlung section can
|
||||
// host the rule_code input next to the picker without duplicating the
|
||||
// wiring (dirty-tracking, pre_image hint, label/for binding).
|
||||
function renderSingleField(
|
||||
f: FieldSpec,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-field";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t(f.labelKey as never);
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = formatFieldForInput(original[f.key], f.inputType);
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
if (f.inputType === "textarea") {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
(input as HTMLTextAreaElement).value = value;
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
(input as HTMLInputElement).type = f.inputType;
|
||||
(input as HTMLInputElement).value = value;
|
||||
}
|
||||
input.dataset.suggestField = f.key;
|
||||
input.dataset.suggestOriginal = value;
|
||||
input.dataset.suggestInputType = f.inputType;
|
||||
if (f.required) input.required = true;
|
||||
|
||||
const inputID = `suggest-field-${f.key}`;
|
||||
input.id = inputID;
|
||||
label.setAttribute("for", inputID);
|
||||
|
||||
wrap.appendChild(input);
|
||||
|
||||
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
|
||||
if (preVal && preVal !== value) {
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "approval-suggest-prehint";
|
||||
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderContextSection(
|
||||
args: ApprovalEditModalArgs,
|
||||
original: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--context";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.context");
|
||||
section.appendChild(h);
|
||||
|
||||
const rows: Array<[string, string]> = [];
|
||||
if (args.projectTitle) {
|
||||
rows.push([t("approvals.suggest.context.project"), args.projectTitle]);
|
||||
}
|
||||
if (args.requesterName) {
|
||||
rows.push([t("approvals.suggest.context.requester"), args.requesterName]);
|
||||
}
|
||||
if (args.requestedAt) {
|
||||
rows.push([t("approvals.suggest.context.requested_at"), formatDateForDisplay(args.requestedAt)]);
|
||||
}
|
||||
// Approval status — entity row's current approval_status (typically
|
||||
// "pending" while the modal is open, but display the requester's
|
||||
// perspective for completeness).
|
||||
const approvalStatus = original.approval_status as string | undefined;
|
||||
if (approvalStatus) {
|
||||
rows.push([
|
||||
t("approvals.suggest.context.approval_status"),
|
||||
t(("approvals.status." + approvalStatus) as never) || approvalStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
section.style.display = "none";
|
||||
return section;
|
||||
}
|
||||
|
||||
const dl = document.createElement("dl");
|
||||
dl.className = "approval-suggest-context-grid";
|
||||
for (const [label, value] of rows) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value;
|
||||
dl.appendChild(dt);
|
||||
dl.appendChild(dd);
|
||||
}
|
||||
section.appendChild(dl);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderNoteSection(): { section: HTMLElement; textarea: HTMLTextAreaElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--note";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-note";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t("approvals.suggest.note_label");
|
||||
label.setAttribute("for", "suggest-note");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.id = "suggest-note";
|
||||
textarea.rows = 3;
|
||||
textarea.placeholder = t("approvals.suggest.note_placeholder");
|
||||
textarea.dataset.suggestNote = "true";
|
||||
wrap.appendChild(textarea);
|
||||
|
||||
section.appendChild(wrap);
|
||||
return { section, textarea };
|
||||
}
|
||||
|
||||
interface BuildResult {
|
||||
counterPayload: Record<string, unknown>;
|
||||
note: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
fieldInputs: ReadonlyArray<HTMLInputElement | HTMLTextAreaElement>,
|
||||
noteEl: HTMLTextAreaElement,
|
||||
original: Record<string, unknown>,
|
||||
eventTypePicker: PickerHandle | null,
|
||||
eventTypePickerLoaded: boolean,
|
||||
): BuildResult {
|
||||
const counterPayload: Record<string, unknown> = {};
|
||||
let dirty = false;
|
||||
|
||||
for (const el of fieldInputs) {
|
||||
const key = el.dataset.suggestField || "";
|
||||
const orig = el.dataset.suggestOriginal || "";
|
||||
const inputType = el.dataset.suggestInputType || "text";
|
||||
if (el.value === orig) continue;
|
||||
counterPayload[key] = formatFieldForServer(el.value, inputType);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (eventTypePicker && eventTypePickerLoaded) {
|
||||
const currentIDs = eventTypePicker.getIDs().slice().sort();
|
||||
const originalIDs = ((original.event_type_ids as string[] | undefined) ?? []).slice().sort();
|
||||
if (currentIDs.length !== originalIDs.length
|
||||
|| currentIDs.some((id, i) => id !== originalIDs[i])) {
|
||||
counterPayload.event_type_ids = currentIDs;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counterPayload,
|
||||
note: noteEl.value.trim(),
|
||||
dirty,
|
||||
};
|
||||
}
|
||||
|
||||
// formatFieldForInput — convert a server-side payload value to the format
|
||||
// the <input> wants. Dates round-trip as YYYY-MM-DD; datetime-local wants
|
||||
// YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps; we
|
||||
// trim to the local-input shape. Text passes through verbatim.
|
||||
function formatFieldForInput(v: unknown, inputType: string): string {
|
||||
if (v == null) return "";
|
||||
const s = String(v);
|
||||
if (inputType === "date") {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return m ? m[1] : s;
|
||||
}
|
||||
if (inputType === "datetime-local") {
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
|
||||
return m ? `${m[1]}T${m[2]}` : s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// formatFieldForServer — convert input value back to server-friendly
|
||||
// shape. Empty string means "clear this nullable field"; the server's
|
||||
// addText helper writes NULL for "". Required fields (title) reach the
|
||||
// server's non-empty CHECK on the column, which surfaces as a 400.
|
||||
function formatFieldForServer(value: string, inputType: string): unknown {
|
||||
if (inputType === "date" || inputType === "datetime-local") {
|
||||
return value || null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDateForDisplay(iso: string): string {
|
||||
const d = Date.parse(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
200
frontend/src/client/components/modal.ts
Normal file
200
frontend/src/client/components/modal.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// Unified modal primitive — t-paliad-217.
|
||||
//
|
||||
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
|
||||
// ARIA, and focus trap. We layer back-button integration and focus
|
||||
// restoration on top so the modal behaves consistently on desktop and on
|
||||
// the iPhone PWA (m's checking surface).
|
||||
//
|
||||
// API:
|
||||
// const result = await openModal<MyResult>({
|
||||
// title: "…",
|
||||
// body: htmlStringOrElement,
|
||||
// primary: { label: "Speichern", handler: (close) => { close(result); } },
|
||||
// secondary: { label: "Abbrechen" }, // optional, defaults to "Abbrechen"
|
||||
// size: "sm" | "md" | "lg" | "full", // optional, defaults to "md"
|
||||
// onClose: () => { /* … */ },
|
||||
// classNames: "extra css classes on the <dialog>",
|
||||
// });
|
||||
// // result is the value passed to close(), or null if the user
|
||||
// // dismissed via ESC / backdrop / secondary / browser back-button.
|
||||
//
|
||||
// All dismiss paths are unified: ESC, backdrop click, secondary button,
|
||||
// the always-rendered close (×) button, and the browser back-button all
|
||||
// resolve the promise with null. Programmatic close from the primary
|
||||
// handler resolves with whatever was passed.
|
||||
//
|
||||
// Migration target: call sites that currently roll their own
|
||||
// modal-overlay + ESC handler + focus management replace all of it with
|
||||
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
|
||||
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
|
||||
// modals migrate in follow-up PRs.
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
export interface ModalConfig<T> {
|
||||
title: string;
|
||||
// body can be either a pre-built HTMLElement (the caller assembled the
|
||||
// DOM and may have local references for read-back) or an HTML string
|
||||
// (caller is responsible for escaping). Element is preferred when the
|
||||
// caller needs to read form state on submit.
|
||||
body: HTMLElement | string;
|
||||
primary: {
|
||||
label: string;
|
||||
handler: (close: (result: T) => void) => void | Promise<void>;
|
||||
};
|
||||
// secondary defaults to a Cancel button that just dismisses. Pass null
|
||||
// explicitly to suppress (rare — primary-only modals like a confirmation
|
||||
// toast).
|
||||
secondary?: { label: string } | null;
|
||||
size?: "sm" | "md" | "lg" | "full";
|
||||
// onClose fires on EVERY dismiss path (including primary handler
|
||||
// resolution). Use for analytics / dirty-state warnings.
|
||||
onClose?: () => void;
|
||||
classNames?: string;
|
||||
}
|
||||
|
||||
// openModal returns a promise that resolves with the value passed to
|
||||
// close() inside the primary handler, or null if the user dismissed via
|
||||
// any other path. Always non-throwing — the primary handler decides
|
||||
// whether to surface errors via its own UI (e.g. inline form errors)
|
||||
// rather than rejecting the promise.
|
||||
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
// Record + restore focus to whatever was focused before the modal
|
||||
// opened. Native <dialog> does NOT do this automatically.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
|
||||
dialog.dataset.size = config.size ?? "md";
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "modal__header";
|
||||
const titleEl = document.createElement("h2");
|
||||
titleEl.className = "modal__title";
|
||||
titleEl.textContent = config.title;
|
||||
header.appendChild(titleEl);
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "modal__close";
|
||||
closeBtn.setAttribute("aria-label", t("modal.close.label"));
|
||||
closeBtn.textContent = "×"; // ×
|
||||
header.appendChild(closeBtn);
|
||||
dialog.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "modal__body";
|
||||
if (typeof config.body === "string") {
|
||||
body.innerHTML = config.body;
|
||||
} else {
|
||||
body.appendChild(config.body);
|
||||
}
|
||||
dialog.appendChild(body);
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "modal__footer";
|
||||
const secondaryCfg = config.secondary === null
|
||||
? null
|
||||
: config.secondary ?? { label: t("common.cancel") };
|
||||
let secondaryBtn: HTMLButtonElement | null = null;
|
||||
if (secondaryCfg) {
|
||||
secondaryBtn = document.createElement("button");
|
||||
secondaryBtn.type = "button";
|
||||
secondaryBtn.className = "btn btn-ghost modal__secondary";
|
||||
secondaryBtn.textContent = secondaryCfg.label;
|
||||
footer.appendChild(secondaryBtn);
|
||||
}
|
||||
const primaryBtn = document.createElement("button");
|
||||
primaryBtn.type = "button";
|
||||
primaryBtn.className = "btn btn-primary modal__primary";
|
||||
primaryBtn.textContent = config.primary.label;
|
||||
footer.appendChild(primaryBtn);
|
||||
dialog.appendChild(footer);
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// History integration (Q5): push a synthetic history state so the
|
||||
// browser back-button closes the modal instead of leaving the page.
|
||||
// We pop the state in finish() unless popstate already fired it.
|
||||
let historyEntryActive = false;
|
||||
try {
|
||||
history.pushState({ paliadModalOpen: true }, "");
|
||||
historyEntryActive = true;
|
||||
} catch (_e) {
|
||||
// pushState may throw in obscure embedded contexts; degrade gracefully.
|
||||
}
|
||||
|
||||
// resolved guards against double-resolution (e.g. ESC fires + then a
|
||||
// microtask-deferred primary handler also calls close).
|
||||
let resolved = false;
|
||||
|
||||
const finish = (value: T | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
|
||||
// Pop our history entry if it's still on the stack. Skip when the
|
||||
// popstate listener already fired (otherwise we'd go back twice).
|
||||
if (historyEntryActive) {
|
||||
historyEntryActive = false;
|
||||
try { history.back(); } catch (_e) { /* same fallback as pushState */ }
|
||||
}
|
||||
|
||||
// Native dialog close. Use the close event's default rather than
|
||||
// the cancel event so we don't fight the browser's own dismissal.
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
|
||||
// Restore focus to whatever the user was on before. The dialog
|
||||
// teardown happens synchronously so the focus call lands on a
|
||||
// live element.
|
||||
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
|
||||
config.onClose?.();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const close = (result: T) => finish(result);
|
||||
|
||||
// Dismiss paths.
|
||||
closeBtn.addEventListener("click", () => finish(null));
|
||||
secondaryBtn?.addEventListener("click", () => finish(null));
|
||||
dialog.addEventListener("click", (e) => {
|
||||
// Backdrop click — only when the click landed on the dialog element
|
||||
// itself (not on a child). Browsers report dialog.click events
|
||||
// through the backdrop too because the backdrop is conceptually
|
||||
// part of the dialog's box.
|
||||
if (e.target === dialog) finish(null);
|
||||
});
|
||||
// <dialog>'s cancel event fires on ESC. preventDefault stops the
|
||||
// browser's default close so we can run our finish() (history pop,
|
||||
// focus restore, onClose, resolve).
|
||||
dialog.addEventListener("cancel", (e) => {
|
||||
e.preventDefault();
|
||||
finish(null);
|
||||
});
|
||||
const onPopState = () => {
|
||||
// Browser back-button. Our history entry is gone by the time this
|
||||
// fires, so skip the history.back() in finish().
|
||||
historyEntryActive = false;
|
||||
finish(null);
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
|
||||
// Primary action.
|
||||
primaryBtn.addEventListener("click", () => {
|
||||
const result = config.primary.handler(close);
|
||||
// Allow async primary handlers (handler returns a promise) — we
|
||||
// don't wait for it explicitly; the handler is responsible for
|
||||
// calling close() when ready.
|
||||
void result;
|
||||
});
|
||||
|
||||
// Open the dialog in the top layer. showModal activates ARIA
|
||||
// role="dialog" + aria-modal=true + focus trap + backdrop.
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
|
||||
//
|
||||
// Before t-paliad-252 the deadline + appointment detail pages did a
|
||||
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
|
||||
// For pending CREATE lifecycles that endpoint silently DELETES the
|
||||
// underlying entity row — m's "withdrawing the approval deletes the event"
|
||||
// surprise.
|
||||
//
|
||||
// This modal replaces the confirm() with three explicit paths:
|
||||
//
|
||||
// 1. Cancel — does nothing
|
||||
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
|
||||
// through POST /approval-requests/{id}/
|
||||
// edit-entity which keeps the request
|
||||
// pending and merges the new fields
|
||||
// into approval_request.payload
|
||||
// 3. Endgültig zurückziehen + — destructive; current /revoke
|
||||
// löschen behaviour (delete for CREATE, revert
|
||||
// for UPDATE/COMPLETE, cancel for
|
||||
// DELETE-lifecycle requests)
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
|
||||
// three-button row sits cleanly inside the body — the primitive only
|
||||
// supports one secondary action, but we paint the destructive button as a
|
||||
// separate row above the footer.
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export type WithdrawAction = "edit" | "withdraw";
|
||||
|
||||
export interface WithdrawWarningArgs {
|
||||
// entityType drives the copy ("event" vs "appointment" labels).
|
||||
entityType: "deadline" | "appointment";
|
||||
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
|
||||
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
|
||||
// cancelling the deletion request).
|
||||
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
|
||||
}
|
||||
|
||||
// openWithdrawWarningModal resolves with the chosen action, or null if the
|
||||
// user dismissed via Cancel / Esc / backdrop / browser back-button.
|
||||
export async function openWithdrawWarningModal(
|
||||
args: WithdrawWarningArgs,
|
||||
): Promise<WithdrawAction | null> {
|
||||
const body = document.createElement("div");
|
||||
body.className = "withdraw-warning-body";
|
||||
|
||||
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
|
||||
// knows what the destructive button will actually do. The /revoke
|
||||
// backend behaviour:
|
||||
// - create → DELETE the entity (the "surprise" m flagged)
|
||||
// - update → revert to pre_image
|
||||
// - complete → revert to pre-complete state
|
||||
// - delete → cancel the delete request (entity stays alive)
|
||||
const intro = document.createElement("p");
|
||||
intro.className = "withdraw-warning-intro";
|
||||
intro.textContent = leadCopyFor(args);
|
||||
body.appendChild(intro);
|
||||
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "withdraw-warning-sub muted";
|
||||
sub.textContent = subCopyFor(args);
|
||||
body.appendChild(sub);
|
||||
|
||||
// The destructive button lives inside the body — the openModal primitive
|
||||
// only exposes one secondary button slot, and we want the safe "Edit"
|
||||
// path to be the primary CTA. Painting it in red here, separated from
|
||||
// the footer, signals "this is the dangerous option" without competing
|
||||
// visually with the primary CTA.
|
||||
const destructiveRow = document.createElement("div");
|
||||
destructiveRow.className = "withdraw-warning-destructive-row";
|
||||
const destructiveBtn = document.createElement("button");
|
||||
destructiveBtn.type = "button";
|
||||
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
|
||||
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
|
||||
destructiveRow.appendChild(destructiveBtn);
|
||||
body.appendChild(destructiveRow);
|
||||
|
||||
return new Promise<WithdrawAction | null>((resolve) => {
|
||||
let chosen: WithdrawAction | null = null;
|
||||
|
||||
// The destructive button has to close the modal and return "withdraw".
|
||||
// We need access to the modal's internal close() — fortunately openModal
|
||||
// exposes it via the primary handler's first arg. We pass through the
|
||||
// outer resolve and let the primary handler (Edit) own the close-fn
|
||||
// route. For the destructive button we resolve the outer promise
|
||||
// directly and then synthesise an ESC keypress so the modal dismisses
|
||||
// — or, simpler, set chosen and use the secondary "Cancel" path that
|
||||
// the modal already supports. (openModal's onClose fires on every
|
||||
// dismiss path including the primary handler resolution.)
|
||||
destructiveBtn.addEventListener("click", () => {
|
||||
chosen = "withdraw";
|
||||
// The unified openModal primitive (modal.ts) wires its dismiss path
|
||||
// through the native <dialog>'s `cancel` event. Dispatching it on
|
||||
// the parent <dialog> runs the same finish() → onClose → resolve
|
||||
// sequence as ESC / backdrop. We then map the resolved `null` back
|
||||
// to "withdraw" via the captured `chosen` in onClose below.
|
||||
const dialogEl = body.closest("dialog");
|
||||
dialogEl?.dispatchEvent(new Event("cancel"));
|
||||
});
|
||||
|
||||
void openModal<WithdrawAction>({
|
||||
title: t("approvals.withdraw.modal.title"),
|
||||
body,
|
||||
size: "md",
|
||||
classNames: "withdraw-warning-modal",
|
||||
primary: {
|
||||
label: t("approvals.withdraw.primary.label"),
|
||||
handler: (close) => {
|
||||
chosen = "edit";
|
||||
close("edit");
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.withdraw.cancel") },
|
||||
onClose: () => {
|
||||
// Resolves whatever was chosen via the destructive button OR the
|
||||
// primary handler. ESC / backdrop / secondary clear `chosen` to
|
||||
// null which is the right "cancel" semantics.
|
||||
resolve(chosen);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function leadCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return args.entityType === "appointment"
|
||||
? t("approvals.withdraw.lead.create.appointment")
|
||||
: t("approvals.withdraw.lead.create.deadline");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.lead.delete");
|
||||
default:
|
||||
// update / complete / unknown → revert semantics
|
||||
return t("approvals.withdraw.lead.update");
|
||||
}
|
||||
}
|
||||
|
||||
function subCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return t("approvals.withdraw.sub.create");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.sub.delete");
|
||||
default:
|
||||
return t("approvals.withdraw.sub.update");
|
||||
}
|
||||
}
|
||||
285
frontend/src/client/dashboard-grid.test.ts
Normal file
285
frontend/src/client/dashboard-grid.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
GRID_COLUMNS,
|
||||
clampH,
|
||||
clampW,
|
||||
placeWidgets,
|
||||
type WidgetPlacementInput,
|
||||
} from "./dashboard-grid";
|
||||
|
||||
// Regression suite for m/paliad#70 (t-paliad-228): the post-#69 edit
|
||||
// mode produced overlapping widgets when a 2-col widget sat next to a
|
||||
// 1-col widget on the same row, when a drag swapped widgets of
|
||||
// different widths, and when a resize grew a widget into a sibling. The
|
||||
// fix moved the placement math into ./dashboard-grid + made it
|
||||
// collision-aware. These tests pin the no-overlap invariant.
|
||||
|
||||
function spec(
|
||||
key: string,
|
||||
x: number | undefined,
|
||||
y: number | undefined,
|
||||
w: number,
|
||||
h = 1,
|
||||
visible = true,
|
||||
): WidgetPlacementInput {
|
||||
return { key, visible, x, y, w, h };
|
||||
}
|
||||
|
||||
// hasOverlap returns true if any placed pair shares a cell. O(n²) is
|
||||
// fine — layouts cap at 32 widgets and the tests stay tiny.
|
||||
function hasOverlap(rects: Map<string, { x: number; y: number; w: number; h: number }>): string | null {
|
||||
const list = Array.from(rects.entries());
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const [ka, a] = list[i];
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const [kb, b] = list[j];
|
||||
const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
|
||||
const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
if (xOverlap && yOverlap) return `${ka} ↔ ${kb} at (${a.x},${a.y},${a.w}x${a.h}) vs (${b.x},${b.y},${b.w}x${b.h})`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("placeWidgets — basic auto-flow", () => {
|
||||
test("places two 6-wide widgets side by side on row 0", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 6),
|
||||
spec("b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("wraps when row doesn't fit", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 8),
|
||||
spec("b", undefined, undefined, 8),
|
||||
]);
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("hidden widgets are skipped and reserve no cells", () => {
|
||||
const out = placeWidgets([
|
||||
spec("hidden", 0, 0, 12, 1, false),
|
||||
spec("visible", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
expect(out.get("visible")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit positions, no collision", () => {
|
||||
test("trusts non-colliding explicit positions exactly", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 6),
|
||||
spec("b", 6, 0, 6),
|
||||
spec("c", 0, 1, 12),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("c")).toEqual({ x: 0, y: 1, w: 12, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — mixed-width collision (m/paliad#70 regression)", () => {
|
||||
test("1-col + 2-col on same row do not overlap when both explicit", () => {
|
||||
// Half-width left + half-width right is the canonical 'two widgets per
|
||||
// row' layout; pre-fix this was fine but the next regression below
|
||||
// exercises the actual bug.
|
||||
const out = placeWidgets([
|
||||
spec("left", 0, 0, 6),
|
||||
spec("right", 6, 0, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("4-col + 8-col both claiming (0,0) end up non-overlapping", () => {
|
||||
// Simulates a post-#69 layout where a 4-wide widget sits at (0, 0)
|
||||
// and an 8-wide widget got accidentally placed at (0, 0) too (e.g.
|
||||
// a buggy reset path or a stale spec from before #70). Placer must
|
||||
// honour the first one's position and fit the second somewhere
|
||||
// free — landing it on the same row at x=4 is acceptable (better
|
||||
// density) as long as nothing overlaps.
|
||||
const out = placeWidgets([
|
||||
spec("first", 0, 0, 4),
|
||||
spec("colliding", 0, 0, 8),
|
||||
]);
|
||||
expect(out.get("first")).toEqual({ x: 0, y: 0, w: 4, h: 1 });
|
||||
expect(out.get("colliding")!.w).toBe(8);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("drag-drop swap of 12-wide onto 6-wide does not overlap", () => {
|
||||
// Setup before swap:
|
||||
// A at (0, 0, w=12) — full width row 0
|
||||
// B at (0, 1, w=6) — half row 1 left
|
||||
// C at (6, 1, w=6) — half row 1 right
|
||||
// User drags A onto B. reorderViaDnd swaps (x, y):
|
||||
// A.x=0, A.y=1
|
||||
// B.x=0, B.y=0
|
||||
// Result must not overlap C.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 1, 12),
|
||||
spec("b", 0, 0, 6),
|
||||
spec("c", 6, 1, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("auto-flow widget steps past explicit blocker on same row", () => {
|
||||
// Explicit widget at (6, 0, w=6); auto-flow widget would pack into
|
||||
// (0, 0, w=6) which is fine — but the next auto-flow widget at w=6
|
||||
// would want (6, 0) which is taken. Placer must wrap it.
|
||||
const out = placeWidgets([
|
||||
spec("flow-a", undefined, undefined, 6),
|
||||
spec("anchored", 6, 0, 6),
|
||||
spec("flow-b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("flow-a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("anchored")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("flow-b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — resize-grow shifts siblings", () => {
|
||||
test("growing a 6-wide to 12-wide bumps the sibling on the same row", () => {
|
||||
// Pre-resize state:
|
||||
// A at (0, 0, w=6)
|
||||
// B at (6, 0, w=6)
|
||||
// User resizes A to w=12. resizeWidget() updates A.w but leaves B
|
||||
// at (6, 0). Placer must shift B down.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 6, 0, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 12, h: 1 });
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("growing widget pushes only the first colliding sibling", () => {
|
||||
// A grows to 12-wide; B and C on row 0 are both colliding. Both must
|
||||
// move; their relative order on row 0 is preserved (B at x=0, C at
|
||||
// x=6) on row 1.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 0, 0, 4),
|
||||
spec("c", 4, 0, 4),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(out.get("c")!.y).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit position overflow clamp", () => {
|
||||
test("x+w > GRID_COLUMNS is clamped not rejected", () => {
|
||||
// A 12-wide widget with x=6 would extend past col 11. Placer must
|
||||
// clamp x to 0 (or wherever fits) so the widget renders inside the
|
||||
// grid.
|
||||
const out = placeWidgets([
|
||||
spec("wide", 6, 0, 12),
|
||||
]);
|
||||
const r = out.get("wide")!;
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
expect(r.w).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — vertical (multi-row) widgets", () => {
|
||||
test("a 2-row-tall widget reserves both rows", () => {
|
||||
const out = placeWidgets([
|
||||
spec("tall", 0, 0, 6, 2),
|
||||
spec("collides-on-row-1", 0, 1, 6, 1),
|
||||
]);
|
||||
expect(out.get("tall")).toEqual({ x: 0, y: 0, w: 6, h: 2 });
|
||||
// The colliding widget must move because tall covers cols 0..5
|
||||
// on both row 0 and row 1. The placer may shift it to the right
|
||||
// half of row 1 (cols 6..11) or to a later row — either is fine
|
||||
// as long as nothing overlaps.
|
||||
const other = out.get("collides-on-row-1")!;
|
||||
expect(other.x >= 6 || other.y >= 2).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — includeHidden (edit mode)", () => {
|
||||
test("hidden widgets are skipped by default", () => {
|
||||
const out = placeWidgets([
|
||||
spec("visible", 0, 0, 6),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
]);
|
||||
expect(out.has("visible")).toBe(true);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
test("includeHidden:true places hidden widgets after visible ones", () => {
|
||||
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
|
||||
// widgets MUST receive a placement, otherwise applyLayout leaves
|
||||
// their inline grid-column empty and CSS Grid auto-flows them as
|
||||
// 1×1 slivers ("super slim greyed-out column").
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 12),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.has("hidden")).toBe(true);
|
||||
const h = out.get("hidden")!;
|
||||
// Must keep its requested width (6), not collapse to 1.
|
||||
expect(h.w).toBe(6);
|
||||
// Must land below the visible widget — never overlap or steal cells.
|
||||
expect(h.y).toBeGreaterThanOrEqual(1);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
|
||||
// Hidden widget stored at (0, 0) shouldn't displace a visible
|
||||
// widget that wants (0, 0). The visible pass runs first, claims
|
||||
// (0, 0); the hidden widget is then placed wherever free — the
|
||||
// placer happily fits it next to the visible widget on the same
|
||||
// row if there's room. The hard invariant is just no-overlap.
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 6),
|
||||
spec("hidden-at-origin", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.has("hidden-at-origin")).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("multiple hidden widgets all receive valid placements", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("h1", undefined, undefined, 6, 1, false),
|
||||
spec("h2", undefined, undefined, 6, 1, false),
|
||||
spec("h3", undefined, undefined, 12, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.size).toBe(4);
|
||||
for (const r of out.values()) {
|
||||
expect(r.w).toBeGreaterThanOrEqual(1);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
}
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp helpers", () => {
|
||||
test("clampW respects min/max bounds", () => {
|
||||
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
|
||||
expect(clampW(20, { min_w: 4, max_w: 12 })).toBe(12);
|
||||
expect(clampW(0, { default_w: 6 })).toBe(6);
|
||||
expect(clampW(NaN, { default_w: 8 })).toBe(8);
|
||||
});
|
||||
|
||||
test("clampH respects min/max bounds and MAX_ROW_SPAN", () => {
|
||||
expect(clampH(0, { default_h: 2 })).toBe(2);
|
||||
expect(clampH(99, undefined)).toBe(5); // MAX_ROW_SPAN
|
||||
expect(clampH(1, { min_h: 3 })).toBe(3);
|
||||
});
|
||||
});
|
||||
264
frontend/src/client/dashboard-grid.ts
Normal file
264
frontend/src/client/dashboard-grid.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// dashboard-grid — pure layout math for the dashboard widget grid.
|
||||
//
|
||||
// Lives outside dashboard.ts so the placement logic is importable from
|
||||
// tests without dragging in the DOM-side rendering code. The grid is a
|
||||
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
|
||||
// rows grow vertically as widgets are placed.
|
||||
//
|
||||
// The core invariant is no-overlap: after placeWidgets() returns, every
|
||||
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
|
||||
// computePlacements() to trust explicit (x, y) without checking — that
|
||||
// produced visual overlap whenever a drag or resize landed a widget on
|
||||
// cells another widget already covered (m/paliad#70). The collision-
|
||||
// aware placer below shifts colliding widgets to the next free row so
|
||||
// the rendered grid never overlaps regardless of the input spec.
|
||||
|
||||
export const GRID_COLUMNS = 12;
|
||||
export const MAX_ROW_SPAN = 5;
|
||||
|
||||
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
|
||||
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
|
||||
// at least one widget, so 256 rows is an order-of-magnitude buffer
|
||||
// against runaway loops on pathological inputs.
|
||||
const MAX_SCAN_ROWS = 256;
|
||||
|
||||
export interface PlacedRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// WidgetSizeBound captures the per-widget min/max/default clamps the
|
||||
// catalog publishes. Optional fields keep callers from having to
|
||||
// synthesize zeroes when the catalog entry is missing.
|
||||
export interface WidgetSizeBound {
|
||||
default_w?: number;
|
||||
default_h?: number;
|
||||
min_w?: number;
|
||||
max_w?: number;
|
||||
min_h?: number;
|
||||
max_h?: number;
|
||||
}
|
||||
|
||||
// WidgetPlacementInput is the per-widget data the placer consumes. The
|
||||
// catalog bound is optional — when missing, defaults fall back to a
|
||||
// full-width 1-row widget.
|
||||
export interface WidgetPlacementInput {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
bound?: WidgetSizeBound;
|
||||
}
|
||||
|
||||
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(w);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
|
||||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||||
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
|
||||
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(h);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
|
||||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||||
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
|
||||
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
|
||||
// created lazily so the map only stores rows the layout actually
|
||||
// reaches. Cell value 1 = occupied.
|
||||
class Occupancy {
|
||||
private rows = new Map<number, Uint8Array>();
|
||||
|
||||
row(y: number): Uint8Array {
|
||||
let r = this.rows.get(y);
|
||||
if (!r) {
|
||||
r = new Uint8Array(GRID_COLUMNS);
|
||||
this.rows.set(y, r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
free(x: number, y: number, w: number, h: number): boolean {
|
||||
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) {
|
||||
if (row[xx]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
mark(x: number, y: number, w: number, h: number): void {
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findFreeSlot scans for the first (x, y) where a w×h block fits without
|
||||
// collision, starting at row startY. At each row preferX is tried first
|
||||
// — that keeps a widget close to its requested column when only the row
|
||||
// is blocked. Falls back to left-to-right scan within the row, then to
|
||||
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
|
||||
function findFreeSlot(
|
||||
occ: Occupancy,
|
||||
startY: number,
|
||||
w: number,
|
||||
h: number,
|
||||
preferX: number,
|
||||
): { x: number; y: number } {
|
||||
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
|
||||
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
|
||||
return { x: preferX, y };
|
||||
}
|
||||
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
|
||||
if (x === preferX) continue;
|
||||
if (occ.free(x, y, w, h)) return { x, y };
|
||||
}
|
||||
}
|
||||
// Pathological fallback — caller's widget cap (32) makes this
|
||||
// unreachable in practice. Snap to the bottom-left so the widget at
|
||||
// least renders somewhere visible instead of vanishing.
|
||||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||||
}
|
||||
|
||||
// PlaceOptions tunes the placer for the caller's render-vs-persist
|
||||
// needs.
|
||||
export interface PlaceOptions {
|
||||
// When true, hidden widgets are placed too — for edit-mode rendering
|
||||
// where the user can see + un-hide them inline. The two-pass order
|
||||
// (visible first, then hidden) guarantees hidden widgets never
|
||||
// displace visible ones: they get whatever cells are left below the
|
||||
// active layout. Default false matches view-mode behaviour and the
|
||||
// persistence path (materializePositions) where hidden widgets
|
||||
// retain their stored coordinates instead of being repacked.
|
||||
//
|
||||
// Without this option, hidden widgets in edit mode were left without
|
||||
// an explicit grid-column inline style by applyLayout(), so CSS Grid
|
||||
// auto-flowed them into the next free cell at 1×1 — the "super slim
|
||||
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to widgets. By
|
||||
// default only visible widgets receive placements; pass
|
||||
// {includeHidden:true} to also place hidden widgets after the visible
|
||||
// pass (used by applyLayout in edit mode).
|
||||
//
|
||||
// Algorithm — per pass:
|
||||
// 1. Clamp w/h against catalog bounds.
|
||||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||||
// collision, search downward starting at the requested y for the
|
||||
// first free w×h block (preferring the requested x).
|
||||
// 3. If only x is explicit, search from y=0 at that x.
|
||||
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
|
||||
// when the row doesn't fit or is blocked by an explicitly-placed
|
||||
// widget, wrap to the next free row.
|
||||
//
|
||||
// The mixed-spec case (some widgets explicit, others auto-flow) is the
|
||||
// real-world layout — placing the explicit widgets first would change
|
||||
// the visual order, so we keep input order and let auto-flow widgets
|
||||
// step around any explicit blockers via the same collision search.
|
||||
//
|
||||
// Two-pass behaviour for hidden widgets: the visible pass owns its
|
||||
// own auto-flow cursor; the hidden pass continues from where the
|
||||
// visible pass left off so the hidden widgets stack right under the
|
||||
// active layout. The shared Occupancy bitmap guarantees the second
|
||||
// pass can never overlap a placed visible widget.
|
||||
export function placeWidgets(
|
||||
widgets: WidgetPlacementInput[],
|
||||
options: PlaceOptions = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
const occ = new Occupancy();
|
||||
|
||||
// Auto-flow cursor — advances as we place flowed widgets. cursorY
|
||||
// tracks the row currently being filled; rowMaxH is the tallest
|
||||
// widget in that row so wrapping advances past it (not just past the
|
||||
// new widget's height — that would let taller previous neighbours
|
||||
// overlap into the wrap row).
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let rowMaxH = 0;
|
||||
|
||||
const placeOne = (w: WidgetPlacementInput): void => {
|
||||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||||
|
||||
const hasX = typeof w.x === "number";
|
||||
const hasY = typeof w.y === "number";
|
||||
let placed: { x: number; y: number };
|
||||
|
||||
if (hasX && hasY) {
|
||||
// Clamp x so the widget never overflows the right edge — drag/
|
||||
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
const prefY = Math.max(0, w.y as number);
|
||||
if (occ.free(prefX, prefY, dw, dh)) {
|
||||
placed = { x: prefX, y: prefY };
|
||||
} else {
|
||||
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
|
||||
}
|
||||
} else if (hasX) {
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
placed = findFreeSlot(occ, 0, dw, dh, prefX);
|
||||
} else {
|
||||
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
|
||||
// remaining columns of the current row, then ask findFreeSlot to
|
||||
// honour the cursor's preferred (x, y) — that lets it step past
|
||||
// any explicit widget that already claimed cells under the
|
||||
// cursor.
|
||||
if (cursorX + dw > GRID_COLUMNS) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
|
||||
if (placed.y > cursorY) {
|
||||
// Wrap was forced by a collision deeper than the current row.
|
||||
cursorY = placed.y;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
cursorX = placed.x + dw;
|
||||
if (dh > rowMaxH) rowMaxH = dh;
|
||||
}
|
||||
|
||||
occ.mark(placed.x, placed.y, dw, dh);
|
||||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||||
};
|
||||
|
||||
// Pass 1: visible widgets. They own the active layout.
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
|
||||
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
|
||||
// start of the next row before the second pass so the hidden tray
|
||||
// visually separates from the active layout — even if the last
|
||||
// visible widget left half a row open.
|
||||
if (options.includeHidden) {
|
||||
if (cursorX > 0) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
for (const w of widgets) {
|
||||
if (w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
project_reference: string;
|
||||
project_title: string;
|
||||
}
|
||||
|
||||
let allDeadlines: Deadline[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0; // 0-11
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const d = new Date(due.slice(0, 10) + "T00:00:00");
|
||||
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
|
||||
if (diffDays < 0) return "frist-urgency-overdue";
|
||||
if (diffDays <= 7) return "frist-urgency-soon";
|
||||
return "frist-urgency-later";
|
||||
}
|
||||
|
||||
async function loadDeadlines() {
|
||||
try {
|
||||
const resp = await fetch("/api/deadlines?status=all");
|
||||
if (resp.ok) allDeadlines = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function deadlinesForDate(iso: string): Deadline[] {
|
||||
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const m = String(month + 1).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const items = deadlinesForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
const grid = document.getElementById("deadline-cal-grid")!;
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(viewYear, viewMonth, 1);
|
||||
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
||||
const hasInMonth = allDeadlines.some((f) => {
|
||||
const iso = f.due_date.slice(0, 10);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
const empty = document.getElementById("deadline-cal-empty")!;
|
||||
empty.style.display = hasInMonth ? "none" : "";
|
||||
}
|
||||
|
||||
function openPopup(iso: string) {
|
||||
const items = deadlinesForDate(iso);
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const dateEl = document.getElementById("cal-popup-date")!;
|
||||
const list = document.getElementById("cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((f) => {
|
||||
const cls = urgencyClass(f.due_date, f.status);
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="frist-cal-dot ${cls}"></span>
|
||||
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
|
||||
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function initPopup() {
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const close = document.getElementById("cal-popup-close")!;
|
||||
close.addEventListener("click", () => (popup.style.display = "none"));
|
||||
popup.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-next")!.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-today")!.addEventListener("click", () => {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadDeadlines();
|
||||
render();
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
@@ -38,6 +39,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -69,6 +73,18 @@ let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
|
||||
// modal, the entity is still in approval_status='pending'. Save must POST
|
||||
// to /api/approval-requests/{id}/edit-entity (which keeps the request
|
||||
// pending + merges the new fields into payload) instead of the regular
|
||||
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
|
||||
// from edit mode + after a successful save.
|
||||
let pendingEditMode = false;
|
||||
|
||||
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
|
||||
// modal handler (initWithdraw) can route into pending-edit mode without
|
||||
// duplicating the edit-mode toggle logic.
|
||||
let pendingEnterEdit: (() => void) | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -366,6 +382,7 @@ function initEdit() {
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -381,6 +398,7 @@ function initEdit() {
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -399,12 +417,50 @@ function initEdit() {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
pendingEditMode = false;
|
||||
}
|
||||
|
||||
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
|
||||
// route into pending-edit mode without re-running the edit-button
|
||||
// visibility gate (which hides the button during pending).
|
||||
pendingEnterEdit = () => {
|
||||
pendingEditMode = true;
|
||||
enterEdit();
|
||||
};
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
|
||||
// head = event_type label (if exactly one Typ chip is in edit)
|
||||
// || rule code+name (when deadline carries a rule)
|
||||
// || "Neue Frist" fallback
|
||||
// suffix = " — <project.reference>" when not already in head
|
||||
titleDefaultBtn?.addEventListener("click", () => {
|
||||
if (!deadline) return;
|
||||
let head = "";
|
||||
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
|
||||
if (ids.length === 1) {
|
||||
const et = eventTypeByID.get(ids[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head && rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
head = code ? `${code} — ${rule.name}` : rule.name;
|
||||
}
|
||||
if (!head && deadline.rule_code) {
|
||||
head = deadline.rule_code;
|
||||
}
|
||||
if (!head) head = t("deadlines.field.title.default_fallback");
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) head = `${head} — ${ref}`;
|
||||
titleEdit.value = head;
|
||||
titleEdit.focus();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
@@ -424,6 +480,35 @@ function initEdit() {
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
|
||||
// t-paliad-252 — pending-edit mode routes through the new endpoint
|
||||
// that updates the entity + merges payload into the still-pending
|
||||
// approval_request. Outside pending-edit mode the regular PATCH
|
||||
// path remains the authoritative one (with its existing 409-on-
|
||||
// pending guard).
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: payload }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error))
|
||||
|| (t("approvals.withdraw.error") || "Fehler");
|
||||
window.alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -501,19 +586,39 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
|
||||
//
|
||||
// Click flow: open the withdraw warning modal (replaces the old
|
||||
// confirm()). The modal returns one of:
|
||||
//
|
||||
// "edit" — open the edit form in pending-edit mode; Save calls
|
||||
// /api/approval-requests/{id}/edit-entity which keeps the
|
||||
// request pending + merges the new fields into payload
|
||||
// "withdraw" — destructive: call the existing /revoke endpoint
|
||||
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
|
||||
// cancel-delete for DELETE lifecycle)
|
||||
// null — user cancelled; nothing happens
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "deadline",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
btn.disabled = false;
|
||||
pendingEnterEdit?.();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → existing destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -521,14 +626,16 @@ function initWithdraw() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
// and the badge / buttons rerender accordingly. For CREATE
|
||||
// lifecycle the entity is gone, so the 404 surfaces as a reload.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
// CREATE lifecycle deleted the entity — bounce to the list.
|
||||
window.location.href = "/events?type=deadline";
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
@@ -24,6 +24,10 @@ interface Project {
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
// t-paliad-251 — used by Type→Rule autofill to narrow rule candidates
|
||||
// to the project's own proceeding. Optional because not every project
|
||||
// is a case/proceeding (clients + matters carry no proceeding type).
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -32,15 +36,32 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule,
|
||||
// AND is inverted to power Typ→Regel auto-fill (t-paliad-251 Part 2):
|
||||
// given a chosen event_type X, candidate rules are those whose
|
||||
// concept_default_event_type_id === X.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
jurisdiction: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
let projectsByID = new Map<string, Project>();
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
@@ -48,6 +69,17 @@ let rulesByID = new Map<string, DeadlineRule>();
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
|
||||
// t-paliad-251 — symmetric flag for the inverse direction. Tracks the
|
||||
// rule ID we most recently injected as the Auto-derived default for the
|
||||
// chosen event_type, so we can replace it silently when the user picks
|
||||
// a different type but leave manual rule picks alone.
|
||||
let lastAutoFilledRuleID: string | null = null;
|
||||
|
||||
// Current sort mode for the Rule select. Persisted to localStorage so
|
||||
// repeat-form users don't have to re-pick their preferred ordering.
|
||||
type RuleSort = "by_proceeding" | "by_court" | "alpha";
|
||||
const RULE_SORT_KEY = "paliad.deadline.rule.sort";
|
||||
|
||||
let preselectedProjectID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -62,6 +94,20 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function ruleLabel(r: DeadlineRule): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const code = r.rule_code || r.code || "";
|
||||
return code ? `${code} — ${name}` : name;
|
||||
}
|
||||
|
||||
function proceedingLabel(pt: ProceedingType | undefined): string {
|
||||
if (!pt) return "";
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
|
||||
return `${pt.jurisdiction} — ${name}`;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
||||
const hint = document.getElementById("deadline-project-empty-hint")!;
|
||||
@@ -69,6 +115,7 @@ async function loadProjects() {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (!resp.ok) return;
|
||||
const projects: Project[] = await resp.json();
|
||||
projectsByID = new Map(projects.map((p) => [p.id, p]));
|
||||
if (projects.length === 0) {
|
||||
hint.style.display = "";
|
||||
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
|
||||
@@ -82,7 +129,7 @@ async function loadProjects() {
|
||||
const ref = p.reference || "";
|
||||
const indent = projectIndent(p.path);
|
||||
options.push(
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
@@ -91,28 +138,186 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal — rule sort falls back to alpha when proceeding-type
|
||||
metadata is missing */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
for (const r of rules) {
|
||||
const code = r.rule_code || r.code || "";
|
||||
const label = code ? `${code} \u2014 ${r.name}` : r.name;
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
renderRuleSelect();
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
}
|
||||
}
|
||||
|
||||
// renderRuleSelect rebuilds the Rule <select> from the current sort
|
||||
// mode + the cached rule set. Called whenever the user changes the sort
|
||||
// dropdown, when the language flips, or after rules + proceeding types
|
||||
// finish loading. The "Keine Regel" sentinel always stays at the top.
|
||||
function renderRuleSelect(): void {
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const previous = sel.value;
|
||||
|
||||
const sort = readRuleSort();
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
|
||||
if (sort === "alpha") {
|
||||
const sorted = [...allRules].sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
|
||||
for (const r of sorted) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
} else if (sort === "by_court") {
|
||||
// Group by proceeding_type.jurisdiction (UPC / EPA / DPMA / DE /
|
||||
// other). Within each group, sort alpha by rule label so the user
|
||||
// can scan a court's rules in stable order.
|
||||
const byJurisdiction = new Map<string, DeadlineRule[]>();
|
||||
for (const r of allRules) {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
const j = pt?.jurisdiction || t("event_types.browse.jurisdiction.none");
|
||||
const list = byJurisdiction.get(j) ?? [];
|
||||
list.push(r);
|
||||
byJurisdiction.set(j, list);
|
||||
}
|
||||
const order = ["UPC", "EPA", "EPO", "DPMA", "DE"];
|
||||
const keys = [...byJurisdiction.keys()].sort((a, b) => {
|
||||
const ai = order.indexOf(a);
|
||||
const bi = order.indexOf(b);
|
||||
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
for (const k of keys) {
|
||||
const list = byJurisdiction.get(k)!.sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
|
||||
opts.push(`<optgroup label="${esc(k === "EPO" ? "EPA" : k)}">`);
|
||||
for (const r of list) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
opts.push(`</optgroup>`);
|
||||
}
|
||||
} else {
|
||||
// by_proceeding — group by proceeding_type, within each preserve the
|
||||
// canonical sequence_order so the user reads "Klageerwiderung →
|
||||
// Replik → Duplik → Verhandlung" in chronological order.
|
||||
const byProceeding = new Map<number | string, DeadlineRule[]>();
|
||||
const noProceedingKey = "__none__";
|
||||
for (const r of allRules) {
|
||||
const k: number | string = r.proceeding_type_id ?? noProceedingKey;
|
||||
const list = byProceeding.get(k) ?? [];
|
||||
list.push(r);
|
||||
byProceeding.set(k, list);
|
||||
}
|
||||
const keys = [...byProceeding.keys()].sort((a, b) => {
|
||||
if (a === noProceedingKey) return 1;
|
||||
if (b === noProceedingKey) return -1;
|
||||
const pa = proceedingTypesByID.get(a as number);
|
||||
const pb = proceedingTypesByID.get(b as number);
|
||||
const sa = pa?.sort_order ?? 9999;
|
||||
const sb = pb?.sort_order ?? 9999;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (pa?.code ?? "").localeCompare(pb?.code ?? "");
|
||||
});
|
||||
for (const k of keys) {
|
||||
const list = (byProceeding.get(k)!).slice().sort(
|
||||
(a, b) => (a.sequence_order ?? 0) - (b.sequence_order ?? 0),
|
||||
);
|
||||
const pt = typeof k === "number" ? proceedingTypesByID.get(k) : undefined;
|
||||
const groupLabel = pt ? proceedingLabel(pt) : t("deadlines.field.rule.sort.other_proceeding");
|
||||
opts.push(`<optgroup label="${esc(groupLabel)}">`);
|
||||
for (const r of list) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
opts.push(`</optgroup>`);
|
||||
}
|
||||
}
|
||||
|
||||
sel.innerHTML = opts.join("");
|
||||
// Restore previous selection if it still exists in the new order.
|
||||
if (previous && rulesByID.has(previous)) {
|
||||
sel.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function readRuleSort(): RuleSort {
|
||||
try {
|
||||
const raw = localStorage.getItem(RULE_SORT_KEY);
|
||||
if (raw === "by_proceeding" || raw === "by_court" || raw === "alpha") return raw;
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
return "by_court";
|
||||
}
|
||||
|
||||
function writeRuleSort(s: RuleSort): void {
|
||||
try {
|
||||
localStorage.setItem(RULE_SORT_KEY, s);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType picks the best-match rule for the chosen event
|
||||
// type, scoring by:
|
||||
// 1. project's proceeding_type_id (if known) — exact match wins,
|
||||
// 2. otherwise event_type.jurisdiction matches the rule's
|
||||
// proceeding's jurisdiction (EPA→EPO canonicalised),
|
||||
// 3. otherwise just the first candidate in the canonical ordering.
|
||||
//
|
||||
// Returns null when no rule maps to this event_type. The caller surfaces
|
||||
// this as "no Auto rule available — pick one manually" rather than
|
||||
// silently leaving the dropdown stuck on whatever the user picked before.
|
||||
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
if (project?.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypesByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
}
|
||||
preselectedProjectIDLocal = preselectedProjectID;
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
//
|
||||
@@ -140,8 +345,14 @@ function refreshRuleView(): void {
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
// t-paliad-251 — when the rule was auto-derived from a user-picked
|
||||
// type (Typ→Regel direction), the collapsed "vorgegeben durch Regel"
|
||||
// copy reads backwards. Show the picker explicitly + surface the
|
||||
// Auto badge on the Rule field instead.
|
||||
const ruleWasAutoDerivedFromType =
|
||||
lastAutoFilledRuleID !== null && ruleID === lastAutoFilledRuleID;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault && !ruleWasAutoDerivedFromType;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
@@ -164,6 +375,58 @@ function refreshRuleView(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// refreshRuleAutoBadgeAndWarning surfaces the Auto badge whenever the
|
||||
// Rule was derived from the Typ (i.e. lastAutoFilledRuleID is currently
|
||||
// selected) AND the warning whenever the user has manually picked a
|
||||
// non-Auto rule that contradicts the Type's derived rule. Both end up
|
||||
// inert when there's no Type chosen.
|
||||
function refreshRuleAutoBadgeAndWarning(): void {
|
||||
const autoEl = document.getElementById("deadline-rule-auto-hint");
|
||||
const autoTextEl = document.getElementById("deadline-rule-auto-hint-text");
|
||||
const warnEl = document.getElementById("deadline-rule-override-warn");
|
||||
if (!autoEl || !autoTextEl || !warnEl) return;
|
||||
|
||||
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!ruleSel) return;
|
||||
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) {
|
||||
autoEl.style.display = "none";
|
||||
warnEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const derived = resolveAutoRuleForType(picked[0], projectID);
|
||||
const currentRuleID = ruleSel.value || "";
|
||||
|
||||
if (currentRuleID && currentRuleID === lastAutoFilledRuleID) {
|
||||
// The current rule was auto-derived (and the user hasn't touched it).
|
||||
autoEl.style.display = "";
|
||||
autoTextEl.textContent = derived ? ` — ${ruleLabel(derived)}` : "";
|
||||
warnEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
autoEl.style.display = "none";
|
||||
|
||||
// Override warning: derived rule exists AND user has picked a
|
||||
// different non-empty rule. The copy names BOTH so the user knows
|
||||
// exactly what's happening — and which one will be applied.
|
||||
if (derived && currentRuleID && currentRuleID !== derived.id) {
|
||||
const current = rulesByID.get(currentRuleID);
|
||||
if (current) {
|
||||
const tmpl = t("deadlines.field.rule.override_warn");
|
||||
const msg = tmpl
|
||||
.replace("{derived}", ruleLabel(derived))
|
||||
.replace("{selected}", ruleLabel(current));
|
||||
warnEl.textContent = msg;
|
||||
warnEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
warnEl.style.display = "none";
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
@@ -200,13 +463,97 @@ function applyRuleAutoFill(): void {
|
||||
refreshRuleView();
|
||||
}
|
||||
|
||||
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`;
|
||||
// applyTypeAutoFillRule is the inverse direction (t-paliad-251 Part 2):
|
||||
// when the user picks a single Typ chip, derive the canonical Rule and
|
||||
// inject it into the Regel select. Like applyRuleAutoFill, it leaves
|
||||
// manual rule picks alone — only replaces when the current rule is the
|
||||
// previous auto-fill (sticky-replace pattern).
|
||||
function applyTypeAutoFillRule(): void {
|
||||
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!ruleSel) return;
|
||||
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
|
||||
if (picked.length !== 1) {
|
||||
// 0 or 2+ Typ chips → no canonical rule to derive. Clear the
|
||||
// sticky auto-fill so a stale Auto suggestion doesn't linger.
|
||||
if (lastAutoFilledRuleID && ruleSel.value === lastAutoFilledRuleID) {
|
||||
ruleSel.value = "";
|
||||
lastAutoFilledRuleID = null;
|
||||
// Mirror to the Regel→Typ path so its mismatch warning recomputes.
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
const derived = resolveAutoRuleForType(picked[0], projectID);
|
||||
const currentRuleID = ruleSel.value || "";
|
||||
const ruleStillReflectsLastSuggestion =
|
||||
lastAutoFilledRuleID !== null && currentRuleID === lastAutoFilledRuleID;
|
||||
const ruleIsEmpty = currentRuleID === "";
|
||||
|
||||
if (derived) {
|
||||
if (ruleIsEmpty || ruleStillReflectsLastSuggestion) {
|
||||
ruleSel.value = derived.id;
|
||||
lastAutoFilledRuleID = derived.id;
|
||||
// Mirror to the Regel→Typ direction — the new rule's collapsed
|
||||
// view + mismatch state needs to recompute now that we changed
|
||||
// the selection programmatically.
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
} else if (ruleStillReflectsLastSuggestion) {
|
||||
// No derived rule for the new type — drop the stale auto-fill.
|
||||
ruleSel.value = "";
|
||||
lastAutoFilledRuleID = null;
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
}
|
||||
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Recipe (documented also in
|
||||
// the commit message so future title templates can mirror it):
|
||||
//
|
||||
// priority order picks the head of the title:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. rule name (when a Rule is set — uses ruleLabel = "code — name")
|
||||
// 3. proceeding type name (when project carries a proceeding_type_id)
|
||||
// 4. fallback: t("deadlines.field.title.default_fallback")
|
||||
//
|
||||
// suffix: " — <project-reference>" when the project has a reference
|
||||
// string and the title doesn't already contain it.
|
||||
//
|
||||
// Returns "" only when even the fallback fails (i18n unavailable) —
|
||||
// callers handle that by leaving the field untouched.
|
||||
function computeDefaultTitle(): string {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
let head = "";
|
||||
if (picked.length === 1) {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = ruleLabel(rule);
|
||||
}
|
||||
if (!head && project?.proceeding_type_id) {
|
||||
const pt = proceedingTypesByID.get(project.proceeding_type_id);
|
||||
if (pt) head = proceedingLabel(pt);
|
||||
}
|
||||
if (!head) {
|
||||
head = t("deadlines.field.title.default_fallback");
|
||||
}
|
||||
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) {
|
||||
return `${head} — ${ref}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
@@ -252,8 +599,8 @@ async function submitForm(e: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedProjectID) {
|
||||
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
if (preselectedProjectIDLocal) {
|
||||
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
|
||||
} else {
|
||||
window.location.href = `/deadlines/${created.id}`;
|
||||
}
|
||||
@@ -343,12 +690,33 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Default due to today
|
||||
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
await Promise.all([loadProjects(), loadRules(), loadMe()]);
|
||||
|
||||
// Wire the sort dropdown to read its initial value from localStorage and
|
||||
// persist user picks back.
|
||||
const sortSel = document.getElementById("deadline-rule-sort") as HTMLSelectElement | null;
|
||||
if (sortSel) {
|
||||
sortSel.value = readRuleSort();
|
||||
sortSel.addEventListener("change", () => {
|
||||
writeRuleSort(sortSel.value as RuleSort);
|
||||
renderRuleSelect();
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
// After both rules + proceeding types are in, re-render with the
|
||||
// chosen sort so groups carry proper labels.
|
||||
renderRuleSelect();
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
onChange: () => {
|
||||
// Both directions trigger off picker change: refresh the
|
||||
// Regel→Typ collapsed/expanded state AND the Typ→Regel auto-fill.
|
||||
refreshRuleView();
|
||||
applyTypeAutoFillRule();
|
||||
},
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
@@ -358,13 +726,18 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
// manually edited away from the previous rule's suggestion. ALSO
|
||||
// resets the Typ→Regel auto-fill marker since the user just made a
|
||||
// manual rule pick.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
lastAutoFilledRuleID = null;
|
||||
applyRuleAutoFill();
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
@@ -381,6 +754,20 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
// Project change can shift which rule the Type maps to (via the
|
||||
// project's proceeding_type_id), so re-run the auto-fill.
|
||||
void refreshApprovalHint();
|
||||
applyTypeAutoFillRule();
|
||||
});
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button replaces the title with
|
||||
// a derived default. No destructive confirmation because the user
|
||||
// invoked it explicitly.
|
||||
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
|
||||
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
|
||||
if (!titleInput) return;
|
||||
const derived = computeDefaultTitle();
|
||||
if (derived) titleInput.value = derived;
|
||||
titleInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
let selected = new Set<string>(opts.initialIDs);
|
||||
let searchQuery = "";
|
||||
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
|
||||
// jurisdiction). Any non-null value matches event_types.jurisdiction;
|
||||
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
|
||||
let activeJurisdiction: string | null = null;
|
||||
|
||||
// Surface every jurisdiction present in the data — "any" stays bucketed
|
||||
// separately so users still have a "show generic-only" chip. EPA is
|
||||
// canonicalised to EPO in event_types (see mig 074); the chip label
|
||||
// shows EPA to match the legal vocabulary the lawyers use.
|
||||
const jurisdictionsPresent = new Set<string>();
|
||||
for (const et of opts.types) {
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
if (j) jurisdictionsPresent.add(j);
|
||||
}
|
||||
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
|
||||
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
|
||||
// Any jurisdiction in the data that isn't in our ordered list lands at
|
||||
// the end so the chip row never silently drops a court flavour.
|
||||
for (const j of jurisdictionsPresent) {
|
||||
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
|
||||
}
|
||||
|
||||
function chipLabel(j: string): string {
|
||||
if (j === "EPO") return "EPA";
|
||||
if (j === "any") return t("event_types.browse.jurisdiction.none");
|
||||
return j;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-browse-overlay";
|
||||
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
|
||||
<div class="event-type-browse-header">
|
||||
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
|
||||
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
|
||||
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
|
||||
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
|
||||
${chipJurisdictions
|
||||
.map(
|
||||
(j) =>
|
||||
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
|
||||
<div class="event-type-browse-actions">
|
||||
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
|
||||
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
|
||||
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
|
||||
|
||||
const groups = groupByCategory(opts.types);
|
||||
|
||||
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
|
||||
return j;
|
||||
}
|
||||
|
||||
function jurisdictionMatches(et: EventType): boolean {
|
||||
if (activeJurisdiction === null) return true;
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
return j === activeJurisdiction;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
countEl.textContent = t("event_types.browse.selected_count").replace(
|
||||
"{n}",
|
||||
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
|
||||
function renderList() {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!jurisdictionMatches(et)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
|
||||
renderList();
|
||||
});
|
||||
|
||||
chipButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const raw = btn.dataset.jurisdiction ?? "";
|
||||
activeJurisdiction = raw === "" ? null : raw;
|
||||
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
|
||||
btn.classList.add("event-type-browse-chip--active");
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
function close(value: string[] | null) {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
overlay.remove();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type FilterHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
@@ -125,12 +126,16 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
|
||||
{ value: "completed", key: "deadlines.filter.completed" },
|
||||
];
|
||||
|
||||
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
|
||||
// "Ab heute" option was a UI lie (backend never narrowed past events for
|
||||
// appointments) and is removed. 'today' is the sane default — matches the
|
||||
// dashboard tile. 'all' stays as the explicit opt-in for past events.
|
||||
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
|
||||
{ value: "all", key: "events.filter.status.all" },
|
||||
{ value: "today", key: "deadlines.filter.today" },
|
||||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||||
{ value: "later", key: "deadlines.filter.later" },
|
||||
{ value: "all", key: "events.filter.status.all" },
|
||||
];
|
||||
|
||||
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||||
@@ -139,7 +144,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||||
}
|
||||
|
||||
function defaultStatusFor(type: EventTypeChoice): string {
|
||||
return type === "appointment" ? "all" : "pending";
|
||||
return type === "appointment" ? "today" : "pending";
|
||||
}
|
||||
|
||||
let currentType: EventTypeChoice = "deadline";
|
||||
@@ -153,8 +158,10 @@ let me: Me | null = null;
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
let eventTypeByID: Map<string, EventType> = new Map();
|
||||
let loadedOK = false;
|
||||
let calYear = 0;
|
||||
let calMonth = 0;
|
||||
// Calendar handle is created lazily when /events first switches into the
|
||||
// Kalender view (t-paliad-224). The handle owns its own month/week/day
|
||||
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
|
||||
let calendar: CalendarHandle | null = null;
|
||||
|
||||
function urlParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.search);
|
||||
@@ -425,12 +432,13 @@ function hideTableAndCalendar() {
|
||||
const calWrap = document.getElementById("events-calendar-wrap");
|
||||
if (tableWrap) tableWrap.style.display = "none";
|
||||
if (calWrap) calWrap.hidden = true;
|
||||
teardownCalendar();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
if (currentView === "calendar") {
|
||||
renderCalendar();
|
||||
renderCalendarView();
|
||||
} else {
|
||||
renderTable();
|
||||
}
|
||||
@@ -553,135 +561,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
|
||||
// plotting an event onto the calendar. Deadlines bucket on due_date;
|
||||
// appointments on start_at's local-date component.
|
||||
function itemDateISO(item: EventListItem): string {
|
||||
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
|
||||
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
|
||||
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
|
||||
// event_date); appointments bucket on start_at (fallback to event_date).
|
||||
function toCalendarItem(item: EventListItem): CalendarItem {
|
||||
let bucketDate: string;
|
||||
if (item.type === "deadline") {
|
||||
const src = item.due_date ?? item.event_date;
|
||||
return src.slice(0, 10);
|
||||
bucketDate = item.due_date ?? item.event_date;
|
||||
} else if (item.start_at) {
|
||||
bucketDate = item.start_at;
|
||||
} else {
|
||||
bucketDate = item.event_date;
|
||||
}
|
||||
if (!item.start_at) return item.event_date.slice(0, 10);
|
||||
const d = new Date(item.start_at);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return {
|
||||
kind: item.type,
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
event_date: bucketDate,
|
||||
project_id: item.project_id,
|
||||
project_title: item.project_title,
|
||||
project_reference: item.project_reference,
|
||||
};
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtMonthYear(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function calDotClass(item: EventListItem): string {
|
||||
// Per-item dot colour. Deadlines reuse the existing urgency palette;
|
||||
// appointments get their own colour so they're visually distinct from
|
||||
// deadlines on a mixed (Beides) calendar.
|
||||
if (item.type === "appointment") return "events-cal-dot-appointment";
|
||||
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const wrap = document.getElementById("events-calendar-wrap")!;
|
||||
const grid = document.getElementById("events-cal-grid")!;
|
||||
const empty = document.getElementById("events-cal-empty") as HTMLElement;
|
||||
const monthLabel = document.getElementById("events-cal-month-label")!;
|
||||
function renderCalendarView() {
|
||||
const host = document.getElementById("events-calendar-wrap");
|
||||
if (!host) return;
|
||||
const tableEmpty = document.getElementById("events-empty")!;
|
||||
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
|
||||
|
||||
// Calendar always renders the visible month from allItems, regardless of
|
||||
// pristine vs filtered state — empty calendar is allowed (the per-month
|
||||
// empty hint communicates "no items in this month" without confusing it
|
||||
// with the table-mode "no items at all" empty state).
|
||||
tableEmpty.style.display = "none";
|
||||
tableEmptyFiltered.style.display = "none";
|
||||
wrap.hidden = false;
|
||||
(host as HTMLElement).hidden = false;
|
||||
|
||||
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
|
||||
|
||||
const firstDay = new Date(calYear, calMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
|
||||
const byDate = new Map<string, EventListItem[]>();
|
||||
for (const item of allItems) {
|
||||
const iso = itemDateISO(item);
|
||||
const list = byDate.get(iso);
|
||||
if (list) list.push(item);
|
||||
else byDate.set(iso, [item]);
|
||||
const items = allItems.map(toCalendarItem);
|
||||
if (calendar) {
|
||||
calendar.update(items);
|
||||
return;
|
||||
}
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(calYear, calMonth, day);
|
||||
const items = byDate.get(iso) ?? [];
|
||||
const isToday = iso === todayISO;
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
|
||||
// urlState=true: the Kalender tab persists its month/week/day + anchor
|
||||
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
|
||||
// calendar state (per t-paliad-224 §11 Q3 head decision).
|
||||
calendar = mountCalendar(host as HTMLElement, items, {
|
||||
urlState: true,
|
||||
defaultView: "month",
|
||||
});
|
||||
|
||||
const monthStart = isoDate(calYear, calMonth, 1);
|
||||
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
|
||||
const hasInMonth = allItems.some((it) => {
|
||||
const iso = itemDateISO(it);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
empty.hidden = hasInMonth;
|
||||
}
|
||||
|
||||
function openCalPopup(iso: string, items: EventListItem[]) {
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
const dateEl = document.getElementById("events-cal-popup-date")!;
|
||||
const list = document.getElementById("events-cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((it) => {
|
||||
const cls = calDotClass(it);
|
||||
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
|
||||
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
|
||||
const projectLabel = it.project_reference ?? "";
|
||||
const projectCell = projectHref
|
||||
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
|
||||
: "";
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="frist-cal-dot ${cls}"></span>
|
||||
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
|
||||
${projectCell}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
function teardownCalendar() {
|
||||
if (!calendar) return;
|
||||
calendar.destroy();
|
||||
calendar = null;
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
@@ -702,12 +632,18 @@ function applyView() {
|
||||
// Cards view = the original layout (5-card summary + table).
|
||||
// List view = no summary cards, table only — gives more vertical space
|
||||
// and matches users' mental model of a flat list.
|
||||
// Calendar view = month grid; cards + table both hidden.
|
||||
// Calendar view = mountCalendar() canon (month/week/day); cards + table
|
||||
// both hidden. The handle is torn down when the user leaves Kalender
|
||||
// so its URL state isn't reapplied to other shapes.
|
||||
summary.style.display = currentView === "cards" ? "" : "none";
|
||||
tableWrap.style.display = currentView === "calendar" ? "none" : "";
|
||||
calWrap.hidden = currentView !== "calendar";
|
||||
|
||||
if (currentView === "calendar" && loadedOK) renderCalendar();
|
||||
if (currentView === "calendar") {
|
||||
if (loadedOK) renderCalendarView();
|
||||
} else {
|
||||
teardownCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
function wireRowHandlers(tbody: HTMLElement) {
|
||||
@@ -1009,12 +945,10 @@ function initFilters() {
|
||||
}
|
||||
|
||||
function initView() {
|
||||
// Calendar always opens on the current month — month navigation is
|
||||
// local to the view (cheap pagination, doesn't refetch).
|
||||
const now = new Date();
|
||||
calYear = now.getFullYear();
|
||||
calMonth = now.getMonth();
|
||||
|
||||
// Kalender state (view + anchor) lives inside mountCalendar; no
|
||||
// events-page-level wiring needed. The view chips below switch
|
||||
// between Karten / Liste / Kalender; applyView() handles the
|
||||
// mount + teardown.
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = btn.dataset.eventView as EventView;
|
||||
@@ -1024,31 +958,6 @@ function initView() {
|
||||
syncURLParams();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
|
||||
calMonth -= 1;
|
||||
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-next")?.addEventListener("click", () => {
|
||||
calMonth += 1;
|
||||
if (calMonth > 11) { calMonth = 0; calYear += 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-today")?.addEventListener("click", () => {
|
||||
const t = new Date();
|
||||
calYear = t.getFullYear();
|
||||
calMonth = t.getMonth();
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
|
||||
popup.style.display = "none";
|
||||
});
|
||||
popup?.addEventListener("click", (e) => {
|
||||
if (e.target === popup) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initSummaryCards() {
|
||||
|
||||
@@ -162,10 +162,11 @@ function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
{ value: "changes_requested", key: "views.bar.approval_status.changes_requested" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||
//
|
||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
||||
// `./views/verfahrensablauf-core` and are shared with the
|
||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
||||
// click-to-edit — none of which Verfahrensablauf wants.
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
|
||||
// date editor) live in `./views/verfahrensablauf-core` and are shared
|
||||
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
|
||||
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
|
||||
// wants.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
priorityRendering,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
@@ -57,6 +58,19 @@ type ProcedureView = "timeline" | "columns";
|
||||
// HLC team than the single vertical line.
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — off by default; per-rule notes render as a compact
|
||||
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
|
||||
// localStorage-persisted (paliad.fristen.notes-show key shared with
|
||||
// /tools/verfahrensablauf so the preference carries across both).
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
onLangChange(() => {
|
||||
if (lastResponse) renderProcedureResults(lastResponse);
|
||||
// Update trigger event name if a proceeding is selected
|
||||
@@ -173,12 +187,21 @@ interface ProjectOption {
|
||||
// (Slice 3b) can scope the cascade by the project's jurisdiction
|
||||
// without an extra fetch.
|
||||
proceeding_type_id?: number | null;
|
||||
// our_side carries which side the firm represents on this project
|
||||
// (t-paliad-164). When a user selects an Akte, the perspective chip
|
||||
// pre-locks to this value; a small hint above the strip flags the
|
||||
// our_side carries which side the firm represents on this case
|
||||
// project (Client Role; t-paliad-164, widened in t-paliad-222).
|
||||
// When a user selects an Akte, the perspective chip pre-locks via
|
||||
// ourSideToPerspective(); a small hint above the strip flags the
|
||||
// pre-selection and the user can still click another chip to
|
||||
// override. NULL/undefined leaves the chip unset (free-pick).
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
our_side?:
|
||||
| "claimant"
|
||||
| "defendant"
|
||||
| "applicant"
|
||||
| "appellant"
|
||||
| "respondent"
|
||||
| "third_party"
|
||||
| "other"
|
||||
| null;
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||||
@@ -237,6 +260,19 @@ function closeSaveModal() {
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
// preselectedProjectId returns the project the user picked in Step 1
|
||||
// (if any) so the various save/add flows can default their project
|
||||
// pickers to it. Carries through anywhere a "save to Akte" pop-out
|
||||
// renders \u2014 preselection is *only* a default; the picker still
|
||||
// renders every available project and the user can override.
|
||||
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
|
||||
// project should be pre-selected" on Add.
|
||||
function preselectedProjectId(): string {
|
||||
return currentStep1Context.kind === "project" && currentStep1Context.projectId
|
||||
? currentStep1Context.projectId
|
||||
: "";
|
||||
}
|
||||
|
||||
async function openSaveModal() {
|
||||
if (!lastResponse) return;
|
||||
ensureSaveModal();
|
||||
@@ -253,6 +289,7 @@ async function openSaveModal() {
|
||||
sel.style.display = "";
|
||||
noProjects.style.display = "none";
|
||||
submit.disabled = false;
|
||||
const preselected = preselectedProjectId();
|
||||
sel.innerHTML = projects
|
||||
.map((p) => {
|
||||
const ref = (p.reference || "").trim();
|
||||
@@ -260,9 +297,11 @@ async function openSaveModal() {
|
||||
const label = ref
|
||||
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
|
||||
: `${indent}${escHtml(p.title)}`;
|
||||
return `<option value="${escAttr(p.id)}">${label}</option>`;
|
||||
const selected = p.id === preselected ? " selected" : "";
|
||||
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
|
||||
})
|
||||
.join("");
|
||||
if (preselected) sel.value = preselected;
|
||||
}
|
||||
|
||||
const list = document.getElementById("frist-save-list")!;
|
||||
@@ -390,9 +429,14 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Pass the chip-strip perspective through as `side` so the column
|
||||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||||
// was on the defendant side, the new labels demand we route the
|
||||
// user's party into the `ours` column.
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true });
|
||||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
printBtn.style.display = "block";
|
||||
@@ -417,54 +461,21 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
applyPendingFocus();
|
||||
}
|
||||
|
||||
// openInlineDateEditor swaps the date span for a date input. On commit
|
||||
// (blur or Enter), the override is recorded and the timeline re-fetched.
|
||||
// On Escape, the editor closes without changing anything. An empty
|
||||
// commit clears the override (lets the user revert to the calculated
|
||||
// date or to the IsCourtSet placeholder).
|
||||
function openInlineDateEditor(span: HTMLElement) {
|
||||
const ruleCode = span.dataset.ruleCode!;
|
||||
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
const commit = (newValue: string) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
// onDateEditCommit is the click-to-edit callback handed to the shared
|
||||
// wireDateEditClicks() helper: persist the per-rule override (empty value
|
||||
// clears it) then recompute so downstream rules re-anchor.
|
||||
function onDateEditCommit(ruleCode: string, newValue: string) {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
}
|
||||
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
|
||||
// openInlineDateEditor / wireDateEditClicks moved to
|
||||
// ./views/verfahrensablauf-core.
|
||||
|
||||
function reset() {
|
||||
selectedType = "";
|
||||
@@ -635,21 +646,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// rules re-anchor on the user's date. Delegated on the container so
|
||||
// it survives renderProcedureResults() innerHTML rewrites.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
timelineContainer.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
timelineContainer.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
}
|
||||
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
|
||||
|
||||
// Reset button
|
||||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||||
@@ -661,6 +658,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const saveBtn = document.getElementById("fristen-save-cta");
|
||||
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderProcedureResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
// View toggle (timeline vs. columns layout) for procedure mode.
|
||||
initViewToggle();
|
||||
|
||||
@@ -1281,19 +1290,27 @@ function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
|
||||
card.classList.add("is-expanded");
|
||||
card.setAttribute("aria-expanded", "true");
|
||||
|
||||
const panel = buildCalcPanel(cardData, rulePills);
|
||||
card.appendChild(panel);
|
||||
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
|
||||
// context is already known — the calc panel renders with that pill
|
||||
// locked in and no "Which context?" picker. The card's pill list is
|
||||
// hidden via CSS while is-expanded so the rules aren't listed twice.
|
||||
// When the user clicked the card body (no autoSelectPill), the picker
|
||||
// is the primary surface — still no duplicate pill list above it.
|
||||
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
|
||||
? rulePills.find((p) =>
|
||||
p.proceeding?.code === autoSelectPill.dataset.proc
|
||||
&& (autoSelectPill.dataset.focus
|
||||
? p.rule_local_code === autoSelectPill.dataset.focus
|
||||
: true))
|
||||
: undefined;
|
||||
|
||||
// Auto-select the clicked pill if it's a rule pill; otherwise the
|
||||
// first pill is preselected by buildCalcPanel.
|
||||
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
|
||||
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
|
||||
}
|
||||
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
|
||||
card.appendChild(panel);
|
||||
|
||||
scheduleCardCalc(card);
|
||||
}
|
||||
|
||||
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
|
||||
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "fristen-card-calc";
|
||||
// stopPropagation so clicks inside the panel don't bubble to the
|
||||
@@ -1304,10 +1321,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
|
||||
const lang = getLang();
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Pill picker (only when >1 rule pill).
|
||||
const pickerHtml = rulePills.length <= 1
|
||||
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
|
||||
: `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
|
||||
// Picker semantics (m/paliad#57 part 4):
|
||||
// - lockedPill set → context known (user clicked a specific
|
||||
// rule pill on the card). Render as a
|
||||
// hidden input only; the calc panel shows
|
||||
// no "Which context?" question. A small
|
||||
// "ändern" link reopens the picker fieldset.
|
||||
// - rulePills.length <= 1 → only one possible context, never a
|
||||
// picker (hidden input carries the data).
|
||||
// - otherwise → show the picker as primary surface; the
|
||||
// card's pill list is hidden via CSS while
|
||||
// the panel is open, so the user isn't
|
||||
// asked the same thing twice.
|
||||
let pickerHtml: string;
|
||||
if (lockedPill) {
|
||||
const procName = lockedPill.proceeding
|
||||
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
|
||||
: "";
|
||||
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
|
||||
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
|
||||
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
|
||||
pickerHtml = `<div class="fristen-card-calc-pill-locked">
|
||||
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
|
||||
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
|
||||
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
|
||||
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
|
||||
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
|
||||
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
|
||||
</div>`;
|
||||
} else if (rulePills.length <= 1) {
|
||||
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
|
||||
} else {
|
||||
pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
|
||||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||||
${rulePills.map((p, i) => {
|
||||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||||
@@ -1321,6 +1366,7 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
|
||||
</label>`;
|
||||
}).join("")}
|
||||
</fieldset>`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
|
||||
@@ -1373,6 +1419,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
|
||||
void addCalcToProject(card, last);
|
||||
});
|
||||
|
||||
// "ändern" — swap the locked-context caption for the full radio
|
||||
// picker so the user can change context without collapsing the panel.
|
||||
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
|
||||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||||
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
|
||||
if (!card || !locked) return;
|
||||
const fieldset = document.createElement("fieldset");
|
||||
fieldset.className = "fristen-card-calc-pill-picker";
|
||||
fieldset.setAttribute("role", "radiogroup");
|
||||
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
|
||||
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
|
||||
fieldset.innerHTML = `
|
||||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||||
${rulePills.map((p, i) => {
|
||||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||||
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
|
||||
const src = p.legal_source_display || p.legal_source || "";
|
||||
const isChecked = (p.proceeding?.code || "") === lockedProc
|
||||
&& (p.rule_local_code || "") === lockedFocus;
|
||||
return `<label class="fristen-card-calc-pill-option">
|
||||
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
|
||||
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
|
||||
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
|
||||
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
|
||||
</label>`;
|
||||
}).join("")}`;
|
||||
locked.replaceWith(fieldset);
|
||||
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
|
||||
r.addEventListener("change", () => scheduleCardCalc(card, 0));
|
||||
});
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -1576,6 +1654,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
const lang = getLang();
|
||||
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
|
||||
const dueLabel = formatDate(calc.dueDate);
|
||||
const preselected = preselectedProjectId();
|
||||
msgEl.innerHTML = `
|
||||
<div class="fristen-card-calc-add-picker">
|
||||
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
|
||||
@@ -1584,7 +1663,8 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
const ref = (p.reference || "").trim();
|
||||
const indent = projectIndent(p.path);
|
||||
const label = ref ? `${indent}${ref} — ${p.title}` : `${indent}${p.title}`;
|
||||
return `<option value="${escAttr(p.id)}">${escHtml(label)}</option>`;
|
||||
const selected = p.id === preselected ? " selected" : "";
|
||||
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
|
||||
}).join("")}
|
||||
</select>
|
||||
</label>
|
||||
@@ -1594,6 +1674,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
`;
|
||||
|
||||
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
|
||||
if (preselected) sel.value = preselected;
|
||||
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
|
||||
msgEl.innerHTML = "";
|
||||
addBtn.disabled = false;
|
||||
@@ -1663,12 +1744,12 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
|
||||
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
|
||||
|
||||
const ruleSection = rulePills.length === 0 ? "" : `
|
||||
<div class="fristen-card-pills-section">
|
||||
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
|
||||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
|
||||
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
|
||||
</div>`;
|
||||
const triggerSection = triggerPills.length === 0 ? "" : `
|
||||
<div class="fristen-card-pills-section">
|
||||
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
|
||||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
|
||||
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
|
||||
</div>`;
|
||||
@@ -2444,6 +2525,17 @@ interface EventCategoryNode {
|
||||
let eventCategoryTree: EventCategoryNode[] | null = null;
|
||||
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
|
||||
|
||||
// Top-level cascade roots that represent forward-looking workflows ("I
|
||||
// want to file X, what deadlines does my action trigger?") rather than
|
||||
// the backward-looking calc the Fristenrechner is built for ("event Y
|
||||
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
|
||||
// remove these from the "Was ist passiert?" picker — they belong in a
|
||||
// future forward-workflow tool, not here. The DB rows stay so that
|
||||
// future tool can pick them back up; we just hide them at the UI layer.
|
||||
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
|
||||
"ich-moechte-einreichen",
|
||||
]);
|
||||
|
||||
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
if (eventCategoryTree) return eventCategoryTree;
|
||||
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
|
||||
@@ -2452,7 +2544,8 @@ async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
const r = await fetch("/api/tools/fristenrechner/event-categories");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
|
||||
const raw = (data.tree || []) as EventCategoryNode[];
|
||||
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
|
||||
return eventCategoryTree;
|
||||
} finally {
|
||||
eventCategoryFetchInflight = null;
|
||||
@@ -3722,14 +3815,30 @@ function applyPerspective(p: Perspective) {
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
// ourSideToPerspective maps the project-level "Wir vertreten" enum
|
||||
// onto the chip-strip Perspective. 'court' / 'both' map to null
|
||||
// (chip cleared) — court actions are neutral to the user's side and
|
||||
// "both" is explicit no-filter intent.
|
||||
// ourSideToPerspective maps the project-level "Client Role" enum
|
||||
// (DB column: our_side) onto the chip-strip Perspective.
|
||||
//
|
||||
// Per t-paliad-222 (m/paliad#47) the field carries one of seven
|
||||
// sub-role values grouped at display time:
|
||||
// Active (we initiate) : claimant, applicant, appellant → "claimant"
|
||||
// Reactive (we defend) : defendant, respondent → "defendant"
|
||||
// Other : third_party, other, NULL → null
|
||||
//
|
||||
// Legacy 'court' / 'both' values no longer exist in the column
|
||||
// (mig 110 backfilled them to NULL); both fall through to the null
|
||||
// default arm if a stale value sneaks in.
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
if (os === "claimant") return "claimant";
|
||||
if (os === "defendant") return "defendant";
|
||||
return null;
|
||||
switch (os) {
|
||||
case "claimant":
|
||||
case "applicant":
|
||||
case "appellant":
|
||||
return "claimant";
|
||||
case "defendant":
|
||||
case "respondent":
|
||||
return "defendant";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// applyOurSidePredefine locks the perspective from project.our_side
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { openApprovalEditModal } from "./components/approval-edit-modal";
|
||||
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
@@ -123,11 +124,20 @@ function paint(
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const action = btn.dataset.action as
|
||||
| "approve"
|
||||
| "reject"
|
||||
| "revoke"
|
||||
| "suggest_changes"
|
||||
| undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (action === "suggest_changes") {
|
||||
await handleSuggestChanges(btn, id, li!);
|
||||
return;
|
||||
}
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
@@ -141,8 +151,8 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
const body = await r.json().catch(() => ({} as { error?: string; code?: string }));
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
@@ -156,14 +166,109 @@ function wireApprovalActions(host: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// handleSuggestChanges — t-paliad-216. Open the edit modal with the
|
||||
// requester's original payload + pre_image pre-populated. If the user
|
||||
// submits non-empty changes / note, POST to
|
||||
// /api/approval-requests/{id}/suggest-changes; refresh the bar on success
|
||||
// so the OLD row flips to changes_requested and the NEW pending row
|
||||
// appears.
|
||||
async function handleSuggestChanges(
|
||||
btn: HTMLButtonElement,
|
||||
requestID: string,
|
||||
li: HTMLLIElement,
|
||||
): Promise<void> {
|
||||
// Read the row's detail blob off the data-attrs the shape-list stamped.
|
||||
// shape-list serialises payload/pre_image inline; we fetch fresh via
|
||||
// the per-row API to avoid relying on stale list data.
|
||||
let payload: Record<string, unknown> | null = null;
|
||||
let preImage: Record<string, unknown> | null = null;
|
||||
let entityType: "deadline" | "appointment" = "deadline";
|
||||
let lifecycleEvent = "update";
|
||||
let projectTitle: string | undefined;
|
||||
let requesterName: string | undefined;
|
||||
let requestedAt: string | undefined;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const body = (await r.json()) as {
|
||||
entity_type?: "deadline" | "appointment";
|
||||
lifecycle_event?: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
project_title?: string;
|
||||
requester_name?: string;
|
||||
requested_at?: string;
|
||||
};
|
||||
payload = body.payload ?? null;
|
||||
preImage = body.pre_image ?? null;
|
||||
if (body.entity_type === "appointment") entityType = "appointment";
|
||||
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
|
||||
projectTitle = body.project_title;
|
||||
requesterName = body.requester_name;
|
||||
requestedAt = body.requested_at;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Modal still opens with empty defaults if the fetch fails; the
|
||||
// server-side schema validation catches a misshapen counter.
|
||||
}
|
||||
|
||||
const result = await openApprovalEditModal({
|
||||
entityType,
|
||||
lifecycleEvent,
|
||||
payload,
|
||||
preImage,
|
||||
projectTitle,
|
||||
requesterName,
|
||||
requestedAt,
|
||||
});
|
||||
if (!result) return; // cancel
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${requestID}/suggest-changes`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
counter_payload: result.counterPayload,
|
||||
note: result.note,
|
||||
}),
|
||||
});
|
||||
const body = (await r.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
new_request_id?: string;
|
||||
};
|
||||
if (!r.ok) {
|
||||
alert(mapApprovalError(body.code || body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
btn.disabled = false;
|
||||
|
||||
// Surface the new row's id on the OLD row's <li> so callers (e.g.
|
||||
// tests, future inspection) can find it without re-querying.
|
||||
if (body.new_request_id) {
|
||||
li.dataset.spawnedRequestId = body.new_request_id;
|
||||
}
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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");
|
||||
case "suggestion_requires_change": return t("approvals.error.suggestion_requires_change");
|
||||
case "suggestion_lifecycle_invalid": return t("approvals.error.suggestion_lifecycle_invalid");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,12 +93,13 @@ export function routeNameFor(pathname: string): string {
|
||||
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
|
||||
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
|
||||
if (pathname === "/deadlines/new") return "deadlines.new";
|
||||
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
|
||||
if (pathname === "/deadlines") return "deadlines.list";
|
||||
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
|
||||
if (pathname === "/appointments/new") return "appointments.new";
|
||||
if (pathname === "/appointments/calendar") return "appointments.calendar";
|
||||
if (pathname === "/appointments") return "appointments.list";
|
||||
// /deadlines/calendar + /appointments/calendar are 301 redirects to
|
||||
// /events?type=…&view=calendar since t-paliad-224 — the client never
|
||||
// sees those pathnames any more.
|
||||
if (pathname === "/agenda") return "agenda";
|
||||
if (pathname === "/inbox") return "inbox";
|
||||
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
// Late-response polling (t-paliad-235 rewrite).
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
// When the SSE stream closes mid-turn with an error event, the bubble
|
||||
// can't tell from the wire whether (a) the upstream is still finishing
|
||||
// the turn and we just lost transport, or (b) the upstream is truly
|
||||
// dead.
|
||||
//
|
||||
// This module hits the dispatching recovery endpoint
|
||||
// `/api/paliadin/turns/{id}/recover`, which knows the active backend:
|
||||
//
|
||||
// - aichat backend → asks aichat via its conversation API whether
|
||||
// the turn actually completed upstream
|
||||
// - legacy backend → reads the local row (paliad's filesystem
|
||||
// janitor patches it when claude writes the
|
||||
// response file late)
|
||||
//
|
||||
// The endpoint returns:
|
||||
//
|
||||
// recovery_state="recovered" → response is in the payload, render it
|
||||
// recovery_state="pending" → keep polling
|
||||
// recovery_state="lost" → upstream is truly gone, give up
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
@@ -28,6 +37,10 @@ export interface LatePollOptions {
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
// onLost — backend confirmed the turn is unrecoverable. Caller should
|
||||
// swap the bubble copy to the "verloren" string. Distinct from
|
||||
// onGiveUp (which fires only on the local timeout).
|
||||
onLost?: () => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
@@ -35,6 +48,20 @@ export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
interface RecoverResponse {
|
||||
turn_id: string;
|
||||
started_at: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
recovery_state: "recovered" | "pending" | "lost";
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
@@ -50,18 +77,24 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}/recover`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
const body = (await r.json()) as RecoverResponse;
|
||||
if (body.recovery_state === "recovered" && body.response) {
|
||||
opts.onLateResponse(toLateTurn(body));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
if (body.recovery_state === "lost") {
|
||||
opts.onLost?.();
|
||||
return;
|
||||
}
|
||||
// pending — keep polling
|
||||
} else if (r.status === 404) {
|
||||
// Row gone — give up. Different signal from `lost`: a missing row
|
||||
// is a paliad-side bookkeeping problem; aichat may still have the
|
||||
// answer but we can't surface it without the row.
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +105,8 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
// the dispatch endpoint on the very first tick (gives the upstream a
|
||||
// moment to actually settle the row after the stream drop).
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
@@ -82,3 +116,17 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toLateTurn(body: RecoverResponse): LateTurn {
|
||||
return {
|
||||
turn_id: body.turn_id,
|
||||
response: body.response,
|
||||
error_code: body.error_code,
|
||||
finished_at: body.finished_at,
|
||||
duration_ms: body.duration_ms,
|
||||
used_tools: body.used_tools ?? [],
|
||||
rows_seen: body.rows_seen ?? [],
|
||||
chip_count: body.chip_count ?? 0,
|
||||
classifier_tag: body.classifier_tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,11 +381,32 @@ async function sendTurn(): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
activeStream = es;
|
||||
|
||||
startWidgetThinking(placeholder);
|
||||
|
||||
let fullText = "";
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateWidgetThinking(placeholder, elapsed);
|
||||
});
|
||||
es.addEventListener("content", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
if (typeof data.delta === "string" && data.delta) {
|
||||
// Streamed delta (aichat backend) — append.
|
||||
stopWidgetThinking(placeholder);
|
||||
fullText += data.delta;
|
||||
setBubbleText(placeholder, fullText);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot full-text payload.
|
||||
fullText = String(data.text || "");
|
||||
stopWidgetThinking(placeholder);
|
||||
setBubbleText(placeholder, fullText);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
@@ -393,13 +414,15 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
es.addEventListener("end", () => {
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopWidgetThinking(placeholder);
|
||||
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
stopWidgetThinking(placeholder);
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -412,6 +435,39 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function startWidgetThinking(bubble: HTMLElement): void {
|
||||
if (bubble.querySelector(".paliadin-widget-thinking")) return;
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// the canonical "denkt nach" signal.
|
||||
const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
const node = document.createElement("div");
|
||||
node.className = "paliadin-widget-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-widget-thinking-label"></span>
|
||||
<span class="paliadin-widget-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-widget-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
updateWidgetThinking(bubble, 0);
|
||||
}
|
||||
|
||||
function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
|
||||
elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
|
||||
}
|
||||
}
|
||||
|
||||
function stopWidgetThinking(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-widget-thinking")?.remove();
|
||||
}
|
||||
|
||||
function cleanupStream(): void {
|
||||
activeStream?.close();
|
||||
activeStream = null;
|
||||
@@ -427,13 +483,24 @@ function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLost(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-widget-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-widget-bubble--lost");
|
||||
setBubbleText(bubble, t("paliadin.late.lost"));
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
|
||||
@@ -3,16 +3,25 @@ import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
// Paliadin chat panel client (t-paliad-146 PoC, streaming upgrade
|
||||
// t-paliad-235).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// State machine: empty → typing → sending → thinking → streaming → done.
|
||||
// History lives in localStorage under "paliadin:history:<sessionId>"
|
||||
// — design §0.5.4 session-only persistence.
|
||||
//
|
||||
// SSE consumer subscribes to `event: meta`, `event: content`,
|
||||
// `event: end`, `event: error`, `event: ping`. Backend currently
|
||||
// emits one `content` blob per turn (real chunked streaming is
|
||||
// production-v1; PoC simulates with a typewriter effect).
|
||||
// `event: thinking`, `event: end`, `event: error`, `event: ping`.
|
||||
//
|
||||
// `content` events from the aichat backend arrive as incremental
|
||||
// `{delta: "..."}` chunks; the bubble accumulates them in real time —
|
||||
// no typewriter simulation needed. Legacy backends still emit a single
|
||||
// `{text: "..."}` payload and we fall back to the typewriter for that
|
||||
// shape.
|
||||
//
|
||||
// `thinking` events fire while the upstream is alive but hasn't
|
||||
// produced content yet (or stalled mid-stream); the bubble renders a
|
||||
// pulse + counter so the user can SEE the chat is still working.
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
@@ -167,25 +176,53 @@ async function sendTurn(text: string): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
// Show the thinking pulse immediately — the placeholder text already
|
||||
// says "denkt nach", but the visible pulse + counter is the live
|
||||
// proof-of-life signal m needs to trust that the chat is working.
|
||||
startThinkingIndicator(placeholder);
|
||||
// Reset the streamed accumulator for this turn.
|
||||
placeholder.dataset.fullText = "";
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") {
|
||||
elapsed = data.elapsed_seconds;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateThinkingIndicator(placeholder, elapsed);
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const delta = typeof data.delta === "string" ? data.delta : "";
|
||||
if (delta) {
|
||||
// Aichat streaming path — accumulate the delta into the bubble.
|
||||
stopThinkingIndicator(placeholder);
|
||||
const current = placeholder.dataset.fullText ?? "";
|
||||
const next = current + delta;
|
||||
placeholder.dataset.fullText = next;
|
||||
writeStreamedText(placeholder, next);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot path — full body in `text`.
|
||||
const text = String(data.text || "");
|
||||
// Cache the full text on the bubble so finishBubble can render the
|
||||
// complete response even when the typewriter is mid-flight when end
|
||||
// arrives. textContent reflects only what's been typed so far and
|
||||
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
|
||||
// saw "## Proje" instead of the full 1408-byte body).
|
||||
placeholder.dataset.fullText = text;
|
||||
stopThinkingIndicator(placeholder);
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopThinkingIndicator(placeholder);
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
@@ -210,12 +247,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
stopThinkingIndicator(placeholder);
|
||||
// Honest copy: we don't claim "nachgereicht" because the recovery
|
||||
// path may report "lost". Frame it as "checking" while we ask the
|
||||
// backend whether the turn actually completed upstream.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
errText + " " + t("paliadin.late.checking");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -232,6 +269,65 @@ async function sendTurn(text: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// thinking indicator — proof-of-life pulse + elapsed counter
|
||||
// =============================================================================
|
||||
|
||||
function startThinkingIndicator(bubble: HTMLElement): void {
|
||||
// Append a thinking node next to the bubble text (sibling, so the
|
||||
// typewriter rewriting text content doesn't clobber it). The node
|
||||
// shows a pulse dot + the elapsed counter.
|
||||
let node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (node) return; // already running
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// now the canonical "denkt nach" signal. Leaving the text in place
|
||||
// would render the same phrase twice.
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
node = document.createElement("div");
|
||||
node.className = "paliadin-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-thinking-label"></span>
|
||||
<span class="paliadin-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
// Initial 0s — replaced as soon as a thinking event arrives or our
|
||||
// local ticker fires.
|
||||
updateThinkingIndicator(bubble, 0);
|
||||
}
|
||||
|
||||
function updateThinkingIndicator(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
elapsed.textContent = formatThinkingSeconds(elapsedSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function stopThinkingIndicator(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-thinking")?.remove();
|
||||
}
|
||||
|
||||
function formatThinkingSeconds(s: number): string {
|
||||
if (s < 0) s = 0;
|
||||
return t("paliadin.thinking.seconds").replace("{seconds}", String(Math.round(s)));
|
||||
}
|
||||
|
||||
// writeStreamedText fills the bubble with raw text as it accumulates.
|
||||
// Cheaper than the typewriter — we already have the real cadence from
|
||||
// the wire, no need to simulate it.
|
||||
function writeStreamedText(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (!node) return;
|
||||
node.textContent = text;
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (stream) stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// Server emits SSE error events as JSON `{code, message}`. Map known
|
||||
// codes to localised, user-friendly text; fall through to a generic
|
||||
// "connection lost" for anything we don't recognise (including raw
|
||||
@@ -361,11 +457,12 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
}
|
||||
|
||||
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
// startLatePoll registers the recovery-endpoint poller for one errored
|
||||
// turn. When the row gains a response we swap the bubble's content +
|
||||
// drop the error class + retroactively replace the history entry
|
||||
// (which was never written for the failed turn — append now so reload
|
||||
// renders the late reply). When the backend confirms the turn is
|
||||
// "lost", we swap the bubble to the honest "verloren" copy.
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
@@ -376,13 +473,25 @@ function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
});
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyLostResponse(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--lost");
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (node) node.textContent = t("paliadin.late.lost");
|
||||
}
|
||||
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
import { t, tDyn } from "./i18n";
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
|
||||
// Shared logic for the Project form rendered by ProjectFormFields.tsx.
|
||||
// Used by /projects/new and the edit modal on /projects/{id}.
|
||||
|
||||
export interface ProceedingTypeRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
// loadProceedingTypes fetches active fristenrechner-category proceeding
|
||||
// types — the only set a project may bind to (mig 087/088 + service
|
||||
// validation guard `validateProceedingTypeCategory`). Cached at module
|
||||
// level so the page only pays for one fetch even when both the new-
|
||||
// project page and the edit modal exercise the picker.
|
||||
export async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
return proceedingTypesCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectMini {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree. Populated by the service projection on every
|
||||
// /api/projects response, so the picker can show the code without an
|
||||
// extra fetch.
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ProjectFormState {
|
||||
@@ -48,9 +82,11 @@ function tryGet(id: string): HTMLElement | null {
|
||||
export function showFieldsForType(typeSel: string) {
|
||||
const parentWrap = tryGet("projekt-parent-wrap") as HTMLDivElement | null;
|
||||
const clientFields = tryGet("fields-client") as HTMLDivElement | null;
|
||||
const litigationFields = tryGet("fields-litigation") as HTMLDivElement | null;
|
||||
const patentFields = tryGet("fields-patent") as HTMLDivElement | null;
|
||||
const caseFields = tryGet("fields-case") as HTMLDivElement | null;
|
||||
if (clientFields) clientFields.style.display = typeSel === "client" ? "block" : "none";
|
||||
if (litigationFields) litigationFields.style.display = typeSel === "litigation" ? "block" : "none";
|
||||
if (patentFields) patentFields.style.display = typeSel === "patent" ? "block" : "none";
|
||||
if (caseFields) caseFields.style.display = typeSel === "case" ? "block" : "none";
|
||||
if (parentWrap) parentWrap.style.display = typeSel === "client" ? "none" : "block";
|
||||
@@ -88,18 +124,28 @@ export function initParentPicker() {
|
||||
}
|
||||
const matches = parentCandidates
|
||||
.filter((p) => {
|
||||
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
|
||||
// Search across title + manual reference + auto-derived code
|
||||
// so the user can type "EXMPL" or "INF.CFI" and find the row.
|
||||
const hay = (p.title + " " + (p.reference || "") + " " + (p.code || "")).toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(p) =>
|
||||
`<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
.map((p) => {
|
||||
// Render the auto-derived code (if any, and distinct from
|
||||
// reference) as a small mono badge on the right so the user
|
||||
// can disambiguate two same-titled projects by their tree
|
||||
// position. Single template literal kept readable inline.
|
||||
const code = p.code && p.code !== (p.reference || "") ? p.code : "";
|
||||
const codeBadge = code
|
||||
? `<span class="entity-ref entity-ref-code">${esc(code)}</span>`
|
||||
: "";
|
||||
return `<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
<strong>${esc(p.title)}</strong>
|
||||
<span class="entity-type-chip entity-type-${esc(p.type)}">${esc(tDyn("projects.type." + p.type) || p.type)}</span>
|
||||
</div>`,
|
||||
)
|
||||
${codeBadge}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
@@ -119,6 +165,34 @@ export function wireTypeChange() {
|
||||
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
|
||||
}
|
||||
|
||||
// populateProceedingTypeSelect fills #project-proceeding-type-id with one
|
||||
// option per fristenrechner-category proceeding type, ordered by `code`
|
||||
// (so the user scans `de.*`, `dpma.*`, `epa.*`, `upc.*` in stable
|
||||
// jurisdiction-grouped order). The first option is the empty "unset"
|
||||
// choice already in the markup; this helper only appends rows below it.
|
||||
// Idempotent — clearing rows[1..] on re-call so a re-open of the edit
|
||||
// modal doesn't double-render the list.
|
||||
export async function populateProceedingTypeSelect(): Promise<void> {
|
||||
const sel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const rows = await loadProceedingTypes();
|
||||
rows.sort((a, b) => a.code.localeCompare(b.code));
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
const isEN = getLang() === "en";
|
||||
for (const row of rows) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(row.id);
|
||||
const label = isEN && row.name_en ? row.name_en : row.name;
|
||||
opt.textContent = `${label} (${row.code})`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// Honour a pre-selection value that prefillForm wrote before the
|
||||
// option set existed. dataset.preselect is set to "" or the saved id;
|
||||
// restoring it here keeps the edit modal's saved value visible.
|
||||
const preselect = sel.dataset.preselect;
|
||||
if (preselect !== undefined) sel.value = preselect;
|
||||
}
|
||||
|
||||
// readPayload collects the form's current values into a CreateProjectInput /
|
||||
// UpdateProjectInput compatible JSON payload. Returns null + sets msg when
|
||||
// title is missing.
|
||||
@@ -174,20 +248,48 @@ export function readPayload(
|
||||
const gd = ($("project-grant-date") as HTMLInputElement).value;
|
||||
if (gd) payload.grant_date = gd + "T00:00:00Z";
|
||||
}
|
||||
if (type === "litigation") {
|
||||
// opponent_code is the litigation-only short slug used as the
|
||||
// middle segment when BuildProjectCode auto-derives a project
|
||||
// code from the ancestor tree (t-paliad-222 / m/paliad#50).
|
||||
// Uppercased on submit so the user can type lowercase comfortably
|
||||
// — the DB CHECK enforces the [A-Z0-9-]{1,16} pattern.
|
||||
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
||||
if (ocEl) {
|
||||
const v = ocEl.value.trim().toUpperCase();
|
||||
if (v) payload.opponent_code = v;
|
||||
else if (!opts.omitEmpty) payload.opponent_code = "";
|
||||
}
|
||||
}
|
||||
if (type === "case") {
|
||||
stringField("project-court", "court");
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
// Proceeding type — optional picker. Per t-paliad-232, an empty
|
||||
// pick simply omits the key from the payload (create: column stays
|
||||
// NULL; edit: server's `omitempty` skips the SET). Clearing a
|
||||
// previously-set value isn't supported in this slice; once bound,
|
||||
// a project's proceeding type can be swapped but not unset from
|
||||
// the form. The server's validateProceedingTypeCategory backs the
|
||||
// selected id with a category check.
|
||||
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (ptSel) {
|
||||
const v = ptSel.value.trim();
|
||||
if (v) {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n)) payload.proceeding_type_id = n;
|
||||
}
|
||||
}
|
||||
|
||||
// Client Role (DB column: our_side) — case-only after t-paliad-222.
|
||||
// The select uses "" for the unset option; the service maps empty
|
||||
// string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
@@ -228,6 +330,18 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
||||
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
|
||||
// Proceeding-type picker — populated lazily by populateProceedingTypeSelect.
|
||||
// Set the value here even if the options haven't arrived yet; the post-
|
||||
// populate render runs ApplyProceedingTypeValue to re-select the saved id
|
||||
// once the option exists.
|
||||
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (ptSel) {
|
||||
const v = p.proceeding_type_id == null ? "" : String(p.proceeding_type_id);
|
||||
ptSel.dataset.preselect = v;
|
||||
ptSel.value = v;
|
||||
}
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
wireTypeChange,
|
||||
prefillForm,
|
||||
readPayload,
|
||||
populateProceedingTypeSelect,
|
||||
loadProceedingTypes as loadProceedingTypesShared,
|
||||
} from "./project-form";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -20,6 +24,12 @@ interface Project {
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree (e.g. EXMPL.OPNT.789.INF.CFI). Populated by the
|
||||
// service layer on every projection; equal to `reference` when the
|
||||
// user typed an override.
|
||||
code?: string;
|
||||
opponent_code?: string | null;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
@@ -33,6 +43,12 @@ interface Project {
|
||||
grant_date?: string | null;
|
||||
court?: string | null;
|
||||
case_number?: string | null;
|
||||
// t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so
|
||||
// the team panel can render an inline <select> for callers who can
|
||||
// change responsibilities (global_admin or effective_project_admin on
|
||||
// this project / ancestor). Optional for back-compat with cached
|
||||
// payloads.
|
||||
effective_admin?: boolean;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -158,7 +174,9 @@ type TabId =
|
||||
| "deadlines"
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists";
|
||||
| "checklists"
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -169,6 +187,8 @@ const VALID_TABS: TabId[] = [
|
||||
"appointments",
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -196,6 +216,9 @@ interface ChecklistTemplateSummary {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE?: string;
|
||||
descriptionEN?: string;
|
||||
regime?: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
@@ -221,6 +244,12 @@ let attachedUnits: AttachedUnit[] = [];
|
||||
let allUnits: { id: string; name: string; office: string }[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
|
||||
|
||||
// t-paliad-231 — checkbox selection backing the "Mail an Auswahl"
|
||||
// mailto: button on the Team tab. Pure client state, wiped on page
|
||||
// navigation. Pruned to currently-visible user_ids on every renderTeam
|
||||
// so removed/filtered-out members don't ride along in the next mailto.
|
||||
const selectedMailUserIDs: Set<string> = new Set();
|
||||
|
||||
const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
@@ -1086,6 +1115,24 @@ function renderHeader() {
|
||||
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
|
||||
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
|
||||
|
||||
// t-paliad-222 / m/paliad#50 — show the auto-derived project code
|
||||
// as a second badge whenever it's non-empty AND distinct from the
|
||||
// manual reference. Hides when the derived value equals reference
|
||||
// (avoids visual duplication when the user typed the same string)
|
||||
// or when no derivation produced a value.
|
||||
const codeEl = document.getElementById("project-code-display") as HTMLElement | null;
|
||||
if (codeEl) {
|
||||
const code = project.code ?? "";
|
||||
const ref = project.reference ?? "";
|
||||
if (code && code !== ref) {
|
||||
codeEl.textContent = code;
|
||||
codeEl.style.display = "";
|
||||
} else {
|
||||
codeEl.textContent = "";
|
||||
codeEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-177 — link from Verlauf header to standalone chart page.
|
||||
// Wired here (not in the TSX shell) because we need the resolved
|
||||
// project id, which only exists after the detail fetch settles.
|
||||
@@ -1140,13 +1187,16 @@ function renderHeader() {
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
// Delete visibility: partner/admin only. The Verwaltung tab's archive
|
||||
// sub-section mirrors the same gate (t-paliad-245) — it only points at
|
||||
// the Edit-modal danger zone, so it's pointless to show when the danger
|
||||
// zone itself is hidden.
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
const archiveSection = document.getElementById("project-settings-archive");
|
||||
const canArchive = !!me && me.global_role === "global_admin";
|
||||
deleteWrap.style.display = canArchive ? "" : "none";
|
||||
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
|
||||
updateSettingsTabVisibility();
|
||||
}
|
||||
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
@@ -1410,36 +1460,9 @@ function initSmartTimelineAddModal(id: string) {
|
||||
initCounterclaimRoute(id, modal, choices, form);
|
||||
}
|
||||
|
||||
interface ProceedingTypeRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
// loadProceedingTypes fetches active proceeding types for the project
|
||||
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
|
||||
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
|
||||
// picker only ever shows those — never the 7 legacy litigation codes
|
||||
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
|
||||
// server-side service validation + DB trigger (mig 088) are the
|
||||
// defence-in-depth backstops for any non-UI writer.
|
||||
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
return proceedingTypesCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// loadProceedingTypes is shared from ./project-form so the counterclaim
|
||||
// modal here and the project-edit picker hit the same cache.
|
||||
const loadProceedingTypes = loadProceedingTypesShared;
|
||||
|
||||
function initCounterclaimRoute(
|
||||
id: string,
|
||||
@@ -1610,21 +1633,40 @@ function showTab(tab: TabId) {
|
||||
if (tab === "checklists" && project) {
|
||||
void loadAndRenderChecklistInstances(project.id);
|
||||
}
|
||||
if (tab === "submissions" && project) {
|
||||
void loadAndRenderSubmissions(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
async function loadAndRenderChecklistInstances(projectID: string) {
|
||||
if (checklistInstancesInited) return;
|
||||
let checklistCatalogLoaded = false;
|
||||
|
||||
// loadChecklistCatalog populates `checklistTemplates` (slug → template) from
|
||||
// `/api/checklists`. Reused by the tab renderer and the add-instance modal so
|
||||
// the second open doesn't refetch the catalog (t-paliad-239).
|
||||
async function loadChecklistCatalog(): Promise<ChecklistTemplateSummary[]> {
|
||||
if (checklistCatalogLoaded) return Object.values(checklistTemplates);
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists`);
|
||||
const list = resp.ok ? (((await resp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of list) checklistTemplates[tpl.slug] = tpl;
|
||||
checklistCatalogLoaded = true;
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderChecklistInstances(projectID: string, force = false) {
|
||||
if (checklistInstancesInited && !force) return;
|
||||
checklistInstancesInited = true;
|
||||
try {
|
||||
const [instResp, tplResp] = await Promise.all([
|
||||
const [instResp] = await Promise.all([
|
||||
fetch(`/api/projects/${projectID}/checklists`),
|
||||
fetch(`/api/checklists`),
|
||||
loadChecklistCatalog(),
|
||||
]);
|
||||
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
|
||||
const templates = tplResp.ok ? (((await tplResp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of templates) checklistTemplates[tpl.slug] = tpl;
|
||||
} catch {
|
||||
checklistInstances = [];
|
||||
}
|
||||
@@ -1684,6 +1726,143 @@ function renderChecklistInstances() {
|
||||
});
|
||||
}
|
||||
|
||||
// initAddChecklistModal wires the "Checkliste hinzufügen" button on the
|
||||
// project-detail Checklists tab (t-paliad-239). Opens a template picker
|
||||
// modal; on pick, POSTs to /api/checklists/{slug}/instances with the
|
||||
// current project_id and the template title as the instance name.
|
||||
function initAddChecklistModal(projectID: string) {
|
||||
const addBtn = document.getElementById("checklist-add-btn") as HTMLButtonElement | null;
|
||||
const modal = document.getElementById("add-checklist-modal") as HTMLDivElement | null;
|
||||
const closeBtn = document.getElementById("add-checklist-close") as HTMLButtonElement | null;
|
||||
const search = document.getElementById("add-checklist-search") as HTMLInputElement | null;
|
||||
const list = document.getElementById("add-checklist-list") as HTMLDivElement | null;
|
||||
const empty = document.getElementById("add-checklist-empty") as HTMLParagraphElement | null;
|
||||
const modalMsg = document.getElementById("add-checklist-msg") as HTMLParagraphElement | null;
|
||||
const tabMsg = document.getElementById("project-checklists-msg") as HTMLParagraphElement | null;
|
||||
if (!addBtn || !modal || !closeBtn || !search || !list || !empty || !modalMsg || !tabMsg) return;
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = "none";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
};
|
||||
|
||||
const renderPicker = () => {
|
||||
const isEN = getLang() === "en";
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const all = Object.values(checklistTemplates);
|
||||
all.sort((a, b) => {
|
||||
const at = (isEN ? a.titleEN : a.titleDE) || a.slug;
|
||||
const bt = (isEN ? b.titleEN : b.titleDE) || b.slug;
|
||||
return at.localeCompare(bt, isEN ? "en" : "de");
|
||||
});
|
||||
const filtered = q
|
||||
? all.filter((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || "";
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
return title.toLowerCase().includes(q)
|
||||
|| desc.toLowerCase().includes(q)
|
||||
|| (tpl.regime || "").toLowerCase().includes(q);
|
||||
})
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = filtered.map((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
const regime = tpl.regime || "";
|
||||
const regimeChip = regime
|
||||
? `<span class="checklist-regime checklist-regime-${escapeHtml(regime)}">${escapeHtml(regime)}</span>`
|
||||
: "";
|
||||
const descLine = desc ? `<p class="add-checklist-row-desc">${escapeHtml(desc)}</p>` : "";
|
||||
return `<button type="button" class="add-checklist-row" data-slug="${escapeHtml(tpl.slug)}">
|
||||
<div class="add-checklist-row-head">
|
||||
<span class="add-checklist-row-title">${escapeHtml(title)}</span>
|
||||
${regimeChip}
|
||||
</div>
|
||||
${descLine}
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const slug = btn.dataset.slug!;
|
||||
void pickTemplate(slug, btn);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const pickTemplate = async (slug: string, btn: HTMLButtonElement) => {
|
||||
const tpl = checklistTemplates[slug];
|
||||
if (!tpl) return;
|
||||
const isEN = getLang() === "en";
|
||||
const name = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
close();
|
||||
flashTabMsg(t("projects.detail.checklisten.add.created"));
|
||||
await loadAndRenderChecklistInstances(projectID, true);
|
||||
} catch {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let flashTimer = 0;
|
||||
const flashTabMsg = (text: string) => {
|
||||
tabMsg.textContent = text;
|
||||
tabMsg.className = "form-msg form-msg-success";
|
||||
if (flashTimer) window.clearTimeout(flashTimer);
|
||||
flashTimer = window.setTimeout(() => {
|
||||
tabMsg.textContent = "";
|
||||
tabMsg.className = "form-msg";
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", async () => {
|
||||
await loadChecklistCatalog();
|
||||
search.value = "";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
renderPicker();
|
||||
modal.style.display = "flex";
|
||||
search.focus();
|
||||
});
|
||||
closeBtn.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
search.addEventListener("input", renderPicker);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") close();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
@@ -1717,9 +1896,14 @@ async function prepareEditForm() {
|
||||
// as the new parent (server would reject anyway).
|
||||
await loadParentCandidates(project?.id);
|
||||
initParentPicker();
|
||||
await populateProceedingTypeSelect();
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
// openEditModal opens the project-edit modal, optionally scrolling +
|
||||
// focusing a specific field after the form is prefilled. Callers like
|
||||
// the Schriftsätze empty-state CTA pass focusFieldID="project-proceeding-
|
||||
// type-id" to land the user directly on the picker they came to set.
|
||||
function openEditModal(focusFieldID?: string) {
|
||||
if (!project) return;
|
||||
const modal = document.getElementById("project-edit-modal");
|
||||
const msg = document.getElementById("project-edit-msg");
|
||||
@@ -1755,6 +1939,19 @@ function openEditModal() {
|
||||
};
|
||||
}
|
||||
renderTypeChangeWarning();
|
||||
if (focusFieldID) {
|
||||
// Wait a tick so the modal has laid out before scrolling — the
|
||||
// wrapping flex container is display:flex so the field's offset
|
||||
// height is only reliable after the next animation frame.
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.getElementById(focusFieldID);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (target instanceof HTMLSelectElement || target instanceof HTMLInputElement) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
@@ -1833,13 +2030,37 @@ function initEditModal() {
|
||||
const msg = document.getElementById("project-edit-msg") as HTMLParagraphElement | null;
|
||||
if (!editBtn || !modal || !closeBtn || !cancelBtn || !form || !msg) return;
|
||||
|
||||
editBtn.addEventListener("click", openEditModal);
|
||||
editBtn.addEventListener("click", () => openEditModal());
|
||||
closeBtn.addEventListener("click", closeEditModal);
|
||||
cancelBtn.addEventListener("click", closeEditModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeEditModal();
|
||||
});
|
||||
|
||||
// Schriftsätze empty-state CTA — when the panel reports "no proceeding
|
||||
// set", clicking the button opens the edit modal directly on the
|
||||
// Verfahrenstyp picker so the lawyer can resolve the gap in one step
|
||||
// (t-paliad-232).
|
||||
const submissionsCTA = document.getElementById(
|
||||
"project-submissions-edit-cta",
|
||||
) as HTMLButtonElement | null;
|
||||
if (submissionsCTA) {
|
||||
submissionsCTA.addEventListener("click", () => {
|
||||
openEditModal("project-proceeding-type-id");
|
||||
});
|
||||
}
|
||||
|
||||
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
|
||||
// the danger-zone archive button (t-paliad-245).
|
||||
const archiveLink = document.getElementById(
|
||||
"project-settings-archive-link",
|
||||
) as HTMLButtonElement | null;
|
||||
if (archiveLink) {
|
||||
archiveLink.addEventListener("click", () => {
|
||||
openEditModal("project-delete-btn");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!project) return;
|
||||
@@ -2056,8 +2277,10 @@ async function main() {
|
||||
initSmartTimelineClientToggle(id);
|
||||
initSmartTimelineAddModal(id);
|
||||
initAttachUnitForm(id);
|
||||
initAddChecklistModal(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
@@ -2470,6 +2693,7 @@ async function loadUserList() {
|
||||
function renderTeam() {
|
||||
const body = document.getElementById("team-body")!;
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
const mailtoControls = document.getElementById("team-mailto-controls") as HTMLDivElement | null;
|
||||
|
||||
// Existing team-body shows the direct + ancestor-inherited members
|
||||
// returned by /api/projects/{id}/team. The derived + descendant
|
||||
@@ -2480,12 +2704,26 @@ function renderTeam() {
|
||||
if (totalRows === 0) {
|
||||
body.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
if (mailtoControls) mailtoControls.style.display = "none";
|
||||
selectedMailUserIDs.clear();
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
renderDescendantStaffed();
|
||||
renderDerivedMembers();
|
||||
renderAttachedUnits();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
if (mailtoControls) mailtoControls.style.display = teamMembers.length > 0 ? "" : "none";
|
||||
// Prune the selection to whoever is actually rendered in team-body
|
||||
// right now (e.g. a member just got removed). Invariant: selection ⊆
|
||||
// currently-visible team-body rows.
|
||||
pruneMailSelectionToVisible();
|
||||
|
||||
// t-paliad-223: callers with effective_project_admin authority see an
|
||||
// inline <select> on the Rolle cell. Everyone else sees the read-only
|
||||
// <span>. The bool comes from the GET /api/projects/{id} payload.
|
||||
const canEditResponsibility = !!project?.effective_admin;
|
||||
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
@@ -2512,17 +2750,51 @@ function renderTeam() {
|
||||
: "";
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
|
||||
|
||||
// Inline-select only on direct rows where the caller can edit.
|
||||
// Inherited rows stay read-only — the edit must happen at the
|
||||
// ancestor where the row is direct.
|
||||
const responsibilityCell =
|
||||
canEditResponsibility && !m.inherited
|
||||
? renderResponsibilitySelect(m.user_id, responsibility)
|
||||
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
|
||||
|
||||
// t-paliad-231: per-row checkbox feeding selectedMailUserIDs. Only
|
||||
// rows with a real email participate in the mailto: build; rows
|
||||
// without are still rendered with a disabled checkbox so the
|
||||
// column geometry stays uniform.
|
||||
const hasEmail = !!(m.user_email && m.user_email.trim());
|
||||
const checked = hasEmail && selectedMailUserIDs.has(m.user_id) ? " checked" : "";
|
||||
const disabled = hasEmail ? "" : " disabled";
|
||||
const checkboxCell = `<td class="team-col-select"><input type="checkbox" class="team-mail-select" data-user-id="${esc(m.user_id)}" data-email="${escAttr(m.user_email || "")}" aria-label="${escAttr(t("projects.team.mailto.select_row") || "Mitglied auswählen")}"${checked}${disabled} /></td>`;
|
||||
|
||||
return `<tr>
|
||||
${checkboxCell}
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td>${responsibilityCell}</td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// t-paliad-231 — wire row checkboxes + master + mailto button.
|
||||
body.querySelectorAll<HTMLInputElement>(".team-mail-select").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const userID = cb.dataset.userId!;
|
||||
if (cb.checked) selectedMailUserIDs.add(userID);
|
||||
else selectedMailUserIDs.delete(userID);
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
});
|
||||
});
|
||||
wireMailtoMaster();
|
||||
wireMailtoButton();
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!project) return;
|
||||
@@ -2535,6 +2807,47 @@ function renderTeam() {
|
||||
if (resp.ok) {
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} else {
|
||||
await showTeamErrorToast(resp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll<HTMLSelectElement>(".team-responsibility-select").forEach((sel) => {
|
||||
// Capture the pre-change value on focus so we can roll back the
|
||||
// <select> if the PATCH fails (e.g. last-admin guard).
|
||||
sel.dataset.previous = sel.value;
|
||||
sel.addEventListener("focus", () => {
|
||||
sel.dataset.previous = sel.value;
|
||||
});
|
||||
sel.addEventListener("change", async () => {
|
||||
if (!project) return;
|
||||
const userID = sel.dataset.userId!;
|
||||
const previous = sel.dataset.previous || "member";
|
||||
const next = sel.value;
|
||||
if (next === previous) return;
|
||||
sel.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ responsibility: next }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
sel.value = previous;
|
||||
await showTeamErrorToast(resp);
|
||||
return;
|
||||
}
|
||||
sel.dataset.previous = next;
|
||||
// Refresh the team list so derived/descendant sections re-render
|
||||
// with the new authority shape.
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} finally {
|
||||
sel.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2680,10 +2993,214 @@ function canManagePartnerUnits(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
|
||||
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
|
||||
// reveal the export button — server still re-enforces on the request.
|
||||
function canExportProject(): boolean {
|
||||
if (!me || !project) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
return teamMembers.some(
|
||||
(m) =>
|
||||
m.user_id === me!.id &&
|
||||
m.project_id === project!.id &&
|
||||
(m.responsibility === "lead" || m.responsibility === "member"),
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals the Export sub-section of the Verwaltung tab
|
||||
// (t-paliad-245) and hooks up the project-export button. Triggers a
|
||||
// download via a transient <a download> — same pattern as the personal
|
||||
// export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const section = document.getElementById("project-settings-export") as HTMLElement | null;
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!section || !btn) return;
|
||||
if (!canExportProject()) {
|
||||
section.style.display = "none";
|
||||
updateSettingsTabVisibility();
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
updateSettingsTabVisibility();
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
|
||||
// sub-sections are visible to the current user — an empty tab is worse
|
||||
// UX than no tab. Called whenever a sub-section's visibility flips.
|
||||
function updateSettingsTabVisibility(): void {
|
||||
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
|
||||
if (!tab) return;
|
||||
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
|
||||
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
|
||||
tab.style.display = exportShown || archiveShown ? "" : "none";
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
return me.global_role === "global_admin";
|
||||
if (me.global_role === "global_admin") return true;
|
||||
// t-paliad-223: effective_project_admin (from the project payload)
|
||||
// also covers remove. RLS makes the request fail anyway if the bit is
|
||||
// stale; this just hides the affordance.
|
||||
return !!project?.effective_admin;
|
||||
}
|
||||
|
||||
// t-paliad-223: build the inline <select> for the responsibility cell.
|
||||
// Options mirror the IsValidResponsibility set in approval_levels.go.
|
||||
function renderResponsibilitySelect(userID: string, current: string): string {
|
||||
const options = ["admin", "lead", "member", "observer", "external"]
|
||||
.map((v) => {
|
||||
const label = tDyn(`projects.team.responsibility.${v}`) || v;
|
||||
const sel = v === current ? " selected" : "";
|
||||
return `<option value="${esc(v)}"${sel}>${esc(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
return `<select class="team-responsibility-select projekt-team-responsibility" data-user-id="${esc(userID)}">${options}</select>`;
|
||||
}
|
||||
|
||||
// t-paliad-223: surface backend error responses (last-admin guard / 403
|
||||
// from RLS / etc.) as a transient toast. We have no global toast service
|
||||
// yet on this page, so write into #team-msg.
|
||||
async function showTeamErrorToast(resp: Response): Promise<void> {
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
if (!msg) return;
|
||||
let text = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
text = data?.error || "";
|
||||
} catch {
|
||||
text = "";
|
||||
}
|
||||
if (!text) {
|
||||
if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.";
|
||||
else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt.";
|
||||
else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen.";
|
||||
}
|
||||
msg.textContent = text;
|
||||
msg.classList.add("form-msg--error");
|
||||
// Auto-clear after 5s so a stale error doesn't linger past the next
|
||||
// successful action.
|
||||
window.setTimeout(() => {
|
||||
if (msg.textContent === text) {
|
||||
msg.textContent = "";
|
||||
msg.classList.remove("form-msg--error");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// t-paliad-231 — mailto: selection helpers for the Team tab. The
|
||||
// admin-only server SMTP broadcast (POST /api/team/broadcast) lives
|
||||
// elsewhere; this is the non-admin / quick-CC variant that opens the
|
||||
// user's local mail client. Pure client; no server call.
|
||||
function pruneMailSelectionToVisible(): void {
|
||||
const visible = new Set<string>();
|
||||
for (const m of teamMembers) {
|
||||
if (m.user_email && m.user_email.trim()) visible.add(m.user_id);
|
||||
}
|
||||
for (const id of Array.from(selectedMailUserIDs)) {
|
||||
if (!visible.has(id)) selectedMailUserIDs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedMailRecipients(): BroadcastRecipient[] {
|
||||
const out: BroadcastRecipient[] = [];
|
||||
for (const m of teamMembers) {
|
||||
if (!selectedMailUserIDs.has(m.user_id)) continue;
|
||||
if (!m.user_email || !m.user_email.trim()) continue;
|
||||
out.push({
|
||||
user_id: m.user_id,
|
||||
email: m.user_email,
|
||||
display_name: m.user_display_name || m.user_email,
|
||||
first_name: (m.user_display_name || m.user_email).trim().split(/\s+/)[0] ?? "",
|
||||
role_on_project: m.responsibility || "member",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncMailtoButton(): void {
|
||||
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
|
||||
const label = document.getElementById("team-mailto-label") as HTMLSpanElement | null;
|
||||
if (!btn || !label) return;
|
||||
const n = selectedMailRecipients().length;
|
||||
const baseLabel = t("projects.team.mailto.label") || "Mail an Auswahl";
|
||||
if (n === 0) {
|
||||
btn.disabled = true;
|
||||
label.textContent = baseLabel;
|
||||
btn.title = t("projects.team.mailto.empty") || "Mindestens ein Mitglied auswählen";
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
label.textContent = `${baseLabel} (${n})`;
|
||||
const tooltip = (t("projects.team.mailto.count") || "{n} ausgewählt").replace("{n}", String(n));
|
||||
btn.title = tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMasterCheckbox(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
// Only count rows that actually rendered with an enabled checkbox —
|
||||
// members without an email don't participate.
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>(
|
||||
"#team-body .team-mail-select:not(:disabled)",
|
||||
);
|
||||
const total = checkboxes.length;
|
||||
let selected = 0;
|
||||
checkboxes.forEach((cb) => {
|
||||
if (selectedMailUserIDs.has(cb.dataset.userId!)) selected++;
|
||||
});
|
||||
master.disabled = total === 0;
|
||||
if (total === 0 || selected === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
} else if (selected === total) {
|
||||
master.checked = true;
|
||||
master.indeterminate = false;
|
||||
} else {
|
||||
master.checked = false;
|
||||
master.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// wireMailtoMaster is idempotent — registers once via a sentinel data
|
||||
// attr so re-renders don't stack click handlers.
|
||||
function wireMailtoMaster(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master || master.dataset.wired === "1") return;
|
||||
master.dataset.wired = "1";
|
||||
master.addEventListener("change", () => {
|
||||
const turnOn = master.checked;
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>("#team-body .team-mail-select:not(:disabled)")
|
||||
.forEach((cb) => {
|
||||
const id = cb.dataset.userId!;
|
||||
if (turnOn) selectedMailUserIDs.add(id);
|
||||
else selectedMailUserIDs.delete(id);
|
||||
cb.checked = turnOn;
|
||||
});
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
});
|
||||
}
|
||||
|
||||
function wireMailtoButton(): void {
|
||||
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
|
||||
if (!btn || btn.dataset.wired === "1") return;
|
||||
btn.dataset.wired = "1";
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const recipients = selectedMailRecipients();
|
||||
if (recipients.length === 0) return;
|
||||
window.location.href = buildMailtoHref(recipients);
|
||||
});
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
wireTypeChange,
|
||||
showFieldsForType,
|
||||
readPayload,
|
||||
populateProceedingTypeSelect,
|
||||
} from "./project-form";
|
||||
|
||||
// /projects/new client. Posts v2 CreateProjectInput shape using the shared
|
||||
@@ -106,5 +107,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadParentCandidates();
|
||||
initParentPicker();
|
||||
await applyParentFromQueryString();
|
||||
// Fire-and-forget — the picker is hidden until type=case, so no need
|
||||
// to block initial render on the fetch.
|
||||
void populateProceedingTypeSelect();
|
||||
submitForm();
|
||||
});
|
||||
|
||||
@@ -51,8 +51,8 @@ interface SyncLogEntry {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"];
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
|
||||
const DEFAULT_TAB: TabName = "profil";
|
||||
|
||||
let me: Me | null = null;
|
||||
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
|
||||
if (tab === "profil") void loadProfilTab();
|
||||
else if (tab === "benachrichtigungen") void loadPrefsTab();
|
||||
else if (tab === "caldav") void loadCalDAVTab();
|
||||
else if (tab === "export") void loadExportTab();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +412,11 @@ async function loadCalDAVTab() {
|
||||
fillCalDAVForm();
|
||||
renderCalDAVStatus();
|
||||
await loadCalDAVLog();
|
||||
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
|
||||
// project picker for scope=project; runs in parallel with the binding
|
||||
// list fetch.
|
||||
void loadBindingProjects();
|
||||
await loadBindings();
|
||||
}
|
||||
|
||||
async function loadCalDAVConfig(): Promise<boolean> {
|
||||
@@ -596,6 +602,415 @@ async function deleteCalDAVConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
|
||||
|
||||
interface UserCalendarBinding {
|
||||
id: string;
|
||||
user_id: string;
|
||||
calendar_path: string;
|
||||
display_name: string;
|
||||
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
|
||||
scope_id?: string | null;
|
||||
include_personal: boolean;
|
||||
enabled: boolean;
|
||||
last_sync_at?: string | null;
|
||||
last_sync_error?: string | null;
|
||||
}
|
||||
|
||||
interface DiscoveredCalendar {
|
||||
href: string;
|
||||
display_name: string;
|
||||
supported_components?: string[];
|
||||
}
|
||||
|
||||
interface ProjectListItem {
|
||||
id: string;
|
||||
reference?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
let bindings: UserCalendarBinding[] = [];
|
||||
let discoveredCalendars: DiscoveredCalendar[] = [];
|
||||
let bindingProjects: ProjectListItem[] = [];
|
||||
let editingBindingID: string | null = null;
|
||||
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
|
||||
// true = MKCALENDAR supported (show "Create new calendar" radio),
|
||||
// false = degrade UX (hide radio, surface bilingual notice).
|
||||
let supportsMKCalendar: boolean | null = null;
|
||||
|
||||
async function loadBindings(): Promise<void> {
|
||||
const section = document.getElementById("caldav-bindings-section");
|
||||
if (!section) return;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-bindings");
|
||||
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
|
||||
if (!resp.ok) return;
|
||||
bindings = (await resp.json()) as UserCalendarBinding[];
|
||||
section.style.display = "";
|
||||
renderBindingsList();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingsList(): void {
|
||||
const list = document.getElementById("caldav-bindings-list")!;
|
||||
const empty = document.getElementById("caldav-bindings-empty")!;
|
||||
if (!bindings.length) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = bindings.map(renderBindingCard).join("");
|
||||
// Wire per-card buttons.
|
||||
for (const b of bindings) {
|
||||
const card = document.getElementById(`caldav-binding-card-${b.id}`);
|
||||
if (!card) continue;
|
||||
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
|
||||
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
|
||||
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
|
||||
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingCard(b: UserCalendarBinding): string {
|
||||
const label = b.display_name || b.calendar_path;
|
||||
const scope = scopeLabel(b);
|
||||
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
|
||||
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
|
||||
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
|
||||
<div class="caldav-binding-card-row">
|
||||
<div class="caldav-binding-card-title">
|
||||
<strong>${esc(label)}</strong>
|
||||
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
|
||||
</div>
|
||||
<label class="caldav-toggle-label">
|
||||
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
|
||||
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="caldav-binding-card-row caldav-binding-card-meta">
|
||||
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
|
||||
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
|
||||
</div>
|
||||
<div class="caldav-binding-card-actions">
|
||||
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
|
||||
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function scopeLabel(b: UserCalendarBinding): string {
|
||||
switch (b.scope_kind) {
|
||||
case "all_visible":
|
||||
return t("caldav.bindings.scope.all_visible");
|
||||
case "personal_only":
|
||||
return t("caldav.bindings.scope.personal_only");
|
||||
case "project": {
|
||||
const p = bindingProjects.find((p) => p.id === b.scope_id);
|
||||
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
|
||||
return `${t("caldav.bindings.scope.project")}: ${name}`;
|
||||
}
|
||||
default:
|
||||
return b.scope_kind;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBindingProjects(): Promise<void> {
|
||||
if (bindingProjects.length) return;
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiscoveredCalendars(): Promise<void> {
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-discover");
|
||||
if (!resp.ok) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
calendars: DiscoveredCalendar[];
|
||||
supports_mkcalendar?: boolean | null;
|
||||
};
|
||||
discoveredCalendars = data.calendars || [];
|
||||
supportsMKCalendar = data.supports_mkcalendar ?? null;
|
||||
if (!discoveredCalendars.length) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
|
||||
} else {
|
||||
sel.innerHTML = discoveredCalendars
|
||||
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
syncBindingSourceModeUI();
|
||||
} catch {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
}
|
||||
}
|
||||
|
||||
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
|
||||
// radio + the Google-degrade notice based on the cached
|
||||
// supports_mkcalendar capability. Also flips the visible input
|
||||
// (dropdown vs URL text box) to match the currently selected mode.
|
||||
function syncBindingSourceModeUI(): void {
|
||||
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
|
||||
const degrade = document.getElementById("caldav-binding-degrade-notice");
|
||||
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
|
||||
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
|
||||
|
||||
// If supports_mkcalendar flipped to false while "create" was selected,
|
||||
// fall back to "existing" so the user isn't staring at a hidden radio.
|
||||
if (supportsMKCalendar !== true) {
|
||||
const createRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="create"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (createRadio?.checked) {
|
||||
const existing = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existing) existing.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = currentBindingSourceMode();
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
sel.style.display = mode === "existing" ? "" : "none";
|
||||
customInput.style.display = mode === "custom" ? "" : "none";
|
||||
}
|
||||
|
||||
function currentBindingSourceMode(): "existing" | "create" | "custom" {
|
||||
const checked = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"]:checked',
|
||||
) as HTMLInputElement | null;
|
||||
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
|
||||
}
|
||||
|
||||
function openBindingModal(b: UserCalendarBinding | null) {
|
||||
editingBindingID = b ? b.id : null;
|
||||
const modal = document.getElementById("caldav-binding-modal")!;
|
||||
const title = document.getElementById("caldav-binding-modal-title")!;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
|
||||
const sourceField = document.getElementById("caldav-binding-source-field")!;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
|
||||
if (b) {
|
||||
title.textContent = t("caldav.bindings.modal.edit_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
|
||||
sourceField.style.display = "none";
|
||||
nameInput.value = b.display_name;
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
|
||||
if (radio) radio.checked = true;
|
||||
} else {
|
||||
title.textContent = t("caldav.bindings.modal.add_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
|
||||
sourceField.style.display = "";
|
||||
// Reset the 3-way source-mode radio to "existing" (most common path).
|
||||
const existingRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existingRadio) existingRadio.checked = true;
|
||||
customInput.value = "";
|
||||
nameInput.value = "";
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
|
||||
radio.checked = true;
|
||||
void loadDiscoveredCalendars();
|
||||
}
|
||||
|
||||
// Project picker — populate options when project scope is picked.
|
||||
projectSel.innerHTML = bindingProjects
|
||||
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
|
||||
.join("");
|
||||
if (b && b.scope_kind === "project" && b.scope_id) {
|
||||
projectSel.value = b.scope_id;
|
||||
projectSel.disabled = false;
|
||||
}
|
||||
syncBindingScopeUI();
|
||||
syncBindingSourceModeUI();
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeBindingModal() {
|
||||
document.getElementById("caldav-binding-modal")!.style.display = "none";
|
||||
editingBindingID = null;
|
||||
}
|
||||
|
||||
function syncBindingScopeUI(): void {
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
projectSel.disabled = scope !== "project";
|
||||
}
|
||||
|
||||
async function submitBindingModal(ev: Event): Promise<void> {
|
||||
ev.preventDefault();
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
|
||||
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
if (!scope) {
|
||||
msg.textContent = t("caldav.bindings.error.scope");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (scope === "project" && !projectSel.value) {
|
||||
msg.textContent = t("caldav.bindings.error.scope_project");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
if (editingBindingID) {
|
||||
const patchPayload: Record<string, unknown> = {
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") patchPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const mode = currentBindingSourceMode();
|
||||
if (mode === "create") {
|
||||
// Slice 2c MKCALENDAR path.
|
||||
const displayName = nameInput.value.trim();
|
||||
if (!displayName) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const createPayload: Record<string, unknown> = {
|
||||
display_name: displayName,
|
||||
scope_kind: scope,
|
||||
};
|
||||
if (scope === "project") createPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch("/api/caldav-mkcalendar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(createPayload),
|
||||
});
|
||||
if (resp.status === 501) {
|
||||
// Race: probe flipped to false between modal-open and submit.
|
||||
// Re-sync the UI and surface a helpful message.
|
||||
supportsMKCalendar = false;
|
||||
syncBindingSourceModeUI();
|
||||
msg.textContent = t("caldav.bindings.error.create_unsupported");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_taken");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// existing | custom — POST /api/caldav-bindings with the path.
|
||||
const path = mode === "custom" ? customInput.value.trim() : sel.value;
|
||||
if (!path) {
|
||||
msg.textContent = t("caldav.bindings.error.path");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const postPayload: Record<string, unknown> = {
|
||||
calendar_path: path,
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") postPayload.scope_id = projectSel.value;
|
||||
if (!postPayload.display_name && mode === "existing") {
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
postPayload.display_name = opt ? opt.text : "";
|
||||
}
|
||||
const resp = await fetch("/api/caldav-bindings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(postPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
closeBindingModal();
|
||||
await loadBindings();
|
||||
} catch {
|
||||
msg.textContent = t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
|
||||
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
return;
|
||||
}
|
||||
await loadBindings();
|
||||
} catch {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
b.enabled = enabled;
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// --- "Meine Partner Units" card on the profile tab -------------------------
|
||||
//
|
||||
// Read-only summary of the current user's structural memberships. Membership
|
||||
@@ -662,6 +1077,48 @@ async function renderMyPartnerUnits(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export tab (t-paliad-214 Slice 1) -------------------------------------
|
||||
|
||||
// Personal data export. One button; on click hits GET /api/me/export and the
|
||||
// browser handles the download via Content-Disposition. We use an anchor +
|
||||
// hidden iframe pattern so any non-200 response can surface inline instead
|
||||
// of silently triggering a save dialog with an error-html body.
|
||||
async function loadExportTab(): Promise<void> {
|
||||
// Nothing to fetch on render; the tab is static text + button. Wired in
|
||||
// the DOMContentLoaded handler.
|
||||
}
|
||||
|
||||
function runExport(): void {
|
||||
const msg = document.getElementById("export-msg");
|
||||
const btn = document.getElementById("export-btn") as HTMLButtonElement | null;
|
||||
if (msg) msg.textContent = "";
|
||||
if (btn) btn.disabled = true;
|
||||
// Trigger a navigation to the endpoint. The server sets
|
||||
// Content-Disposition: attachment which the browser respects.
|
||||
// We use a transient <a download> so the click goes through the
|
||||
// normal download path even on browsers that try to render text/json.
|
||||
const a = document.createElement("a");
|
||||
a.href = "/api/me/export";
|
||||
// download="" tells the browser to keep the server-provided filename
|
||||
// when one is set via Content-Disposition.
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Re-enable after a short timeout so users can re-trigger if needed.
|
||||
// We don't try to detect download completion — there's no portable
|
||||
// browser API for it.
|
||||
if (btn) {
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
if (msg)
|
||||
msg.textContent =
|
||||
t("einstellungen.export.started") ||
|
||||
"Download gestartet. Falls nichts passiert, prüfen Sie Ihren Browser-Downloadordner.";
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init -------------------------------------------------------------------
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -675,6 +1132,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
|
||||
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
|
||||
|
||||
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
|
||||
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
|
||||
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
|
||||
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingSourceModeUI);
|
||||
});
|
||||
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingScopeUI);
|
||||
});
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
if (exportBtn) exportBtn.addEventListener("click", runExport);
|
||||
|
||||
onLangChange(() => {
|
||||
if (loadedTabs.has("profil")) renderOfficeOptions();
|
||||
if (loadedTabs.has("caldav")) {
|
||||
|
||||
@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
|
||||
const SIDEBAR_WIDTH_MIN = 180;
|
||||
const SIDEBAR_WIDTH_MAX = 480;
|
||||
const SIDEBAR_WIDTH_DEFAULT = 240;
|
||||
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
|
||||
// on every scroll event, restored on initSidebar() so a full-page nav
|
||||
// click doesn't bounce the user back to the top of a long sidebar
|
||||
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
|
||||
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
|
||||
// starts that tab fresh at the top, which matches user expectation.
|
||||
const SCROLL_KEY = "paliad.sidebar.scroll";
|
||||
|
||||
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
|
||||
// BottomNav menu slot can call it without duplicating the open/close
|
||||
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
|
||||
}
|
||||
|
||||
// readStoredScroll returns the persisted scrollTop or 0 when missing /
|
||||
// malformed. Bounds are checked at apply time against the actual
|
||||
// scrollHeight, so a stale value pointing past the current scroll range
|
||||
// is harmless (the browser clamps assignments to [0, max]).
|
||||
function readStoredScroll(): number {
|
||||
const raw = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (raw === null) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function applySidebarScroll(nav: HTMLElement, px: number): void {
|
||||
if (px <= 0) return;
|
||||
nav.scrollTop = px;
|
||||
}
|
||||
|
||||
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
|
||||
// first load and removes the stale entry. Drop this fallback once the rename
|
||||
// grace period is over.
|
||||
@@ -79,6 +103,7 @@ export function initSidebar() {
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
initSidebarScrollRestore(sidebar);
|
||||
|
||||
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
|
||||
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
|
||||
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
|
||||
// sessionStorage so the user's scroll position survives a full-page
|
||||
// navigation (every sidebar link click is a real reload — see m/paliad#85).
|
||||
// Restore is synchronous on init so the first paint is already at the
|
||||
// right offset; the passive scroll listener persists subsequent moves.
|
||||
// reapplySidebarScroll() exists so callers that mutate sidebar content
|
||||
// async (initUserViewsGroup appending /api/user-views into the Ansichten
|
||||
// group) can nudge the scroll back to where it was after the layout shift.
|
||||
function initSidebarScrollRestore(sidebar: HTMLElement): void {
|
||||
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
nav.addEventListener("scroll", () => {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function reapplySidebarScroll(): void {
|
||||
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
}
|
||||
|
||||
// Changelog badge — fetches the count of entries newer than the locally
|
||||
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
|
||||
// link. Skipped on the changelog page itself because changelog.ts stamps
|
||||
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
|
||||
for (const view of views) {
|
||||
items.appendChild(renderUserViewItem(view, currentPath));
|
||||
}
|
||||
// The synchronous restore in initSidebarScrollRestore() happened
|
||||
// before these views were appended, so a saved scrollTop that
|
||||
// pointed below the Ansichten group would now sit on the wrong
|
||||
// row. Re-apply once the layout has stabilised.
|
||||
reapplySidebarScroll();
|
||||
// After rendering, kick off count refresh for views that opted in.
|
||||
for (const view of views) {
|
||||
if (view.show_count) {
|
||||
|
||||
955
frontend/src/client/submission-draft.ts
Normal file
955
frontend/src/client/submission-draft.ts
Normal file
@@ -0,0 +1,955 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
// /projects/{id}/submissions/{code}/draft
|
||||
// /projects/{id}/submissions/{code}/draft/{draft_id}
|
||||
//
|
||||
// Reads (project_id, submission_code, optional draft_id) from the URL,
|
||||
// loads the editor payload (draft row + resolved bag + merged bag +
|
||||
// HTML preview), and wires the sidebar / preview / autosave / export.
|
||||
//
|
||||
// Autosave is debounced 500ms after the lawyer stops typing in any
|
||||
// variable input. Each PATCH returns a fresh editor payload, so the
|
||||
// preview pane stays in lockstep with the variable overrides.
|
||||
|
||||
interface SubmissionDraftJSON {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
submission_code: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
variables: Record<string, string>;
|
||||
last_exported_at?: string | null;
|
||||
last_exported_sha?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SubmissionRuleSummary {
|
||||
name: string;
|
||||
name_en: string;
|
||||
submission_code: string;
|
||||
primary_party?: string;
|
||||
event_type?: string;
|
||||
legal_source?: string;
|
||||
legal_source_pretty?: string;
|
||||
}
|
||||
|
||||
interface SubmissionDraftView {
|
||||
draft: SubmissionDraftJSON;
|
||||
rule?: SubmissionRuleSummary;
|
||||
resolved_bag: Record<string, string>;
|
||||
merged_bag: Record<string, string>;
|
||||
preview_html: string;
|
||||
lang: string;
|
||||
has_template: boolean;
|
||||
template_missing?: boolean;
|
||||
}
|
||||
|
||||
interface SubmissionDraftListResponse {
|
||||
project_id: string;
|
||||
submission_code: string;
|
||||
drafts: SubmissionDraftJSON[];
|
||||
}
|
||||
|
||||
interface ParsedPath {
|
||||
// Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
|
||||
// Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
|
||||
// from the loaded draft row after fetch.
|
||||
projectID: string | null;
|
||||
submissionCode: string | null;
|
||||
draftID?: string;
|
||||
// mode tracks the URL shape we were entered from. Affects redirect
|
||||
// semantics when we create a new draft or navigate away.
|
||||
mode: "project" | "global";
|
||||
}
|
||||
|
||||
const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
|
||||
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;
|
||||
|
||||
function parsePath(): ParsedPath | null {
|
||||
let m = PROJECT_PATH_RE.exec(window.location.pathname);
|
||||
if (m) {
|
||||
return {
|
||||
projectID: m[1],
|
||||
submissionCode: decodeURIComponent(m[2]),
|
||||
draftID: m[3],
|
||||
mode: "project",
|
||||
};
|
||||
}
|
||||
m = GLOBAL_PATH_RE.exec(window.location.pathname);
|
||||
if (m) {
|
||||
return {
|
||||
projectID: null,
|
||||
submissionCode: null,
|
||||
draftID: m[1],
|
||||
mode: "global",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||||
// Mirrors the same shape the email-template variables sidebar uses;
|
||||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
de: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
interface VariableGroup {
|
||||
id: string;
|
||||
label: VariableLabel;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||||
"today": { de: "Heute", en: "Today" },
|
||||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||||
"user.email": { de: "E-Mail", en: "Email" },
|
||||
"user.office": { de: "Büro", en: "Office" },
|
||||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||||
"project.court": { de: "Gericht", en: "Court" },
|
||||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||||
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
|
||||
"rule.name": { de: "Schriftsatz", en: "Submission" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
|
||||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
{
|
||||
id: "rule",
|
||||
label: { de: "Schriftsatz", en: "Submission" },
|
||||
keys: [
|
||||
"rule.name",
|
||||
"rule.legal_source_pretty",
|
||||
"rule.primary_party",
|
||||
"rule.event_type",
|
||||
"rule.submission_code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
label: { de: "Verfahren", en: "Proceeding" },
|
||||
keys: [
|
||||
"project.title",
|
||||
"project.case_number",
|
||||
"project.court",
|
||||
"project.patent_number",
|
||||
"project.patent_number_upc",
|
||||
"project.filing_date",
|
||||
"project.grant_date",
|
||||
"project.our_side",
|
||||
"project.proceeding.name",
|
||||
"project.client_number",
|
||||
"project.matter_number",
|
||||
"project.reference",
|
||||
"project.instance_level",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deadline",
|
||||
label: { de: "Frist", en: "Deadline" },
|
||||
keys: [
|
||||
"deadline.due_date",
|
||||
"deadline.due_date_long_de",
|
||||
"deadline.due_date_long_en",
|
||||
"deadline.computed_from",
|
||||
"deadline.title",
|
||||
"deadline.original_due_date",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "firm",
|
||||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
||||
keys: [
|
||||
"firm.name",
|
||||
"user.display_name",
|
||||
"user.email",
|
||||
"user.office",
|
||||
"today.long_de",
|
||||
"today.long_en",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Module state
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface State {
|
||||
parsed: ParsedPath;
|
||||
view: SubmissionDraftView | null;
|
||||
drafts: SubmissionDraftJSON[];
|
||||
saveTimer: number | null;
|
||||
pendingOverrides: Record<string, string> | null;
|
||||
inFlight: AbortController | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
parsed: null as unknown as ParsedPath,
|
||||
view: null,
|
||||
drafts: [],
|
||||
saveTimer: null,
|
||||
pendingOverrides: null,
|
||||
inFlight: null,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const parsed = parsePath();
|
||||
if (!parsed) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
state.parsed = parsed;
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||||
// list (the sidebar switcher) is scoped to the same project +
|
||||
// submission_code AFTER we've loaded the draft.
|
||||
if (!parsed.draftID) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const view = await fetchGlobalView(parsed.draftID);
|
||||
state.view = view;
|
||||
// Backfill parsed.* from the loaded draft so the sidebar
|
||||
// switcher can list peers; project-less drafts get no peer list
|
||||
// beyond themselves (no useful (project, code) cross-section).
|
||||
state.parsed = {
|
||||
...parsed,
|
||||
projectID: view.draft.project_id,
|
||||
submissionCode: view.draft.submission_code,
|
||||
};
|
||||
if (view.draft.project_id) {
|
||||
try {
|
||||
const list = await fetchDrafts(state.parsed);
|
||||
state.drafts = list.drafts;
|
||||
} catch { state.drafts = [view.draft]; }
|
||||
} else {
|
||||
state.drafts = [view.draft];
|
||||
}
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
|
||||
// Project-scoped path: same logic as before.
|
||||
if (!parsed.projectID || !parsed.submissionCode) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const list = await fetchDrafts(parsed);
|
||||
state.drafts = list.drafts;
|
||||
let draft: SubmissionDraftJSON | null = null;
|
||||
if (parsed.draftID) {
|
||||
draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
|
||||
if (!draft) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
} else if (state.drafts.length > 0) {
|
||||
draft = state.drafts[0];
|
||||
// Redirect to the canonical /draft/{id} URL so refresh + share
|
||||
// both land on the same draft.
|
||||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||||
window.history.replaceState({}, "", url);
|
||||
state.parsed = { ...parsed, draftID: draft.id };
|
||||
} else {
|
||||
draft = await createProjectDraft(parsed);
|
||||
state.drafts = [draft];
|
||||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||||
window.history.replaceState({}, "", url);
|
||||
state.parsed = { ...parsed, draftID: draft.id };
|
||||
}
|
||||
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
|
||||
state.view = view;
|
||||
paint();
|
||||
} catch (err) {
|
||||
console.error("submission-draft boot:", err);
|
||||
showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// API
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
|
||||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
|
||||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||||
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
|
||||
if (!resp.ok) throw new Error(`create draft ${resp.status}`);
|
||||
const view = (await resp.json()) as SubmissionDraftView;
|
||||
return view.draft;
|
||||
}
|
||||
|
||||
async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
const resp = await fetch(`/api/submission-drafts/${draftID}`);
|
||||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
state.inFlight.abort();
|
||||
state.inFlight = null;
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
state.inFlight = ctl;
|
||||
// The global PATCH endpoint accepts both project-scoped and
|
||||
// project-less drafts — route everything through it so attach (set
|
||||
// project_id) works from both URL shapes.
|
||||
const url = `/api/submission-drafts/${p.draftID}`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
|
||||
return resp.json();
|
||||
} finally {
|
||||
if (state.inFlight === ctl) state.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDraft(): Promise<void> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) return;
|
||||
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Render
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function paint(): void {
|
||||
if (!state.view) return;
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-notfound");
|
||||
hide("submission-draft-error");
|
||||
show("submission-draft-body");
|
||||
|
||||
paintHeader();
|
||||
paintBackLink();
|
||||
paintNoProjectBanner();
|
||||
paintSwitcher();
|
||||
paintNameRow();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
function paintHeader(): void {
|
||||
const view = state.view!;
|
||||
const title = document.getElementById("submission-draft-title");
|
||||
if (title) {
|
||||
const ruleName = view.rule?.name ?? view.draft.submission_code;
|
||||
title.textContent = ruleName;
|
||||
}
|
||||
const subtitle = document.getElementById("submission-draft-subtitle");
|
||||
if (subtitle) {
|
||||
const code = view.draft.submission_code;
|
||||
const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
|
||||
const parts: string[] = [code];
|
||||
if (source) parts.push(source);
|
||||
subtitle.textContent = parts.join(" · ");
|
||||
}
|
||||
}
|
||||
|
||||
function paintBackLink(): void {
|
||||
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
|
||||
if (!back || !state.view) return;
|
||||
if (state.view.draft.project_id) {
|
||||
back.href = `/projects/${state.view.draft.project_id}/submissions`;
|
||||
back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
|
||||
} else {
|
||||
back.href = "/submissions";
|
||||
back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
|
||||
}
|
||||
}
|
||||
|
||||
// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
|
||||
// banner above the editor body. The banner offers a "Projekt zuweisen"
|
||||
// button that opens an inline project picker — same modal pattern the
|
||||
// /submissions/new page uses. Removed once the draft has a project_id.
|
||||
function paintNoProjectBanner(): void {
|
||||
const body = document.getElementById("submission-draft-body");
|
||||
if (!body || !state.view) return;
|
||||
let banner = document.getElementById("submission-draft-noproject-banner");
|
||||
|
||||
if (state.view.draft.project_id) {
|
||||
if (banner) banner.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = isEN()
|
||||
? "No project assigned — all variables are filled manually."
|
||||
: "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
|
||||
const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
|
||||
const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
|
||||
<button type="button" id="submission-draft-noproject-assign"
|
||||
class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;
|
||||
|
||||
if (banner) {
|
||||
banner.innerHTML = html;
|
||||
} else {
|
||||
banner = document.createElement("aside");
|
||||
banner.id = "submission-draft-noproject-banner";
|
||||
banner.className = "submission-draft-noproject-banner";
|
||||
banner.innerHTML = html;
|
||||
// Insert before the header.
|
||||
const header = body.querySelector(".submission-draft-header");
|
||||
if (header && header.parentElement) {
|
||||
header.parentElement.insertBefore(banner, header);
|
||||
} else {
|
||||
body.prepend(banner);
|
||||
}
|
||||
}
|
||||
|
||||
const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
|
||||
if (btn) btn.onclick = () => openProjectAssignPicker();
|
||||
}
|
||||
|
||||
function paintSwitcher(): void {
|
||||
const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
|
||||
if (!sel || !state.view) return;
|
||||
sel.innerHTML = state.drafts
|
||||
.map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
|
||||
.join("");
|
||||
sel.onchange = () => {
|
||||
const id = sel.value;
|
||||
if (!id || !state.view) return;
|
||||
if (id === state.view.draft.id) return;
|
||||
const p = state.parsed;
|
||||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
|
||||
window.location.href = url;
|
||||
};
|
||||
}
|
||||
|
||||
function paintNameRow(): void {
|
||||
const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
|
||||
const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
|
||||
if (input && state.view) {
|
||||
input.value = state.view.draft.name;
|
||||
input.onchange = () => {
|
||||
const newName = input.value.trim();
|
||||
if (!newName || newName === state.view!.draft.name) return;
|
||||
void renameDraft(newName);
|
||||
};
|
||||
}
|
||||
if (del) del.onclick = () => onDelete();
|
||||
|
||||
const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
|
||||
if (newBtn) newBtn.onclick = () => onCreateNew();
|
||||
|
||||
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
|
||||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||||
}
|
||||
|
||||
function paintVariables(): void {
|
||||
const host = document.getElementById("submission-draft-variables");
|
||||
if (!host || !state.view) return;
|
||||
const overrides = state.view.draft.variables ?? {};
|
||||
const resolved = state.view.resolved_bag ?? {};
|
||||
const merged = state.view.merged_bag ?? {};
|
||||
|
||||
let html = "";
|
||||
for (const group of VARIABLE_GROUPS) {
|
||||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
for (const key of group.keys) {
|
||||
const label = labelFor(key);
|
||||
const override = overrides[key];
|
||||
const resolvedVal = resolved[key] ?? "";
|
||||
const mergedVal = merged[key] ?? "";
|
||||
const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
|
||||
html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
|
||||
html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
|
||||
html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
|
||||
html += ` data-var="${escapeHtml(key)}"`;
|
||||
html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
|
||||
html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
|
||||
const hintParts: string[] = [];
|
||||
hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
|
||||
if (overridden) {
|
||||
if (override === "") {
|
||||
hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
|
||||
} else if (override !== resolvedVal) {
|
||||
const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
|
||||
hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
|
||||
}
|
||||
}
|
||||
html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
|
||||
if (overridden && override !== resolvedVal) {
|
||||
html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
|
||||
}
|
||||
html += `</label>`;
|
||||
// Visual hint: marker text appears in preview when override is "".
|
||||
void mergedVal;
|
||||
}
|
||||
html += `</section>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
|
||||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||
inp.addEventListener("input", () => onVarChange(inp));
|
||||
});
|
||||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
function paintPreview(): void {
|
||||
const host = document.getElementById("submission-draft-preview");
|
||||
if (!host || !state.view) return;
|
||||
host.innerHTML = state.view.preview_html ?? "";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Event handlers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function onVarChange(input: HTMLInputElement): void {
|
||||
const key = input.dataset.var;
|
||||
if (!key || !state.view) return;
|
||||
// Stage the override on the draft view so paintPreview reflects.
|
||||
const overrides = { ...state.view.draft.variables };
|
||||
overrides[key] = input.value;
|
||||
state.view.draft.variables = overrides;
|
||||
state.pendingOverrides = overrides;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||||
state.saveTimer = window.setTimeout(() => {
|
||||
void flushAutosave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onVarReset(key: string): void {
|
||||
if (!state.view) return;
|
||||
const overrides = { ...state.view.draft.variables };
|
||||
delete overrides[key];
|
||||
state.view.draft.variables = overrides;
|
||||
state.pendingOverrides = overrides;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||||
state.saveTimer = window.setTimeout(() => {
|
||||
void flushAutosave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function flushAutosave(): Promise<void> {
|
||||
if (!state.pendingOverrides) return;
|
||||
const payload = { variables: state.pendingOverrides };
|
||||
state.pendingOverrides = null;
|
||||
try {
|
||||
const view = await patchDraft(payload);
|
||||
state.view = view;
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft autosave:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameDraft(newName: string): Promise<void> {
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ name: newName });
|
||||
state.view = view;
|
||||
// Refresh the draft list cache.
|
||||
const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
|
||||
if (idx >= 0) state.drafts[idx].name = view.draft.name;
|
||||
paintSwitcher();
|
||||
setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft rename:", err);
|
||||
const msg = (err as Error).message?.includes("409")
|
||||
? (isEN() ? "Name already in use" : "Name bereits vergeben")
|
||||
: (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
|
||||
setSaveStatus(msg, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateNew(): Promise<void> {
|
||||
const p = state.parsed;
|
||||
// From a project-less draft, "Neuer Entwurf" can't auto-pick a
|
||||
// (project, code) cross-section — kick the user out to the global
|
||||
// picker instead.
|
||||
if (!p.projectID || !p.submissionCode) {
|
||||
window.location.href = "/submissions/new";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fresh = await createProjectDraft(p);
|
||||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error("submission-draft new:", err);
|
||||
setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const msg = isEN()
|
||||
? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
|
||||
: `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
|
||||
if (!window.confirm(msg)) return;
|
||||
try {
|
||||
await deleteDraft();
|
||||
const p = state.parsed;
|
||||
const url = p.projectID && p.submissionCode
|
||||
? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
|
||||
: "/submissions";
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error("submission-draft delete:", err);
|
||||
setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onExport(btn: HTMLButtonElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) return;
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
|
||||
try {
|
||||
// Use the global export endpoint for both project-scoped and
|
||||
// project-less drafts; the handler routes audit + project_events
|
||||
// writes based on the draft row's project_id.
|
||||
const url = `/api/submission-drafts/${p.draftID}/export`;
|
||||
const resp = await fetch(url, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* fallthrough */ }
|
||||
alert((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project assign picker (project-less → project-scoped)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PickerProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
let assignPickerProjects: PickerProjectRow[] = [];
|
||||
let assignPickerLoaded = false;
|
||||
|
||||
function openProjectAssignPicker(): void {
|
||||
ensureAssignPickerDOM();
|
||||
const modal = document.getElementById("submission-draft-assign-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!assignPickerLoaded) {
|
||||
void loadAssignPickerProjects();
|
||||
} else {
|
||||
renderAssignPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectAssignPicker(): void {
|
||||
const modal = document.getElementById("submission-draft-assign-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
function ensureAssignPickerDOM(): void {
|
||||
if (document.getElementById("submission-draft-assign-modal")) return;
|
||||
const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
|
||||
const placeholder = isEN()
|
||||
? "Search project (title or reference)…"
|
||||
: "Projekt suchen (Titel oder Aktenzeichen)…";
|
||||
const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
|
||||
const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "submission-draft-assign-modal";
|
||||
modal.className = "modal-overlay";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.style.display = "none";
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card">
|
||||
<header class="modal-header">
|
||||
<h2>${escapeHtml(titleTxt)}</h2>
|
||||
<button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
|
||||
<ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
|
||||
<p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
|
||||
<p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectAssignPicker();
|
||||
});
|
||||
const closeBtn = document.getElementById("submission-draft-assign-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAssignPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submission-draft-assign-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as PickerProjectRow[];
|
||||
assignPickerProjects = rows ?? [];
|
||||
assignPickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submission-draft assignPicker:", err);
|
||||
assignPickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderAssignPickerList();
|
||||
}
|
||||
|
||||
function renderAssignPickerList(): void {
|
||||
const list = document.getElementById("submission-draft-assign-list");
|
||||
const empty = document.getElementById("submission-draft-assign-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = assignPickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${escapeHtml(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
if (pid) void onAssignProject(pid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onAssignProject(projectID: string): Promise<void> {
|
||||
closeProjectAssignPicker();
|
||||
setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
|
||||
try {
|
||||
const view = await patchDraft({ project_id: projectID });
|
||||
state.view = view;
|
||||
setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
|
||||
// Redirect to the project-scoped URL so the editor's URL matches the
|
||||
// attached project and the project-scoped draft list (sidebar
|
||||
// switcher) loads on refresh.
|
||||
const code = view.draft.submission_code;
|
||||
window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
|
||||
} catch (err) {
|
||||
console.error("submission-draft assign:", err);
|
||||
setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function show(id: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = "";
|
||||
}
|
||||
|
||||
function hide(id: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
|
||||
function showNotFound(): void {
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-body");
|
||||
show("submission-draft-notfound");
|
||||
}
|
||||
|
||||
function showError(msg: string): void {
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-body");
|
||||
const el = document.getElementById("submission-draft-error");
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function setSaveStatus(msg: string, errorState: boolean = false): void {
|
||||
const el = document.getElementById("submission-draft-savestatus");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle("submission-draft-savestatus--error", errorState);
|
||||
}
|
||||
|
||||
function parseFilename(header: string): string | null {
|
||||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
// Keep t() referenced so the bundler doesn't tree-shake it; future
|
||||
// affordances will use the per-page i18n keys.
|
||||
void t;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => { void boot(); });
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
130
frontend/src/client/submissions-index.ts
Normal file
130
frontend/src/client/submissions-index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Loads
|
||||
// /api/user/submission-drafts and renders one entity-table row per
|
||||
// draft. Row click → editor at /projects/{project_id}/submissions/
|
||||
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
|
||||
// contract: a table whose rows look clickable must navigate on click;
|
||||
// inner links / buttons keep their own affordance.
|
||||
|
||||
interface DraftRow {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
project_title: string | null;
|
||||
project_reference?: string | null;
|
||||
submission_code: string;
|
||||
name: string;
|
||||
last_exported_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let drafts: DraftRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const isEN = getLang() === "en";
|
||||
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-index-loading")!;
|
||||
const empty = document.getElementById("submissions-index-empty")!;
|
||||
const error = document.getElementById("submissions-index-error")!;
|
||||
const wrap = document.getElementById("submissions-index-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/user/submission-drafts");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drafts = (data.drafts ?? []) as DraftRow[];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (drafts.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const body = document.getElementById("submissions-index-body")!;
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
|
||||
|
||||
body.innerHTML = drafts.map((d) => {
|
||||
const projectCell = (() => {
|
||||
if (!d.project_id) {
|
||||
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
|
||||
}
|
||||
const title = esc(d.project_title ?? "");
|
||||
if (d.project_reference) {
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
|
||||
}
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
|
||||
})();
|
||||
|
||||
const href = d.project_id
|
||||
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
|
||||
: `/submissions/draft/${esc(d.id)}`;
|
||||
|
||||
return `<tr class="submissions-index-row" data-href="${esc(href)}">
|
||||
<td>${projectCell}</td>
|
||||
<td>${esc(d.submission_code)}</td>
|
||||
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
|
||||
<td>${esc(fmtDate(d.updated_at))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
|
||||
const href = row.dataset.href!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Inner <a> elements (project link, draft name) handle their own
|
||||
// navigation — let the browser dispatch them.
|
||||
if ((e.target as HTMLElement).closest("a, button")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
});
|
||||
|
||||
// Keep tsc happy for the imported `t` (used only via data-i18n on
|
||||
// static markup — keep the import so future dynamic strings can hook
|
||||
// in without re-importing).
|
||||
void t;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
if (drafts.length > 0) render();
|
||||
});
|
||||
void load();
|
||||
});
|
||||
368
frontend/src/client/submissions-new.ts
Normal file
368
frontend/src/client/submissions-new.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-243 — client for /submissions/new. Fetches the
|
||||
// cross-proceeding submission catalog, groups it by proceeding, filters
|
||||
// by text + chip, and offers two start paths per row: with project
|
||||
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
|
||||
|
||||
interface CatalogEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
entries: CatalogEntry[];
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
entries: CatalogEntry[];
|
||||
activeProceeding: string | null; // null = all
|
||||
searchTerm: string;
|
||||
pickerForCode: string | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
entries: [],
|
||||
activeProceeding: null,
|
||||
searchTerm: "",
|
||||
pickerForCode: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return getLang() === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function partyLabel(role: string | undefined): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN() ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN() ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN() ? "Both" : "Beide";
|
||||
case "court": return isEN() ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-new-loading")!;
|
||||
const error = document.getElementById("submissions-new-error")!;
|
||||
const wrap = document.getElementById("submissions-new-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/submissions/catalog");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as CatalogResponse;
|
||||
state.entries = data.entries ?? [];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
renderChips();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderChips(): void {
|
||||
const host = document.getElementById("submissions-new-proceeding-chips");
|
||||
if (!host) return;
|
||||
const seen = new Map<string, string>();
|
||||
for (const e of state.entries) {
|
||||
if (!seen.has(e.proceeding_code)) {
|
||||
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
|
||||
}
|
||||
}
|
||||
const chips: string[] = [];
|
||||
const allLabel = isEN() ? "All" : "Alle";
|
||||
const allActive = state.activeProceeding === null;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
|
||||
for (const [code, name] of seen) {
|
||||
const active = state.activeProceeding === code;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
|
||||
}
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code ?? "";
|
||||
state.activeProceeding = code === "" ? null : code;
|
||||
renderChips();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filtered(): CatalogEntry[] {
|
||||
const term = state.searchTerm.trim().toLowerCase();
|
||||
return state.entries.filter((e) => {
|
||||
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
|
||||
return false;
|
||||
}
|
||||
if (term === "") return true;
|
||||
const name = isEN() && e.name_en ? e.name_en : e.name;
|
||||
const hay = [
|
||||
name,
|
||||
e.submission_code,
|
||||
e.legal_source ?? "",
|
||||
e.proceeding_code,
|
||||
e.proceeding_name,
|
||||
e.proceeding_name_en,
|
||||
].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(): void {
|
||||
const body = document.getElementById("submissions-new-body");
|
||||
const empty = document.getElementById("submissions-new-empty");
|
||||
const wrap = document.getElementById("submissions-new-tablewrap");
|
||||
if (!body || !empty || !wrap) return;
|
||||
|
||||
const rows = filtered();
|
||||
if (rows.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
// Group by proceeding.
|
||||
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
|
||||
for (const e of rows) {
|
||||
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
|
||||
const bucket = groups.get(e.proceeding_code);
|
||||
if (bucket) {
|
||||
bucket.entries.push(e);
|
||||
} else {
|
||||
groups.set(e.proceeding_code, { name: gname, entries: [e] });
|
||||
}
|
||||
}
|
||||
|
||||
const colspan = 4;
|
||||
const html: string[] = [];
|
||||
for (const [code, group] of groups) {
|
||||
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) void startDraft(code, null);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) openProjectPicker(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: CatalogEntry): string {
|
||||
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
|
||||
const source = entry.legal_source ?? "";
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
|
||||
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
|
||||
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
|
||||
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${esc(name)}</span>
|
||||
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${esc(partyLabel(entry.primary_party))}</td>
|
||||
<td>${esc(source)}</td>
|
||||
<td class="submission-action-cell">
|
||||
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/submission-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* ignore */ }
|
||||
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
|
||||
const id = view.draft.id;
|
||||
const pid = view.draft.project_id;
|
||||
const code = view.draft.submission_code;
|
||||
if (pid) {
|
||||
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
|
||||
} else {
|
||||
window.location.href = `/submissions/draft/${id}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submissions-new createDraft:", err);
|
||||
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pickerProjects: ProjectRow[] = [];
|
||||
let pickerLoaded = false;
|
||||
|
||||
function openProjectPicker(submissionCode: string): void {
|
||||
state.pickerForCode = submissionCode;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!pickerLoaded) {
|
||||
void loadPickerProjects();
|
||||
} else {
|
||||
renderPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectPicker(): void {
|
||||
state.pickerForCode = null;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
async function loadPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submissions-new-project-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as ProjectRow[];
|
||||
pickerProjects = rows ?? [];
|
||||
pickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submissions-new loadPickerProjects:", err);
|
||||
pickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderPickerList();
|
||||
}
|
||||
|
||||
function renderPickerList(): void {
|
||||
const list = document.getElementById("submissions-new-project-list");
|
||||
const empty = document.getElementById("submissions-new-project-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = pickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
const code = state.pickerForCode;
|
||||
if (pid && code) {
|
||||
closeProjectPicker();
|
||||
void startDraft(code, pid);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wireToolbar(): void {
|
||||
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
|
||||
if (search) {
|
||||
search.addEventListener("input", () => {
|
||||
state.searchTerm = search.value;
|
||||
renderTable();
|
||||
});
|
||||
}
|
||||
|
||||
const closeBtn = document.getElementById("submissions-new-project-modal-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
|
||||
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) {
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (pickerSearch) {
|
||||
pickerSearch.addEventListener("input", () => renderPickerList());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireToolbar();
|
||||
void loadCatalog();
|
||||
});
|
||||
269
frontend/src/client/submissions.ts
Normal file
269
frontend/src/client/submissions.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// Submissions panel — fetches the full submission catalog across every
|
||||
// proceeding and renders it grouped by proceeding, with the project's
|
||||
// own proceeding pinned at the top.
|
||||
//
|
||||
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
|
||||
// broadened it to the catalog: from any project a lawyer can pick a
|
||||
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
|
||||
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
|
||||
// handles missing variables gracefully via the [KEIN WERT: …] marker,
|
||||
// so cross-proceeding picks still render cleanly.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
interface SubmissionEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
project_proceeding_code?: string;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
// Module state — set once per page load when the user first opens the
|
||||
// tab. Subsequent activations re-use the cached result so the lawyer
|
||||
// doesn't pay for repeat list calls flipping between tabs.
|
||||
let cached: { projectID: string; data: SubmissionListResponse } | null = null;
|
||||
let loading = false;
|
||||
|
||||
/**
|
||||
* Load + render the submissions panel for the given project.
|
||||
*
|
||||
* Idempotent: safe to call on every tab activation. The second call
|
||||
* paints from cache instantly; the first call shows a loading state
|
||||
* until the list response arrives.
|
||||
*/
|
||||
export async function loadAndRenderSubmissions(projectID: string): Promise<void> {
|
||||
if (loading) return;
|
||||
if (cached && cached.projectID === projectID) {
|
||||
render(cached.data);
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${projectID}/submissions`);
|
||||
if (!resp.ok) {
|
||||
renderError();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as SubmissionListResponse;
|
||||
cached = { projectID, data };
|
||||
render(data);
|
||||
} catch {
|
||||
renderError();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function render(data: SubmissionListResponse): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
// t-paliad-242: the catalog is shown to every project regardless of
|
||||
// whether a proceeding is bound — the no-proceeding hint stays as a
|
||||
// soft nudge above the table, but no longer hides the catalog.
|
||||
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
|
||||
? ""
|
||||
: "none";
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
|
||||
// Group entries by proceeding_code. Build a stable group order:
|
||||
// project's own proceeding first (when present), then alphabetical
|
||||
// by proceeding_code for the rest.
|
||||
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
|
||||
for (const entry of data.entries) {
|
||||
const key = entry.proceeding_code || "";
|
||||
const groupName = isEN && entry.proceeding_name_en
|
||||
? entry.proceeding_name_en
|
||||
: entry.proceeding_name;
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) {
|
||||
bucket.entries.push(entry);
|
||||
} else {
|
||||
groups.set(key, { name: groupName, entries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
const ownCode = data.project_proceeding_code ?? "";
|
||||
const orderedCodes: string[] = [];
|
||||
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
|
||||
for (const code of Array.from(groups.keys()).sort()) {
|
||||
if (code !== ownCode) orderedCodes.push(code);
|
||||
}
|
||||
|
||||
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
|
||||
const colspan = 4;
|
||||
|
||||
const html: string[] = [];
|
||||
for (const code of orderedCodes) {
|
||||
const group = groups.get(code);
|
||||
if (!group) continue;
|
||||
const isOwn = code === ownCode;
|
||||
const label = group.name + (isOwn ? ownSuffix : "");
|
||||
const headerClass = isOwn
|
||||
? "entity-table-group-header entity-table-group-header--own"
|
||||
: "entity-table-group-header";
|
||||
html.push(`<tr class="${headerClass}">`
|
||||
+ `<th colspan="${colspan}" scope="colgroup">`
|
||||
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
|
||||
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
|
||||
+ `</th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry, data.project_id, isEN));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
// Wire button clicks. One handler per render to avoid stale closures
|
||||
// from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onGenerateClick(btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
|
||||
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
|
||||
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(projectID)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
|
||||
const action = `${editBtn} ${generateBtn}`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
const wrap = document.getElementById("project-submissions-tablewrap");
|
||||
if (!empty || !noProc || !wrap) return;
|
||||
noProc.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = document.documentElement.lang === "en"
|
||||
? "Failed to load submissions list."
|
||||
: "Schriftsatzliste konnte nicht geladen werden.";
|
||||
}
|
||||
|
||||
function formatParty(role: string | undefined, isEN: boolean): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN ? "Both" : "Beide";
|
||||
case "court": return isEN ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// onGenerateClick triggers a download. Disables the button while the
|
||||
// request is in flight to prevent double-submits and surfaces an
|
||||
// inline error on failure.
|
||||
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
const code = btn.dataset.code;
|
||||
const projectID = btn.dataset.project;
|
||||
if (!code || !projectID) return;
|
||||
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
|
||||
|
||||
try {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
||||
const resp = await fetch(url, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = await resp.json() as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
alert(
|
||||
(document.documentElement.lang === "en"
|
||||
? "Generation failed."
|
||||
: "Generieren fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "")
|
||||
?? `${code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// parseFilename pulls the filename out of a Content-Disposition
|
||||
// header. Supports both unquoted and quoted forms.
|
||||
function parseFilename(header: string): string | null {
|
||||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// triggerDownload creates an <a> with an object URL, clicks it, and
|
||||
// revokes the URL. Standard browser-side download pattern.
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke on next tick so the click actually triggers the download
|
||||
// before the URL is gone.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -77,6 +77,25 @@ let activeRole = "all";
|
||||
let activeProjectIDs: Set<string> = new Set();
|
||||
let searchQuery = "";
|
||||
|
||||
// t-paliad-223 (#53) — explicit click-to-select layer ON TOP of the existing
|
||||
// filter pills. When selection.size > 0 the sticky footer takes over the
|
||||
// broadcast action and targets only the explicit subset; with empty
|
||||
// selection the existing top-bar broadcast button still targets the whole
|
||||
// filter result (purely additive).
|
||||
//
|
||||
// Invariant: selection only ever holds user_ids that match the current
|
||||
// filter set — render() prunes drop-outs every cycle. This keeps the
|
||||
// counter honest and avoids "hidden-but-selected" debug nightmares.
|
||||
const selectedUserIDs: Set<string> = new Set();
|
||||
// For Shift-click range select — the user_id of the most recent toggle
|
||||
// in the currently-rendered list order. Reset to null on any filter
|
||||
// change so the range never spans an invisible row.
|
||||
let lastToggledUserID: string | null = null;
|
||||
// Snapshot of the rendered user-IDs in DOM order, refreshed on each render.
|
||||
// Drives Shift-click range expansion and the master-checkbox "select all
|
||||
// visible" action.
|
||||
let renderedUserIDs: string[] = [];
|
||||
|
||||
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
const ICON_PIN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
|
||||
|
||||
@@ -322,28 +341,64 @@ function buildProjectFilter() {
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
// Wait for /api/me so the affordance never flickers between admin (form)
|
||||
// and non-admin (mailto) on initial paint. canBroadcast() already returns
|
||||
// false when me is null but we'd briefly render the mailto anchor before
|
||||
// the admin form, which is visually jarring.
|
||||
if (!me) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
|
||||
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
|
||||
if (canBroadcast()) {
|
||||
// Admin path (global_admin or project-lead-of-selected): opens the
|
||||
// in-app compose modal that POSTs to /api/team/broadcast.
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${label} ${counter}
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
} else {
|
||||
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
|
||||
// the current filter set. href is refreshed in updateBroadcastButton()
|
||||
// whenever filters change so the link always reflects what's visible.
|
||||
wrap.innerHTML = `
|
||||
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
|
||||
${label} ${counter}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const recipients = displayedRecipients();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
if (countEl) countEl.textContent = String(recipients.length);
|
||||
const btn = document.getElementById("team-broadcast-btn");
|
||||
if (!btn) return;
|
||||
if (btn.tagName === "BUTTON") {
|
||||
(btn as HTMLButtonElement).disabled = recipients.length === 0;
|
||||
} else {
|
||||
// Anchor (non-admin): regenerate the mailto: href against the current
|
||||
// visible recipients, and disable the affordance when empty so a click
|
||||
// doesn't open an empty mail composer.
|
||||
const a = btn as HTMLAnchorElement;
|
||||
if (recipients.length === 0) {
|
||||
a.setAttribute("href", "mailto:");
|
||||
a.setAttribute("aria-disabled", "true");
|
||||
a.style.pointerEvents = "none";
|
||||
a.style.opacity = "0.5";
|
||||
} else {
|
||||
a.setAttribute("href", buildMailtoHref(recipients));
|
||||
a.removeAttribute("aria-disabled");
|
||||
a.style.pointerEvents = "";
|
||||
a.style.opacity = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,8 +458,17 @@ function memberAsUser(m: DepartmentMember): User | undefined {
|
||||
function renderUserCard(u: User): string {
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
const jobTitle = (u.job_title ?? "").trim();
|
||||
// t-paliad-223 (#53): per-row select-checkbox. Wrapped in a label so a
|
||||
// click on the checkbox cell triggers the toggle; the rest of the card
|
||||
// (links, email, etc.) keeps its native behaviour. Selection state
|
||||
// mirrored to data-selected so the CSS can highlight the card.
|
||||
const selected = selectedUserIDs.has(u.id);
|
||||
const selectAria = t("team.selection.toggle_card") || "Kontakt auswählen";
|
||||
return `
|
||||
<article class="team-card">
|
||||
<article class="team-card" data-user-id="${esc(u.id)}" data-selected="${selected ? "true" : "false"}">
|
||||
<label class="team-card-select" title="${escAttr(selectAria)}">
|
||||
<input type="checkbox" class="team-card-select-input" data-user-id="${esc(u.id)}"${selected ? " checked" : ""} aria-label="${escAttr(selectAria)}" />
|
||||
</label>
|
||||
<div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
|
||||
<div class="team-card-body">
|
||||
<div class="team-card-name">${esc(u.display_name)}</div>
|
||||
@@ -418,6 +482,13 @@ function renderUserCard(u: User): string {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// escAttr is the attribute-context counterpart of esc. Used in title=""
|
||||
// + aria-label="" where esc()'s div-textContent trick is fine but
|
||||
// double-quote-escaping is the bit we actually need.
|
||||
function escAttr(s: string): string {
|
||||
return esc(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderGroupByOffice(filtered: User[]): string {
|
||||
const present = presentOffices();
|
||||
const sections = present
|
||||
@@ -505,12 +576,22 @@ function render() {
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
|
||||
// t-paliad-223 (#53): prune drop-outs from the explicit selection. The
|
||||
// invariant is "selection ⊆ visible"; carrying invisible IDs forward
|
||||
// would create stale "12 selected" counters that don't match what the
|
||||
// user sees on screen.
|
||||
pruneSelectionToVisible(new Set(filtered.map((u) => u.id)));
|
||||
|
||||
count.textContent = `${filtered.length} / ${users.length}`;
|
||||
updateBroadcastButton();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
renderedUserIDs = [];
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
@@ -518,6 +599,233 @@ function render() {
|
||||
list.innerHTML = groupBy === "office"
|
||||
? renderGroupByOffice(filtered)
|
||||
: renderGroupByDepartment(filtered);
|
||||
|
||||
// Refresh the DOM-order snapshot Shift-click + master-checkbox rely on.
|
||||
renderedUserIDs = Array.from(
|
||||
list.querySelectorAll<HTMLElement>(".team-card"),
|
||||
).map((el) => el.dataset.userId || "");
|
||||
|
||||
wireSelectionCheckboxes(list);
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
}
|
||||
|
||||
// pruneSelectionToVisible drops user_ids from selection that no longer
|
||||
// match the visible set. Always called from render() before painting so
|
||||
// the per-row "checked" state and the footer counter stay in sync.
|
||||
function pruneSelectionToVisible(visible: Set<string>): void {
|
||||
const removed: string[] = [];
|
||||
for (const id of selectedUserIDs) {
|
||||
if (!visible.has(id)) removed.push(id);
|
||||
}
|
||||
for (const id of removed) selectedUserIDs.delete(id);
|
||||
if (removed.length > 0 && lastToggledUserID && !visible.has(lastToggledUserID)) {
|
||||
lastToggledUserID = null;
|
||||
}
|
||||
}
|
||||
|
||||
// wireSelectionCheckboxes attaches click handlers to every per-row
|
||||
// checkbox in the freshly-rendered list. Each click toggles the
|
||||
// underlying selection Set + the data-selected attribute on the card.
|
||||
// Shift-click extends a contiguous range from the previous toggle to
|
||||
// the current row using renderedUserIDs as the order reference.
|
||||
function wireSelectionCheckboxes(list: HTMLElement): void {
|
||||
list.querySelectorAll<HTMLInputElement>(".team-card-select-input").forEach((cb) => {
|
||||
cb.addEventListener("click", (ev) => {
|
||||
const id = cb.dataset.userId || "";
|
||||
if (!id) return;
|
||||
const checked = cb.checked;
|
||||
if ((ev as MouseEvent).shiftKey && lastToggledUserID && lastToggledUserID !== id) {
|
||||
applyRangeSelection(lastToggledUserID, id, checked);
|
||||
} else {
|
||||
if (checked) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
lastToggledUserID = id;
|
||||
// Visual + footer refresh without a full re-render (selection
|
||||
// changes don't affect the filter set; render() is reserved for
|
||||
// filter/data changes to keep typing in the search box fast).
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// applyRangeSelection sets selection state for every user between
|
||||
// (inclusive) startID and endID in renderedUserIDs order. Mode = the
|
||||
// final state — checked => add to selection, unchecked => remove.
|
||||
function applyRangeSelection(startID: string, endID: string, mode: boolean): void {
|
||||
const a = renderedUserIDs.indexOf(startID);
|
||||
const b = renderedUserIDs.indexOf(endID);
|
||||
if (a === -1 || b === -1) {
|
||||
// One of the anchors dropped out of the current visible set; fall
|
||||
// back to a single-row toggle of the end-id.
|
||||
if (mode) selectedUserIDs.add(endID);
|
||||
else selectedUserIDs.delete(endID);
|
||||
return;
|
||||
}
|
||||
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const id = renderedUserIDs[i];
|
||||
if (mode) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// refreshCardSelectedAttribute syncs every visible card's data-selected
|
||||
// + checkbox.checked to the canonical Set, without a full re-render.
|
||||
function refreshCardSelectedAttribute(): void {
|
||||
const list = document.getElementById("team-list");
|
||||
if (!list) return;
|
||||
list.querySelectorAll<HTMLElement>(".team-card").forEach((card) => {
|
||||
const id = card.dataset.userId || "";
|
||||
const selected = selectedUserIDs.has(id);
|
||||
card.dataset.selected = selected ? "true" : "false";
|
||||
const cb = card.querySelector<HTMLInputElement>(".team-card-select-input");
|
||||
if (cb) cb.checked = selected;
|
||||
});
|
||||
}
|
||||
|
||||
// renderSelectionFooter mounts (or hides) the sticky footer that takes
|
||||
// over the broadcast action when ≥ 1 row is checked. The footer lives
|
||||
// outside the main content tree so it can be position: fixed without
|
||||
// fighting any of the existing layout rules.
|
||||
function renderSelectionFooter(): void {
|
||||
let footer = document.getElementById("team-selection-footer") as HTMLDivElement | null;
|
||||
const n = selectedUserIDs.size;
|
||||
if (n === 0) {
|
||||
if (footer) footer.style.display = "none";
|
||||
document.body.classList.remove("team-has-selection");
|
||||
return;
|
||||
}
|
||||
if (!footer) {
|
||||
footer = document.createElement("div");
|
||||
footer.id = "team-selection-footer";
|
||||
footer.className = "team-selection-footer";
|
||||
document.body.appendChild(footer);
|
||||
}
|
||||
const countLabel = (t("team.selection.count") || "{n} ausgewählt").replace(
|
||||
"{n}",
|
||||
String(n),
|
||||
);
|
||||
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
|
||||
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
|
||||
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
|
||||
// compose modal; non-admin gets a native mailto: anchor pre-filled with
|
||||
// the explicit selection.
|
||||
const adminPath = canBroadcast();
|
||||
const sendAction = adminPath
|
||||
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
|
||||
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
|
||||
footer.innerHTML = `
|
||||
<span class="team-selection-count">${esc(countLabel)}</span>
|
||||
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
|
||||
${esc(t("team.selection.clear") || "Auswahl aufheben")}
|
||||
</button>
|
||||
${sendAction}
|
||||
`;
|
||||
footer.style.display = "";
|
||||
document.body.classList.add("team-has-selection");
|
||||
document.getElementById("team-selection-clear")?.addEventListener("click", () => {
|
||||
selectedUserIDs.clear();
|
||||
lastToggledUserID = null;
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
if (adminPath) {
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
}
|
||||
// Anchor path has no click handler — native href open is the action.
|
||||
}
|
||||
|
||||
// selectedRecipients maps the explicit selection Set into the
|
||||
// BroadcastRecipient shape openBroadcastModal expects. Mirrors the
|
||||
// role-resolution rules of displayedRecipients() (active project
|
||||
// filter wins; falls back to first available role).
|
||||
function selectedRecipients(): BroadcastRecipient[] {
|
||||
const out: BroadcastRecipient[] = [];
|
||||
for (const id of selectedUserIDs) {
|
||||
const u = users.find((u) => u.id === id);
|
||||
if (!u) continue;
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
let role = "";
|
||||
if (m) {
|
||||
if (activeProjectIDs.size > 0) {
|
||||
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
|
||||
if (idx >= 0) role = m.roles[idx];
|
||||
} else if (m.roles.length > 0) {
|
||||
role = m.roles[0];
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
first_name: firstName(u.display_name),
|
||||
role_on_project: role,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function onBroadcastFromSelection(): void {
|
||||
const recipients = selectedRecipients();
|
||||
if (recipients.length === 0) return;
|
||||
const selectedProjectIDs = Array.from(activeProjectIDs);
|
||||
// Same scope-resolution as displayedRecipients/onBroadcastClick: pass
|
||||
// project_id only when exactly one is selected so the server can
|
||||
// verify lead-ship; multi-project relies on global_admin.
|
||||
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
|
||||
const offices = activeOffice === "all" ? [] : [activeOffice];
|
||||
const roles = activeRole === "all" ? [] : [activeRole];
|
||||
openBroadcastModal({
|
||||
recipients,
|
||||
projectID,
|
||||
projectIDs: selectedProjectIDs,
|
||||
offices,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
// syncMasterCheckbox refreshes the master "select all visible" checkbox
|
||||
// to one of three states: empty / partial / full. The HTML element lives
|
||||
// in team.tsx (#team-select-master); when missing (older shells) the
|
||||
// helper no-ops so the page still works.
|
||||
function syncMasterCheckbox(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
const visible = renderedUserIDs.length;
|
||||
if (visible === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
master.disabled = true;
|
||||
return;
|
||||
}
|
||||
master.disabled = false;
|
||||
let selectedHere = 0;
|
||||
for (const id of renderedUserIDs) {
|
||||
if (selectedUserIDs.has(id)) selectedHere++;
|
||||
}
|
||||
master.checked = selectedHere === visible;
|
||||
master.indeterminate = selectedHere > 0 && selectedHere < visible;
|
||||
}
|
||||
|
||||
function onMasterToggle(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
const checked = master.checked;
|
||||
for (const id of renderedUserIDs) {
|
||||
if (checked) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
lastToggledUserID = checked && renderedUserIDs.length > 0 ? renderedUserIDs[renderedUserIDs.length - 1] : null;
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
}
|
||||
|
||||
function initToggle() {
|
||||
@@ -547,6 +855,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initToggle();
|
||||
// t-paliad-223 (#53): master checkbox toggles every visible row.
|
||||
document.getElementById("team-select-master")?.addEventListener("change", onMasterToggle);
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
buildRoleFilters();
|
||||
|
||||
@@ -12,19 +12,108 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
"dpma.appeal.bpatg",
|
||||
"dpma.appeal.bgh",
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||
// semantic as /tools/fristenrechner.
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Notes toggle — when off (default), per-rule descriptive notes render
|
||||
// as a compact ⓘ icon next to the meta line (hover for full text). When
|
||||
// on, the full notes block expands under each card. Choice persists in
|
||||
// localStorage so a reload or recalc keeps the user's preference.
|
||||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||||
function readNotesPref(): boolean {
|
||||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeNotesPref(on: boolean): void {
|
||||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
// Jurisdiction display prefix for the proceeding-summary chip + the
|
||||
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
||||
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
||||
@@ -112,10 +201,14 @@ async function doCalc() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
@@ -126,17 +219,34 @@ async function doCalc() {
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. The root rule (isRootEvent=true) is
|
||||
// the first event in the proceeding — e.g. Klageerhebung for
|
||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||
// active proceeding name if no root rule fires (shouldn't happen for
|
||||
// healthy data, but safer than a blank).
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
// 1. Server-supplied triggerEventLabel from proceeding_types
|
||||
// (mig 121, m/paliad#81). UPC Appeal sets this to
|
||||
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
|
||||
// all carry a non-zero duration off the trigger date so none is
|
||||
// the root, and the proceedingName fallback ("Berufungsverfahren")
|
||||
// misnamed the input as the proceeding itself.
|
||||
// 2. Root rule (isRootEvent=true) — the first event in the
|
||||
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
|
||||
// Nichtigkeitsklage for upc.rev.cfi.
|
||||
// 3. Active proceeding name — last-resort fallback. Language-aware
|
||||
// (m/paliad#58: prior code rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel)
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN);
|
||||
if (curated) return curated;
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
return data.proceedingName || "";
|
||||
if (lang === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
}
|
||||
|
||||
function syncTriggerEventLabel() {
|
||||
@@ -166,11 +276,28 @@ function renderResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
// Sub-track contextual note (m/paliad#58). Surfaces above the
|
||||
// timeline body when the server routed the user-picked proceeding
|
||||
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
|
||||
// Plain-text banner — server-side copy is plain text per the
|
||||
// SubTrackRouting contract.
|
||||
const noteText = getLang() === "en"
|
||||
? (data.contextualNoteEN || data.contextualNote || "")
|
||||
: (data.contextualNote || data.contextualNoteEN || "");
|
||||
const noteHtml = noteText
|
||||
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
|
||||
: "";
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
|
||||
@@ -216,7 +343,12 @@ function syncInfAmendEnabled() {
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedType = btn.dataset.code || "";
|
||||
const nextType = btn.dataset.code || "";
|
||||
// Different proceeding tree → previously-set overrides reference
|
||||
// rule codes that don't exist in the new tree. Clear before the
|
||||
// next calc so the fresh proceeding starts unanchored.
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
@@ -225,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -232,18 +365,53 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
}
|
||||
}
|
||||
|
||||
function syncRadioGroup(name: string, value: string) {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
||||
// Mirrors the events.ts pattern (body.events-view-*). The print
|
||||
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
||||
// `@page paliad-landscape`, so flipping this class is what lets a
|
||||
// user print the horizontal timeline in landscape without affecting
|
||||
// the columns view (which stays portrait).
|
||||
document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
|
||||
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
|
||||
}
|
||||
|
||||
function initViewToggle() {
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
const initial = new URLSearchParams(window.location.search).get("view");
|
||||
if (initial === "timeline") procedureView = "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
|
||||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||||
input.checked = input.value === procedureView;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
const url = new URL(window.location.href);
|
||||
if (procedureView === "columns") {
|
||||
url.searchParams.delete("view");
|
||||
@@ -258,6 +426,38 @@ function initViewToggle() {
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
// initPerspectiveControls hydrates side+appellant from the URL,
|
||||
// reflects state into the radio inputs, and wires onchange handlers
|
||||
// that update state + URL + re-render. Re-render path skips the
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -299,7 +499,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
// Click-to-edit on timeline / column date cells — same delegated
|
||||
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
||||
// innerHTML rewrites because the listener lives on the container.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
if (notesShowCb) {
|
||||
notesShowCb.checked = showNotes;
|
||||
notesShowCb.addEventListener("change", () => {
|
||||
showNotes = notesShowCb.checked;
|
||||
writeNotesPref(showNotes);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
onLangChange(() => {
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
|
||||
@@ -204,6 +204,12 @@ function setActiveShape(shape: RenderShape | null): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shape === shape);
|
||||
});
|
||||
// Mirror the active shape on <body> so the print stylesheet can opt
|
||||
// calendar / timeline into landscape (`@page paliad-landscape`) while
|
||||
// list / cards stay portrait — t-paliad-233.
|
||||
for (const s of ["list", "cards", "calendar", "timeline"]) {
|
||||
document.body.classList.toggle(`views-shape-active-${s}`, shape === s);
|
||||
}
|
||||
}
|
||||
|
||||
let timelineHandle: ChartHandle | null = null;
|
||||
|
||||
@@ -1,525 +1,28 @@
|
||||
import { t, tDyn, type I18nKey, getLang } from "../i18n";
|
||||
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-calendar: month / week / day views. The view switcher is rendered
|
||||
// inline above the grid; the active view persists in the URL via
|
||||
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
|
||||
// shareable deep-link. Each view buckets the same flat ViewRow[] by
|
||||
// ISO-date — only the rendering differs.
|
||||
|
||||
type CalView = "month" | "week" | "day";
|
||||
|
||||
const VIEW_PARAM = "cal_view";
|
||||
const DATE_PARAM = "cal_date";
|
||||
const MAX_PILLS_PER_MONTH_CELL = 3;
|
||||
// shape-calendar — Custom Views calendar shape. Since t-paliad-224 this
|
||||
// is a thin adapter on top of the canonical mountCalendar() in
|
||||
// frontend/src/client/calendar/mount-calendar.ts. /events Kalender tab
|
||||
// uses the same module so both surfaces render identical DOM.
|
||||
// See docs/design-calendar-view-align-2026-05-20.md.
|
||||
|
||||
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const cfg = render.calendar ?? {};
|
||||
|
||||
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
|
||||
// screens). Documented in design §9 trade-off 8.
|
||||
if (window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const initialView = readView(cfg.default_view);
|
||||
const anchor = readAnchor(rows);
|
||||
paint(host, rows, anchor, initialView);
|
||||
}
|
||||
|
||||
// paint redraws the calendar in the supplied view + anchor. Called from
|
||||
// the view switcher and from the day/week navigation buttons. Each paint
|
||||
// clears the host so we don't leak prior DOM.
|
||||
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
|
||||
// Keep the mobile-notice (first child) if present; everything else is
|
||||
// re-rendered each time.
|
||||
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
|
||||
host.innerHTML = "";
|
||||
if (notice) host.appendChild(notice);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
|
||||
writeURL(nextView, nextAnchor);
|
||||
paint(host, rows, nextAnchor, nextView);
|
||||
}));
|
||||
|
||||
if (view === "month") {
|
||||
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
|
||||
writeURL("day", clickedDate);
|
||||
paint(host, rows, clickedDate, "day");
|
||||
}));
|
||||
} else if (view === "week") {
|
||||
wrap.appendChild(renderWeek(anchor, rows));
|
||||
} else {
|
||||
wrap.appendChild(renderDay(anchor, rows));
|
||||
}
|
||||
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
// --- Toolbar -------------------------------------------------------------
|
||||
|
||||
function renderToolbar(
|
||||
view: CalView,
|
||||
anchor: Date,
|
||||
onNav: (view: CalView, anchor: Date) => void,
|
||||
): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "views-calendar-toolbar";
|
||||
|
||||
// View switcher: month / week / day chips.
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "views-calendar-view-switcher agenda-chip-row";
|
||||
switcher.setAttribute("role", "tablist");
|
||||
for (const v of ["month", "week", "day"] as CalView[]) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
|
||||
chip.dataset.calView = v;
|
||||
chip.setAttribute("role", "tab");
|
||||
chip.setAttribute("aria-selected", v === view ? "true" : "false");
|
||||
chip.textContent = t(`cal.view.${v}` as I18nKey);
|
||||
chip.addEventListener("click", () => {
|
||||
if (v === view) return;
|
||||
onNav(v, anchor);
|
||||
});
|
||||
switcher.appendChild(chip);
|
||||
}
|
||||
bar.appendChild(switcher);
|
||||
|
||||
// Prev / current-label / next. Step size depends on the view.
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "views-calendar-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
|
||||
prev.textContent = "‹";
|
||||
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
|
||||
nav.appendChild(prev);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "views-calendar-nav-label";
|
||||
label.textContent = formatRangeLabel(view, anchor);
|
||||
nav.appendChild(label);
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
|
||||
next.textContent = "›";
|
||||
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
|
||||
nav.appendChild(next);
|
||||
|
||||
// Day/week view: provide a "Zurück zum Monat" link so users can climb
|
||||
// back without hunting for the switcher chip.
|
||||
if (view !== "month") {
|
||||
const backToMonth = document.createElement("button");
|
||||
backToMonth.type = "button";
|
||||
backToMonth.className = "btn-link views-calendar-back-to-month";
|
||||
backToMonth.textContent = t("cal.day.back_to_month");
|
||||
backToMonth.addEventListener("click", () => onNav("month", anchor));
|
||||
nav.appendChild(backToMonth);
|
||||
}
|
||||
|
||||
bar.appendChild(nav);
|
||||
return bar;
|
||||
}
|
||||
|
||||
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
|
||||
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
|
||||
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
|
||||
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
|
||||
}
|
||||
|
||||
// --- Month view ----------------------------------------------------------
|
||||
|
||||
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
// Single grid with one column-template that the weekday row and the day
|
||||
// cells share. The header row is added with `grid-column: span 7` so
|
||||
// it spans the full width above the day grid (laid out below).
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const weekdayKeys: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
// Pad start with prev-month spillover.
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
|
||||
const byDate = bucketByDate(rows, (d) =>
|
||||
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
|
||||
);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
|
||||
const dateKey = isoDate(dayDate);
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderMonthCell(
|
||||
dayDate: Date,
|
||||
dayNum: number,
|
||||
dayRows: ViewRow[],
|
||||
onDayDrill: (d: Date) => void,
|
||||
): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
|
||||
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
|
||||
|
||||
// Day-number is a click-target that switches to the day view. We render
|
||||
// it as a button to keep keyboard semantics; the surrounding cell stays
|
||||
// a div so it doesn't compete with the inner row anchors.
|
||||
const dayLabel = document.createElement("button");
|
||||
dayLabel.type = "button";
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(dayNum);
|
||||
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
dayLabel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
|
||||
for (const row of visible) {
|
||||
ul.appendChild(renderPill(row));
|
||||
}
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
const moreBtn = document.createElement("button");
|
||||
moreBtn.type = "button";
|
||||
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
|
||||
moreBtn.textContent = `+${dayRows.length - visible.length}`;
|
||||
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
moreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
more.appendChild(moreBtn);
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
// --- Week view -----------------------------------------------------------
|
||||
|
||||
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-week";
|
||||
|
||||
const weekStart = startOfWeek(anchor);
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-week-grid";
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
const col = renderWeekColumn(day, rows);
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const col = document.createElement("div");
|
||||
col.className = "views-calendar-week-column";
|
||||
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-calendar-week-head";
|
||||
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
|
||||
const dow = document.createElement("span");
|
||||
dow.className = "views-calendar-week-dow";
|
||||
dow.textContent = t(weekdayKey);
|
||||
const dnum = document.createElement("span");
|
||||
dnum.className = "views-calendar-week-dnum";
|
||||
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
head.appendChild(dow);
|
||||
head.appendChild(dnum);
|
||||
col.appendChild(head);
|
||||
|
||||
// No 3-row cap on week / day views — show everything for that day.
|
||||
const dayRows = filterByDay(rows, day);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-week-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
col.appendChild(empty);
|
||||
return col;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-week-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "week"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
col.appendChild(ul);
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- Day view ------------------------------------------------------------
|
||||
|
||||
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-day-wrap";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const dayRows = filterByDay(rows, anchor);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-day-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-day-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "day"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// --- Row rendering -------------------------------------------------------
|
||||
|
||||
function renderPill(row: ViewRow): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
a.textContent = row.title;
|
||||
a.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
// Pills are anchors — month-cell day-button click ignores them via
|
||||
// stopPropagation on the button; cell-level handlers would intercept
|
||||
// them otherwise.
|
||||
a.addEventListener("click", (e) => e.stopPropagation());
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
|
||||
a.appendChild(dot);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "views-calendar-row-body";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-calendar-row-title";
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(tDyn("views.kind." + row.kind));
|
||||
if (row.project_reference) metaParts.push(row.project_reference);
|
||||
else if (row.project_title) metaParts.push(row.project_title);
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "views-calendar-row-meta";
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
a.appendChild(body);
|
||||
return a;
|
||||
}
|
||||
|
||||
function rowHref(row: ViewRow): string {
|
||||
switch (row.kind) {
|
||||
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
|
||||
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
|
||||
case "approval_request": return `/inbox`;
|
||||
case "project_event":
|
||||
// project_events surface on the project's Verlauf — best we can do
|
||||
// is link to the project. If no project, leave as a non-link target.
|
||||
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bucketing / date helpers --------------------------------------------
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
|
||||
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
|
||||
const out = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (!filter(d)) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = out.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else out.set(key, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
|
||||
const key = isoDate(day);
|
||||
return rows.filter((r) => {
|
||||
const d = new Date(r.event_date);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
return isoDate(d) === key;
|
||||
const items: CalendarItem[] = rows.map(toCalendarItem);
|
||||
mountCalendar(host, items, {
|
||||
defaultView: render.calendar?.default_view ?? "month",
|
||||
urlState: true,
|
||||
});
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const offset = (out.getDay() + 6) % 7; // Mon=0
|
||||
out.setDate(out.getDate() - offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
function shift(d: Date, view: CalView, dir: number): Date {
|
||||
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||||
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatRangeLabel(view: CalView, anchor: Date): string {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (view === "month") {
|
||||
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(anchor);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return formatWeekHeader(start, end, lang);
|
||||
}
|
||||
return anchor.toLocaleDateString(lang, {
|
||||
weekday: "short", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekHeader(start: Date, end: Date, lang: string): string {
|
||||
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
// --- URL state -----------------------------------------------------------
|
||||
|
||||
function readView(defaultView: CalView | undefined): CalView {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(VIEW_PARAM);
|
||||
if (raw === "month" || raw === "week" || raw === "day") return raw;
|
||||
return defaultView ?? "month";
|
||||
}
|
||||
|
||||
function readAnchor(rows: ViewRow[]): Date {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(DATE_PARAM);
|
||||
if (raw) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
// No URL anchor — pick the first row's date, or today.
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function writeURL(view: CalView, anchor: Date): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(VIEW_PARAM, view);
|
||||
url.searchParams.set(DATE_PARAM, isoDate(anchor));
|
||||
history.replaceState(null, "", url.toString());
|
||||
function toCalendarItem(row: ViewRow): CalendarItem {
|
||||
return {
|
||||
kind: row.kind,
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
event_date: row.event_date,
|
||||
project_id: row.project_id,
|
||||
project_title: row.project_title,
|
||||
project_reference: row.project_reference,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +196,12 @@ interface ApprovalDetail {
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
// counter_payload + next_request_id — populated on the OLD row of a
|
||||
// suggest-changes pair (t-paliad-216). The new row's id lets us
|
||||
// render a back-link "→ Neuer Vorschlag von {decider}". Both stay
|
||||
// unset on any non-changes_requested status.
|
||||
counter_payload?: Record<string, unknown> | null;
|
||||
next_request_id?: string;
|
||||
// Per-viewer eligibility flags resolved server-side against the caller
|
||||
// (t-paliad-202). Used to grey out actions the server would reject.
|
||||
// Optional so an older payload still renders — falsy means "treat as
|
||||
@@ -204,6 +210,11 @@ interface ApprovalDetail {
|
||||
viewer_is_requester?: boolean;
|
||||
}
|
||||
|
||||
// Pending-row action set. suggest_changes was added in t-paliad-216 as
|
||||
// the fourth action — the approver authors a counter-proposal which
|
||||
// becomes a NEW pending row authored by them.
|
||||
type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes";
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
@@ -262,13 +273,20 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// All three actions are stamped on every pending row; the per-viewer
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
@@ -285,6 +303,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
@@ -321,17 +355,24 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
}
|
||||
|
||||
function approvalActionBtn(
|
||||
action: "approve" | "reject" | "revoke",
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
// suggest_changes shares the secondary style with revoke; reject is
|
||||
// danger (terminal "no"); approve is primary.
|
||||
const cls = action === "approve"
|
||||
? "btn-primary"
|
||||
: action === "reject"
|
||||
? "btn-danger"
|
||||
: "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
|
||||
// approve / reject share the eligibility gate; revoke is requester-only.
|
||||
// approve / reject / suggest_changes share the canApprove eligibility
|
||||
// gate; revoke is requester-only.
|
||||
const reason = disabledReasonFor(action, detail);
|
||||
if (reason) {
|
||||
btn.disabled = true;
|
||||
@@ -341,13 +382,13 @@ function approvalActionBtn(
|
||||
}
|
||||
|
||||
function disabledReasonFor(
|
||||
action: "approve" | "reject" | "revoke",
|
||||
action: ApprovalAction,
|
||||
detail: ApprovalDetail,
|
||||
): I18nKey | null {
|
||||
if (action === "revoke") {
|
||||
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
|
||||
}
|
||||
// approve + reject — same gate as the server's canApprove.
|
||||
// approve / reject / suggest_changes — same gate as the server's canApprove.
|
||||
if (detail.viewer_can_approve) return null;
|
||||
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
|
||||
return "approvals.disabled.not_authorized";
|
||||
|
||||
@@ -99,6 +99,21 @@ export interface PredecessorMissingPayload {
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
// t-paliad-237 — server tells us the anchored rule belongs to the
|
||||
// parent infringement project, not this CCR. Frontend renders the
|
||||
// message with a clickable link to the parent project.
|
||||
export interface CrossProceedingAnchorPayload {
|
||||
error: "cross_proceeding_anchor";
|
||||
requested_rule_code: string;
|
||||
requested_rule_name_de: string;
|
||||
requested_rule_name_en: string;
|
||||
parent_project_id: string;
|
||||
parent_project_title: string;
|
||||
parent_project_url: string;
|
||||
message_de: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||||
today?: string;
|
||||
@@ -822,7 +837,13 @@ function buildAnchorEditor(
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||||
const payload = (await resp.json()) as
|
||||
| PredecessorMissingPayload
|
||||
| CrossProceedingAnchorPayload;
|
||||
if (payload.error === "cross_proceeding_anchor") {
|
||||
renderCrossProceedingError(msg, payload, opts);
|
||||
return;
|
||||
}
|
||||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||||
return;
|
||||
}
|
||||
@@ -886,6 +907,34 @@ function renderPredecessorError(
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
// t-paliad-237 — rule belongs to the parent inf project, not this CCR.
|
||||
// Render the bilingual message + a link to the parent project so the
|
||||
// user can navigate over and anchor the rule there. We deliberately do
|
||||
// NOT auto-route the write across projects (out of scope per brief).
|
||||
function renderCrossProceedingError(
|
||||
msg: HTMLElement,
|
||||
payload: CrossProceedingAnchorPayload,
|
||||
opts: RenderOptions,
|
||||
): void {
|
||||
msg.innerHTML = "";
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--cross-proceeding");
|
||||
|
||||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||||
const main = document.createElement("p");
|
||||
main.textContent = lang === "en" ? payload.message_en : payload.message_de;
|
||||
msg.appendChild(main);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "smart-timeline-anchor-parent-link";
|
||||
link.href = payload.parent_project_url;
|
||||
link.textContent =
|
||||
lang === "en"
|
||||
? `Open „${payload.parent_project_title}“`
|
||||
: `„${payload.parent_project_title}“ öffnen`;
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||||
for (const r of Array.from(rows)) {
|
||||
|
||||
206
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
206
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
|
||||
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
|
||||
// date` on the date span. Pages then attach a delegated click handler that
|
||||
// resolves that selector to swap in an inline `<input type="date">`. If a
|
||||
// future refactor drops the attrs, /tools/verfahrensablauf and
|
||||
// /tools/fristenrechner both silently lose click-to-edit (no script error,
|
||||
// nothing happens on click). These tests pin the contract.
|
||||
//
|
||||
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
|
||||
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
|
||||
// in plain Node without jsdom).
|
||||
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "upc-rop-12",
|
||||
name: "Klageerwiderung",
|
||||
nameEN: "Statement of Defence",
|
||||
party: "defendant",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-15",
|
||||
originalDate: "2026-07-15",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
|
||||
expect(html).toContain('class="timeline-date frist-date-edit"');
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
expect(html).toContain('data-current-date="2026-07-15"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
expect(html).not.toContain('role="button"');
|
||||
});
|
||||
|
||||
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
|
||||
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
|
||||
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
|
||||
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
|
||||
expect(html).toContain("timeline-court-set frist-date-edit");
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
});
|
||||
|
||||
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
|
||||
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
// left") instead of the misleading Proaktiv/Reaktiv pair.
|
||||
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
|
||||
// in pure-Node territory (renderColumnsBody goes through escHtml ->
|
||||
// document.createElement which isn't available in plain bun test).
|
||||
//
|
||||
// Scenario fixture mirrors the UPC Appeal "both parties" case m
|
||||
// pasted into #81: every filing rule carries party='both' so the
|
||||
// legacy mirror path duplicates every row across both columns.
|
||||
// With ?appellant= set, the duplicate must collapse to a single
|
||||
// row in the appellant's column.
|
||||
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
|
||||
const both = (name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
...both(name, due),
|
||||
party,
|
||||
});
|
||||
|
||||
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].court).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
|
||||
{ appellant: "claimant" },
|
||||
);
|
||||
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
|
||||
["Notice of Appeal"],
|
||||
["Statement of Grounds"],
|
||||
]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
|
||||
// User is on the defendant side: defendant filings land in 'ours'
|
||||
// (left), claimant filings land in 'opponent' (right). Court rules
|
||||
// stay in court regardless of side.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
partySpecific("court", "Urteil", "2026-10-01"),
|
||||
],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
|
||||
// The user is the defendant AND the appellant, so the appellant's
|
||||
// column == the user's own column == ours after the swap.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
|
||||
// Side flip + appellant axis combined: the claimant is the appellant
|
||||
// but NOT us, so the collapsed 'both' row lands in the opponent
|
||||
// column (right). This is the UPC Appeal "they appealed, we
|
||||
// respond" scenario.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "claimant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "A", sameDate),
|
||||
partySpecific("defendant", "B", sameDate),
|
||||
partySpecific("court", "C", sameDate),
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("court", "Oral Hearing", ""),
|
||||
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
|
||||
partySpecific("court", "Decision", ""),
|
||||
]);
|
||||
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
|
||||
["Statement of Claim"],
|
||||
["Oral Hearing"],
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -95,8 +95,31 @@ export function priorityRendering(
|
||||
export interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
// proceedingNameEN: English label of the picked proceeding. Empty
|
||||
// when not populated server-side; frontend falls back to
|
||||
// proceedingName. Used for the "Trigger event" fallback when the
|
||||
// timeline has no root rule. (m/paliad#58)
|
||||
proceedingNameEN?: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
// contextualNote / contextualNoteEN render as a banner above the
|
||||
// timeline. Populated when the picked proceeding is a sub-track of
|
||||
// another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
|
||||
// with_ccr) — the server routes to the parent's rules but keeps the
|
||||
// picked proceeding's identity in the response, and the note
|
||||
// explains the framing. (m/paliad#58)
|
||||
contextualNote?: string;
|
||||
contextualNoteEN?: string;
|
||||
// triggerEventLabel / triggerEventLabelEN: optional caption for the
|
||||
// "Auslösendes Ereignis" / "Triggering event" field on
|
||||
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
|
||||
// when set (mig 121). The page prefers this over the proceedingName
|
||||
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
|
||||
// uses this so the field reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -219,6 +242,13 @@ export interface CardOpts {
|
||||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||||
// there's no anchor-override state on that page in Slice 1.
|
||||
editable?: boolean;
|
||||
// showNotes controls how the per-rule descriptive notes render:
|
||||
// true → expanded `<div class="timeline-notes">…</div>` below the card
|
||||
// false → compact ⓘ icon next to the meta line, full text on hover
|
||||
// (browser-native `title` attribute) and screen-reader-readable
|
||||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||||
// re-renders. Default false — notes are noisy on long timelines.
|
||||
showNotes?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
@@ -264,14 +294,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
}
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
const noteHint = noteText && !showNotes
|
||||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
const meta = (opts.showParty || ruleRef || noteHint)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
${noteHint}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
@@ -284,7 +319,88 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
${notesBlock}`;
|
||||
}
|
||||
|
||||
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
|
||||
//
|
||||
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
|
||||
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
|
||||
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
|
||||
// result container once, and the delegated click/keydown handlers swap a
|
||||
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
|
||||
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
|
||||
// newValue means "revert" (clear the anchor override and let the calculator
|
||||
// re-project). The actual recompute is the caller's job — they own the
|
||||
// anchor-overrides map + the calc dispatch.
|
||||
|
||||
export function openInlineDateEditor(
|
||||
span: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
const ruleCode = span.dataset.ruleCode || "";
|
||||
if (!ruleCode) return;
|
||||
const current = span.dataset.currentDate || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
let done = false;
|
||||
const cancel = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
const commit = (newValue: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
onCommit(ruleCode, newValue);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
// wireDateEditClicks attaches delegated click + keyboard handlers to the
|
||||
// timeline result container so click-to-edit survives every innerHTML
|
||||
// rewrite the page does on recalc. Idempotent — re-calling on the same
|
||||
// container does nothing (the dataset flag short-circuits).
|
||||
export function wireDateEditClicks(
|
||||
container: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
if (container.dataset.dateEditWired === "1") return;
|
||||
container.dataset.dateEditWired = "1";
|
||||
container.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
container.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
@@ -306,42 +422,124 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
|
||||
//
|
||||
// The columns are user-perspective ("WE are always on the left", per
|
||||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||||
// placement re-resolves around that pick.
|
||||
//
|
||||
// Column assignment per deadline (default opts.side === null keeps
|
||||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||||
//
|
||||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||||
// - party=court → court (independent of side)
|
||||
// - party=both → BOTH ours AND opponent (mirror)
|
||||
//
|
||||
// When `opts.appellant` is set (claimant|defendant), "both" rows
|
||||
// collapse to a single row in the appellant's column — the intent is
|
||||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
|
||||
// really means "either party files, depending on who initiated".
|
||||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||||
// appellant selector pins which party appealed; the side toggle
|
||||
// still picks which of those is us.
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
// Internal column-position alias. "ours" is always rendered in the
|
||||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||||
// bucketing primitive reads as a direct mapping.
|
||||
type ColumnPosition = "ours" | "opponent";
|
||||
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
side?: Side;
|
||||
// appellant: which side initiated the appeal / counterclaim.
|
||||
// When set, party=both rows go to the appellant's column ONLY
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
// so unit tests can hit the pure routing logic without going through
|
||||
// document.createElement (no jsdom in this repo).
|
||||
export interface ColumnsRow {
|
||||
key: string;
|
||||
ours: CalculatedDeadline[];
|
||||
court: CalculatedDeadline[];
|
||||
opponent: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
// renderColumnsBody uses. Extracted as its own export so the per-row
|
||||
// column placement (including the side-swap + appellant-collapse
|
||||
// logic from m/paliad#81 and the user-perspective re-frame from
|
||||
// m/paliad#88) is unit-testable without a DOM. The returned rows are
|
||||
// sorted: dated rows ascending by dueDate, then unscheduled rows in
|
||||
// declaration order (each keyed by sequence).
|
||||
export function bucketDeadlinesIntoColumns(
|
||||
deadlines: CalculatedDeadline[],
|
||||
opts: BucketingOpts = {},
|
||||
): ColumnsRow[] {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
// Default (side=null) treats the user as claimant — keeps the
|
||||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||||
const appellantColumn: ColumnPosition | null =
|
||||
opts.appellant === "claimant" ? claimantColumn
|
||||
: opts.appellant === "defendant" ? defendantColumn
|
||||
: null;
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
const rowsMap = new Map<string, ColumnsRow>();
|
||||
const ensureRow = (key: string): ColumnsRow => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
r = { key, ours: [], court: [], opponent: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
row[claimantColumn].push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
row[defendantColumn].push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
@@ -356,9 +554,20 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
|
||||
}
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
@@ -366,7 +575,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
@@ -381,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
html += renderCell(row.opponent);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
|
||||
@@ -22,6 +22,7 @@ export function ProjectFormFields(): string {
|
||||
<option value="patent" data-i18n="projects.type.patent">Patent</option>
|
||||
<option value="case" data-i18n="projects.type.case">Verfahren</option>
|
||||
<option value="project" data-i18n="projects.type.project">Projekt (generisch)</option>
|
||||
<option value="other" data-i18n="projects.type.other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +140,24 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Litigation-specific */}
|
||||
<div className="projekt-fields projekt-fields-litigation" id="fields-litigation" style="display:none">
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-opponent-code" data-i18n="projects.field.opponent_code">Gegner-Kürzel</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-opponent-code"
|
||||
maxLength={16}
|
||||
pattern="[A-Z0-9-]{1,16}"
|
||||
placeholder="OPNT"
|
||||
data-i18n-placeholder="projects.field.opponent_code.placeholder"
|
||||
/>
|
||||
<p className="form-hint" data-i18n="projects.field.opponent_code.hint">
|
||||
Kurzes Kürzel der Gegenseite (Grossbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case-specific */}
|
||||
<div className="projekt-fields projekt-fields-case" id="fields-case" style="display:none">
|
||||
<div className="form-field-row">
|
||||
@@ -151,20 +170,39 @@ export function ProjectFormFields(): string {
|
||||
<input type="text" id="project-case-number" placeholder="UPC_CFI_123/2026" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-proceeding-type-id" data-i18n="projects.field.proceeding_type">Verfahrenstyp</label>
|
||||
<select id="project-proceeding-type-id">
|
||||
<option value="" data-i18n="projects.field.proceeding_type.unset">(nicht gesetzt)</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.proceeding_type.hint">
|
||||
Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.client_role.unset">Unbekannt</option>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.active" label="Aktiv (wir greifen an)">
|
||||
<option value="claimant" data-i18n="projects.field.client_role.claimant">Klägerseite</option>
|
||||
<option value="applicant" data-i18n="projects.field.client_role.applicant">Antragsteller</option>
|
||||
<option value="appellant" data-i18n="projects.field.client_role.appellant">Berufungsführer</option>
|
||||
</optgroup>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.reactive" label="Reaktiv (wir verteidigen)">
|
||||
<option value="defendant" data-i18n="projects.field.client_role.defendant">Beklagtenseite</option>
|
||||
<option value="respondent" data-i18n="projects.field.client_role.respondent">Antragsgegner</option>
|
||||
</optgroup>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.other" label="Dritte / Sonstige">
|
||||
<option value="third_party" data-i18n="projects.field.client_role.third_party">Streithelfer / Dritter</option>
|
||||
<option value="other" data-i18n="projects.field.client_role.other">Sonstige Beteiligte</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.client_role.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
|
||||
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
|
||||
// affordance reads as "a draft document" at a glance.
|
||||
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
|
||||
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
|
||||
@@ -5,12 +5,14 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
|
||||
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
|
||||
// once in the output.
|
||||
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
|
||||
// request time by the Go handler (internal/handlers/dashboard_shell.go)
|
||||
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
|
||||
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
|
||||
// Keep each token intact and exactly once in the output. The latter two
|
||||
// power the per-user configurable layout (t-paliad-219).
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
|
||||
|
||||
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
|
||||
// it 90deg clockwise when the section is open via the
|
||||
@@ -23,12 +25,13 @@ const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
// renders all sections expanded so unstyled fallback is sensible.
|
||||
function CollapsibleSection(props: {
|
||||
id: string;
|
||||
widgetKey: string;
|
||||
headingI18n: string;
|
||||
headingDe: string;
|
||||
children: any;
|
||||
}): string {
|
||||
return (
|
||||
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
|
||||
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
|
||||
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
|
||||
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
|
||||
<span className="dashboard-section-chevron" aria-hidden="true"
|
||||
@@ -73,6 +76,17 @@ export function renderDashboard(): string {
|
||||
<span className="dashboard-date" id="dashboard-date"></span>
|
||||
</p>
|
||||
</div>
|
||||
{/* "Anpassen" toggle (t-paliad-219 Slice B). Off by
|
||||
default — when on, body.dashboard-editing reveals
|
||||
drag handles / ↑↓ / x / ⚙ chrome on each widget plus
|
||||
the edit-footer below the widget stack. */}
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-toggle"
|
||||
className="btn btn-ghost dashboard-edit-toggle"
|
||||
aria-pressed="false"
|
||||
data-i18n="dashboard.edit.toggle"
|
||||
>Anpassen</button>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-unavailable" className="dashboard-unavailable" style="display:none">
|
||||
@@ -87,105 +101,184 @@ export function renderDashboard(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">Überfällig</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">Nächste Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Matter summary card — single tappable card, kept outside the
|
||||
collapsible scaffold because its h3 is internal to the card
|
||||
and doubles as the navigation affordance. */}
|
||||
<section className="dashboard-matters">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
<h3 data-i18n="dashboard.matters.heading">Meine Akten</h3>
|
||||
<span className="dashboard-matter-arrow" aria-hidden="true">→</span>
|
||||
{/* Configurable widget grid (t-paliad-227 overhaul). All
|
||||
widgets live as direct children of the single
|
||||
.dashboard-grid container so applyLayout can place them
|
||||
via grid-column/grid-row inline styles. Pre-overhaul
|
||||
this stack had nested wrappers (.dashboard-columns,
|
||||
standalone <section>s) that fought the layout engine
|
||||
and made cross-row drags appear to fail. */}
|
||||
<div className="dashboard-grid" id="dashboard-grid">
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/events?type=deadline&status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">Überfällig</div>
|
||||
</a>
|
||||
<a href="/events?type=deadline&status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
|
||||
</a>
|
||||
<a href="/events?type=deadline&status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
|
||||
</a>
|
||||
<a href="/events?type=deadline&status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">Nächste Woche</div>
|
||||
</a>
|
||||
<a href="/events?type=deadline&status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="dashboard-matter-stats">
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Two-column lists — each column is its own collapsible section
|
||||
so users can hide deadlines or appointments independently.
|
||||
The .dashboard-columns wrapper is preserved so the grid
|
||||
layout still applies; collapse hides the body of each col
|
||||
but leaves the heading row in the grid. */}
|
||||
<div className="dashboard-columns">
|
||||
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
{/* Matter summary — uses CollapsibleSection now so it
|
||||
participates in the grid like every other widget. The
|
||||
inner card heading was redundant with the section
|
||||
heading; we keep the stats grid + the projects link. */}
|
||||
<CollapsibleSection id="matters" widgetKey="matter-summary" headingI18n="dashboard.matters.heading" headingDe="Meine Akten">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-stats">
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<div className="dashboard-calendar" id="dashboard-deadlines-calendar" style="display:none"></div>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<div className="dashboard-calendar" id="dashboard-appointments-calendar" style="display:none"></div>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Inline Agenda (t-paliad-162). Same item shape as the
|
||||
standalone /agenda page, rendered via the shared
|
||||
agenda-render module. The dashboard variant is read-only:
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
Keine Fälligkeiten in den nächsten 30 Tagen.
|
||||
{/* Inline Agenda (t-paliad-162). Same item shape as the
|
||||
standalone /agenda page, rendered via the shared
|
||||
agenda-render module. The dashboard variant is read-only:
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<ul className="dashboard-list" id="dashboard-agenda-list" style="display:none"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
Keine Fälligkeiten in den nächsten 30 Tagen.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollständige Agenda öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
|
||||
list mirrors /inbox's "Approver" axis but capped at the
|
||||
widget's count setting. Renders the empty state when
|
||||
the user has no open approvals to review. */}
|
||||
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
|
||||
<div className="dashboard-inbox">
|
||||
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
|
||||
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
|
||||
Keine offenen Freigaben.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollständigen Posteingang öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Pinned-projects widget (t-paliad-219 Slice C). Reads
|
||||
PinService via DashboardData.pinned_projects (server-
|
||||
joined to titles + refs). Default-hidden — users opt
|
||||
in via the picker. */}
|
||||
<CollapsibleSection id="pinned-projects" widgetKey="pinned-projects" headingI18n="dashboard.pinned.heading" headingDe="Angepinnte Akten">
|
||||
<ul className="dashboard-list" id="dashboard-pinned-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-pinned-empty" style="display:none" data-i18n="dashboard.pinned.empty">
|
||||
Noch keine Akten angepinnt.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollständige Agenda öffnen →</a>
|
||||
<a href="/projects" data-i18n="dashboard.pinned.full_link">Alle Akten öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
{/* Quick-actions widget (t-paliad-219 Slice C). Pure UI;
|
||||
no backend data path. Default-hidden — surfaced via the
|
||||
picker. */}
|
||||
<CollapsibleSection id="quick-actions" widgetKey="quick-actions" headingI18n="dashboard.quick.heading" headingDe="Schnellzugriff">
|
||||
<div className="dashboard-quick-actions">
|
||||
<a href="/projects/new" className="btn btn-primary dashboard-quick-btn" data-i18n="dashboard.quick.new_project">+ Akte</a>
|
||||
<a href="/deadlines/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_deadline">+ Frist</a>
|
||||
<a href="/appointments/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_appointment">+ Termin</a>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Edit-mode footer (t-paliad-219 Slice B). Hidden via CSS
|
||||
unless body.dashboard-editing — see dashboard.ts.
|
||||
Slice C added the admin "Promote to firm default"
|
||||
button — it stays hidden unless data.user.global_role
|
||||
is 'global_admin'; dashboard.ts toggles it. */}
|
||||
<div id="dashboard-edit-footer" className="dashboard-edit-footer">
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-add"
|
||||
className="btn btn-secondary dashboard-edit-add"
|
||||
data-i18n="dashboard.edit.add_widget"
|
||||
>Widget hinzufügen</button>
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-promote"
|
||||
className="btn btn-ghost dashboard-edit-promote"
|
||||
style="display:none"
|
||||
data-i18n="dashboard.edit.promote"
|
||||
>Als Firmen-Standard speichern</button>
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-reset"
|
||||
className="dashboard-edit-reset-link"
|
||||
data-i18n="dashboard.edit.reset"
|
||||
>Auf Standard zurücksetzen</button>
|
||||
</div>
|
||||
|
||||
{/* Save toast slot — managed by dashboard.ts. */}
|
||||
<div
|
||||
id="dashboard-save-toast"
|
||||
className="dashboard-save-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderDeadlinesCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="deadlines.kalender.title">Fristenkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/events?type=deadline" />
|
||||
<BottomNav currentPath="/events?type=deadline" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 data-i18n="deadlines.kalender.heading">Fristenkalender</h1>
|
||||
<p className="tool-subtitle" data-i18n="deadlines.kalender.subtitle">
|
||||
Monatsübersicht aller Fristen Ihrer Akten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="fristen-header-actions">
|
||||
<a href="/events?type=deadline" className="btn-secondary" data-i18n="deadlines.kalender.list">Listenansicht</a>
|
||||
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">Neue Frist</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</button>
|
||||
<h2 id="cal-month-label" className="frist-cal-month-label" />
|
||||
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="Nächster Monat">→</button>
|
||||
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar" id="deadline-calendar">
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
|
||||
<div id="deadline-cal-grid" className="frist-cal-grid" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="deadline-cal-empty" style="display:none" data-i18n="deadlines.kalender.empty">
|
||||
Keine Fristen im ausgewählten Zeitraum.
|
||||
</p>
|
||||
|
||||
<div className="modal-overlay" id="cal-popup" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="cal-popup-date" />
|
||||
<button className="modal-close" id="cal-popup-close" type="button">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="cal-popup-list" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-title-col">
|
||||
<h1 id="deadline-title-display" />
|
||||
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
|
||||
{/* t-paliad-251 Part 4 — Standardtitel button only
|
||||
visible in edit mode; clicking replaces the
|
||||
title with a default derived from the project
|
||||
and the deadline's event types / rule. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
@@ -82,15 +95,21 @@ export function renderDeadlinesDetail(): string {
|
||||
<input type="date" id="deadline-due-edit" style="display:none" />
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
{/* m/paliad#56 — Verfahrenshandlung block.
|
||||
Event type (parent concept) renders first; rule
|
||||
sits beneath as the citation under that event
|
||||
type. Editor splits them back into separate
|
||||
pickers but the read-only stack reads as one
|
||||
compound "Typ — Regel" surface. */}
|
||||
<dt data-i18n="deadlines.field.event_type">Typ (optional)</dt>
|
||||
<dd>
|
||||
<span id="deadline-event-types-display">—</span>
|
||||
<div id="deadline-event-types-edit" className="event-type-picker-host" style="display:none" />
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
|
||||
@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
|
||||
currently-known context (event type → rule → proceeding
|
||||
type → fallback) with the project reference as suffix.
|
||||
Always replaces the title; no destructive confirmation
|
||||
because the user invoked it explicitly. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-title"
|
||||
@@ -101,18 +116,53 @@ export function renderDeadlinesNew(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">Fälligkeitsdatum</label>
|
||||
<input type="date" id="deadline-due" required />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
{/* m/paliad#56 — Regel sits directly beneath the Typ
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
<div className="form-field">
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
{/* t-paliad-251 Part 2 — sort options for the Rule
|
||||
select. Defaults to "by_court" so users in the
|
||||
UPC bucket find UPC rules quickly. */}
|
||||
<select id="deadline-rule-sort" className="rule-sort-select" aria-label="Sortierung">
|
||||
<option value="by_proceeding" data-i18n="deadlines.field.rule.sort.by_proceeding">Nach Verfahrensablauf</option>
|
||||
<option value="by_court" data-i18n="deadlines.field.rule.sort.by_court" selected>Nach Gerichtsart</option>
|
||||
<option value="alpha" data-i18n="deadlines.field.rule.sort.alpha">Alphabetisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
{/* t-paliad-251 Part 3 — explicit Auto badge surfaces
|
||||
whenever the Rule was auto-derived from the Typ.
|
||||
Hidden when the user has manually picked a rule. */}
|
||||
<p
|
||||
className="form-hint form-hint--auto"
|
||||
id="deadline-rule-auto-hint"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="form-hint-badge"
|
||||
data-i18n="deadlines.field.rule.auto_badge"
|
||||
>Auto</span>
|
||||
<span id="deadline-rule-auto-hint-text" />
|
||||
</p>
|
||||
{/* t-paliad-251 Part 3 — clearer override warning that
|
||||
names BOTH the type-derived rule and the actually-
|
||||
applied rule. Replaces the older Regel→Typ-only
|
||||
mismatch warning when the contradiction goes the
|
||||
other direction. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-rule-override-warn"
|
||||
style="display:none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">Fälligkeitsdatum</label>
|
||||
<input type="date" id="deadline-due" required />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -236,37 +236,10 @@ export function renderEvents(): string {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</button>
|
||||
<h2 id="events-cal-month-label" className="frist-cal-month-label" />
|
||||
<button type="button" id="events-cal-next" className="btn-secondary btn-small" aria-label="Nächster Monat">→</button>
|
||||
<button type="button" id="events-cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
|
||||
</div>
|
||||
<div className="frist-calendar">
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
|
||||
<div id="events-cal-grid" className="frist-cal-grid" />
|
||||
</div>
|
||||
<p className="entity-events-empty" id="events-cal-empty" hidden data-i18n="events.calendar.empty">
|
||||
Keine Einträge im ausgewählten Zeitraum.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-overlay" id="events-cal-popup" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="events-cal-popup-date" />
|
||||
<button className="modal-close" id="events-cal-popup-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Calendar host — mountCalendar() (t-paliad-224) builds the
|
||||
month/week/day grid + toolbar into this container when
|
||||
the Kalender view chip is active. Empty until then. */}
|
||||
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden />
|
||||
|
||||
<div className="entity-empty" id="events-empty" style="display:none">
|
||||
<h2 data-i18n="events.empty.title">Keine Einträge vorhanden</h2>
|
||||
|
||||
@@ -161,19 +161,19 @@ export function renderFristenrechner(): string {
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
Custom UPC proceeding
|
||||
UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
Custom DE proceeding
|
||||
DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
Custom EPA proceeding
|
||||
EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
Custom DPMA proceeding
|
||||
DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +485,10 @@ export function renderFristenrechner(): string {
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
@@ -546,6 +549,10 @@ export function renderFristenrechner(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
@@ -440,7 +440,23 @@ export type I18nKey =
|
||||
| "admin.section.planned"
|
||||
| "admin.subtitle"
|
||||
| "admin.team.add.direct"
|
||||
| "admin.team.add.full"
|
||||
| "admin.team.add.invite"
|
||||
| "admin.team.add_full.body"
|
||||
| "admin.team.add_full.cancel"
|
||||
| "admin.team.add_full.email"
|
||||
| "admin.team.add_full.error.email_exists"
|
||||
| "admin.team.add_full.error.generic"
|
||||
| "admin.team.add_full.error.unavailable"
|
||||
| "admin.team.add_full.feedback.added"
|
||||
| "admin.team.add_full.job_title"
|
||||
| "admin.team.add_full.lang"
|
||||
| "admin.team.add_full.name"
|
||||
| "admin.team.add_full.office"
|
||||
| "admin.team.add_full.profession"
|
||||
| "admin.team.add_full.send_welcome"
|
||||
| "admin.team.add_full.submit"
|
||||
| "admin.team.add_full.title"
|
||||
| "admin.team.col.actions"
|
||||
| "admin.team.col.additional"
|
||||
| "admin.team.col.created"
|
||||
@@ -486,6 +502,13 @@ export type I18nKey =
|
||||
| "agenda.appointment_type.deadline_hearing"
|
||||
| "agenda.appointment_type.hearing"
|
||||
| "agenda.appointment_type.meeting"
|
||||
| "agenda.day.di"
|
||||
| "agenda.day.do"
|
||||
| "agenda.day.fr"
|
||||
| "agenda.day.mi"
|
||||
| "agenda.day.mo"
|
||||
| "agenda.day.sa"
|
||||
| "agenda.day.so"
|
||||
| "agenda.day.today"
|
||||
| "agenda.day.tomorrow"
|
||||
| "agenda.empty.hint"
|
||||
@@ -555,12 +578,6 @@ export type I18nKey =
|
||||
| "appointments.filter.type"
|
||||
| "appointments.filter.type.all"
|
||||
| "appointments.form.approval_hint"
|
||||
| "appointments.kalender.empty"
|
||||
| "appointments.kalender.heading"
|
||||
| "appointments.kalender.list"
|
||||
| "appointments.kalender.subtitle"
|
||||
| "appointments.kalender.title"
|
||||
| "appointments.list.calendar"
|
||||
| "appointments.list.heading"
|
||||
| "appointments.list.new"
|
||||
| "appointments.list.subtitle"
|
||||
@@ -583,6 +600,7 @@ export type I18nKey =
|
||||
| "approvals.action.approve"
|
||||
| "approvals.action.reject"
|
||||
| "approvals.action.revoke"
|
||||
| "approvals.action.suggest_changes"
|
||||
| "approvals.agent.byline"
|
||||
| "approvals.agent.label"
|
||||
| "approvals.agent.suggestion_pending"
|
||||
@@ -595,6 +613,7 @@ export type I18nKey =
|
||||
| "approvals.disabled.not_authorized"
|
||||
| "approvals.disabled.revoke_not_requester"
|
||||
| "approvals.disabled.self_approval"
|
||||
| "approvals.disabled.suggest_lifecycle"
|
||||
| "approvals.empty.mine"
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
@@ -605,6 +624,8 @@ export type I18nKey =
|
||||
| "approvals.error.not_authorized"
|
||||
| "approvals.error.request_not_pending"
|
||||
| "approvals.error.self_approval"
|
||||
| "approvals.error.suggestion_lifecycle_invalid"
|
||||
| "approvals.error.suggestion_requires_change"
|
||||
| "approvals.heading"
|
||||
| "approvals.lifecycle.complete"
|
||||
| "approvals.lifecycle.create"
|
||||
@@ -631,17 +652,50 @@ export type I18nKey =
|
||||
| "approvals.required_role.pa"
|
||||
| "approvals.required_role.senior_pa"
|
||||
| "approvals.status.approved"
|
||||
| "approvals.status.changes_requested"
|
||||
| "approvals.status.pending"
|
||||
| "approvals.status.rejected"
|
||||
| "approvals.status.revoked"
|
||||
| "approvals.status.superseded"
|
||||
| "approvals.subtitle"
|
||||
| "approvals.suggest.cancel"
|
||||
| "approvals.suggest.context.approval_status"
|
||||
| "approvals.suggest.context.project"
|
||||
| "approvals.suggest.context.requested_at"
|
||||
| "approvals.suggest.context.requester"
|
||||
| "approvals.suggest.event_type_picker_unavailable"
|
||||
| "approvals.suggest.field.description"
|
||||
| "approvals.suggest.field.original_due_date"
|
||||
| "approvals.suggest.field.rule_code"
|
||||
| "approvals.suggest.field.warning_date"
|
||||
| "approvals.suggest.intro"
|
||||
| "approvals.suggest.modal_title"
|
||||
| "approvals.suggest.next_request_link"
|
||||
| "approvals.suggest.note_label"
|
||||
| "approvals.suggest.note_placeholder"
|
||||
| "approvals.suggest.section.context"
|
||||
| "approvals.suggest.section.editable"
|
||||
| "approvals.suggest.section.event_type_rule"
|
||||
| "approvals.suggest.submit"
|
||||
| "approvals.suggest.submit_disabled_hint"
|
||||
| "approvals.suggest.unsupported_lifecycle"
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.cancel"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.destructive.label"
|
||||
| "approvals.withdraw.error"
|
||||
| "approvals.withdraw.lead.create.appointment"
|
||||
| "approvals.withdraw.lead.create.deadline"
|
||||
| "approvals.withdraw.lead.delete"
|
||||
| "approvals.withdraw.lead.update"
|
||||
| "approvals.withdraw.modal.title"
|
||||
| "approvals.withdraw.primary.label"
|
||||
| "approvals.withdraw.sub.create"
|
||||
| "approvals.withdraw.sub.delete"
|
||||
| "approvals.withdraw.sub.update"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -679,11 +733,49 @@ export type I18nKey =
|
||||
| "cal.month.9"
|
||||
| "cal.month.next"
|
||||
| "cal.month.prev"
|
||||
| "cal.today"
|
||||
| "cal.view.day"
|
||||
| "cal.view.month"
|
||||
| "cal.view.week"
|
||||
| "cal.week.next"
|
||||
| "cal.week.prev"
|
||||
| "caldav.bindings.add"
|
||||
| "caldav.bindings.card.edit"
|
||||
| "caldav.bindings.card.enabled"
|
||||
| "caldav.bindings.card.remove"
|
||||
| "caldav.bindings.delete.confirm"
|
||||
| "caldav.bindings.delete.failed"
|
||||
| "caldav.bindings.empty"
|
||||
| "caldav.bindings.error.create_name_required"
|
||||
| "caldav.bindings.error.create_name_taken"
|
||||
| "caldav.bindings.error.create_unsupported"
|
||||
| "caldav.bindings.error.path"
|
||||
| "caldav.bindings.error.scope"
|
||||
| "caldav.bindings.error.scope_project"
|
||||
| "caldav.bindings.heading"
|
||||
| "caldav.bindings.hint"
|
||||
| "caldav.bindings.modal.add_title"
|
||||
| "caldav.bindings.modal.display_name"
|
||||
| "caldav.bindings.modal.display_name.placeholder"
|
||||
| "caldav.bindings.modal.edit_title"
|
||||
| "caldav.bindings.modal.scope"
|
||||
| "caldav.bindings.modal.scope.all_visible"
|
||||
| "caldav.bindings.modal.scope.personal_only"
|
||||
| "caldav.bindings.modal.scope.project"
|
||||
| "caldav.bindings.modal.scope.project.loading"
|
||||
| "caldav.bindings.modal.source"
|
||||
| "caldav.bindings.modal.source.create"
|
||||
| "caldav.bindings.modal.source.custom"
|
||||
| "caldav.bindings.modal.source.degrade"
|
||||
| "caldav.bindings.modal.source.discover_empty"
|
||||
| "caldav.bindings.modal.source.discover_failed"
|
||||
| "caldav.bindings.modal.source.existing"
|
||||
| "caldav.bindings.modal.source.loading"
|
||||
| "caldav.bindings.modal.submit_add"
|
||||
| "caldav.bindings.modal.submit_edit"
|
||||
| "caldav.bindings.scope.all_visible"
|
||||
| "caldav.bindings.scope.personal_only"
|
||||
| "caldav.bindings.scope.project"
|
||||
| "caldav.delete"
|
||||
| "caldav.delete.confirm"
|
||||
| "caldav.delete.done"
|
||||
@@ -726,7 +818,54 @@ export type I18nKey =
|
||||
| "changelog.tag.feature"
|
||||
| "changelog.tag.fix"
|
||||
| "changelog.title"
|
||||
| "checklisten.author.cancel"
|
||||
| "checklisten.author.error.generic"
|
||||
| "checklisten.author.error.no_groups"
|
||||
| "checklisten.author.error.notfound"
|
||||
| "checklisten.author.error.title"
|
||||
| "checklisten.author.field.court"
|
||||
| "checklisten.author.field.deadline"
|
||||
| "checklisten.author.field.description"
|
||||
| "checklisten.author.field.lang"
|
||||
| "checklisten.author.field.reference"
|
||||
| "checklisten.author.field.regime"
|
||||
| "checklisten.author.field.title"
|
||||
| "checklisten.author.field.title.hint"
|
||||
| "checklisten.author.field.visibility"
|
||||
| "checklisten.author.group.remove"
|
||||
| "checklisten.author.group.title"
|
||||
| "checklisten.author.groups.add"
|
||||
| "checklisten.author.groups.heading"
|
||||
| "checklisten.author.heading.edit"
|
||||
| "checklisten.author.heading.new"
|
||||
| "checklisten.author.item.add"
|
||||
| "checklisten.author.item.label"
|
||||
| "checklisten.author.item.note"
|
||||
| "checklisten.author.item.remove"
|
||||
| "checklisten.author.item.rule"
|
||||
| "checklisten.author.save"
|
||||
| "checklisten.author.saving"
|
||||
| "checklisten.author.subtitle"
|
||||
| "checklisten.author.title"
|
||||
| "checklisten.author.title.edit"
|
||||
| "checklisten.author.visibility.firm.hint"
|
||||
| "checklisten.author.visibility.private.hint"
|
||||
| "checklisten.back"
|
||||
| "checklisten.detail.authored.by"
|
||||
| "checklisten.detail.delete"
|
||||
| "checklisten.detail.delete.confirm"
|
||||
| "checklisten.detail.delete.error"
|
||||
| "checklisten.detail.demote"
|
||||
| "checklisten.detail.demote.confirm"
|
||||
| "checklisten.detail.edit"
|
||||
| "checklisten.detail.promote"
|
||||
| "checklisten.detail.promote.confirm"
|
||||
| "checklisten.detail.promote.error"
|
||||
| "checklisten.detail.share"
|
||||
| "checklisten.detail.visibility"
|
||||
| "checklisten.detail.visibility.error"
|
||||
| "checklisten.detail.visibility.set.firm"
|
||||
| "checklisten.detail.visibility.set.private"
|
||||
| "checklisten.disclaimer"
|
||||
| "checklisten.empty"
|
||||
| "checklisten.feedback.btn"
|
||||
@@ -744,11 +883,23 @@ export type I18nKey =
|
||||
| "checklisten.feedback.type"
|
||||
| "checklisten.filter.all"
|
||||
| "checklisten.filter.de"
|
||||
| "checklisten.filter.other"
|
||||
| "checklisten.gallery.empty"
|
||||
| "checklisten.heading"
|
||||
| "checklisten.instance.akte.open"
|
||||
| "checklisten.instance.back"
|
||||
| "checklisten.instance.diff.added"
|
||||
| "checklisten.instance.diff.changed"
|
||||
| "checklisten.instance.diff.close"
|
||||
| "checklisten.instance.diff.empty"
|
||||
| "checklisten.instance.diff.error"
|
||||
| "checklisten.instance.diff.removed"
|
||||
| "checklisten.instance.diff.title"
|
||||
| "checklisten.instance.loading"
|
||||
| "checklisten.instance.notfound"
|
||||
| "checklisten.instance.outdated.badge"
|
||||
| "checklisten.instance.outdated.diff"
|
||||
| "checklisten.instance.outdated.note"
|
||||
| "checklisten.instance.rename"
|
||||
| "checklisten.instance.rename.error"
|
||||
| "checklisten.instance.rename.save"
|
||||
@@ -771,6 +922,18 @@ export type I18nKey =
|
||||
| "checklisten.instances.heading"
|
||||
| "checklisten.instances.loading"
|
||||
| "checklisten.instances.sub"
|
||||
| "checklisten.mine.delete"
|
||||
| "checklisten.mine.delete.confirm"
|
||||
| "checklisten.mine.delete.error"
|
||||
| "checklisten.mine.edit"
|
||||
| "checklisten.mine.empty"
|
||||
| "checklisten.mine.loading"
|
||||
| "checklisten.mine.new"
|
||||
| "checklisten.mine.origin.authored"
|
||||
| "checklisten.mine.visibility.firm"
|
||||
| "checklisten.mine.visibility.global"
|
||||
| "checklisten.mine.visibility.private"
|
||||
| "checklisten.mine.visibility.shared"
|
||||
| "checklisten.newInstance"
|
||||
| "checklisten.newInstance.akte"
|
||||
| "checklisten.newInstance.akte.hint"
|
||||
@@ -787,8 +950,31 @@ export type I18nKey =
|
||||
| "checklisten.reset"
|
||||
| "checklisten.reset.confirm"
|
||||
| "checklisten.reset.error"
|
||||
| "checklisten.share.cancel"
|
||||
| "checklisten.share.error.generic"
|
||||
| "checklisten.share.error.pick"
|
||||
| "checklisten.share.grants.empty"
|
||||
| "checklisten.share.grants.heading"
|
||||
| "checklisten.share.grants.recipient.office"
|
||||
| "checklisten.share.grants.recipient.partner_unit"
|
||||
| "checklisten.share.grants.recipient.project"
|
||||
| "checklisten.share.grants.recipient.user"
|
||||
| "checklisten.share.grants.revoke"
|
||||
| "checklisten.share.grants.revoke.confirm"
|
||||
| "checklisten.share.grants.revoke.error"
|
||||
| "checklisten.share.kind"
|
||||
| "checklisten.share.kind.office"
|
||||
| "checklisten.share.kind.partner_unit"
|
||||
| "checklisten.share.kind.project"
|
||||
| "checklisten.share.kind.user"
|
||||
| "checklisten.share.pick"
|
||||
| "checklisten.share.submit"
|
||||
| "checklisten.share.success"
|
||||
| "checklisten.share.title"
|
||||
| "checklisten.subtitle"
|
||||
| "checklisten.tab.gallery"
|
||||
| "checklisten.tab.instances"
|
||||
| "checklisten.tab.mine"
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "common.cancel"
|
||||
@@ -864,12 +1050,54 @@ export type I18nKey =
|
||||
| "dashboard.appointments.heading"
|
||||
| "dashboard.deadlines.empty"
|
||||
| "dashboard.deadlines.heading"
|
||||
| "dashboard.edit.add_widget"
|
||||
| "dashboard.edit.drag"
|
||||
| "dashboard.edit.exit"
|
||||
| "dashboard.edit.hide"
|
||||
| "dashboard.edit.move_down"
|
||||
| "dashboard.edit.move_up"
|
||||
| "dashboard.edit.promote"
|
||||
| "dashboard.edit.promote_confirm"
|
||||
| "dashboard.edit.promoted"
|
||||
| "dashboard.edit.reset"
|
||||
| "dashboard.edit.reset_confirm"
|
||||
| "dashboard.edit.resize"
|
||||
| "dashboard.edit.save_failed"
|
||||
| "dashboard.edit.saved"
|
||||
| "dashboard.edit.setting.count"
|
||||
| "dashboard.edit.setting.count.custom"
|
||||
| "dashboard.edit.setting.horizon"
|
||||
| "dashboard.edit.setting.horizon.custom"
|
||||
| "dashboard.edit.setting.horizon.days"
|
||||
| "dashboard.edit.setting.position"
|
||||
| "dashboard.edit.setting.size"
|
||||
| "dashboard.edit.setting.view"
|
||||
| "dashboard.edit.settings"
|
||||
| "dashboard.edit.toggle"
|
||||
| "dashboard.greeting.prefix"
|
||||
| "dashboard.inbox.empty"
|
||||
| "dashboard.inbox.entity.appointment"
|
||||
| "dashboard.inbox.entity.deadline"
|
||||
| "dashboard.inbox.full_link"
|
||||
| "dashboard.inbox.heading"
|
||||
| "dashboard.matters.active"
|
||||
| "dashboard.matters.archived"
|
||||
| "dashboard.matters.heading"
|
||||
| "dashboard.matters.total"
|
||||
| "dashboard.onboarding"
|
||||
| "dashboard.picker.close"
|
||||
| "dashboard.picker.empty"
|
||||
| "dashboard.picker.status.absent"
|
||||
| "dashboard.picker.status.active"
|
||||
| "dashboard.picker.status.hidden"
|
||||
| "dashboard.picker.title"
|
||||
| "dashboard.pinned.empty"
|
||||
| "dashboard.pinned.full_link"
|
||||
| "dashboard.pinned.heading"
|
||||
| "dashboard.quick.heading"
|
||||
| "dashboard.quick.new_appointment"
|
||||
| "dashboard.quick.new_deadline"
|
||||
| "dashboard.quick.new_project"
|
||||
| "dashboard.section.collapse"
|
||||
| "dashboard.section.expand"
|
||||
| "dashboard.summary.completed"
|
||||
@@ -895,6 +1123,10 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -904,7 +1136,9 @@ export type I18nKey =
|
||||
| "deadlines.card.calc.flag.with_cci"
|
||||
| "deadlines.card.calc.flag.with_ccr"
|
||||
| "deadlines.card.calc.flags.label"
|
||||
| "deadlines.card.calc.pill_picker.change"
|
||||
| "deadlines.card.calc.pill_picker.label"
|
||||
| "deadlines.card.calc.pill_picker.locked_label"
|
||||
| "deadlines.card.calc.result.calculating"
|
||||
| "deadlines.card.calc.result.court_set"
|
||||
| "deadlines.card.calc.result.due"
|
||||
@@ -919,8 +1153,8 @@ export type I18nKey =
|
||||
| "deadlines.col.court"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1008,12 +1242,20 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.override_warn"
|
||||
| "deadlines.field.rule.sort.alpha"
|
||||
| "deadlines.field.rule.sort.by_court"
|
||||
| "deadlines.field.rule.sort.by_proceeding"
|
||||
| "deadlines.field.rule.sort.other_proceeding"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.default_btn"
|
||||
| "deadlines.field.title.default_fallback"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
| "deadlines.filter.akte.all"
|
||||
@@ -1050,13 +1292,6 @@ export type I18nKey =
|
||||
| "deadlines.inbox.label"
|
||||
| "deadlines.inbox.posteingang"
|
||||
| "deadlines.inbox.posteingang.title"
|
||||
| "deadlines.kalender.empty"
|
||||
| "deadlines.kalender.heading"
|
||||
| "deadlines.kalender.list"
|
||||
| "deadlines.kalender.subtitle"
|
||||
| "deadlines.kalender.title"
|
||||
| "deadlines.kalender.today"
|
||||
| "deadlines.list.calendar"
|
||||
| "deadlines.list.heading"
|
||||
| "deadlines.list.new"
|
||||
| "deadlines.list.subtitle"
|
||||
@@ -1069,6 +1304,7 @@ export type I18nKey =
|
||||
| "deadlines.neu.submit"
|
||||
| "deadlines.neu.subtitle"
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.notes.show"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
@@ -1153,6 +1389,10 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1229,6 +1469,16 @@ export type I18nKey =
|
||||
| "downloads.subtitle"
|
||||
| "downloads.title"
|
||||
| "einstellungen.error.generic"
|
||||
| "einstellungen.export.audit"
|
||||
| "einstellungen.export.bullet.csv"
|
||||
| "einstellungen.export.bullet.json"
|
||||
| "einstellungen.export.bullet.xlsx"
|
||||
| "einstellungen.export.button"
|
||||
| "einstellungen.export.heading"
|
||||
| "einstellungen.export.scope"
|
||||
| "einstellungen.export.started"
|
||||
| "einstellungen.export.subtitle"
|
||||
| "einstellungen.export.what"
|
||||
| "einstellungen.heading"
|
||||
| "einstellungen.loading"
|
||||
| "einstellungen.optional"
|
||||
@@ -1272,9 +1522,11 @@ export type I18nKey =
|
||||
| "einstellungen.subtitle"
|
||||
| "einstellungen.tab.benachrichtigungen"
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.export"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
| "event.description.appointment_approval_changes_suggested"
|
||||
| "event.description.appointment_approval_rejected"
|
||||
| "event.description.appointment_approval_requested"
|
||||
| "event.description.appointment_approval_revoked"
|
||||
@@ -1283,6 +1535,7 @@ export type I18nKey =
|
||||
| "event.description.appointment_project_changed"
|
||||
| "event.description.appointment_updated"
|
||||
| "event.description.deadline_approval_approved"
|
||||
| "event.description.deadline_approval_changes_suggested"
|
||||
| "event.description.deadline_approval_rejected"
|
||||
| "event.description.deadline_approval_requested"
|
||||
| "event.description.deadline_approval_revoked"
|
||||
@@ -1298,6 +1551,7 @@ export type I18nKey =
|
||||
| "event.note.parent.deadline"
|
||||
| "event.note.parent.project"
|
||||
| "event.title.appointment_approval_approved"
|
||||
| "event.title.appointment_approval_changes_suggested"
|
||||
| "event.title.appointment_approval_rejected"
|
||||
| "event.title.appointment_approval_requested"
|
||||
| "event.title.appointment_approval_revoked"
|
||||
@@ -1312,6 +1566,7 @@ export type I18nKey =
|
||||
| "event.title.checklist_reset"
|
||||
| "event.title.checklist_unlinked"
|
||||
| "event.title.deadline_approval_approved"
|
||||
| "event.title.deadline_approval_changes_suggested"
|
||||
| "event.title.deadline_approval_rejected"
|
||||
| "event.title.deadline_approval_requested"
|
||||
| "event.title.deadline_approval_revoked"
|
||||
@@ -1346,6 +1601,8 @@ export type I18nKey =
|
||||
| "event_types.browse.apply"
|
||||
| "event_types.browse.cancel"
|
||||
| "event_types.browse.empty"
|
||||
| "event_types.browse.jurisdiction.all"
|
||||
| "event_types.browse.jurisdiction.filter_label"
|
||||
| "event_types.browse.jurisdiction.none"
|
||||
| "event_types.browse.search"
|
||||
| "event_types.browse.selected_count"
|
||||
@@ -1368,7 +1625,6 @@ export type I18nKey =
|
||||
| "event_types.picker.no_match"
|
||||
| "event_types.picker.remove"
|
||||
| "event_types.picker.search"
|
||||
| "events.calendar.empty"
|
||||
| "events.col.appointment_type"
|
||||
| "events.col.date"
|
||||
| "events.col.location"
|
||||
@@ -1376,6 +1632,7 @@ export type I18nKey =
|
||||
| "events.empty.hint"
|
||||
| "events.empty.title"
|
||||
| "events.filter.status.all"
|
||||
| "events.filter.status.upcoming"
|
||||
| "events.row.type.appointment"
|
||||
| "events.row.type.deadline"
|
||||
| "events.summary.later"
|
||||
@@ -1639,6 +1896,7 @@ export type I18nKey =
|
||||
| "login.tab.login"
|
||||
| "login.tab.register"
|
||||
| "login.title"
|
||||
| "modal.close.label"
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
@@ -1675,6 +1933,7 @@ export type I18nKey =
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
@@ -1756,8 +2015,11 @@ export type I18nKey =
|
||||
| "paliadin.error.shim_error"
|
||||
| "paliadin.error.timeout"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.error.upstream_silence"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.checking"
|
||||
| "paliadin.late.lost"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
@@ -1767,6 +2029,8 @@ export type I18nKey =
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.thinking"
|
||||
| "paliadin.thinking.seconds"
|
||||
| "paliadin.title"
|
||||
| "paliadin.widget.close"
|
||||
| "paliadin.widget.context.on_page"
|
||||
@@ -1887,6 +2151,7 @@ export type I18nKey =
|
||||
| "projects.chip.type.case"
|
||||
| "projects.chip.type.client"
|
||||
| "projects.chip.type.litigation"
|
||||
| "projects.chip.type.other"
|
||||
| "projects.chip.type.patent"
|
||||
| "projects.chip.type.project"
|
||||
| "projects.col.clientmatter"
|
||||
@@ -1902,6 +2167,11 @@ export type I18nKey =
|
||||
| "projects.detail.appointments.form.cancel"
|
||||
| "projects.detail.appointments.form.submit"
|
||||
| "projects.detail.back"
|
||||
| "projects.detail.checklisten.add"
|
||||
| "projects.detail.checklisten.add.created"
|
||||
| "projects.detail.checklisten.add.empty_pick"
|
||||
| "projects.detail.checklisten.add.error"
|
||||
| "projects.detail.checklisten.add.search"
|
||||
| "projects.detail.checklisten.col.created"
|
||||
| "projects.detail.checklisten.col.name"
|
||||
| "projects.detail.checklisten.col.progress"
|
||||
@@ -1923,6 +2193,8 @@ export type I18nKey =
|
||||
| "projects.detail.edit"
|
||||
| "projects.detail.edit.modal.title"
|
||||
| "projects.detail.edit.type_change_warning.title"
|
||||
| "projects.detail.export.button"
|
||||
| "projects.detail.export.tooltip"
|
||||
| "projects.detail.firmwide.off"
|
||||
| "projects.detail.firmwide.on"
|
||||
| "projects.detail.kinder.add"
|
||||
@@ -1945,6 +2217,11 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.settings.archive.cta"
|
||||
| "projects.detail.settings.archive.description"
|
||||
| "projects.detail.settings.archive.heading"
|
||||
| "projects.detail.settings.export.description"
|
||||
| "projects.detail.settings.export.heading"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
@@ -2018,11 +2295,24 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.edit"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
| "projects.detail.submissions.col.name"
|
||||
| "projects.detail.submissions.col.party"
|
||||
| "projects.detail.submissions.col.source"
|
||||
| "projects.detail.submissions.empty"
|
||||
| "projects.detail.submissions.empty.no_proceeding"
|
||||
| "projects.detail.submissions.empty.no_proceeding.cta"
|
||||
| "projects.detail.submissions.hint"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.settings"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
| "projects.detail.tab.verlauf"
|
||||
@@ -2062,6 +2352,19 @@ export type I18nKey =
|
||||
| "projects.field.billing_reference"
|
||||
| "projects.field.case_number"
|
||||
| "projects.field.client_number"
|
||||
| "projects.field.client_role"
|
||||
| "projects.field.client_role.appellant"
|
||||
| "projects.field.client_role.applicant"
|
||||
| "projects.field.client_role.claimant"
|
||||
| "projects.field.client_role.defendant"
|
||||
| "projects.field.client_role.group.active"
|
||||
| "projects.field.client_role.group.other"
|
||||
| "projects.field.client_role.group.reactive"
|
||||
| "projects.field.client_role.hint"
|
||||
| "projects.field.client_role.other"
|
||||
| "projects.field.client_role.respondent"
|
||||
| "projects.field.client_role.third_party"
|
||||
| "projects.field.client_role.unset"
|
||||
| "projects.field.clientmatter.hint"
|
||||
| "projects.field.collaborators"
|
||||
| "projects.field.collaborators.hint"
|
||||
@@ -2079,18 +2382,29 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.opponent_code"
|
||||
| "projects.field.opponent_code.hint"
|
||||
| "projects.field.opponent_code.placeholder"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.appellant"
|
||||
| "projects.field.our_side.applicant"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.other"
|
||||
| "projects.field.our_side.respondent"
|
||||
| "projects.field.our_side.third_party"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
| "projects.field.patent_number"
|
||||
| "projects.field.proceeding_type"
|
||||
| "projects.field.proceeding_type.hint"
|
||||
| "projects.field.proceeding_type.unset"
|
||||
| "projects.field.proceeding_type_id"
|
||||
| "projects.field.ref"
|
||||
| "projects.field.ref.placeholder"
|
||||
@@ -2132,7 +2446,15 @@ export type I18nKey =
|
||||
| "projects.team.derived.from"
|
||||
| "projects.team.derived.visibility"
|
||||
| "projects.team.direct"
|
||||
| "projects.team.error.forbidden"
|
||||
| "projects.team.error.generic"
|
||||
| "projects.team.error.last_admin"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.mailto.count"
|
||||
| "projects.team.mailto.empty"
|
||||
| "projects.team.mailto.label"
|
||||
| "projects.team.mailto.select_all"
|
||||
| "projects.team.mailto.select_row"
|
||||
| "projects.team.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
| "projects.team.profession.none"
|
||||
@@ -2142,6 +2464,8 @@ export type I18nKey =
|
||||
| "projects.team.profession.paralegal"
|
||||
| "projects.team.profession.partner"
|
||||
| "projects.team.profession.senior_pa"
|
||||
| "projects.team.responsibility.admin"
|
||||
| "projects.team.responsibility.admin.hint"
|
||||
| "projects.team.responsibility.external"
|
||||
| "projects.team.responsibility.lead"
|
||||
| "projects.team.responsibility.member"
|
||||
@@ -2190,6 +2514,7 @@ export type I18nKey =
|
||||
| "projects.type.case"
|
||||
| "projects.type.client"
|
||||
| "projects.type.litigation"
|
||||
| "projects.type.other"
|
||||
| "projects.type.patent"
|
||||
| "projects.type.project"
|
||||
| "projects.unavailable"
|
||||
@@ -2207,6 +2532,45 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
| "submissions.index.col.draft"
|
||||
| "submissions.index.col.project"
|
||||
| "submissions.index.col.submission"
|
||||
| "submissions.index.col.updated"
|
||||
| "submissions.index.empty"
|
||||
| "submissions.index.empty.cta"
|
||||
| "submissions.index.error"
|
||||
| "submissions.index.heading"
|
||||
| "submissions.index.loading"
|
||||
| "submissions.index.subtitle"
|
||||
| "submissions.index.title"
|
||||
| "submissions.new.back"
|
||||
| "submissions.new.col.actions"
|
||||
| "submissions.new.col.name"
|
||||
| "submissions.new.col.party"
|
||||
| "submissions.new.col.source"
|
||||
| "submissions.new.empty.filtered"
|
||||
| "submissions.new.error"
|
||||
| "submissions.new.heading"
|
||||
| "submissions.new.loading"
|
||||
| "submissions.new.picker.empty"
|
||||
| "submissions.new.picker.loading"
|
||||
| "submissions.new.picker.placeholder"
|
||||
| "submissions.new.picker.title"
|
||||
| "submissions.new.search.placeholder"
|
||||
| "submissions.new.subtitle"
|
||||
| "submissions.new.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
@@ -2256,6 +2620,11 @@ export type I18nKey =
|
||||
| "team.role.senior_associate"
|
||||
| "team.role.trainee"
|
||||
| "team.search.placeholder"
|
||||
| "team.selection.clear"
|
||||
| "team.selection.count"
|
||||
| "team.selection.select_all"
|
||||
| "team.selection.send"
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "theme.toggle.auto"
|
||||
@@ -2287,6 +2656,7 @@ export type I18nKey =
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.changes_requested"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
|
||||
@@ -9,7 +9,6 @@ const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
|
||||
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_GLOSSAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></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>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
@@ -108,19 +107,6 @@ export function renderIndex(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sections">
|
||||
<div className="container">
|
||||
<h3 className="section-heading" data-i18n="index.downloads">Downloads</h3>
|
||||
<div className="grid grid-2">
|
||||
<a href="/files/hl-patents-style.dotm" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_DOWNLOAD }} />
|
||||
<h2 data-i18n="index.style.title">{`${FIRM} Patents Style`}</h2>
|
||||
<p data-i18n="index.style.desc">{`Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros für standardisierte Schriftsätze.`}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="offices">
|
||||
<div className="container">
|
||||
<h3 data-i18n="index.offices">Standorte</h3>
|
||||
|
||||
@@ -104,7 +104,7 @@ export function renderKostenrechner(): string {
|
||||
<title data-i18n="kosten.title">Prozesskostenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-kostenrechner">
|
||||
<Sidebar currentPath="/tools/kostenrechner" />
|
||||
<BottomNav currentPath="/tools/kostenrechner" />
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function renderProjectsChart(): string {
|
||||
<title data-i18n="projects.chart.title">Projekt-Chart — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-projects-chart">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
|
||||
@@ -50,6 +50,14 @@ export function renderProjectsDetail(): string {
|
||||
<div className="entity-detail-meta">
|
||||
<span id="project-type-chip" className="entity-type-chip" />
|
||||
<span className="entity-ref" id="project-ref-display" />
|
||||
{/* Auto-derived project code (t-paliad-222 / m/paliad#50).
|
||||
Rendered as a separate badge so the user can still
|
||||
distinguish a custom reference (left badge) from a
|
||||
tree-derived code (right badge); when reference is
|
||||
blank, the derived code IS reference and only this
|
||||
badge shows. Hidden via inline style until the
|
||||
client populates it. */}
|
||||
<span className="entity-ref entity-ref-code" id="project-code-display" style="display:none" title="Auto-derived project code" />
|
||||
<span id="project-clientmatter" className="entity-ref" />
|
||||
<span id="project-status-chip" className="entity-status-chip" />
|
||||
<a id="project-netdocs" className="netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments ↗</a>
|
||||
@@ -80,6 +88,10 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Sits
|
||||
last in the tab list per t-paliad-245. */}
|
||||
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -247,6 +259,7 @@ export function renderProjectsDetail(): string {
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
|
||||
<select id="team-responsibility">
|
||||
<option value="admin" data-i18n="projects.team.responsibility.admin">Admin</option>
|
||||
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
|
||||
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
|
||||
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
|
||||
@@ -262,9 +275,33 @@ export function renderProjectsDetail(): string {
|
||||
<p className="form-msg" id="team-msg" />
|
||||
</form>
|
||||
|
||||
{/* t-paliad-231 — pure-client mailto: for non-admins.
|
||||
Button stays disabled until at least one row is
|
||||
selected; click opens a mailto: with every selected
|
||||
member in the To: line. No server call. */}
|
||||
<div className="party-controls team-mailto-controls" id="team-mailto-controls" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="team-mailto-btn"
|
||||
className="btn-secondary btn-small"
|
||||
disabled
|
||||
data-i18n-title="projects.team.mailto.empty"
|
||||
title="Mindestens ein Mitglied auswählen"
|
||||
>
|
||||
<span id="team-mailto-label" data-i18n="projects.team.mailto.label">Mail an Auswahl</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="party-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="team-col-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="team-select-master"
|
||||
aria-label="Alle sichtbaren auswählen"
|
||||
/>
|
||||
</th>
|
||||
<th data-i18n="projects.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projects.detail.team.col.profession">Profession</th>
|
||||
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
|
||||
@@ -548,6 +585,12 @@ export function renderProjectsDetail(): string {
|
||||
|
||||
{/* Checklists (Checklisten) */}
|
||||
<section className="entity-tab-panel" id="tab-checklists" style="display:none">
|
||||
<div className="party-controls">
|
||||
<button id="checklist-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projects.detail.checklisten.add">
|
||||
Checkliste hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-msg" id="project-checklists-msg" />
|
||||
<p id="project-checklists-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.checklisten.empty">
|
||||
Für dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
|
||||
</p>
|
||||
@@ -565,17 +608,86 @@ export function renderProjectsDetail(): string {
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle checklists-hint">
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Instanzen werden auf der Vorlagen-Seite unter </span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Vorlagen werden auf der </span>
|
||||
<a href="/checklists" data-i18n="projects.detail.checklisten.hint.link">Checklisten</a>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix"> angelegt.</span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix">-Seite angelegt und bearbeitet.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
|
||||
the original t-paliad-215 list to the full
|
||||
cross-proceeding catalog. The table shows every
|
||||
active filing rule across every proceeding, grouped
|
||||
by proceeding; the project's own proceeding is
|
||||
pinned to the top. The no-proceeding hint stays as
|
||||
a soft nudge above the catalog (the table renders
|
||||
regardless). */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
|
||||
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-submissions-edit-cta"
|
||||
className="btn-primary btn-small"
|
||||
data-i18n="projects.detail.submissions.empty.no_proceeding.cta">
|
||||
Projekt bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projects.detail.submissions.col.name">Schriftsatz</th>
|
||||
<th data-i18n="projects.detail.submissions.col.party">Partei</th>
|
||||
<th data-i18n="projects.detail.submissions.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="projects.detail.submissions.col.action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="project-submissions-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle submissions-hint" data-i18n="projects.detail.submissions.hint">
|
||||
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Verwaltung — rare admin actions (export, archive). Each
|
||||
sub-section hides itself if the caller is not entitled
|
||||
(export: §4 gate; archive: global_admin). */}
|
||||
<section className="entity-tab-panel" id="tab-settings" style="display:none">
|
||||
<div className="settings-section" id="project-settings-export" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
|
||||
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" id="project-settings-archive" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
|
||||
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-settings-archive-link"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.settings.archive.cta">
|
||||
Bearbeiten öffnen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Full edit modal — same form as /projects/new, pre-filled. */}
|
||||
@@ -607,6 +719,33 @@ export function renderProjectsDetail(): string {
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Danger zone — destructive action, visually separated from Save/Cancel. */}
|
||||
<div className="modal-danger-zone" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-link-danger" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-checklist-instance modal (t-paliad-239) — picks a
|
||||
template and POSTs to /api/checklists/{slug}/instances
|
||||
with the current project_id; the row appears in the
|
||||
Checklists tab list on success. */}
|
||||
<div className="modal-overlay" id="add-checklist-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="projects.detail.checklisten.add">Checkliste hinzufügen</h2>
|
||||
<button className="modal-close" id="add-checklist-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<input type="text" id="add-checklist-search" autocomplete="off" data-i18n-placeholder="projects.detail.checklisten.add.search" placeholder="Vorlage suchen…" />
|
||||
</div>
|
||||
<div className="add-checklist-list" id="add-checklist-list" />
|
||||
<p className="entity-events-empty" id="add-checklist-empty" style="display:none" data-i18n="projects.detail.checklisten.add.empty_pick">
|
||||
Keine passenden Vorlagen gefunden.
|
||||
</p>
|
||||
<p className="form-msg" id="add-checklist-msg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,7 +127,8 @@ export function renderProjects(): string {
|
||||
<label><input type="checkbox" value="litigation" /><span data-i18n="projects.chip.type.litigation">Streitsache</span></label>
|
||||
<label><input type="checkbox" value="patent" /><span data-i18n="projects.chip.type.patent">Patent</span></label>
|
||||
<label><input type="checkbox" value="case" /><span data-i18n="projects.chip.type.case">Verfahren</span></label>
|
||||
<label><input type="checkbox" value="project" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></label>
|
||||
<label><input type="checkbox" value="project" /><span data-i18n="projects.chip.type.project">Projekt</span></label>
|
||||
<label><input type="checkbox" value="other" /><span data-i18n="projects.chip.type.other">Sonstiges</span></label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" className="projects-chip" data-chip="has_open_deadlines" data-i18n="projects.chip.has_open_deadlines">Mit aktiven Fristen</button>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderSettings(): string {
|
||||
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
|
||||
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
|
||||
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
|
||||
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
|
||||
</nav>
|
||||
|
||||
{/* --- Profil tab ---------------------------------------- */}
|
||||
@@ -322,6 +323,25 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* t-paliad-212 Slice 2b — multi-calendar bindings.
|
||||
Each card is one (calendar, scope) binding layered on the
|
||||
single CalDAV server connection above. */}
|
||||
<div className="caldav-bindings-section" id="caldav-bindings-section" style="display:none">
|
||||
<div className="caldav-bindings-header">
|
||||
<h2 data-i18n="caldav.bindings.heading">Kalender</h2>
|
||||
<button type="button" id="caldav-bindings-add-btn" className="btn-secondary" data-i18n="caldav.bindings.add">
|
||||
+ Kalender hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint" data-i18n="caldav.bindings.hint">
|
||||
Verbinde mehrere Kalender mit Paliad — einen Master für alles oder eigene Kalender pro Projekt.
|
||||
</p>
|
||||
<div id="caldav-bindings-list" className="caldav-bindings-list" />
|
||||
<p className="entity-events-empty" id="caldav-bindings-empty" data-i18n="caldav.bindings.empty" style="display:none">
|
||||
Noch keine Kalender konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="caldav-log-card">
|
||||
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
|
||||
<table className="entity-table entity-table--readonly caldav-log-table">
|
||||
@@ -342,12 +362,138 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Datenexport tab (t-paliad-214 Slice 1) ----------- */}
|
||||
<section className="entity-tab-panel" id="tab-export" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="einstellungen.export.subtitle">
|
||||
Laden Sie Ihre persönlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter.
|
||||
Enthalten ist alles, was Sie aktuell sehen können — Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.
|
||||
</p>
|
||||
|
||||
<div className="caldav-info-card">
|
||||
<h2 data-i18n="einstellungen.export.heading">Persönlicher Datenexport</h2>
|
||||
<p data-i18n="einstellungen.export.what">
|
||||
Das Paket enthält Ihre sichtbaren Daten in drei Formaten in einem <code>.zip</code>:
|
||||
</p>
|
||||
<ul className="form-hint settings-export-list">
|
||||
<li data-i18n="einstellungen.export.bullet.xlsx">
|
||||
<strong>paliad-export.xlsx</strong> — eine Excel-Mappe pro Entität.
|
||||
</li>
|
||||
<li data-i18n="einstellungen.export.bullet.json">
|
||||
<strong>paliad-export.json</strong> — maschinenlesbare Kopie für Skripte und Tools.
|
||||
</li>
|
||||
<li data-i18n="einstellungen.export.bullet.csv">
|
||||
<strong>csv/<sheet>.csv</strong> — Tabellen einzeln als CSV (UTF-8 mit BOM).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="form-hint" data-i18n="einstellungen.export.scope">
|
||||
Umfang: alles, was Sie aktuell in Paliad sehen können (Sichtbarkeit zum Zeitpunkt des Exports).
|
||||
Passwörter, CalDAV-Zugangsdaten und andere Geheimnisse werden nie exportiert.
|
||||
</p>
|
||||
|
||||
<p className="form-hint" data-i18n="einstellungen.export.audit">
|
||||
Jeder Export wird im Audit-Log protokolliert.
|
||||
</p>
|
||||
|
||||
<p className="form-msg" id="export-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" id="export-btn" className="btn-primary btn-cta-lime" data-i18n="einstellungen.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
{/* t-paliad-212 Slice 2b — single-step Add/Edit modal for
|
||||
calendar bindings. Source picker (existing dropdown or
|
||||
custom URL) + scope radio + display name. Edit mode hides
|
||||
the source picker (path is fixed). */}
|
||||
<div id="caldav-binding-modal" className="modal-backdrop" style="display:none">
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-header">
|
||||
<h2 id="caldav-binding-modal-title" data-i18n="caldav.bindings.modal.add_title">Kalender hinzufügen</h2>
|
||||
<button type="button" className="modal-close" id="caldav-binding-modal-close" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
|
||||
<div className="form-field" id="caldav-binding-source-field">
|
||||
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
|
||||
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
|
||||
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender wählen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="create" />
|
||||
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="custom" />
|
||||
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="caldav-binding-discover-select">
|
||||
<option value="" data-i18n="caldav.bindings.modal.source.loading">Lädt…</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="caldav-binding-custom-path"
|
||||
placeholder="https://..."
|
||||
style="display:none"
|
||||
/>
|
||||
{/* Slice 2c — Google-degrade notice. Shown when
|
||||
supports_mkcalendar=false; the create-new radio is
|
||||
hidden in that state, so users are nudged to the
|
||||
custom-URL path. */}
|
||||
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
|
||||
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
|
||||
Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="caldav-binding-display-name" data-i18n="caldav.bindings.modal.display_name">Anzeigename (optional)</label>
|
||||
<input type="text" id="caldav-binding-display-name" data-i18n-placeholder="caldav.bindings.modal.display_name.placeholder" placeholder="z.B. Projekt Acme v Bosch" />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label data-i18n="caldav.bindings.modal.scope">Inhalt</label>
|
||||
<div className="caldav-binding-scope-radios">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="all_visible" checked />
|
||||
<span data-i18n="caldav.bindings.modal.scope.all_visible">Alles, was ich sehe</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="personal_only" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.personal_only">Nur persönliche Termine</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="project" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.project">Ein Projekt:</span>
|
||||
<select id="caldav-binding-project-select" disabled>
|
||||
<option value="" data-i18n="caldav.bindings.modal.scope.project.loading">Lädt…</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="caldav-binding-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" id="caldav-binding-cancel-btn" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" id="caldav-binding-submit-btn" data-i18n="caldav.bindings.modal.submit_add">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
143
frontend/src/submission-draft.tsx
Normal file
143
frontend/src/submission-draft.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-238 Slice A — dedicated Submissions/Schriftsätze editor page.
|
||||
//
|
||||
// Lawyer picks (or creates) a draft for one (project, submission_code),
|
||||
// edits placeholder variables in a sticky sidebar, sees a read-only
|
||||
// HTML preview of the merged document, and exports the result as
|
||||
// .docx. Drafts persist server-side per paliad.submission_drafts.
|
||||
//
|
||||
// Pure shell: client/submission-draft.ts hydrates draft list + variable
|
||||
// form + preview pane after page load. The same dist/submission-draft.html
|
||||
// serves every (project_id, submission_code, [draft_id]) URL.
|
||||
//
|
||||
// Design ref: docs/design-submission-page-2026-05-22.md §6.
|
||||
|
||||
export function renderSubmissionDraft(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.draft.title">Schriftsatz bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-submission-draft">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submission-draft-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="submission-draft-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="submissions.draft.back">
|
||||
← Zurück zum Projekt
|
||||
</a>
|
||||
|
||||
<div id="submission-draft-loading" className="entity-loading">
|
||||
<p data-i18n="submissions.draft.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="submissions.draft.notfound">
|
||||
Schriftsatz nicht gefunden oder keine Berechtigung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-error" className="entity-empty" style="display:none" />
|
||||
|
||||
<div id="submission-draft-body" style="display:none">
|
||||
<header className="submission-draft-header">
|
||||
<div className="submission-draft-header-text">
|
||||
<h1 id="submission-draft-title" />
|
||||
<p id="submission-draft-subtitle" className="tool-subtitle" />
|
||||
</div>
|
||||
<div className="submission-draft-header-actions">
|
||||
<button
|
||||
id="submission-draft-export-btn"
|
||||
type="button"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.draft.action.export">
|
||||
Als .docx exportieren
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="submission-draft-grid">
|
||||
{/* Sidebar — draft switcher + variable groups. */}
|
||||
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
|
||||
<div className="submission-draft-switcher">
|
||||
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
|
||||
Entwurf
|
||||
</label>
|
||||
<select id="submission-draft-pick" />
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-new-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.action.new">
|
||||
+ Neuer Entwurf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-name-row">
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-name"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.name.placeholder"
|
||||
placeholder="Name dieses Entwurfs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-delete-btn"
|
||||
className="btn-small btn-link-danger"
|
||||
data-i18n="submissions.draft.action.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
<header className="submission-draft-preview-header">
|
||||
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
|
||||
<span
|
||||
className="submission-draft-preview-hint"
|
||||
data-i18n="submissions.draft.preview.hint">
|
||||
Read-only Vorschau — finale Bearbeitung in Word.
|
||||
</span>
|
||||
</header>
|
||||
<div className="submission-draft-preview" id="submission-draft-preview" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/submission-draft.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
84
frontend/src/submissions-index.tsx
Normal file
84
frontend/src/submissions-index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Top-level sidebar
|
||||
// entry that lists every draft the caller owns across visible projects.
|
||||
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
|
||||
// this page only adds a discovery surface and click-through to it.
|
||||
|
||||
export function renderSubmissionsIndex(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.index.title">Schriftsätze — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="submissions-index-headline">
|
||||
<div>
|
||||
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
|
||||
Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-index-loading"
|
||||
data-i18n="submissions.index.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-empty" style="display:none">
|
||||
<p data-i18n="submissions.index.empty">
|
||||
Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.
|
||||
</p>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-error" style="display:none">
|
||||
<p data-i18n="submissions.index.error">Schriftsätze konnten nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.index.col.project">Projekt</th>
|
||||
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
|
||||
<th data-i18n="submissions.index.col.draft">Entwurf</th>
|
||||
<th data-i18n="submissions.index.col.updated">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-index-body" />
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
118
frontend/src/submissions-new.tsx
Normal file
118
frontend/src/submissions-new.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-243 — global Schriftsatz picker. Lists the full
|
||||
// cross-proceeding submission catalog (grouped by proceeding,
|
||||
// filterable) and lets the lawyer start a draft with or without
|
||||
// binding a project. Picking "Ohne Projekt" jumps straight to
|
||||
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
|
||||
// autocomplete project picker, then redirects to the project-scoped
|
||||
// editor.
|
||||
|
||||
export function renderSubmissionsNew(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.new.title">Neuer Schriftsatz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submissions-new-page">
|
||||
<div className="container">
|
||||
<a href="/submissions" className="back-link"
|
||||
data-i18n="submissions.new.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
|
||||
Wählen Sie eine Vorlage. Optional verknüpfen Sie den
|
||||
Entwurf mit einem Projekt — sonst füllen Sie alle
|
||||
Variablen manuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="submissions-new-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.search.placeholder"
|
||||
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
|
||||
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-new-loading"
|
||||
data-i18n="submissions.new.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-new-error" style="display:none">
|
||||
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
|
||||
<th data-i18n="submissions.new.col.party">Partei</th>
|
||||
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-new-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="entity-empty" id="submissions-new-empty" style="display:none">
|
||||
<span data-i18n="submissions.new.empty.filtered">
|
||||
Keine passenden Schriftsätze. Filter zurücksetzen.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
|
||||
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="modal-card">
|
||||
<header className="modal-header">
|
||||
<h2 data-i18n="submissions.new.picker.title">Projekt wählen</h2>
|
||||
<button type="button" id="submissions-new-project-modal-close"
|
||||
className="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-project-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.picker.placeholder"
|
||||
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
|
||||
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
|
||||
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.loading">Lädt Projekte…</p>
|
||||
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,14 @@ export function renderTeam(): string {
|
||||
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
|
||||
</div>
|
||||
|
||||
{/* t-paliad-223 (#53) — master "select all visible" checkbox. */}
|
||||
<div className="team-select-master-row">
|
||||
<label className="team-select-master-label">
|
||||
<input type="checkbox" id="team-select-master" />
|
||||
<span data-i18n="team.selection.select_all">Alle sichtbaren auswählen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="team-list" id="team-list" />
|
||||
|
||||
<div className="glossar-empty" id="team-empty" style="display:none">
|
||||
|
||||
@@ -84,7 +84,7 @@ export function renderVerfahrensablauf(): string {
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-verfahrensablauf">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
@@ -163,7 +163,10 @@ export function renderVerfahrensablauf(): string {
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
@@ -207,6 +210,53 @@ export function renderVerfahrensablauf(): string {
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
|
||||
swaps the column LABELS so the user's own side is
|
||||
proactive (= "your filings"). Appellant collapses
|
||||
party=both rows to a single column when set — only
|
||||
relevant for role-swap proceedings (Appeal etc.);
|
||||
the row hides itself when the picked proceeding has
|
||||
no appellant axis (see hasAppellantAxis() in the
|
||||
client). Both selectors are URL-driven (?side= +
|
||||
?appellant=) so the perspective survives reload
|
||||
and is shareable. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
@@ -225,6 +275,10 @@ export function renderVerfahrensablauf(): string {
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
15
go.mod
15
go.mod
@@ -4,8 +4,21 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
)
|
||||
|
||||
76
go.sum
76
go.sum
@@ -1,39 +1,11 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
@@ -43,33 +15,29 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -30,6 +30,78 @@ type Entry struct {
|
||||
// Entries lists everything shipped so far, newest first. Append new rows
|
||||
// at the top.
|
||||
var Entries = []Entry{
|
||||
{
|
||||
Date: "2026-05-21",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Konfigurierbares Dashboard",
|
||||
TitleEN: "Configurable dashboard",
|
||||
BodyDE: "Das Dashboard lässt sich jetzt frei zusammenstellen: Widgets per Drag-and-drop verschieben, in der Größe ändern und einzeln konfigurieren. Der Katalog umfasst Fristen-Ampel, Termine, Agenda, Inbox-Übersicht, angepinnte Projekte und Schnellaktionen. Admins können eine kanzleiweite Standardanordnung festlegen, von der jeder Nutzer startet und sie nach Wunsch anpasst.",
|
||||
BodyEN: "The dashboard can now be assembled freely: drag-and-drop widgets, resize them and configure each one individually. The catalog covers the deadline traffic-light, appointments, agenda, inbox summary, pinned projects and quick actions. Admins can set a firm-wide default layout that every user starts from and then tweaks to taste.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Einreichungs-Checklisten",
|
||||
TitleEN: "User-authored checklists",
|
||||
BodyDE: "Eigene Checklisten lassen sich per Wizard anlegen und gezielt mit einzelnen Kolleg:innen, einem Büro, einer Partnereinheit oder einem Projekt teilen. Admins können besonders gute Vorlagen kanzleiweit unter „Geteilte Vorlagen\" freigeben. Wird eine Vorlage später überarbeitet, erscheint an laufenden Instanzen ein Hinweis-Badge auf die neuere Version.",
|
||||
BodyEN: "Build your own filing checklists through a wizard and share them explicitly with individual colleagues, an office, a partner unit or a project. Admins can promote the best templates firm-wide under „Shared templates\". When a template is later revised, running instances surface a notice badge pointing at the newer version.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Genehmigungen: Änderungen vorschlagen",
|
||||
TitleEN: "Approvals: suggest changes",
|
||||
BodyDE: "Im Inbox gibt es eine dritte Aktion neben „Genehmigen\" und „Ablehnen\": „Änderungen vorschlagen\". Ein Modal zeigt den ursprünglichen Wert, der Gegenvorschlag wandert mit einem Kommentar zurück an die Antragsteller:in. Der gesamte Austausch erscheint im Verlauf des Eintrags.",
|
||||
BodyEN: "Inbox now offers a third action alongside „Approve\" and „Reject\": „Suggest changes\". A modal shows the original value, the counter-proposal travels back to the requester together with a comment. The full exchange shows up in the entry's Verlauf.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Mandant:innen-Rolle und automatische Projekt-Codes",
|
||||
TitleEN: "Client role and auto-derived project codes",
|
||||
BodyDE: "Mandant:innen lassen sich jetzt als eigene Rolle in das Team eines Projekts aufnehmen — separat von HLC-Mitgliedern und mit eigenem Sichtbarkeitsumfang. Außerdem leitet Paliad pro Projekt einen kompakten Code aus dem Baum ab (etwa /9999-1-EP123-CFI) und zeigt ihn als zweites Badge im Header und in jedem Projekt-Picker.",
|
||||
BodyEN: "Clients can now be added to a project's team as their own role — separate from HLC members and with their own visibility scope. In addition, Paliad derives a compact code per project from the ancestor tree (e.g. /9999-1-EP123-CFI) and shows it as a second badge in the header and in every project picker.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-19",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Datenexport — Excel, CSV, JSON",
|
||||
TitleEN: "Data export — Excel, CSV, JSON",
|
||||
BodyDE: "Unter Einstellungen → Datenexport lassen sich alle sichtbaren Projekte, Fristen, Termine, Notizen und Checklisten als Excel-, CSV- oder JSON-Datei herunterladen. Auf jeder Projekt-Seite gibt es zusätzlich einen „Daten exportieren\"-Button, der nur den jeweiligen Teilbaum mitnimmt.",
|
||||
BodyEN: "Settings → Data export lets you download every project, deadline, appointment, note and checklist you can see as an Excel, CSV or JSON file. Each project page additionally offers a „Daten exportieren\" button that exports just that subtree.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-15",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Sichten — Liste, Karten, Kalender, Timeline",
|
||||
TitleEN: "Custom views — list, cards, calendar, timeline",
|
||||
BodyDE: "Eigene Filter über Fristen, Termine und Projekte lassen sich speichern und als Liste, Karten, Kalender oder Timeline rendern. Jede Sicht erhält einen permanenten Link, lässt sich als SVG, PNG, CSV, JSON oder iCal exportieren und erscheint in der Seitenleiste unter „Meine Sichten\".",
|
||||
BodyEN: "Custom filters over deadlines, appointments and projects can be saved and rendered as list, cards, calendar or timeline. Each view gets a permalink, can be exported as SVG, PNG, CSV, JSON or iCal and shows up in the sidebar under „Meine Sichten\".",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-07",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Projekte-Seite mit Baum, Pinnungen und Karten-Ansicht",
|
||||
TitleEN: "Projects page with tree, pins and cards view",
|
||||
BodyDE: "Die Projekte-Seite öffnet jetzt mit einem zusammenklappbaren Baum, Volltextsuche und Chips für Mandant, Ort und Status. Häufig genutzte Projekte lassen sich oben anpinnen; die alternative Karten-Ansicht erlaubt frei per Drag-and-drop sortierbare Layouts pro Nutzer.",
|
||||
BodyEN: "The Projects page now opens with a collapsible tree, full-text search and chips for client, location and status. Frequently used projects can be pinned to the top; the alternative cards view supports per-user drag-and-drop layouts.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-06",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Vier-Augen-Prinzip für Fristen und Termine",
|
||||
TitleEN: "Four-eyes principle for deadlines and appointments",
|
||||
BodyDE: "Pro Projekt lässt sich festlegen, dass Anlegen, Ändern, Abhaken und Löschen von Fristen oder Terminen durch eine zweite Person freigegeben werden müssen. Anfragen erscheinen im Inbox, am Eintrag selbst und mit „PENDING\"-Vermerk im CalDAV-Kalender. Admins pflegen die Regeln zentral unter /admin/approval-policies.",
|
||||
BodyEN: "Per project you can require that creating, editing, completing or deleting a deadline or appointment must be cleared by a second person. Requests show up in the inbox, on the entry itself and as a „PENDING\" marker in the CalDAV calendar. Admins maintain the rules centrally under /admin/approval-policies.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-05",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Fristenrechner v3 — Entscheidungsbaum, Begriffe, DE/EPA/DPMA",
|
||||
TitleEN: "Deadline calculator v3 — decision tree, concepts, DE/EPA/DPMA",
|
||||
BodyDE: "Der Fristenrechner wurde grundlegend überarbeitet: ein Entscheidungsbaum führt durch Verfahren und Fristart, eine neue Begriffsebene fasst Wiedereinsetzung, Säumnis, Schriftsatznachreichung und Weiterbehandlung als wiederverwendbare Konzepte zusammen. Der Regelbestand wurde um deutsche Verfahren (PatG, BPatG, BGH), EPA- und DPMA-Strecken erweitert, mit aktuellen Werten und Querverweisen.",
|
||||
BodyEN: "The deadline calculator has been overhauled from the ground up: a decision tree walks you through proceeding and deadline type, and a new concept layer treats Wiedereinsetzung, default, post-filing and further processing as reusable cross-cutting building blocks. The rule corpus has been extended with German proceedings (PatG, BPatG, BGH), EPO and DPMA tracks, with current values and cross-references.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-30",
|
||||
Tag: TagFeature,
|
||||
|
||||
@@ -1,46 +1,78 @@
|
||||
// Package db owns the Paliad Postgres connection and embedded schema migrations.
|
||||
//
|
||||
// Migrations are golang-migrate format (NNN_description.up.sql / .down.sql) and
|
||||
// live in the migrations/ subdirectory, embedded into the binary so a single
|
||||
// artifact ships with its schema. The server applies pending migrations at
|
||||
// startup before binding the HTTP listener.
|
||||
// Migrations are NNN_description.up.sql / .down.sql files in the migrations/
|
||||
// subdirectory, embedded into the binary so a single artifact ships with its
|
||||
// schema. The server applies pending migrations at startup before binding
|
||||
// the HTTP listener.
|
||||
//
|
||||
// The runner tracks applied state as a set, not a counter: every applied
|
||||
// migration gets its own row in paliad.applied_migrations(version PK, name,
|
||||
// applied_at, checksum). On every deploy, pending = on_disk \ applied, in
|
||||
// ascending version order. Gaps in the version space are first-class — a
|
||||
// version that's missing from applied_migrations runs on the next deploy,
|
||||
// regardless of which higher versions are already applied.
|
||||
//
|
||||
// This is what closes the parallel-merge skip-hole that the single-counter
|
||||
// tracker (golang-migrate) silently fell into on 2026-05-20 (m/paliad#44).
|
||||
// Background and design: docs/design-migration-runner-applied-set-2026-05-20.md.
|
||||
//
|
||||
// .down.sql files ship in the embedded FS as reference material but are not
|
||||
// auto-applied — there are no call sites for rolling back, and operator
|
||||
// recovery (psql .down.sql + DELETE FROM paliad.applied_migrations WHERE
|
||||
// version=N) is the documented path. If a real call site for auto-rollback
|
||||
// materializes later, add it as a focused follow-up.
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// migrationsTable is the name of the golang-migrate tracking table. We use a
|
||||
// uniquely-named table (not the default "schema_migrations") because the
|
||||
// production Supabase instance hosts multiple apps in the `public` schema,
|
||||
// and a differently-shaped `public.schema_migrations` already exists there.
|
||||
// Using "paliad_schema_migrations" prevents collision at startup.
|
||||
// advisoryLockID is the Postgres advisory-lock id the runner takes around
|
||||
// the apply loop. Derived once from the table name so the value is stable
|
||||
// across processes — two concurrent deploys (rolling Dokploy update, dev
|
||||
// laptop hitting the same scratch DB as CI) serialize on this id rather
|
||||
// than racing on the pending set.
|
||||
//
|
||||
// The table lives in the `public` schema (golang-migrate's default) rather
|
||||
// than `paliad`. Rationale: migration 001's down-step is
|
||||
// DROP SCHEMA IF EXISTS paliad CASCADE
|
||||
// which would take the tracking table with it — breaking any subsequent
|
||||
// migrate.Up() call. Keeping the tracker in `public` makes the down-path
|
||||
// safe and idempotent.
|
||||
const migrationsTable = "paliad_schema_migrations"
|
||||
// FNV-1a-64 is good enough: the id only has to be a stable int64, not
|
||||
// cryptographically uniform. Process-wide constant.
|
||||
var advisoryLockID = func() int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte("paliad.applied_migrations"))
|
||||
return int64(h.Sum64())
|
||||
}()
|
||||
|
||||
// ApplyMigrations runs all pending up-migrations against the given database
|
||||
// URL. Returns nil if no migrations were pending. Safe to call repeatedly.
|
||||
// migration is one *.up.sql file from the embedded FS.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
filename string
|
||||
}
|
||||
|
||||
// ApplyMigrations applies every pending up-migration to the given database.
|
||||
//
|
||||
// Pre-creates the `paliad` schema before invoking golang-migrate because the
|
||||
// first migration creates it and golang-migrate's tracking table would
|
||||
// otherwise be created in whatever `current_schema()` happens to be.
|
||||
// Safe to call repeatedly; a fully-applied tree is a no-op. Returns the
|
||||
// first error encountered (with the offending migration filename wrapped
|
||||
// in the message) and leaves the rest of pending unapplied — same fail-fast
|
||||
// posture as the previous golang-migrate runner.
|
||||
//
|
||||
// On first deploy of this code path against a database that still has the
|
||||
// legacy paliad.paliad_schema_migrations counter at version N, the runner
|
||||
// seeds paliad.applied_migrations with rows 1..N (checksum NULL) before
|
||||
// applying anything new. The first deploy is therefore effectively a
|
||||
// no-op against the schema — the bootstrap just relabels existing state.
|
||||
func ApplyMigrations(databaseURL string) error {
|
||||
if databaseURL == "" {
|
||||
return errors.New("database URL is empty")
|
||||
@@ -51,39 +83,250 @@ func ApplyMigrations(databaseURL string) error {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap the paliad schema so later migrations can target it cleanly.
|
||||
// This duplicates migration 001, but is idempotent via IF NOT EXISTS and
|
||||
// ensures the schema exists before golang-migrate touches the DB.
|
||||
// Ensure the paliad schema exists. Mig 001 also creates it; the
|
||||
// applied_migrations table lives in paliad.* and gets created before
|
||||
// any migrations run, so the schema must exist first.
|
||||
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
|
||||
return fmt.Errorf("ensure paliad schema: %w", err)
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open migration source: %w", err)
|
||||
if _, err := conn.Exec(`SELECT pg_advisory_lock($1)`, advisoryLockID); err != nil {
|
||||
return fmt.Errorf("acquire advisory lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(`SELECT pg_advisory_unlock($1)`, advisoryLockID)
|
||||
}()
|
||||
|
||||
if _, err := conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS paliad.applied_migrations (
|
||||
version int NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
checksum text NULL
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create applied_migrations: %w", err)
|
||||
}
|
||||
|
||||
driver, err := postgres.WithInstance(conn, &postgres.Config{
|
||||
// Unique tracking-table name avoids collision with pre-existing
|
||||
// public.schema_migrations owned by other apps on this Postgres.
|
||||
MigrationsTable: migrationsTable,
|
||||
})
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migration driver: %w", err)
|
||||
return fmt.Errorf("scan embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrator: %w", err)
|
||||
if err := bootstrapFromLegacyTracker(conn, onDisk); err != nil {
|
||||
return fmt.Errorf("bootstrap from legacy tracker: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
applied, err := readAppliedMigrations(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read applied_migrations: %w", err)
|
||||
}
|
||||
|
||||
if err := checkNameAgreement(onDisk, applied); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if _, ok := applied[m.version]; ok {
|
||||
continue
|
||||
}
|
||||
if err := applyOne(conn, m); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", m.filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanEmbeddedMigrations returns every NNN_*.up.sql in the embedded FS,
|
||||
// sorted by version ascending. Hard-fails on two files sharing the same
|
||||
// version prefix — that's the failure mode the parallel-merge incident
|
||||
// exposed, and the runner refuses to start rather than silently picking one.
|
||||
func scanEmbeddedMigrations() ([]migration, error) {
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
seen := map[int]string{}
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
v, n, ok := parseMigrationFilename(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unparseable migration filename %q "+
|
||||
"(expected NNN_description.up.sql)", name)
|
||||
}
|
||||
if prior, dup := seen[v]; dup {
|
||||
return nil, fmt.Errorf("two migrations at version %d: %q and %q — "+
|
||||
"rename one and redeploy", v, prior, name)
|
||||
}
|
||||
seen[v] = name
|
||||
out = append(out, migration{version: v, name: n, filename: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMigrationFilename splits "NNN_description.up.sql" into (NNN, description).
|
||||
// Returns ok=false on any deviation from that shape.
|
||||
func parseMigrationFilename(filename string) (version int, name string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".up.sql")
|
||||
if base == filename {
|
||||
return 0, "", false
|
||||
}
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
return v, base[underscore+1:], true
|
||||
}
|
||||
|
||||
// readAppliedMigrations returns a map version → name from
|
||||
// paliad.applied_migrations. Returns an empty map (no error) if the table
|
||||
// is missing — that's the fresh-DB path before the CREATE TABLE in
|
||||
// ApplyMigrations runs against it.
|
||||
func readAppliedMigrations(conn *sql.DB) (map[int]string, error) {
|
||||
rows, err := conn.Query(`SELECT version, name FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]string{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
var n string
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// bootstrapFromLegacyTracker seeds paliad.applied_migrations from
|
||||
// paliad.paliad_schema_migrations on the first deploy of the new runner
|
||||
// against a DB that previously ran golang-migrate.
|
||||
//
|
||||
// Behavior:
|
||||
// - applied_migrations already has rows → no-op (idempotent).
|
||||
// - applied_migrations empty AND legacy tracker missing → no-op
|
||||
// (virgin DB; the apply loop will run everything from scratch).
|
||||
// - applied_migrations empty AND legacy tracker present, clean, version N
|
||||
// → INSERT rows for every on-disk version ≤ N with checksum NULL.
|
||||
// - applied_migrations empty AND legacy tracker dirty → hard-fail.
|
||||
// The operator must recover the legacy tracker first (it being dirty
|
||||
// means a prior golang-migrate run crashed mid-flight); we will not
|
||||
// paper over an unknown state by guessing what landed.
|
||||
//
|
||||
// Backfilled rows have checksum NULL because the legacy runner didn't hash
|
||||
// anything — we can't fabricate a provenance hash today without falsely
|
||||
// claiming we know the byte-identity of what shipped historically.
|
||||
func bootstrapFromLegacyTracker(conn *sql.DB, onDisk []migration) error {
|
||||
var count int
|
||||
if err := conn.QueryRow(`SELECT count(*) FROM paliad.applied_migrations`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count applied_migrations: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacyVer int
|
||||
var legacyDirty bool
|
||||
err := conn.QueryRow(`SELECT version, dirty FROM paliad.paliad_schema_migrations LIMIT 1`).
|
||||
Scan(&legacyVer, &legacyDirty)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read legacy tracker: %w", err)
|
||||
}
|
||||
if legacyDirty {
|
||||
return fmt.Errorf("legacy paliad.paliad_schema_migrations is dirty at version %d — "+
|
||||
"recover manually before deploying", legacyVer)
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if m.version > legacyVer {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), NULL)
|
||||
ON CONFLICT (version) DO NOTHING
|
||||
`, m.version, m.name); err != nil {
|
||||
return fmt.Errorf("backfill version %d: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNameAgreement hard-fails if a version that's already applied has a
|
||||
// different name on disk than in the DB. Catches the post-merge rename
|
||||
// accident where someone renames `098_foo.up.sql` to `098_bar.up.sql` —
|
||||
// the SQL has already run on prod with the old name, so the rename is a
|
||||
// lie about history. Operator recovery: revert the rename, or update the
|
||||
// DB row if the rename is intentional.
|
||||
//
|
||||
// Backfilled rows have a name pulled from the on-disk filename, so an
|
||||
// out-of-the-box backfill never trips this check.
|
||||
func checkNameAgreement(onDisk []migration, applied map[int]string) error {
|
||||
for _, m := range onDisk {
|
||||
dbName, ok := applied[m.version]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if dbName != m.name {
|
||||
return fmt.Errorf("migration %d: disk name %q != DB name %q "+
|
||||
"(renamed after apply? revert the rename, or UPDATE paliad.applied_migrations "+
|
||||
"SET name=%q WHERE version=%d if the rename is intentional)",
|
||||
m.version, m.name, dbName, m.name, m.version)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyOne runs one migration's .up.sql plus its INSERT row in a single
|
||||
// transaction. All-or-nothing per migration: if the SQL fails, the row
|
||||
// isn't inserted and the next deploy re-tries from the same point. If
|
||||
// the INSERT fails (e.g. PK violation because the lock wasn't held), the
|
||||
// SQL rolls back too.
|
||||
func applyOne(conn *sql.DB, m migration) error {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", m.filename, err)
|
||||
}
|
||||
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
|
||||
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
return fmt.Errorf("exec sql: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), $3)
|
||||
`, m.version, m.name, checksum); err != nil {
|
||||
return fmt.Errorf("record applied: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
145
internal/db/migrate_test.go
Normal file
145
internal/db/migrate_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Package db tests — migration dry-run gate.
|
||||
//
|
||||
// This is the test that catches mig-N crash-loops before they reach prod.
|
||||
// The new runner tracks applied state as a set in paliad.applied_migrations
|
||||
// (one row per migration; see migrate.go). A migration that compiles cleanly
|
||||
// but fails on apply (typo, missing column, wrong CHECK shape) crashes the
|
||||
// Dokploy container loop before paliad.de finishes binding :8080, and the
|
||||
// only way to learn about it today is to watch the deploy log.
|
||||
//
|
||||
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
|
||||
// directory whose version is NOT present in paliad.applied_migrations on
|
||||
// the scratch DB, it opens a transaction, runs the SQL, and ROLLBACKs.
|
||||
// Any error fails the test with the file name + Postgres error. Always
|
||||
// non-destructive — the ROLLBACK runs even on success, so the scratch DB
|
||||
// stays at its starting set.
|
||||
//
|
||||
// "Pending" means: a version that's on disk but not in applied_migrations.
|
||||
// In CI against a fresh scratch DB (where applied_migrations either
|
||||
// doesn't exist or is empty), every migration is pending and gets
|
||||
// verified. On a developer laptop whose scratch DB is already at HEAD,
|
||||
// no migrations are pending and the test logs and passes — the protection
|
||||
// only kicks in the moment a new *.up.sql lands in the tree before the
|
||||
// developer runs `db.ApplyMigrations` against the same scratch DB.
|
||||
//
|
||||
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
|
||||
// tests). Skipped without it.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
|
||||
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
|
||||
// fails the test on the first SQL error. Reports per-file as a sub-test so
|
||||
// `go test -v` shows which migration failed.
|
||||
func TestMigrations_DryRun(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping migration dry-run")
|
||||
}
|
||||
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.Ping(); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
|
||||
// The paliad schema must exist before migration 001 runs against it,
|
||||
// mirroring the bootstrap step in ApplyMigrations. Without this, a
|
||||
// fresh scratch DB would fail migration 001's CREATE TABLE paliad.*
|
||||
// statements inside the BEGIN/ROLLBACK probe with "schema paliad does
|
||||
// not exist" — a false negative that distracts from real errors.
|
||||
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
|
||||
t.Fatalf("ensure paliad schema: %v", err)
|
||||
}
|
||||
|
||||
applied, err := readAppliedVersions(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
}
|
||||
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("scan embedded migrations: %v", err)
|
||||
}
|
||||
|
||||
var pending []migration
|
||||
for _, m := range onDisk {
|
||||
if !applied[m.version] {
|
||||
pending = append(pending, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
t.Logf("no pending migrations — scratch DB applied set covers every on-disk version (%d total)",
|
||||
len(onDisk))
|
||||
return
|
||||
}
|
||||
t.Logf("scratch DB has %d/%d on-disk migrations applied; walking %d pending",
|
||||
len(applied), len(onDisk), len(pending))
|
||||
|
||||
for _, m := range pending {
|
||||
t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", m.filename, err)
|
||||
}
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
// Always rollback; the dry-run must not leave the scratch
|
||||
// DB at a different applied set than where it started.
|
||||
// Rollback is safe after a failed Exec — Postgres aborts
|
||||
// the transaction internally on the first error.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
t.Fatalf("migration %s failed dry-run: %v", m.filename, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readAppliedVersions returns the set of versions present in
|
||||
// paliad.applied_migrations on the scratch DB. Missing table → empty set
|
||||
// (fresh-DB path; the table only exists after the runner has been called).
|
||||
//
|
||||
// We don't pre-create the table here because the dry-run is supposed to be
|
||||
// a passive observer — it must not mutate the scratch DB outside of its
|
||||
// own per-mig BEGIN/ROLLBACK probes. A "table doesn't exist" outcome is
|
||||
// the right read against a virgin scratch DB.
|
||||
func readAppliedVersions(conn *sql.DB) (map[int]bool, error) {
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]bool{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]bool{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Reverse of 101_caldav_multi_calendar.up.sql.
|
||||
--
|
||||
-- Drop the new join + binding tables. CASCADE on the FK references
|
||||
-- isn't needed because we drop targets before bindings, and Postgres
|
||||
-- handles RLS policies / indexes automatically on DROP TABLE.
|
||||
--
|
||||
-- The legacy paliad.appointments.caldav_uid / caldav_etag columns are
|
||||
-- untouched by the up migration, so they're untouched here too —
|
||||
-- rollback returns the system to the pre-Slice-1 state where those
|
||||
-- scalars are the single source of CalDAV truth.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.appointment_caldav_targets;
|
||||
DROP TABLE IF EXISTS paliad.user_calendar_bindings;
|
||||
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
@@ -0,0 +1,350 @@
|
||||
-- t-paliad-212 — Slice 1 of the CalDAV multi-calendar design (see
|
||||
-- docs/design-caldav-multi-calendar-2026-05-19.md). Pure schema +
|
||||
-- backfill; the sync engine is NOT touched in this migration. Slice 2
|
||||
-- wires the per-binding fan-out.
|
||||
--
|
||||
-- What we add:
|
||||
-- 1. paliad.user_calendar_bindings — N bindings per user, each with
|
||||
-- a scope_kind enum (all_visible / personal_only / project /
|
||||
-- client / litigation / patent / case) and an optional scope_id
|
||||
-- pointing at a paliad.projects row when the scope is hierarchy-
|
||||
-- anchored. The same Appointment can be PUT into multiple of
|
||||
-- these bindings (e.g. master cal + per-project cal).
|
||||
-- 2. paliad.appointment_caldav_targets — (appointment_id, binding_id)
|
||||
-- join carrying the per-target caldav_uid + caldav_etag. The
|
||||
-- canonical UID is still per-appointment (paliad-appointment-
|
||||
-- <uuid>@paliad.de) so the same event in N cals shares one UID.
|
||||
-- 3. Backfill: one all_visible binding per existing
|
||||
-- user_caldav_config row, plus one target row per Appointment
|
||||
-- already pushed (caldav_uid IS NOT NULL). Backfill maps the
|
||||
-- target's binding_id to the appointment creator's binding —
|
||||
-- that matches today's Phase F semantics, where the creator's
|
||||
-- sync goroutine owns the etag.
|
||||
--
|
||||
-- The scalar columns paliad.appointments.caldav_uid / caldav_etag
|
||||
-- STAY in place through Slice 1 and Slice 2. Slice 1 keeps them as
|
||||
-- read-once denormalised pointers to the default binding's target
|
||||
-- row; Slice 4 drops them after telemetry confirms no path still
|
||||
-- reads them.
|
||||
--
|
||||
-- Idempotent: every CREATE uses IF NOT EXISTS, both backfills are
|
||||
-- guarded by NOT EXISTS. Safe to re-run.
|
||||
--
|
||||
-- audit_reason set_config required at the top because m's recent
|
||||
-- migration friction had several mig failures from missing reasons.
|
||||
-- The trigger raising 'audit reason required' is on
|
||||
-- paliad.deadline_rules only — this migration doesn't touch that
|
||||
-- table — but we set the reason for symmetry per paliadin's 2026-05-19
|
||||
-- coder-shift brief.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 101: CalDAV multi-calendar schema + backfill (Slice 1 of t-paliad-212; design doc docs/design-caldav-multi-calendar-2026-05-19.md). No row mutations on existing trigger-guarded tables; this is a defensive symmetry set_config.',
|
||||
true);
|
||||
|
||||
-- =========================================================================
|
||||
-- 1. paliad.user_calendar_bindings
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.user_calendar_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Full URL or path under user_caldav_config.url. The CalDAV client
|
||||
-- resolves it against the user's server URL the same way it
|
||||
-- resolves the legacy user_caldav_config.calendar_path today.
|
||||
calendar_path text NOT NULL,
|
||||
|
||||
-- What the picker UI shows for this binding. Discovered via
|
||||
-- PROPFIND <displayname/> at add-time and cached here so we don't
|
||||
-- re-fetch every render. Default '' (Slice 1 backfill leaves it
|
||||
-- empty; Slice 2 fills it during the picker flow).
|
||||
display_name text NOT NULL DEFAULT '',
|
||||
|
||||
-- Which appointments push into this calendar. Slice 1 only really
|
||||
-- needs 'all_visible' (that's all the backfill creates) but we
|
||||
-- ship the full enum now so the schema is final and Slice 2/3
|
||||
-- don't have to ALTER it.
|
||||
scope_kind text NOT NULL,
|
||||
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
|
||||
-- Only meaningful when scope_kind is hierarchy-anchored
|
||||
-- (project / client / litigation / patent / case). When true,
|
||||
-- the binding ALSO receives the user's personal (project_id IS
|
||||
-- NULL AND created_by = user_id) appointments. Ignored for
|
||||
-- 'all_visible' (already includes them) and 'personal_only'.
|
||||
include_personal boolean NOT NULL DEFAULT false,
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_error text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT user_calendar_bindings_scope_kind_chk CHECK (
|
||||
scope_kind IN ('all_visible','personal_only','project','client','litigation','patent','case')
|
||||
),
|
||||
CONSTRAINT user_calendar_bindings_scope_id_chk CHECK (
|
||||
(scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
|
||||
OR
|
||||
(scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- One binding per (user, calendar) — can't bind the same external
|
||||
-- calendar twice for the same user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_user_path_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, calendar_path);
|
||||
|
||||
-- One hierarchy binding per (user, scope_kind, scope_id) — a user
|
||||
-- can't have two bindings for the same project, but CAN have a
|
||||
-- 'project' binding for project X alongside an 'all_visible'
|
||||
-- master binding (different scope_kind ⇒ different row).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_hier_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind, scope_id)
|
||||
WHERE scope_id IS NOT NULL;
|
||||
|
||||
-- One scope-less binding per (user, scope_kind) — at most one
|
||||
-- 'all_visible' and one 'personal_only' per user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_root_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind)
|
||||
WHERE scope_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_calendar_bindings_user_idx
|
||||
ON paliad.user_calendar_bindings (user_id)
|
||||
WHERE enabled;
|
||||
|
||||
-- No updated_at trigger — paliad.user_caldav_config also doesn't have
|
||||
-- one. The Go service layer sets updated_at = NOW() explicitly on
|
||||
-- every write (see SaveConfig in caldav_service.go); we follow the
|
||||
-- same convention here so all CalDAV-related tables are consistent.
|
||||
|
||||
ALTER TABLE paliad.user_calendar_bindings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Same shape as user_caldav_config policies: a user sees + mutates
|
||||
-- only their own rows. auth.uid() returns the authenticated user's
|
||||
-- id (mirrors auth.uid()).
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_select ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_select ON paliad.user_calendar_bindings
|
||||
FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_insert ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_insert ON paliad.user_calendar_bindings
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_update ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_update ON paliad.user_calendar_bindings
|
||||
FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_delete ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_delete ON paliad.user_calendar_bindings
|
||||
FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 2. paliad.appointment_caldav_targets
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.appointment_caldav_targets (
|
||||
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
|
||||
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'paliad-appointment-<uuid>@paliad.de' — derived from
|
||||
-- appointment_id, identical across all bindings of one appointment.
|
||||
caldav_uid text NOT NULL,
|
||||
|
||||
-- ETag returned by the CalDAV server on the last successful PUT.
|
||||
-- NULLABLE to match the legacy paliad.appointments.caldav_etag
|
||||
-- column: some servers don't return ETag on PUT and we
|
||||
-- re-PROPFIND lazily on next tick.
|
||||
caldav_etag text,
|
||||
|
||||
last_pushed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (appointment_id, binding_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_binding_idx
|
||||
ON paliad.appointment_caldav_targets (binding_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_uid_idx
|
||||
ON paliad.appointment_caldav_targets (caldav_uid);
|
||||
|
||||
ALTER TABLE paliad.appointment_caldav_targets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- A target row is visible/mutable to the user who owns the binding.
|
||||
-- Appointment-side visibility is enforced separately by AppointmentService;
|
||||
-- the target is a sync-state row, scoped per-user.
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets
|
||||
FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets
|
||||
FOR UPDATE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets
|
||||
FOR DELETE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 3. Backfill — one all_visible binding per existing CalDAV-configured user
|
||||
-- =========================================================================
|
||||
|
||||
-- For every paliad.user_caldav_config row, insert an 'all_visible'
|
||||
-- binding that mirrors today's single-target Phase F push. The new
|
||||
-- binding inherits the legacy `calendar_path` (or, when that's empty,
|
||||
-- the server URL itself — same fallback the client uses today). The
|
||||
-- enabled flag carries over.
|
||||
--
|
||||
-- Idempotent: skipped when this user already has an all_visible binding
|
||||
-- (re-running the migration is a no-op).
|
||||
INSERT INTO paliad.user_calendar_bindings
|
||||
(user_id, calendar_path, display_name, scope_kind, scope_id, include_personal, enabled)
|
||||
SELECT
|
||||
c.user_id,
|
||||
COALESCE(NULLIF(c.calendar_path, ''), c.url),
|
||||
'',
|
||||
'all_visible',
|
||||
NULL,
|
||||
false,
|
||||
c.enabled
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 4. Backfill — one target row per already-pushed appointment
|
||||
-- =========================================================================
|
||||
|
||||
-- For every appointment with a non-null caldav_uid, insert one target
|
||||
-- row pointing at the appointment creator's new all_visible binding.
|
||||
-- That preserves the (appointment, calendar) sync state exactly as it
|
||||
-- existed before this migration.
|
||||
--
|
||||
-- Why created_by, not "every visible user": today's Phase F
|
||||
-- caldav_uid/caldav_etag scalars on appointments are populated by
|
||||
-- whoever happened to push last; in practice the etag almost always
|
||||
-- belongs to the creator's calendar because pull-side updates only
|
||||
-- run when CreatedBy = userID (caldav_service.go:449). Mapping the
|
||||
-- backfill target to the creator's binding keeps the etag pointing
|
||||
-- where it actually came from. Other users' goroutines will create
|
||||
-- their own target rows on their next sync tick after Slice 2 ships.
|
||||
--
|
||||
-- Idempotent: skipped when (appointment_id, binding_id) target already
|
||||
-- exists.
|
||||
INSERT INTO paliad.appointment_caldav_targets
|
||||
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
|
||||
SELECT
|
||||
a.id,
|
||||
b.id,
|
||||
a.caldav_uid,
|
||||
a.caldav_etag,
|
||||
a.updated_at
|
||||
FROM paliad.appointments a
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.appointment_caldav_targets t
|
||||
WHERE t.appointment_id = a.id
|
||||
AND t.binding_id = b.id
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 5. Assertions — hard fail if the backfill didn't catch every row
|
||||
-- =========================================================================
|
||||
|
||||
-- Every paliad.user_caldav_config row must have at least one
|
||||
-- all_visible binding after this migration. If it doesn't, either a
|
||||
-- row was inserted between the backfill and the assertion (race —
|
||||
-- run is wrapped in a transaction by golang-migrate, so this can't
|
||||
-- happen) or the backfill is buggy. Hard fail either way.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_users int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_users
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_users > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % paliad.user_caldav_config row(s) without an all_visible binding',
|
||||
missing_users;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Every appointment with a non-null caldav_uid AND a non-null
|
||||
-- created_by must have a target row pointing at its creator's
|
||||
-- all_visible binding. created_by can be NULL on legacy rows
|
||||
-- (e.g. seed data) so we exclude those from the assertion.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_targets int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_targets
|
||||
FROM paliad.appointments a
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.appointment_caldav_targets t
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.id = t.binding_id
|
||||
WHERE t.appointment_id = a.id
|
||||
AND b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_targets > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % appointment(s) with caldav_uid but no all_visible target row',
|
||||
missing_targets;
|
||||
END IF;
|
||||
END $$;
|
||||
15
internal/db/migrations/102_system_audit_log.down.sql
Normal file
15
internal/db/migrations/102_system_audit_log.down.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Revert mig 102 — drop paliad.system_audit_log and its indexes / policies.
|
||||
-- audit_reason set_config required by the mig 079 trigger pattern.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 102 down: drop paliad.system_audit_log (t-paliad-214 Slice 1 revert)',
|
||||
true);
|
||||
|
||||
DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log;
|
||||
DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.system_audit_log_event_type_created_at_idx;
|
||||
DROP INDEX IF EXISTS paliad.system_audit_log_actor_id_created_at_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.system_audit_log;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user