feat: Checklisten — interactive filing checklists with localStorage state

Six bilingual patent-workflow checklists (UPC Statement of Claim, Defence,
Confidentiality Application, Representative Registration; BPatG Nullity;
EPO Opposition) with grouped items, rule references, and tips. Index page
lists cards with regime filter and per-checklist progress; detail page
persists check state in localStorage (patholo:checklist:<slug>), shows a
live progress bar, supports reset and print, and submits feedback via
Supabase checklisten_feedback.
This commit is contained in:
m
2026-04-16 10:48:42 +02:00
parent 69d1402c0a
commit 40ff17657f
12 changed files with 1504 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
-- Migration: Checklisten feedback table for patholo
-- Target: ydb.youpc.org (patholo's Supabase instance)
-- Apply via Supabase SQL editor or psql
CREATE TABLE IF NOT EXISTS checklisten_feedback (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
feedback_type text NOT NULL,
checklist text NOT NULL DEFAULT '',
message text NOT NULL,
submitted_by text DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE checklisten_feedback ENABLE ROW LEVEL SECURITY;
-- Authenticated users may insert feedback; read access is admin-only
-- (matches the gebuehrentabellen_feedback / glossar_suggestions pattern).
CREATE POLICY "authenticated_insert_feedback" ON checklisten_feedback
FOR INSERT TO authenticated
WITH CHECK (true);

View File

@@ -8,6 +8,8 @@ import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossar } from "./src/glossar";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklisten } from "./src/checklisten";
import { renderChecklistenDetail } from "./src/checklisten-detail";
const DIST = join(import.meta.dir, "dist");
@@ -27,6 +29,8 @@ async function build() {
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossar.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklisten.ts"),
join(import.meta.dir, "src/client/checklisten-detail.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -56,6 +60,8 @@ async function build() {
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossar.html"), renderGlossar());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklisten.html"), renderChecklisten());
await Bun.write(join(DIST, "checklisten-detail.html"), renderChecklistenDetail());
console.log("Build complete \u2192 dist/");
}

View File

@@ -0,0 +1,95 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderChecklistenDetail(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="checklisten.title">Checkliste &mdash; patHoLo</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklisten" />
<main>
<section className="tool-page">
<div className="container">
<a href="/checklisten" className="checklist-back">
<span className="checklist-back-arrow">&larr;</span>
<span data-i18n="checklisten.back">Zur&uuml;ck zur &Uuml;bersicht</span>
</a>
<div className="tool-header checklist-detail-header">
<div className="checklist-detail-head-row">
<div>
<h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
<dl className="checklist-meta" id="checklist-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
<button type="button" id="btn-reset" className="btn-ghost" data-i18n="checklisten.reset">Zur&uuml;cksetzen</button>
<button type="button" id="btn-feedback" className="btn-suggest">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
</div>
</div>
<div className="checklist-progress">
<div className="checklist-progress-bar">
<div className="checklist-progress-fill" id="progress-fill" />
</div>
<span className="checklist-progress-label" id="progress-label">0 / 0</span>
</div>
</div>
<div id="checklist-groups" className="checklist-groups" />
<div className="checklist-print-footer">
<p className="checklist-disclaimer" data-i18n="checklisten.disclaimer">
Hinweis: Diese Checklisten dienen als Ged&auml;chtnisst&uuml;tze und ersetzen keine Pr&uuml;fung im Einzelfall. Ma&szlig;geblich sind die jeweils geltenden Verfahrensregeln.
</p>
</div>
</div>
</section>
</main>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="checklisten.feedback.title">Feedback zur Checkliste</h2>
<button className="modal-close" id="modal-close" type="button">&times;</button>
</div>
<form id="feedback-form">
<div className="form-field">
<label htmlFor="feedback-type" data-i18n="checklisten.feedback.type">Art</label>
<select id="feedback-type" required>
<option value="error" data-i18n="checklisten.feedback.error">Fehler gefunden</option>
<option value="missing" data-i18n="checklisten.feedback.missing">Fehlender Punkt</option>
<option value="suggestion" data-i18n="checklisten.feedback.suggestion">Verbesserungsvorschlag</option>
<option value="other" data-i18n="checklisten.feedback.other">Sonstiges</option>
</select>
</div>
<div className="form-field">
<label htmlFor="feedback-message" data-i18n="checklisten.feedback.message">Nachricht</label>
<textarea id="feedback-message" rows={4} required />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="checklisten.feedback.cancel">Abbrechen</button>
<button type="submit" className="btn-submit" data-i18n="checklisten.feedback.submit">Absenden</button>
</div>
<p className="form-msg" id="feedback-msg" />
</form>
</div>
</div>
<Footer />
<script src="/assets/checklisten-detail.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,44 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderChecklisten(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="checklisten.title">Checklisten &mdash; patHoLo</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklisten" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="checklisten.heading">Checklisten</h1>
<p className="tool-subtitle" data-i18n="checklisten.subtitle">
Interaktive Checklisten f&uuml;r typische Verfahrensschritte vor UPC, BPatG und EPA.
</p>
</div>
<div className="checklist-filters" id="checklist-filters">
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
</div>
<div className="checklist-grid" id="checklist-grid" />
</div>
</section>
</main>
<Footer />
<script src="/assets/checklisten.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,272 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistItem {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface ChecklistGroup {
titleDE: string;
titleEN: string;
items: ChecklistItem[];
}
interface Checklist {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
deadlineDE?: string;
deadlineEN?: string;
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
}
let checklist: Checklist | null = null;
let state: Record<string, boolean> = {};
function storageKey(slug: string): string {
return `patholo:checklist:${slug}`;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function itemKey(groupIdx: number, itemIdx: number): string {
return `g${groupIdx}-i${itemIdx}`;
}
function loadState() {
if (!checklist) return;
try {
const raw = localStorage.getItem(storageKey(checklist.slug));
state = raw ? (JSON.parse(raw) as Record<string, boolean>) : {};
} catch {
state = {};
}
}
function saveState() {
if (!checklist) return;
localStorage.setItem(storageKey(checklist.slug), JSON.stringify(state));
}
function totalItems(): number {
if (!checklist) return 0;
return checklist.groups.reduce((n, g) => n + g.items.length, 0);
}
function doneItems(): number {
return Object.values(state).filter(Boolean).length;
}
async function load() {
const slug = window.location.pathname.split("/").pop() ?? "";
const resp = await fetch(`/api/checklisten/${encodeURIComponent(slug)}`);
if (!resp.ok) {
document.title = "404 — patHoLo";
const title = document.getElementById("checklist-title")!;
title.textContent = t("checklisten.notfound");
return;
}
checklist = await resp.json();
loadState();
renderAll();
}
function renderAll() {
if (!checklist) return;
renderHeader();
renderGroups();
updateProgress();
}
function renderHeader() {
if (!checklist) return;
const isEN = getLang() === "en";
const title = isEN ? checklist.titleEN : checklist.titleDE;
const desc = isEN ? checklist.descriptionEN : checklist.descriptionDE;
const court = isEN ? checklist.courtEN : checklist.courtDE;
const deadline = isEN ? checklist.deadlineEN : checklist.deadlineDE;
const reference = isEN ? checklist.referenceEN : checklist.referenceDE;
document.title = `${title} — patHoLo`;
document.getElementById("checklist-title")!.textContent = title;
document.getElementById("checklist-subtitle")!.textContent = desc;
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
const deadlineLabel = isEN ? "Deadline" : "Frist";
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
const regimeLabel = isEN ? "Regime" : "Bereich";
const parts: string[] = [];
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(checklist.regime)}">${esc(checklist.regime)}</span></dd></div>`);
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
if (deadline) {
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
}
if (reference) {
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
}
document.getElementById("checklist-meta")!.innerHTML = parts.join("");
}
function renderGroups() {
if (!checklist) return;
const isEN = getLang() === "en";
const container = document.getElementById("checklist-groups")!;
container.innerHTML = checklist.groups.map((g, gi) => {
const groupTitle = isEN ? g.titleEN : g.titleDE;
const items = g.items.map((item, ii) => {
const key = itemKey(gi, ii);
const checked = !!state[key];
const label = isEN ? item.labelEN : item.labelDE;
const note = isEN ? item.noteEN : item.noteDE;
const rule = item.rule;
const noteHTML = note ? `<p class="checklist-item-note">${esc(note)}</p>` : "";
const ruleHTML = rule ? `<span class="checklist-item-rule">${esc(rule)}</span>` : "";
return `<li class="checklist-item${checked ? " checked" : ""}" data-key="${key}">
<label class="checklist-item-label">
<input type="checkbox" class="checklist-checkbox" data-key="${key}"${checked ? " checked" : ""} />
<span class="checklist-item-body">
<span class="checklist-item-row">
<span class="checklist-item-text">${esc(label)}</span>
${ruleHTML}
</span>
${noteHTML}
</span>
</label>
</li>`;
}).join("");
return `<section class="checklist-group">
<h2 class="checklist-group-title">${esc(groupTitle)}</h2>
<ol class="checklist-list">${items}</ol>
</section>`;
}).join("");
container.querySelectorAll<HTMLInputElement>(".checklist-checkbox").forEach((cb) => {
cb.addEventListener("change", () => {
const key = cb.dataset.key!;
state[key] = cb.checked;
saveState();
const li = cb.closest(".checklist-item");
if (li) li.classList.toggle("checked", cb.checked);
updateProgress();
});
});
}
function updateProgress() {
const total = totalItems();
const done = doneItems();
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
const fill = document.getElementById("progress-fill");
if (fill) (fill as HTMLElement).style.width = `${pct}%`;
const label = document.getElementById("progress-label");
if (label) {
const doneLabel = getLang() === "en" ? "done" : "erledigt";
label.textContent = `${done} / ${total} ${doneLabel}`;
}
}
function initReset() {
document.getElementById("btn-reset")!.addEventListener("click", () => {
if (!checklist) return;
const ok = confirm(t("checklisten.reset.confirm"));
if (!ok) return;
state = {};
saveState();
renderGroups();
updateProgress();
});
}
function initPrint() {
document.getElementById("btn-print")!.addEventListener("click", () => {
window.print();
});
}
function initFeedback() {
const modal = document.getElementById("feedback-modal")!;
const form = document.getElementById("feedback-form")!;
const msg = document.getElementById("feedback-msg")!;
document.getElementById("btn-feedback")!.addEventListener("click", () => {
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
});
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!checklist) return;
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
const payload = {
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
checklist: checklist.slug,
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
};
if (!payload.message) {
msg.textContent = t("checklisten.feedback.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/checklisten/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.feedback.success");
msg.className = "form-msg form-msg-success";
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
setTimeout(() => { modal.style.display = "none"; }, 1500);
} catch {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initReset();
initPrint();
initFeedback();
onLangChange(renderAll);
load();
});

View File

@@ -0,0 +1,104 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistSummary {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
itemCount: number;
}
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
function storageKey(slug: string): string {
return `patholo:checklist:${slug}`;
}
function progressFor(slug: string, total: number): { done: number; total: number } {
try {
const raw = localStorage.getItem(storageKey(slug));
if (!raw) return { done: 0, total };
const obj = JSON.parse(raw) as Record<string, boolean>;
const done = Object.values(obj).filter(Boolean).length;
return { done: Math.min(done, total), total };
} catch {
return { done: 0, total };
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function load() {
const resp = await fetch("/api/checklisten");
if (!resp.ok) return;
allChecklists = await resp.json();
render();
}
function render() {
const grid = document.getElementById("checklist-grid")!;
const isEN = getLang() === "en";
const filtered = activeRegime === "all"
? allChecklists
: allChecklists.filter((c) => c.regime === activeRegime);
if (filtered.length === 0) {
grid.innerHTML = `<p class="checklist-empty" data-i18n="checklisten.empty">${t("checklisten.empty")}</p>`;
return;
}
grid.innerHTML = filtered.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const { done, total } = progressFor(c.slug, c.itemCount);
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
const doneLabel = isEN ? "done" : "erledigt";
return `<a href="/checklisten/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${total} ${isEN ? "items" : "Punkte"}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
<div class="checklist-card-progress">
<div class="checklist-progress-bar">
<div class="checklist-progress-fill" style="width:${pct}%"></div>
</div>
<span class="checklist-progress-label">${done} / ${total} ${doneLabel}</span>
</div>
</a>`;
}).join("");
}
function initFilters() {
const container = document.getElementById("checklist-filters")!;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
activeRegime = btn.dataset.regime ?? "all";
render();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
onLangChange(render);
load();
});

View File

@@ -18,6 +18,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.links": "Links",
"nav.glossar": "Glossar",
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
"nav.checklisten": "Checklisten",
"nav.logout": "Abmelden",
// Footer
@@ -275,6 +276,35 @@ const translations: Record<Lang, Record<string, string>> = {
// Index — Geb\u00fchrentabellen card
"index.gebuehren.title": "Geb\u00fchrentabellen",
"index.gebuehren.desc": "Interaktive Geb\u00fchrentabellen f\u00fcr GKG, RVG, UPC, EPA und PatKostG. Streitwert eingeben, Geb\u00fchr ablesen.",
// Checklisten
"index.checklisten.title": "Checklisten",
"index.checklisten.desc": "Interaktive Checklisten f\u00fcr UPC-, DE- und EPA-Verfahren. Fortschritt wird lokal gespeichert.",
"checklisten.title": "Checklisten \u2014 patHoLo",
"checklisten.heading": "Checklisten",
"checklisten.subtitle": "Interaktive Checklisten f\u00fcr typische Verfahrensschritte vor UPC, BPatG und EPA. Abhaken, ausdrucken, kein Punkt vergessen.",
"checklisten.filter.all": "Alle",
"checklisten.filter.de": "DE",
"checklisten.empty": "Keine Checklisten in dieser Kategorie.",
"checklisten.back": "Zur\u00fcck zur \u00dcbersicht",
"checklisten.print": "Drucken",
"checklisten.reset": "Zur\u00fccksetzen",
"checklisten.reset.confirm": "Alle H\u00e4kchen dieser Checkliste wirklich zur\u00fccksetzen?",
"checklisten.notfound": "Checkliste nicht gefunden.",
"checklisten.disclaimer": "Hinweis: Diese Checklisten dienen als Ged\u00e4chtnisst\u00fctze und ersetzen keine Pr\u00fcfung im Einzelfall. Ma\u00dfgeblich sind die jeweils geltenden Verfahrensregeln.",
"checklisten.feedback.btn": "Feedback",
"checklisten.feedback.title": "Feedback zur Checkliste",
"checklisten.feedback.type": "Art",
"checklisten.feedback.error": "Fehler gefunden",
"checklisten.feedback.missing": "Fehlender Punkt",
"checklisten.feedback.suggestion": "Verbesserungsvorschlag",
"checklisten.feedback.other": "Sonstiges",
"checklisten.feedback.message": "Nachricht",
"checklisten.feedback.submit": "Absenden",
"checklisten.feedback.cancel": "Abbrechen",
"checklisten.feedback.success": "Danke f\u00fcr Ihr Feedback!",
"checklisten.feedback.error.required": "Bitte geben Sie eine Nachricht ein.",
"checklisten.feedback.error.generic": "Fehler beim Senden. Bitte versuchen Sie es erneut.",
},
en: {
@@ -286,6 +316,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.links": "Links",
"nav.glossar": "Glossary",
"nav.gebuehrentabellen": "Fee Schedules",
"nav.checklisten": "Checklists",
"nav.logout": "Sign Out",
// Footer
@@ -543,6 +574,35 @@ const translations: Record<Lang, Record<string, string>> = {
// Index \u2014 Geb\u00fchrentabellen card
"index.gebuehren.title": "Fee Schedules",
"index.gebuehren.desc": "Interactive fee schedules for GKG, RVG, UPC, EPO, and PatKostG. Enter a dispute value, read the fee.",
// Checklisten
"index.checklisten.title": "Checklists",
"index.checklisten.desc": "Interactive filing checklists for UPC, German, and EPO proceedings. Progress is stored locally.",
"checklisten.title": "Checklists \u2014 patHoLo",
"checklisten.heading": "Checklists",
"checklisten.subtitle": "Interactive checklists for typical procedural steps before the UPC, German Patent Court, and EPO. Tick off, print, miss nothing.",
"checklisten.filter.all": "All",
"checklisten.filter.de": "DE",
"checklisten.empty": "No checklists in this category.",
"checklisten.back": "Back to overview",
"checklisten.print": "Print",
"checklisten.reset": "Reset",
"checklisten.reset.confirm": "Really reset all checkboxes for this checklist?",
"checklisten.notfound": "Checklist not found.",
"checklisten.disclaimer": "Note: These checklists are aides-memoire only and do not replace case-by-case review. The applicable procedural rules are controlling.",
"checklisten.feedback.btn": "Feedback",
"checklisten.feedback.title": "Checklist feedback",
"checklisten.feedback.type": "Type",
"checklisten.feedback.error": "Error found",
"checklisten.feedback.missing": "Missing item",
"checklisten.feedback.suggestion": "Improvement suggestion",
"checklisten.feedback.other": "Other",
"checklisten.feedback.message": "Message",
"checklisten.feedback.submit": "Submit",
"checklisten.feedback.cancel": "Cancel",
"checklisten.feedback.success": "Thank you for your feedback!",
"checklisten.feedback.error.required": "Please enter a message.",
"checklisten.feedback.error.generic": "Error submitting. Please try again.",
},
};

View File

@@ -7,6 +7,7 @@ const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
const ICON_BOOK = '<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>';
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_LOGOUT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
const ICON_PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v6l-2 4h10l-2-4V4"/><line x1="12" y1="16" x2="12" y2="21"/><line x1="8" y1="4" x2="16" y2="4"/></svg>';
@@ -45,6 +46,7 @@ export function Sidebar({ currentPath }: SidebarProps): string {
{navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath)}
{navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath)}
{navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath)}
{navItem("/checklisten", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath)}
{navItem("/glossar", ICON_BOOK, "nav.glossar", "Glossar", currentPath)}
{navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath)}
{navItem("/links", ICON_LINK, "nav.links", "Links", currentPath)}

View File

@@ -10,6 +10,7 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
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>';
export function renderIndex(): string {
return "<!DOCTYPE html>" + (
@@ -84,6 +85,12 @@ export function renderIndex(): string {
<h2 data-i18n="index.gebuehren.title">Geb&uuml;hrentabellen</h2>
<p data-i18n="index.gebuehren.desc">Interaktive Geb&uuml;hrentabellen f&uuml;r GKG, RVG, UPC, EPA und PatKostG. Streitwert eingeben, Geb&uuml;hr ablesen.</p>
</a>
<a href="/checklisten" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_CHECK }} />
<h2 data-i18n="index.checklisten.title">Checklisten</h2>
<p data-i18n="index.checklisten.desc">Interaktive Checklisten f&uuml;r UPC-, DE- und EPA-Verfahren. Fortschritt wird lokal gespeichert.</p>
</a>
</div>
</div>
</section>

View File

@@ -2804,3 +2804,382 @@ input[type="range"]::-moz-range-thumb {
border: 1px solid #ccc;
}
}
/* --- Checklisten --- */
.checklist-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1.5rem;
}
.checklist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.checklist-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1.1rem 1.2rem 1.2rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.checklist-card:hover {
border-color: var(--color-accent);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.checklist-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.checklist-card-count {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.checklist-card-title {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.3;
margin: 0;
}
.checklist-card-desc {
color: var(--color-text-muted);
font-size: 0.88rem;
line-height: 1.5;
margin: 0;
}
.checklist-card-court {
font-size: 0.78rem;
color: var(--color-text-muted);
margin: 0;
font-style: italic;
}
.checklist-card-progress {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.2rem;
}
.checklist-regime {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 99px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
background: rgba(101, 163, 13, 0.12);
color: var(--color-accent);
}
.checklist-regime-UPC {
background: rgba(101, 163, 13, 0.12);
color: var(--color-accent);
}
.checklist-regime-DE {
background: rgba(26, 54, 93, 0.08);
color: #1b365d;
}
.checklist-regime-EPA {
background: rgba(100, 100, 122, 0.12);
color: var(--color-text-muted);
}
.checklist-empty {
color: var(--color-text-muted);
text-align: center;
padding: 2rem 1rem;
grid-column: 1 / -1;
}
/* --- Checklist detail --- */
.checklist-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
text-decoration: none;
margin-bottom: 1rem;
transition: color 0.15s ease;
}
.checklist-back:hover {
color: var(--color-accent);
}
.checklist-back-arrow {
font-size: 1rem;
}
.checklist-detail-header {
margin-bottom: 1.5rem;
}
.checklist-detail-head-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.checklist-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.9rem;
font-size: 0.82rem;
font-weight: 500;
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s ease;
font-family: var(--font-sans);
}
.btn-ghost:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.checklist-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.5rem;
margin-top: 0.85rem;
font-size: 0.82rem;
}
.checklist-meta-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.checklist-meta-item dt {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
font-weight: 600;
}
.checklist-meta-item dd {
color: var(--color-text);
margin: 0;
}
.checklist-progress {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1.25rem;
}
.checklist-progress-bar {
flex: 1;
height: 0.4rem;
background: var(--color-border);
border-radius: 99px;
overflow: hidden;
}
.checklist-progress-fill {
height: 100%;
background: var(--color-accent);
border-radius: 99px;
transition: width 0.2s ease;
width: 0%;
}
.checklist-progress-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.checklist-groups {
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.checklist-group-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.75rem;
}
.checklist-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.checklist-item {
padding: 0.55rem 0.6rem;
border-radius: var(--radius);
transition: background 0.15s ease;
}
.checklist-item:hover {
background: rgba(101, 163, 13, 0.04);
}
.checklist-item.checked .checklist-item-text {
color: var(--color-text-muted);
text-decoration: line-through;
}
.checklist-item-label {
display: flex;
align-items: flex-start;
gap: 0.7rem;
cursor: pointer;
}
.checklist-checkbox {
width: 1.05rem;
height: 1.05rem;
margin-top: 0.2rem;
accent-color: var(--color-accent);
flex-shrink: 0;
cursor: pointer;
}
.checklist-item-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.checklist-item-row {
display: flex;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
}
.checklist-item-text {
font-size: 0.92rem;
line-height: 1.5;
color: var(--color-text);
}
.checklist-item-rule {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 99px;
background: rgba(26, 54, 93, 0.06);
color: #1b365d;
font-family: var(--font-mono);
white-space: nowrap;
}
.checklist-item-note {
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.5;
margin: 0;
}
.checklist-print-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.checklist-disclaimer {
font-size: 0.75rem;
color: var(--color-text-muted);
line-height: 1.6;
margin: 0;
}
@media (max-width: 640px) {
.checklist-detail-head-row {
flex-direction: column;
}
.checklist-actions {
width: 100%;
}
}
@media print {
.checklist-back,
.checklist-actions,
.checklist-filters,
.checklist-card-progress {
display: none !important;
}
.checklist-item {
break-inside: avoid;
border-bottom: 1px dotted #ccc;
padding: 0.3rem 0;
}
.checklist-item:hover {
background: none;
}
.checklist-checkbox {
/* Keep checkbox visible with actual state */
accent-color: #000;
}
.checklist-group {
break-inside: avoid-page;
margin-bottom: 1rem;
}
.checklist-progress {
margin-top: 0.5rem;
}
.checklist-progress-bar {
display: none;
}
}

View File

@@ -0,0 +1,510 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"mgit.msbls.de/m/patholo/internal/auth"
)
type ChecklistItem struct {
LabelDE string `json:"labelDE"`
LabelEN string `json:"labelEN"`
NoteDE string `json:"noteDE,omitempty"`
NoteEN string `json:"noteEN,omitempty"`
Rule string `json:"rule,omitempty"`
}
type ChecklistGroup struct {
TitleDE string `json:"titleDE"`
TitleEN string `json:"titleEN"`
Items []ChecklistItem `json:"items"`
}
type Checklist struct {
Slug string `json:"slug"`
TitleDE string `json:"titleDE"`
TitleEN string `json:"titleEN"`
DescriptionDE string `json:"descriptionDE"`
DescriptionEN string `json:"descriptionEN"`
Regime string `json:"regime"` // UPC | DE | EPA
CourtDE string `json:"courtDE"`
CourtEN string `json:"courtEN"`
DeadlineDE string `json:"deadlineDE,omitempty"`
DeadlineEN string `json:"deadlineEN,omitempty"`
ReferenceDE string `json:"referenceDE,omitempty"`
ReferenceEN string `json:"referenceEN,omitempty"`
Groups []ChecklistGroup `json:"groups"`
}
type ChecklistSummary struct {
Slug string `json:"slug"`
TitleDE string `json:"titleDE"`
TitleEN string `json:"titleEN"`
DescriptionDE string `json:"descriptionDE"`
DescriptionEN string `json:"descriptionEN"`
Regime string `json:"regime"`
CourtDE string `json:"courtDE"`
CourtEN string `json:"courtEN"`
ItemCount int `json:"itemCount"`
}
type ChecklistFeedback struct {
FeedbackType string `json:"feedback_type"`
Checklist string `json:"checklist"`
Message string `json:"message"`
}
var checklists = []Checklist{
{
Slug: "upc-statement-of-claim",
TitleDE: "UPC Klageschrift",
TitleEN: "UPC Statement of Claim",
DescriptionDE: "Pflichtbestandteile einer Klageschrift vor dem Einheitlichen Patentgericht.",
DescriptionEN: "Mandatory content of a statement of claim before the Unified Patent Court.",
Regime: "UPC",
CourtDE: "Einheitliches Patentgericht (Lokal-/Regional-/Zentralkammer)",
CourtEN: "Unified Patent Court (local/regional/central division)",
ReferenceDE: "RoP 13",
ReferenceEN: "RoP 13",
Groups: []ChecklistGroup{
{
TitleDE: "Formale Angaben",
TitleEN: "Formal details",
Items: []ChecklistItem{
{LabelDE: "Bezeichnung der Parteien (inkl. Vertreter)", LabelEN: "Names and addresses of the parties (and representatives)", Rule: "RoP 13(1)(a)-(b)"},
{LabelDE: "Angabe der Lokal-, Regional- oder Zentralkammer", LabelEN: "Identification of the division where the action is brought", Rule: "RoP 13(1)(c)"},
{LabelDE: "Verfahrenssprache gewählt und ggf. begründet", LabelEN: "Language of proceedings selected and justified if required", Rule: "RoP 14"},
{LabelDE: "Anschrift für Zustellungen", LabelEN: "Address for service", Rule: "RoP 8.1, 13(1)(b)"},
{LabelDE: "Vollmacht bzw. Vertretungsnachweis des Prozessbevollmächtigten", LabelEN: "Evidence of representation / authorisation", NoteDE: "Art. 48 EPGÜ; Vertretung durch zugelassenen Rechtsanwalt oder EPA mit EPLC.", NoteEN: "Art. 48 UPCA; representation by authorised lawyer or EPA with EPLC."},
},
},
{
TitleDE: "Patent und Verletzung",
TitleEN: "Patent and infringement",
Items: []ChecklistItem{
{LabelDE: "Patentnummer und Angabe, ob Einheitspatent oder EP-Bündelpatent", LabelEN: "Patent number and indication whether unitary or classical EP", Rule: "RoP 13(1)(d)"},
{LabelDE: "Nachweis der Inhaberschaft / Aktivlegitimation", LabelEN: "Evidence of ownership / entitlement to sue", Rule: "RoP 13(1)(e)"},
{LabelDE: "Angriffsgegenstand (streitige Ausführungsform) konkret bezeichnet", LabelEN: "Identification of the contested embodiments", Rule: "RoP 13(1)(g)"},
{LabelDE: "Verletzungsargumentation mit Merkmalsgliederung", LabelEN: "Infringement analysis with feature-by-feature mapping", Rule: "RoP 13(1)(l)"},
{LabelDE: "Angabe von Hintergrund und Stand der Technik (soweit erforderlich)", LabelEN: "Relevant technical and factual background", Rule: "RoP 13(1)(k)"},
},
},
{
TitleDE: "Anträge und Streitwert",
TitleEN: "Relief sought and value",
Items: []ChecklistItem{
{LabelDE: "Konkrete Anträge (Unterlassung, Rückruf, Vernichtung, Schadensersatz, Auskunft)", LabelEN: "Orders sought (injunction, recall, destruction, damages, information)", Rule: "RoP 13(1)(p)"},
{LabelDE: "Streitwert und Begründung der Streitwertangabe", LabelEN: "Value of the action with reasoned indication", Rule: "RoP 13(1)(o), 370"},
{LabelDE: "Antrag zu den Kosten", LabelEN: "Request regarding costs", Rule: "RoP 13(1)(p)"},
{LabelDE: "Ggf. Antrag auf einstweilige Maßnahmen oder Beschleunigung", LabelEN: "If applicable: request for provisional or accelerated measures", Rule: "RoP 206, 118"},
},
},
{
TitleDE: "Zuständigkeit und Zulässigkeit",
TitleEN: "Jurisdiction and admissibility",
Items: []ChecklistItem{
{LabelDE: "Begründung der Zuständigkeit der gewählten Kammer", LabelEN: "Reasoned statement on competence of the chosen division", Rule: "Art. 33 EPGÜ / UPCA; RoP 13(1)(i)"},
{LabelDE: "Kein Opt-out des Patents (sofern klassisches EP)", LabelEN: "No opt-out in force (for classical EPs)", NoteDE: "Register des UPC prüfen.", NoteEN: "Check the UPC case management register."},
{LabelDE: "Keine parallele nationale Verletzungsklage ohne Abstimmung", LabelEN: "No parallel national infringement action without coordination"},
},
},
{
TitleDE: "Anlagen und Gebühren",
TitleEN: "Annexes and fees",
Items: []ChecklistItem{
{LabelDE: "Patentschrift (B-Schrift) und ggf. T3/Übersetzung", LabelEN: "Patent specification (B-publication) and translation if required"},
{LabelDE: "Übersetzung der Ansprüche in die Verfahrenssprache", LabelEN: "Translation of the claims into the language of proceedings", Rule: "RoP 14.2"},
{LabelDE: "Relevante Beweismittel (Produktunterlagen, Testberichte, Lizenzverträge)", LabelEN: "Supporting evidence (product materials, test reports, licence agreements)"},
{LabelDE: "Festgebühr und streitwertabhängige Gerichtsgebühr eingezahlt", LabelEN: "Fixed fee and value-based court fee paid", Rule: "RoP 15.2, Rules on Court Fees"},
{LabelDE: "Einreichung über CMS (elektronisch)", LabelEN: "Electronic filing via CMS", Rule: "RoP 4.1"},
},
},
},
},
{
Slug: "upc-statement-of-defence",
TitleDE: "UPC Klageerwiderung",
TitleEN: "UPC Statement of Defence",
DescriptionDE: "Pflichtbestandteile der Klageerwiderung (mit optionaler Nichtigkeits-Widerklage).",
DescriptionEN: "Mandatory content of the statement of defence (with optional counterclaim for revocation).",
Regime: "UPC",
CourtDE: "Einheitliches Patentgericht",
CourtEN: "Unified Patent Court",
DeadlineDE: "3 Monate ab Zustellung der Klage",
DeadlineEN: "3 months from service of the statement of claim",
ReferenceDE: "RoP 23, 24, 25",
ReferenceEN: "RoP 23, 24, 25",
Groups: []ChecklistGroup{
{
TitleDE: "Formale Angaben",
TitleEN: "Formal details",
Items: []ChecklistItem{
{LabelDE: "Aktenzeichen und Bezeichnung der Parteien", LabelEN: "Case number and names of the parties", Rule: "RoP 24(a)"},
{LabelDE: "Anschrift für Zustellungen und Vertretungsnachweis", LabelEN: "Address for service and evidence of representation", Rule: "RoP 24(b)-(c)"},
{LabelDE: "Einhaltung der 3-Monats-Frist", LabelEN: "Compliance with the 3-month deadline", Rule: "RoP 23", NoteDE: "Frist läuft ab Zustellung der Klageschrift.", NoteEN: "Runs from service of the statement of claim."},
},
},
{
TitleDE: "Inhaltliche Verteidigung",
TitleEN: "Substantive defence",
Items: []ChecklistItem{
{LabelDE: "Stellungnahme zu Zuständigkeit, Zulässigkeit und Sprache", LabelEN: "Position on competence, admissibility and language", Rule: "RoP 24(d)-(e)"},
{LabelDE: "Bestrittene und unbestrittene Tatsachen konkret benannt", LabelEN: "Indicate contested and uncontested facts", Rule: "RoP 24(g)"},
{LabelDE: "Gegenargumentation zur Verletzung (Merkmal für Merkmal)", LabelEN: "Non-infringement arguments (feature by feature)", Rule: "RoP 24(g)-(h)"},
{LabelDE: "Ggf. Einreden (Erschöpfung, Vorbenutzung, FRAND, Verjährung, private use)", LabelEN: "Defences raised (exhaustion, prior use, FRAND, statute of limitations, private use)", Rule: "Art. 27, 28 EPGÜ / UPCA"},
},
},
{
TitleDE: "Widerklage und Gegenanträge",
TitleEN: "Counterclaims and counter-requests",
Items: []ChecklistItem{
{LabelDE: "Ggf. Nichtigkeits-Widerklage mit separatem Antrag", LabelEN: "If applicable: counterclaim for revocation with separate request", Rule: "RoP 25"},
{LabelDE: "Bei Nichtigkeits-Widerklage: Nichtigkeitsgründe und Stand der Technik", LabelEN: "For revocation counterclaim: grounds for invalidity and prior art", Rule: "RoP 25.1(b)"},
{LabelDE: "Antrag zu Kosten und Sicherheitsleistung", LabelEN: "Request regarding costs and security"},
},
},
{
TitleDE: "Anlagen und Gebühren",
TitleEN: "Annexes and fees",
Items: []ChecklistItem{
{LabelDE: "Beweismittel für bestrittene Tatsachen", LabelEN: "Evidence for contested facts", Rule: "RoP 24(i)"},
{LabelDE: "Ggf. Gutachten, Entgegenhaltungen, Übersetzungen", LabelEN: "Expert opinions, prior art, translations if applicable"},
{LabelDE: "Gerichtsgebühr für Widerklage (bei Nichtigkeit)", LabelEN: "Court fee for counterclaim (if revocation)", Rule: "RoP 25.2"},
{LabelDE: "Einreichung über CMS", LabelEN: "Electronic filing via CMS"},
},
},
},
},
{
Slug: "upc-confidentiality",
TitleDE: "UPC Vertraulichkeitsantrag",
TitleEN: "UPC Confidentiality Application",
DescriptionDE: "Antrag auf Schutz vertraulicher Informationen (z.B. Geschäftsgeheimnisse) im UPC-Verfahren.",
DescriptionEN: "Application for protection of confidential information (e.g. trade secrets) in UPC proceedings.",
Regime: "UPC",
CourtDE: "Einheitliches Patentgericht",
CourtEN: "Unified Patent Court",
ReferenceDE: "RoP 262A",
ReferenceEN: "RoP 262A",
Groups: []ChecklistGroup{
{
TitleDE: "Antrag und Begründung",
TitleEN: "Request and reasoning",
Items: []ChecklistItem{
{LabelDE: "Konkreter Antrag zur Behandlung als vertraulich", LabelEN: "Specific request for confidential treatment", Rule: "RoP 262A.1"},
{LabelDE: "Bezeichnung der konkret betroffenen Informationen/Dokumente", LabelEN: "Identification of the specific information/documents concerned", Rule: "RoP 262A.2(a)"},
{LabelDE: "Begründung der Vertraulichkeit (Geschäftsgeheimnis, wirtschaftlicher Wert)", LabelEN: "Reasoned justification (trade secret, economic value)", Rule: "RoP 262A.2(b)"},
{LabelDE: "Beschreibung der potentiellen Nachteile bei Offenlegung", LabelEN: "Description of potential harm if disclosed"},
},
},
{
TitleDE: "Vertraulichkeitsklub",
TitleEN: "Confidentiality club",
Items: []ChecklistItem{
{LabelDE: "Vorschlag des Personenkreises mit Zugang (max. 1 natürliche Person je Partei + Vertreter)", LabelEN: "Proposed list of persons with access (min. one natural person per party plus representatives)", Rule: "RoP 262A.5"},
{LabelDE: "Geheimhaltungsverpflichtung für alle Klub-Mitglieder vorgesehen", LabelEN: "Confidentiality undertaking foreseen for all club members"},
{LabelDE: "Ggf. besondere Modalitäten (nur Einsicht vor Ort, keine Kopien)", LabelEN: "Specific modalities (on-site inspection only, no copies) if applicable"},
},
},
{
TitleDE: "Verfahrensrechtliche Aspekte",
TitleEN: "Procedural aspects",
Items: []ChecklistItem{
{LabelDE: "Zeitpunkt des Antrags (so früh wie möglich)", LabelEN: "Timing of the application (as early as possible)"},
{LabelDE: "Abstimmung mit Gegenpartei dokumentiert oder Gründe der Nichtabstimmung", LabelEN: "Coordination with opposing party documented or reasons for absence"},
{LabelDE: "Geschwärzte und ungeschwärzte Fassungen der Dokumente vorbereitet", LabelEN: "Redacted and unredacted versions of documents prepared"},
{LabelDE: "Verhältnismäßigkeit geprüft (Art. 9 Know-how-RL)", LabelEN: "Proportionality considered (Art. 9 Trade Secrets Directive)"},
},
},
},
},
{
Slug: "upc-representative-registration",
TitleDE: "UPC Vertreterregistrierung",
TitleEN: "UPC Representative Registration",
DescriptionDE: "Unterlagen für die Eintragung als zugelassener Vertreter vor dem UPC.",
DescriptionEN: "Documents required to be registered as a qualified representative before the UPC.",
Regime: "UPC",
CourtDE: "Kanzlei des UPC / Registrar",
CourtEN: "UPC Registry",
ReferenceDE: "Art. 48 EPGÜ; RoP 286 ff.",
ReferenceEN: "Art. 48 UPCA; RoP 286 et seq.",
Groups: []ChecklistGroup{
{
TitleDE: "Rechtsanwälte (Art. 48(1))",
TitleEN: "Lawyers (Art. 48(1))",
Items: []ChecklistItem{
{LabelDE: "Nachweis der Zulassung als Rechtsanwalt in einem teilnehmenden Mitgliedstaat", LabelEN: "Proof of authorisation as a lawyer in a contracting member state", NoteDE: "Aktuelle Zulassungsbescheinigung der zuständigen Kammer.", NoteEN: "Recent certificate from the competent bar association."},
{LabelDE: "Aktuelle Registerauskunft (nicht älter als 3 Monate)", LabelEN: "Up-to-date bar register extract (not older than 3 months)"},
{LabelDE: "Berufshaftpflichtversicherung nachgewiesen", LabelEN: "Professional indemnity insurance evidenced"},
},
},
{
TitleDE: "Patentanwälte (Art. 48(2))",
TitleEN: "European patent attorneys (Art. 48(2))",
Items: []ChecklistItem{
{LabelDE: "Eintragung in der Liste der zugelassenen Vertreter beim EPA", LabelEN: "Entry on the EPO list of professional representatives"},
{LabelDE: "EPLC oder gleichwertige Qualifikation (Art. 48(2))", LabelEN: "European Patent Litigation Certificate (EPLC) or equivalent"},
{LabelDE: "Nachweis der Qualifikation (Zeugnis / Bescheinigung)", LabelEN: "Evidence of qualification (certificate)"},
},
},
{
TitleDE: "Allgemeine Unterlagen",
TitleEN: "General documents",
Items: []ChecklistItem{
{LabelDE: "Ausgefülltes UPC-Registrierungsformular", LabelEN: "Completed UPC registration form"},
{LabelDE: "Gültige Ausweiskopie", LabelEN: "Valid identity document copy"},
{LabelDE: "Zustellungsadresse (in einem Mitgliedstaat)", LabelEN: "Address for service (in a contracting member state)"},
{LabelDE: "CMS-Account erstellt und Starke-Authentifizierung eingerichtet", LabelEN: "CMS account created and strong authentication configured"},
{LabelDE: "Bankverbindung für Gebührenzahlungen hinterlegt", LabelEN: "Bank details lodged for fee payments"},
},
},
},
},
{
Slug: "bpatg-nullity-action",
TitleDE: "Nichtigkeitsklage BPatG",
TitleEN: "Patent Nullity Action (BPatG)",
DescriptionDE: "Formale und inhaltliche Anforderungen an eine Nichtigkeitsklage vor dem Bundespatentgericht.",
DescriptionEN: "Formal and substantive requirements for a nullity action before the German Federal Patent Court.",
Regime: "DE",
CourtDE: "Bundespatentgericht, München",
CourtEN: "Federal Patent Court (BPatG), Munich",
ReferenceDE: "§§ 81 ff. PatG",
ReferenceEN: "Sections 81 et seq. Patent Act (PatG)",
Groups: []ChecklistGroup{
{
TitleDE: "Formale Anforderungen",
TitleEN: "Formal requirements",
Items: []ChecklistItem{
{LabelDE: "Bezeichnung der Parteien (Kläger, Beklagter = Patentinhaber)", LabelEN: "Names of the parties (claimant, defendant = patent proprietor)", Rule: "§ 253 ZPO i.V.m. § 99 PatG"},
{LabelDE: "Vertretung durch zugelassenen Rechts- oder Patentanwalt", LabelEN: "Representation by admitted attorney-at-law or patent attorney", Rule: "§ 97 PatG"},
{LabelDE: "Schriftform und Unterschrift des Bevollmächtigten", LabelEN: "Written form and signature of the representative"},
{LabelDE: "Zustellungsbevollmächtigter bei Auslandssitz", LabelEN: "Address for service in Germany if abroad"},
},
},
{
TitleDE: "Inhaltliche Anforderungen",
TitleEN: "Substantive requirements",
Items: []ChecklistItem{
{LabelDE: "Bezeichnung des angegriffenen Patents (Nummer, ggf. Teil)", LabelEN: "Identification of the challenged patent (number, part)", Rule: "§ 81(1) PatG"},
{LabelDE: "Antrag auf Nichtigerklärung (ganz oder teilweise)", LabelEN: "Request for full or partial revocation", Rule: "§ 81(1) PatG"},
{LabelDE: "Nichtigkeitsgründe konkret benannt", LabelEN: "Specific grounds for invalidity", Rule: "§ 22(1), § 81(1) PatG", NoteDE: "Mangelnde Patentfähigkeit (§§ 1-5), unzureichende Offenbarung (§ 21(1) Nr. 2), widerrechtliche Entnahme, unzulässige Erweiterung.", NoteEN: "Lack of patentability, insufficient disclosure, unlawful usurpation, impermissible extension."},
{LabelDE: "Tatsachen und Beweismittel für jeden Nichtigkeitsgrund", LabelEN: "Facts and evidence for each ground"},
{LabelDE: "Substantiierte Merkmalsgliederung und Vergleich mit Stand der Technik", LabelEN: "Substantiated feature analysis and comparison with prior art"},
},
},
{
TitleDE: "Zulässigkeit",
TitleEN: "Admissibility",
Items: []ChecklistItem{
{LabelDE: "Keine parallele Einspruchsfrist am EPA offen (Sperrwirkung)", LabelEN: "No pending opposition period at the EPO (blocking effect)", Rule: "§ 81(2) PatG"},
{LabelDE: "Klageberechtigung (Popularklage bei PatG ja, außer bei Usurpation)", LabelEN: "Standing to sue (popularis action permitted except for usurpation)", Rule: "§ 81(3) PatG"},
{LabelDE: "Streitwert angegeben und begründet", LabelEN: "Value in dispute indicated and reasoned"},
},
},
{
TitleDE: "Anlagen und Gebühren",
TitleEN: "Annexes and fees",
Items: []ChecklistItem{
{LabelDE: "Patentschrift des angegriffenen Patents", LabelEN: "Specification of the challenged patent"},
{LabelDE: "Entgegenhaltungen vollständig vorgelegt und ggf. übersetzt", LabelEN: "Prior art documents submitted in full, with translations if needed"},
{LabelDE: "Vollmacht", LabelEN: "Power of attorney"},
{LabelDE: "Gerichtsgebühr (4,5-facher Gebührensatz PatKostG) gezahlt", LabelEN: "Court fee (4.5x GKG rate, PatKostG) paid", Rule: "GebVerz. PatKostG Nr. 401300"},
{LabelDE: "Zahl der Abschriften für Beklagte(n) + 1", LabelEN: "Copies for defendant(s) plus one"},
},
},
},
},
{
Slug: "epa-opposition",
TitleDE: "EPA Einspruch",
TitleEN: "EPO Opposition",
DescriptionDE: "Formvorschriften und Frist für einen Einspruch gegen ein erteiltes europäisches Patent.",
DescriptionEN: "Formal requirements and deadline for an opposition against a granted European patent.",
Regime: "EPA",
CourtDE: "Europäisches Patentamt, Einspruchsabteilung",
CourtEN: "European Patent Office, Opposition Division",
DeadlineDE: "9 Monate nach Veröffentlichung des Erteilungshinweises",
DeadlineEN: "9 months after publication of the mention of grant",
ReferenceDE: "Art. 99 EPÜ; Regel 76 EPÜ",
ReferenceEN: "Art. 99 EPC; Rule 76 EPC",
Groups: []ChecklistGroup{
{
TitleDE: "Frist und Einreichung",
TitleEN: "Deadline and filing",
Items: []ChecklistItem{
{LabelDE: "9-Monats-Frist ab B-Schriften-Veröffentlichung eingehalten", LabelEN: "9-month deadline from B-publication met", Rule: "Art. 99(1) EPÜ / EPC", NoteDE: "Nicht verlängerbar; Wiedereinsetzung nur eng begrenzt möglich.", NoteEN: "Not extendable; re-establishment only in narrow circumstances."},
{LabelDE: "Einreichung über EPA Online Filing / MyEPO", LabelEN: "Filed via EPO Online Filing / MyEPO"},
{LabelDE: "Eingangstag dokumentiert und Einhaltung der Frist geprüft", LabelEN: "Filing receipt saved and deadline compliance verified"},
},
},
{
TitleDE: "Parteien und Vertretung",
TitleEN: "Parties and representation",
Items: []ChecklistItem{
{LabelDE: "Bezeichnung des Einsprechenden (Name, Anschrift, Staat)", LabelEN: "Identification of the opponent (name, address, state)", Rule: "Regel 76(2)(a) EPÜ / EPC"},
{LabelDE: "Vertretung durch zugelassenen Vertreter oder Rechtsanwalt", LabelEN: "Representation by professional representative or lawyer", Rule: "Art. 134 EPÜ / EPC"},
{LabelDE: "Vollmacht (soweit erforderlich)", LabelEN: "Power of attorney (where required)"},
},
},
{
TitleDE: "Angriff auf das Patent",
TitleEN: "Attack on the patent",
Items: []ChecklistItem{
{LabelDE: "Bezeichnung des angegriffenen europäischen Patents (Nummer, Erteilungsdatum)", LabelEN: "Identification of the challenged European patent (number, grant date)", Rule: "Regel 76(2)(b) EPÜ / EPC"},
{LabelDE: "Umfang des Einspruchs (vollständig / teilweise, Anspruchsnummern)", LabelEN: "Extent of opposition (full / partial, claim numbers)", Rule: "Regel 76(2)(c) EPÜ / EPC"},
{LabelDE: "Einspruchsgründe (Art. 100 EPÜ) konkret genannt", LabelEN: "Grounds of opposition under Art. 100 EPC stated", NoteDE: "a) mangelnde Patentfähigkeit, b) unzureichende Offenbarung, c) unzulässige Erweiterung.", NoteEN: "a) lack of patentability, b) insufficient disclosure, c) impermissible amendment."},
{LabelDE: "Tatsachen und Beweismittel je Einspruchsgrund substantiiert", LabelEN: "Substantiated facts and evidence for each ground", Rule: "Regel 76(2)(c) EPÜ / EPC"},
},
},
{
TitleDE: "Anlagen und Gebühr",
TitleEN: "Annexes and fee",
Items: []ChecklistItem{
{LabelDE: "Entgegenhaltungen vollständig und (soweit nötig) in Amtssprache", LabelEN: "Prior art fully submitted and, if necessary, translated into an official language"},
{LabelDE: "Ggf. Gutachten oder eidesstattliche Erklärungen beigefügt", LabelEN: "Expert opinions or affidavits included where appropriate"},
{LabelDE: "Einspruchsgebühr fristgerecht entrichtet", LabelEN: "Opposition fee paid in time", Rule: "Art. 99(1) EPÜ / EPC; Art. 2(1) GebO / RFees"},
{LabelDE: "Identifizierung der Sprache (Regel 3 EPÜ) beachtet", LabelEN: "Compliant language of proceedings (Rule 3 EPC)", Rule: "Regel 3 EPÜ / EPC"},
},
},
},
},
}
func totalItemCount(c Checklist) int {
n := 0
for _, g := range c.Groups {
n += len(g.Items)
}
return n
}
func findChecklist(slug string) (Checklist, bool) {
for _, c := range checklists {
if c.Slug == slug {
return c, true
}
}
return Checklist{}, false
}
func handleChecklistenPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklisten.html")
}
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if _, ok := findChecklist(slug); !ok {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, "dist/checklisten-detail.html")
}
func handleChecklistenAPI(w http.ResponseWriter, r *http.Request) {
summaries := make([]ChecklistSummary, 0, len(checklists))
for _, c := range checklists {
summaries = append(summaries, ChecklistSummary{
Slug: c.Slug,
TitleDE: c.TitleDE,
TitleEN: c.TitleEN,
DescriptionDE: c.DescriptionDE,
DescriptionEN: c.DescriptionEN,
Regime: c.Regime,
CourtDE: c.CourtDE,
CourtEN: c.CourtEN,
ItemCount: totalItemCount(c),
})
}
writeJSON(w, http.StatusOK, summaries)
}
func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
c, ok := findChecklist(slug)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
return
}
writeJSON(w, http.StatusOK, c)
}
func handleChecklistenFeedback(w http.ResponseWriter, r *http.Request) {
var feedback ChecklistFeedback
if err := json.NewDecoder(r.Body).Decode(&feedback); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage."})
return
}
feedback.FeedbackType = strings.TrimSpace(feedback.FeedbackType)
feedback.Checklist = strings.TrimSpace(feedback.Checklist)
feedback.Message = strings.TrimSpace(feedback.Message)
if feedback.Message == "" || feedback.FeedbackType == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Nachricht und Art sind erforderlich."})
return
}
accessToken := ""
email := ""
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
accessToken = cookie.Value
email = extractEmailFromJWT(cookie.Value)
}
payload := map[string]string{
"feedback_type": feedback.FeedbackType,
"checklist": feedback.Checklist,
"message": feedback.Message,
"submitted_by": email,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
log.Printf("checklisten feedback marshal error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Interner Fehler."})
return
}
endpoint := fmt.Sprintf("%s/rest/v1/checklisten_feedback", authClient.URL)
req2, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody))
if err != nil {
log.Printf("checklisten feedback request error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Interner Fehler."})
return
}
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("apikey", authClient.AnonKey)
if accessToken != "" {
req2.Header.Set("Authorization", "Bearer "+accessToken)
} else {
req2.Header.Set("Authorization", "Bearer "+authClient.AnonKey)
}
req2.Header.Set("Prefer", "return=minimal")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req2)
if err != nil {
log.Printf("checklisten feedback supabase error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Fehler beim Speichern."})
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
log.Printf("checklisten feedback supabase status %d: %s", resp.StatusCode, string(body))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Fehler beim Speichern."})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"ok": "true"})
}

View File

@@ -47,6 +47,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string) {
protected.HandleFunc("GET /api/tools/gebuehrentabellen", handleGebuehrentabellenAPI)
protected.HandleFunc("GET /api/tools/gebuehrentabellen/lookup", handleGebuehrentabellenLookup)
protected.HandleFunc("POST /api/tools/gebuehrentabellen/feedback", handleGebuehrentabellenFeedback)
protected.HandleFunc("GET /checklisten", handleChecklistenPage)
protected.HandleFunc("GET /checklisten/{slug}", handleChecklistDetailPage)
protected.HandleFunc("GET /api/checklisten", handleChecklistenAPI)
protected.HandleFunc("GET /api/checklisten/{slug}", handleChecklistAPI)
protected.HandleFunc("POST /api/checklisten/feedback", handleChecklistenFeedback)
mux.Handle("/", client.Middleware(protected))
}