Files
paliad/frontend/src/projects-detail.tsx
m 8921830f43 feat(pwa): app-shell phase 2 — manifest + icons + service worker + install prompt (t-paliad-042)
Ship the installability bits that t-paliad-041 deferred so iOS / Android
users can add Paliad to their home screen.

What landed:
- frontend/public/manifest.json — name=Paliad, theme_color #65a30d (lime),
  display=standalone, scope=/, start_url=/dashboard, four icon entries
  (192/512 × any/maskable). Served from /manifest.json with the
  spec-mandated application/manifest+json content type (servePWAManifest
  in internal/handlers/pwa.go).
- frontend/public/icons/ — lime "p" logo rendered to 192/512 PNGs in both
  "any" and maskable variants (maskable variant has extra safe-zone
  padding), 180×180 apple-touch-icon, 32×32 favicon. SVG sources kept
  under frontend/icons-src/ for regeneration via rsvg-convert.
- frontend/public/sw.js — minimal cache-first for /assets/* and /icons/*,
  network-first for /api/*, network passthrough for everything else.
  CACHE_VERSION + activate-clean lets us bump and purge cleanly. Served
  from /sw.js so its scope can claim /; Service-Worker-Allowed: / header
  set, no-cache on the SW file itself so updates take effect on next load.
- frontend/src/components/PWAHead.tsx — head fragment (manifest link,
  apple-touch-icon, favicon, app-name metas, <script src="/assets/app.js"
  defer>). Added to all 30 page TSX files via mechanical insertion.
- frontend/src/client/app.ts — universal client bundle loaded on every
  page. Three jobs: register the service worker, init the BottomNav
  (icarus flagged that bottom-nav.ts was written but never wired into
  the build — m reproduced the broken [+] Anlegen and Menü buttons in
  prod), and surface the install banner.
- frontend/src/client/pwa-install.ts — install banner UI. Two flows:
  beforeinstallprompt for Chromium/Android (deferred → CTA → prompt),
  one-time iOS Safari hint pointing at the share sheet. Both dismissals
  persist in localStorage (paliad-install-dismissed / -ios-shown).
- frontend/src/styles/global.css — banner styles, sits above BottomNav on
  mobile and pinned bottom-right on desktop, lime-on-white card with the
  brand "p" mark.
- frontend/build.ts — copies frontend/public → dist verbatim so the
  manifest, icons, and SW land at the application root.

Verification before merge:
- bun run build clean, go build/vet/test clean.
- Local server smoke: curl -sI confirmed manifest.json (200,
  application/manifest+json), all icon files (200, image/png), sw.js
  (200, Service-Worker-Allowed: /), app.js (200, text/javascript).
- Playwright at 390×844: Chrome fired beforeinstallprompt, the banner
  rendered with "Paliad installieren" + "Installieren" CTA in German,
  dismiss persisted across reload via localStorage. Manifest validated
  in-browser (name/short_name/start_url/display/scope all correct, all
  four icon URLs returned 200).
- The InvalidStateError on serviceWorker.register() seen in the MCP
  Playwright profile is a known headless flag; SW registration works in
  real Chrome / Safari on localhost and HTTPS production.

Out of scope: push notifications, runtime offline mode (SW intentionally
stays minimal — cache shell + assets, network passthrough for everything
else).
2026-04-26 10:48:27 +02:00

368 lines
22 KiB
TypeScript

import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Project detail shell (v2). DOM IDs use the English `project-*` /
// `parties-*` / `deadlines-*` / `appointments-*` / `notes-*` / `checklists-*`
// naming. The client TS in client/projects-detail.ts queries these IDs in
// lockstep — keep names in sync.
export function renderProjectsDetail(): 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="#65a30d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="projekte.detail.title">Projekt &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/projects" />
<BottomNav currentPath="/projects" />
<main>
<section className="tool-page">
<div className="container">
<a href="/projects" className="akten-back-link" data-i18n="projekte.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<nav className="projekt-breadcrumb" id="project-breadcrumb" aria-label="Breadcrumb" />
<div id="project-detail-loading" className="akten-loading">
<p data-i18n="projekte.detail.loading">L&auml;dt&hellip;</p>
</div>
<div id="project-detail-notfound" className="akten-empty" style="display:none">
<p data-i18n="projekte.detail.notfound">Projekt nicht gefunden oder keine Berechtigung.</p>
</div>
<div id="project-detail-body" style="display:none">
<header className="akten-detail-header">
<div className="akten-detail-title-row">
<div className="akten-detail-title-col">
<h1 id="project-title-display" />
<input type="text" id="project-title-edit" className="akten-title-input" style="display:none" />
<div className="akten-detail-meta">
<span id="project-type-chip" className="akten-type-chip" />
<span className="akten-ref" id="project-ref-display" />
<span id="project-clientmatter" className="akten-ref" />
<span id="project-status-chip" className="akten-status-chip" />
<a id="project-netdocs" className="akten-netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments &nearr;</a>
</div>
</div>
<div className="akten-detail-actions">
<button id="project-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="projekte.detail.edit" title="Bearbeiten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button id="project-save-btn" className="btn-primary btn-cta-lime" type="button" style="display:none" data-i18n="projekte.detail.save">Speichern</button>
</div>
</div>
</header>
<div className="akten-detail-description">
<h3 data-i18n="projekte.detail.description.heading">Notizen</h3>
<p id="project-description-display" className="akten-detail-description-text" />
<textarea id="project-description-edit" className="akten-detail-description-input" rows={4} style="display:none" />
</div>
<nav className="akten-tabs" id="project-tabs">
<a className="akten-tab" data-tab="history" href="#" data-i18n="projekte.detail.tab.verlauf">Verlauf</a>
<a className="akten-tab" data-tab="team" href="#" data-i18n="projekte.detail.tab.team">Team</a>
<a className="akten-tab" data-tab="children" href="#" data-i18n="projekte.detail.tab.kinder">Untergeordnet</a>
<a className="akten-tab" data-tab="parties" href="#" data-i18n="projekte.detail.tab.parteien">Parteien</a>
<a className="akten-tab" data-tab="deadlines" href="#" data-i18n="projekte.detail.tab.fristen">Fristen</a>
<a className="akten-tab" data-tab="appointments" href="#" data-i18n="projekte.detail.tab.termine">Termine</a>
<a className="akten-tab" data-tab="notes" href="#" data-i18n="projekte.detail.tab.notizen">Notizen</a>
<a className="akten-tab" data-tab="checklists" href="#" data-i18n="projekte.detail.tab.checklisten">Checklisten</a>
</nav>
{/* History (Verlauf) */}
<section className="akten-tab-panel" id="tab-history">
<ul className="akten-events" id="project-events-list" />
<p className="akten-events-empty" id="project-events-empty" style="display:none" data-i18n="projekte.detail.verlauf.empty">
Noch keine Ereignisse aufgezeichnet.
</p>
<div className="akten-events-loadmore" id="project-events-loadmore-wrap" style="display:none">
<button type="button" className="btn-secondary" id="project-events-loadmore" data-i18n="projekte.detail.verlauf.loadMore">
Mehr laden
</button>
</div>
</section>
{/* Team */}
<section className="akten-tab-panel" id="tab-team" style="display:none">
<div className="akten-parteien-controls">
<button id="team-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projekte.detail.team.add">
Mitglied hinzuf&uuml;gen
</button>
</div>
<form id="team-form" className="akten-form akten-partei-form" style="display:none" autocomplete="off">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="team-user-input" data-i18n="projekte.detail.team.form.user">Benutzer</label>
<input type="text" id="team-user-input" placeholder="Name oder E-Mail..." autocomplete="off" />
<input type="hidden" id="team-user-id" />
<div id="team-user-suggestions" className="akten-collab-suggestions" />
</div>
<div className="form-field">
<label htmlFor="team-role" data-i18n="projekte.detail.team.form.role">Rolle</label>
<select id="team-role">
<option value="lead" data-i18n="projekte.team.role.lead">Lead</option>
<option value="associate" selected data-i18n="projekte.team.role.associate">Associate</option>
<option value="pa" data-i18n="projekte.team.role.pa">PA</option>
<option value="of_counsel" data-i18n="projekte.team.role.of_counsel">Of Counsel</option>
<option value="local_counsel" data-i18n="projekte.team.role.local_counsel">Local Counsel</option>
<option value="expert" data-i18n="projekte.team.role.expert">Experte</option>
<option value="observer" data-i18n="projekte.team.role.observer">Beobachter</option>
</select>
</div>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="team-cancel" data-i18n="projekte.detail.team.form.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projekte.detail.team.form.submit">Hinzuf&uuml;gen</button>
</div>
<p className="form-msg" id="team-msg" />
</form>
<table className="akten-parteien-table">
<thead>
<tr>
<th data-i18n="projekte.detail.team.col.name">Name</th>
<th data-i18n="projekte.detail.team.col.role">Rolle</th>
<th data-i18n="projekte.detail.team.col.source">Herkunft</th>
<th />
</tr>
</thead>
<tbody id="team-body" />
</table>
<p className="akten-events-empty" id="team-empty" style="display:none" data-i18n="projekte.detail.team.empty">
Noch keine Teammitglieder.
</p>
</section>
{/* Children (Untergeordnet) */}
<section className="akten-tab-panel" id="tab-children" style="display:none">
<div className="akten-parteien-controls">
<a id="child-add-link" className="btn-primary btn-cta-lime btn-small" href="/projects/new" data-i18n="projekte.detail.kinder.add">
Untervorhaben anlegen
</a>
</div>
<ul id="children-list" className="projekt-children-list" />
<p className="akten-events-empty" id="children-empty" style="display:none" data-i18n="projekte.detail.kinder.empty">
Keine untergeordneten Projekte.
</p>
</section>
{/* Parties (Parteien) */}
<section className="akten-tab-panel" id="tab-parties" style="display:none">
<div className="akten-parteien-controls">
<button id="party-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projekte.detail.parteien.add">
Partei hinzuf&uuml;gen
</button>
</div>
<form id="party-form" className="akten-form akten-partei-form" style="display:none" autocomplete="off">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="party-name" data-i18n="projekte.detail.parteien.form.name">Name</label>
<input type="text" id="party-name" required />
</div>
<div className="form-field">
<label htmlFor="party-role" data-i18n="projekte.detail.parteien.form.role">Rolle</label>
<select id="party-role">
<option value="claimant" data-i18n="projekte.detail.parteien.role.claimant">Kl&auml;ger</option>
<option value="defendant" data-i18n="projekte.detail.parteien.role.defendant">Beklagter</option>
<option value="thirdparty" data-i18n="projekte.detail.parteien.role.thirdparty">Streitverk&uuml;ndeter / Drittpartei</option>
</select>
</div>
</div>
<div className="form-field">
<label htmlFor="party-rep" data-i18n="projekte.detail.parteien.form.rep">Vertreter (optional)</label>
<input type="text" id="party-rep" />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="party-cancel" data-i18n="projekte.detail.parteien.form.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projekte.detail.parteien.form.submit">Hinzuf&uuml;gen</button>
</div>
<p className="form-msg" id="party-msg" />
</form>
<table className="akten-parteien-table">
<thead>
<tr>
<th data-i18n="projekte.detail.parteien.col.name">Name</th>
<th data-i18n="projekte.detail.parteien.col.role">Rolle</th>
<th data-i18n="projekte.detail.parteien.col.rep">Vertreter</th>
<th />
</tr>
</thead>
<tbody id="parties-body" />
</table>
<p className="akten-events-empty" id="parties-empty" style="display:none" data-i18n="projekte.detail.parteien.empty">
Noch keine Parteien eingetragen.
</p>
</section>
{/* Deadlines (Fristen) */}
<section className="akten-tab-panel" id="tab-deadlines" style="display:none">
<div className="akten-parteien-controls">
<a id="deadline-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="projekte.detail.fristen.add" href="#">
Frist hinzuf&uuml;gen
</a>
</div>
<div className="akten-table-wrap" id="project-deadlines-tablewrap">
<table className="akten-table fristen-table">
<thead>
<tr>
<th />
<th data-i18n="fristen.col.due">F&auml;llig</th>
<th data-i18n="fristen.col.title">Titel</th>
<th data-i18n="fristen.col.rule">Regel</th>
<th data-i18n="fristen.col.status">Status</th>
</tr>
</thead>
<tbody id="project-deadlines-body" />
</table>
</div>
<p className="akten-events-empty" id="project-deadlines-empty" style="display:none" data-i18n="projekte.detail.fristen.empty">
F&uuml;r dieses Projekt sind noch keine Fristen erfasst.
</p>
</section>
{/* Appointments (Termine) */}
<section className="akten-tab-panel" id="tab-appointments" style="display:none">
<div className="akten-parteien-controls">
<button type="button" id="appointment-add-btn" className="btn-primary btn-cta-lime btn-small" data-i18n="projekte.detail.termine.add">
Termin hinzuf&uuml;gen
</button>
</div>
<form id="project-appointment-form" className="akten-partei-form" style="display:none">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-appointment-title" data-i18n="termine.field.title">Titel</label>
<input type="text" id="project-appointment-title" required />
</div>
<div className="form-field">
<label htmlFor="project-appointment-type" data-i18n="termine.field.type">Typ</label>
<select id="project-appointment-type">
<option value="" data-i18n="termine.field.type.none">Kein Typ</option>
<option value="hearing" data-i18n="termine.type.hearing">Verhandlung</option>
<option value="meeting" data-i18n="termine.type.meeting">Besprechung</option>
<option value="consultation" data-i18n="termine.type.consultation">Beratung</option>
<option value="deadline_hearing" data-i18n="termine.type.deadline_hearing">Fristverhandlung</option>
</select>
</div>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-appointment-start" data-i18n="termine.field.start">Beginn</label>
<input type="datetime-local" id="project-appointment-start" required />
</div>
<div className="form-field">
<label htmlFor="project-appointment-end" data-i18n="termine.field.end">Ende (optional)</label>
<input type="datetime-local" id="project-appointment-end" />
</div>
</div>
<div className="form-field">
<label htmlFor="project-appointment-location" data-i18n="termine.field.location">Ort</label>
<input type="text" id="project-appointment-location" />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="project-appointment-cancel" data-i18n="projekte.detail.termine.form.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projekte.detail.termine.form.submit">Hinzuf&uuml;gen</button>
</div>
<p className="form-msg" id="project-appointment-msg" />
</form>
<div className="akten-table-wrap" id="project-appointments-tablewrap">
<table className="akten-table fristen-table">
<thead>
<tr>
<th />
<th data-i18n="termine.col.start">Beginn</th>
<th data-i18n="termine.col.title">Titel</th>
<th data-i18n="termine.col.location">Ort</th>
<th data-i18n="termine.col.type">Typ</th>
</tr>
</thead>
<tbody id="project-appointments-body" />
</table>
</div>
<p className="akten-events-empty" id="project-appointments-empty" style="display:none" data-i18n="projekte.detail.termine.empty">
F&uuml;r dieses Projekt sind noch keine Termine erfasst.
</p>
</section>
{/* Notes (Notizen) */}
<section className="akten-tab-panel" id="tab-notes" style="display:none">
<div id="notes-container" className="notiz-container" data-parent-type="project" />
</section>
{/* Checklists (Checklisten) */}
<section className="akten-tab-panel" id="tab-checklists" style="display:none">
<p id="project-checklists-empty" className="akten-events-empty" style="display:none" data-i18n="projekte.detail.checklisten.empty">
F&uuml;r dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
</p>
<div className="akten-table-wrap" id="project-checklists-tablewrap" style="display:none">
<table className="akten-table">
<thead>
<tr>
<th data-i18n="projekte.detail.checklisten.col.template">Vorlage</th>
<th data-i18n="projekte.detail.checklisten.col.name">Name</th>
<th data-i18n="projekte.detail.checklisten.col.progress">Fortschritt</th>
<th data-i18n="projekte.detail.checklisten.col.created">Angelegt</th>
</tr>
</thead>
<tbody id="project-checklists-body" />
</table>
</div>
<p className="tool-subtitle akten-checklisten-hint" data-i18n="projekte.detail.checklisten.hint">
Instanzen werden auf der Vorlagen-Seite unter <a href="/checklists">Checklisten</a> angelegt.
</p>
</section>
<div className="akten-detail-footer" id="project-delete-wrap" style="display:none">
<button id="project-delete-btn" className="btn-danger" type="button" data-i18n="projekte.detail.delete">
Projekt archivieren
</button>
</div>
</div>
{/* Delete confirmation modal */}
<div className="modal-overlay" id="delete-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="projekte.detail.delete.confirm.title">Projekt wirklich archivieren?</h2>
<button className="modal-close" id="delete-modal-close" type="button">&times;</button>
</div>
<p data-i18n="projekte.detail.delete.confirm.body">
Das Projekt wird archiviert. Es kann nicht direkt wiederhergestellt werden.
</p>
<div className="form-actions">
<button type="button" className="btn-cancel" id="delete-modal-cancel" data-i18n="projekte.detail.delete.confirm.cancel">Abbrechen</button>
<button type="button" className="btn-danger" id="delete-modal-confirm" data-i18n="projekte.detail.delete.confirm.ok">Archivieren</button>
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/projects-detail.js"></script>
</body>
</html>
);
}