feat: Gebührentabellen — interactive fee schedule reference

Browsable, interactive fee tables for GKG, RVG, UPC, EPA, and PatKostG:

- New page at /tools/gebuehrentabellen with tabbed view
- API endpoint GET /api/tools/gebuehrentabellen returns all fee data
- Lookup endpoint GET /api/tools/gebuehrentabellen/lookup?streitwert=X
- GKG/RVG tables with version pills (2005, 2013, 2021, 2025/Aktuell)
- Streitwert input for quick lookup — highlights matching row
- UPC fee schedule (pre-2026 vs 2026) with fixed fees, value-based
  fees, recoverable costs ceiling, and SME reduction display
- EPA fees (opposition, appeal, grant, examination, search, filing)
- PatKostG tab with BPatG/BGH court fee factors, DPMA fees, and
  patent annual renewal fees (years 3-20)
- Common multipliers reference table (court fee factors, RA/PA VG/TG)
- Feedback modal with Supabase storage (gebuehrentabellen_feedback)
- Full DE/EN i18n, responsive layout, print-friendly
- Added to sidebar nav, landing page tools section, build pipeline
This commit is contained in:
m
2026-04-14 22:44:55 +02:00
parent 4adb166082
commit 2a368a4b61
9 changed files with 1707 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import { renderFristenrechner } from "./src/fristenrechner";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossar } from "./src/glossar";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
const DIST = join(import.meta.dir, "dist");
@@ -25,6 +26,7 @@ async function build() {
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossar.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -53,6 +55,7 @@ async function build() {
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossar.html"), renderGlossar());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
console.log("Build complete \u2192 dist/");
}

View File

@@ -0,0 +1,578 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// --- Types matching API response ---
interface FeeTableRow {
streitwert: number;
fee: number;
}
interface FeeVersionTable {
version: string;
label: string;
validFrom: string;
isCurrent: boolean;
rows: FeeTableRow[];
}
interface UPCValueRow {
maxValue: number | null;
fee: number;
}
interface UPCRecoverRow {
maxValue: number | null;
ceiling: number;
}
interface UPCScheduleInfo {
version: string;
label: string;
fixedInfringement: number;
fixedRevocation: number;
smeReduction: number;
valueBased: UPCValueRow[];
recoverableCosts: UPCRecoverRow[];
}
interface EPAFeeInfo {
key: string;
label: string;
labelEN: string;
fee: number;
smeFee: number;
}
interface MultiplierInfo {
key: string;
label: string;
labelEN: string;
courtFeeFactor: number;
feeBasis: string;
raVGFactor: number;
raTGFactor: number;
paVGFactor: number;
paTGFactor: number;
}
interface PatKostGCourtFee {
key: string;
label: string;
labelEN: string;
factor: number;
note: string;
noteEN: string;
}
interface DPMAFee {
label: string;
labelEN: string;
fee: number;
}
interface DPMAAnnualFee {
year: number;
fee: number;
}
interface PatKostGInfo {
courtFees: PatKostGCourtFee[];
dpmaFees: DPMAFee[];
annualFees: DPMAAnnualFee[];
}
interface FeeTableData {
gkg: FeeVersionTable[];
rvg: FeeVersionTable[];
upc: UPCScheduleInfo[];
epa: EPAFeeInfo[];
multipliers: MultiplierInfo[];
patkostg: PatKostGInfo;
}
interface LookupResult {
streitwert: number;
gkg: Record<string, number>;
rvg: Record<string, number>;
upc: Record<string, { fixedInfringement: number; valueBasedFee: number; total: number; smeTotal: number; recoverableCeiling: number }>;
}
// --- State ---
let data: FeeTableData | null = null;
let activeTab = "gkg";
let gkgVersion = "2025";
let rvgVersion = "2025";
let upcVersion = "2026";
let lookupStreitwert: number | null = null;
let lookupResult: LookupResult | null = null;
// --- Formatting ---
function fmtEUR(v: number): string {
return v.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtInt(v: number): string {
return v.toLocaleString("de-DE", { maximumFractionDigits: 0 });
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// --- Data loading ---
async function loadData() {
const resp = await fetch("/api/tools/gebuehrentabellen");
if (!resp.ok) return;
data = await resp.json();
renderAll();
}
async function doLookup(streitwert: number) {
const resp = await fetch(`/api/tools/gebuehrentabellen/lookup?streitwert=${streitwert}`);
if (!resp.ok) return;
lookupResult = await resp.json();
lookupStreitwert = streitwert;
showLookupResult();
renderActivePanel();
}
// --- Render all ---
function renderAll() {
if (!data) return;
renderVersionPills("gkg-versions", data.gkg, gkgVersion, (v) => { gkgVersion = v; renderGKG(); });
renderVersionPills("rvg-versions", data.rvg, rvgVersion, (v) => { rvgVersion = v; renderRVG(); });
renderUPCVersionPills();
renderGKG();
renderRVG();
renderUPC();
renderEPA();
renderPatKostG();
renderMultipliers();
}
function renderActivePanel() {
if (!data) return;
switch (activeTab) {
case "gkg": renderGKG(); break;
case "rvg": renderRVG(); break;
case "upc": renderUPC(); break;
}
}
// --- Version pills ---
function renderVersionPills(
containerId: string,
versions: FeeVersionTable[],
activeVersion: string,
onSelect: (v: string) => void
) {
const container = document.getElementById(containerId)!;
container.innerHTML = versions.map((v) => {
const label = v.isCurrent ? `${v.version} (${t("gebuehren.current")})` : v.version;
const cls = v.version === activeVersion ? "filter-pill active" : "filter-pill";
return `<button class="${cls}" data-version="${v.version}" type="button">${esc(label)}</button>`;
}).join("");
container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
btn.addEventListener("click", () => {
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
onSelect(btn.dataset.version!);
});
});
}
function renderUPCVersionPills() {
if (!data) return;
const container = document.getElementById("upc-versions")!;
container.innerHTML = data.upc.map((s) => {
const cls = s.version === upcVersion ? "filter-pill active" : "filter-pill";
return `<button class="${cls}" data-version="${s.version}" type="button">${esc(s.label)}</button>`;
}).join("");
container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
btn.addEventListener("click", () => {
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
upcVersion = btn.dataset.version!;
renderUPC();
});
});
}
// --- GKG / RVG table rendering ---
function renderFeeTable(tbodyId: string, versions: FeeVersionTable[], activeVersion: string, type: "gkg" | "rvg") {
const tbody = document.getElementById(tbodyId)!;
const table = versions.find((v) => v.version === activeVersion);
if (!table) { tbody.innerHTML = ""; return; }
const lookupFee = lookupResult && lookupResult[type] ? lookupResult[type][activeVersion] : null;
const hasLookup = lookupStreitwert !== null && lookupFee !== null;
let rows = table.rows;
let insertedLookup = false;
const html: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// Insert lookup row before the first row with streitwert >= lookupStreitwert
if (hasLookup && !insertedLookup && lookupStreitwert! <= row.streitwert) {
if (lookupStreitwert! === row.streitwert) {
// Exact match — highlight this row
html.push(`<tr class="highlight"><td>${fmtInt(row.streitwert)}</td><td>${fmtEUR(row.fee)}</td></tr>`);
insertedLookup = true;
continue;
}
// Insert custom row
html.push(`<tr class="highlight"><td>${fmtInt(lookupStreitwert!)}</td><td>${fmtEUR(lookupFee!)}</td></tr>`);
insertedLookup = true;
}
html.push(`<tr><td>${fmtInt(row.streitwert)}</td><td>${fmtEUR(row.fee)}</td></tr>`);
}
// Lookup streitwert exceeds all rows
if (hasLookup && !insertedLookup) {
html.push(`<tr class="highlight"><td>${fmtInt(lookupStreitwert!)}</td><td>${fmtEUR(lookupFee!)}</td></tr>`);
}
tbody.innerHTML = html.join("");
// Scroll to highlighted row
const highlighted = tbody.querySelector("tr.highlight");
if (highlighted) {
highlighted.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
function renderGKG() {
if (!data) return;
renderFeeTable("gkg-body", data.gkg, gkgVersion, "gkg");
}
function renderRVG() {
if (!data) return;
renderFeeTable("rvg-body", data.rvg, rvgVersion, "rvg");
}
// --- UPC ---
function renderUPC() {
if (!data) return;
const schedule = data.upc.find((s) => s.version === upcVersion);
if (!schedule) return;
const isEN = getLang() === "en";
const smePercent = Math.round(schedule.smeReduction * 100);
// Summary
const summary = document.getElementById("upc-summary")!;
summary.innerHTML = `<div class="gebuehren-upc-cards">
<div class="gebuehren-upc-card">
<div class="gebuehren-upc-card-label">${isEN ? "Fixed fee (infringement)" : "Festgebühr (Verletzung)"}</div>
<div class="gebuehren-upc-card-value">${fmtEUR(schedule.fixedInfringement)}</div>
</div>
<div class="gebuehren-upc-card">
<div class="gebuehren-upc-card-label">${isEN ? "Fixed fee (revocation)" : "Festgebühr (Nichtigkeit)"}</div>
<div class="gebuehren-upc-card-value">${fmtEUR(schedule.fixedRevocation)}</div>
</div>
<div class="gebuehren-upc-card">
<div class="gebuehren-upc-card-label">${isEN ? "SME reduction" : "KMU-Ermäßigung"}</div>
<div class="gebuehren-upc-card-value">${smePercent}%</div>
</div>
</div>`;
// Value-based table
const valueBody = document.getElementById("upc-value-body")!;
const upcLookup = lookupResult?.upc?.[upcVersion];
valueBody.innerHTML = schedule.valueBased.map((row) => {
const maxLabel = row.maxValue === null
? (isEN ? "unlimited" : "unbegrenzt")
: fmtInt(row.maxValue);
const isHighlighted = lookupStreitwert !== null && upcLookup &&
((row.maxValue === null && lookupStreitwert > (schedule.valueBased[schedule.valueBased.indexOf(row) - 1]?.maxValue ?? 0)) ||
(row.maxValue !== null && lookupStreitwert <= row.maxValue &&
(schedule.valueBased.indexOf(row) === 0 || lookupStreitwert > (schedule.valueBased[schedule.valueBased.indexOf(row) - 1]?.maxValue ?? 0))));
return `<tr${isHighlighted ? ' class="highlight"' : ""}><td>${maxLabel}</td><td>${fmtEUR(row.fee)}</td></tr>`;
}).join("");
// Recoverable costs table
const recoverBody = document.getElementById("upc-recover-body")!;
recoverBody.innerHTML = schedule.recoverableCosts.map((row) => {
const maxLabel = row.maxValue === null
? (isEN ? "unlimited" : "unbegrenzt")
: fmtInt(row.maxValue);
const isHighlighted = lookupStreitwert !== null && upcLookup &&
((row.maxValue === null && lookupStreitwert > (schedule.recoverableCosts[schedule.recoverableCosts.indexOf(row) - 1]?.maxValue ?? 0)) ||
(row.maxValue !== null && lookupStreitwert <= row.maxValue &&
(schedule.recoverableCosts.indexOf(row) === 0 || lookupStreitwert > (schedule.recoverableCosts[schedule.recoverableCosts.indexOf(row) - 1]?.maxValue ?? 0))));
return `<tr${isHighlighted ? ' class="highlight"' : ""}><td>${maxLabel}</td><td>${fmtEUR(row.ceiling)}</td></tr>`;
}).join("");
// Scroll to highlighted
const highlighted = valueBody.querySelector("tr.highlight") || recoverBody.querySelector("tr.highlight");
if (highlighted) {
highlighted.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
// --- EPA ---
function renderEPA() {
if (!data) return;
const isEN = getLang() === "en";
const body = document.getElementById("epa-body")!;
body.innerHTML = data.epa.map((fee) => {
const label = isEN ? fee.labelEN : fee.label;
const smeCol = fee.smeFee !== fee.fee
? fmtEUR(fee.smeFee)
: '<span class="text-muted">\u2014</span>';
return `<tr><td>${esc(label)}</td><td>${fmtEUR(fee.fee)}</td><td>${smeCol}</td></tr>`;
}).join("");
}
// --- PatKostG ---
function renderPatKostG() {
if (!data) return;
const isEN = getLang() === "en";
const pk = data.patkostg;
// Court fees
const courtBody = document.getElementById("patkostg-court-body")!;
courtBody.innerHTML = pk.courtFees.map((f) => {
const label = isEN ? f.labelEN : f.label;
const note = isEN ? f.noteEN : f.note;
return `<tr><td>${esc(label)}</td><td>${f.factor.toFixed(1)}x</td><td>${esc(note)}</td></tr>`;
}).join("");
// DPMA fees
const dpmaBody = document.getElementById("patkostg-dpma-body")!;
dpmaBody.innerHTML = pk.dpmaFees.map((f) => {
const label = isEN ? f.labelEN : f.label;
return `<tr><td>${esc(label)}</td><td>${fmtEUR(f.fee)}</td></tr>`;
}).join("");
// Annual fees
const annualBody = document.getElementById("patkostg-annual-body")!;
annualBody.innerHTML = pk.annualFees.map((f) => {
const yearLabel = isEN ? `Year ${f.year}` : `${f.year}. Patentjahr`;
return `<tr><td>${yearLabel}</td><td>${fmtEUR(f.fee)}</td></tr>`;
}).join("");
}
// --- Multipliers ---
function renderMultipliers() {
if (!data) return;
const isEN = getLang() === "en";
const body = document.getElementById("multipliers-body")!;
body.innerHTML = data.multipliers.map((m) => {
const label = isEN ? m.labelEN : m.label;
return `<tr>
<td>${esc(label)}</td>
<td>${m.courtFeeFactor.toFixed(1)}x</td>
<td>${m.feeBasis}</td>
<td>${m.raVGFactor.toFixed(1)}</td>
<td>${m.raTGFactor.toFixed(1)}</td>
<td>${m.paVGFactor.toFixed(1)}</td>
<td>${m.paTGFactor.toFixed(1)}</td>
</tr>`;
}).join("");
}
// --- Lookup result display ---
function showLookupResult() {
const el = document.getElementById("lookup-result")!;
if (!lookupResult) {
el.style.display = "none";
return;
}
const isEN = getLang() === "en";
const sw = lookupResult.streitwert;
const gkgFee = lookupResult.gkg["2025"] ?? 0;
const rvgFee = lookupResult.rvg["2025"] ?? 0;
el.style.display = "block";
el.innerHTML = `<div class="gebuehren-lookup-cards">
<div class="gebuehren-lookup-card">
<span class="gebuehren-lookup-label">${isEN ? "Dispute value" : "Streitwert"}</span>
<span class="gebuehren-lookup-value">${fmtInt(sw)} EUR</span>
</div>
<div class="gebuehren-lookup-card">
<span class="gebuehren-lookup-label">GKG 1,0</span>
<span class="gebuehren-lookup-value">${fmtEUR(gkgFee)} EUR</span>
</div>
<div class="gebuehren-lookup-card">
<span class="gebuehren-lookup-label">RVG 1,0</span>
<span class="gebuehren-lookup-value">${fmtEUR(rvgFee)} EUR</span>
</div>
</div>`;
}
// --- Tab switching ---
function initTabs() {
const container = document.getElementById("gebuehren-tabs")!;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".gebuehren-tab");
if (!btn) return;
container.querySelectorAll(".gebuehren-tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const tab = btn.dataset.tab!;
activeTab = tab;
// Show/hide panels
document.querySelectorAll<HTMLElement>(".gebuehren-panel").forEach((p) => {
p.style.display = "none";
});
document.getElementById(`tab-${tab}`)!.style.display = "block";
// Show multipliers only on GKG/RVG tabs
const multSection = document.getElementById("multipliers-section")!;
multSection.style.display = (tab === "gkg" || tab === "rvg") ? "block" : "none";
});
// Initial multiplier visibility
document.getElementById("multipliers-section")!.style.display = "block";
}
// --- Streitwert lookup ---
function parseStreitwert(input: string): number | null {
// Remove thousands separators (. or ,) and whitespace, then parse
let cleaned = input.replace(/\s/g, "");
// Handle German format: 1.000.000,50 → 1000000.50
if (cleaned.includes(",") && cleaned.includes(".")) {
cleaned = cleaned.replace(/\./g, "").replace(",", ".");
} else if (cleaned.includes(",")) {
// Could be 1000,50 (decimal) or 1,000,000 (thousands)
const parts = cleaned.split(",");
if (parts.length === 2 && parts[1].length <= 2) {
cleaned = cleaned.replace(",", ".");
} else {
cleaned = cleaned.replace(/,/g, "");
}
} else {
cleaned = cleaned.replace(/\./g, "");
}
const val = parseFloat(cleaned);
return isNaN(val) || val <= 0 ? null : val;
}
function initLookup() {
const input = document.getElementById("streitwert-input") as HTMLInputElement;
const btn = document.getElementById("btn-lookup")!;
const doIt = () => {
const val = parseStreitwert(input.value);
if (val === null) {
lookupStreitwert = null;
lookupResult = null;
document.getElementById("lookup-result")!.style.display = "none";
renderActivePanel();
return;
}
doLookup(val);
};
btn.addEventListener("click", doIt);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); doIt(); }
});
// Clear on empty input
input.addEventListener("input", () => {
if (input.value.trim() === "") {
lookupStreitwert = null;
lookupResult = null;
document.getElementById("lookup-result")!.style.display = "none";
renderAll();
}
});
}
// --- Feedback modal ---
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();
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
const payload = {
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
schedule: (document.getElementById("feedback-schedule") as HTMLSelectElement).value,
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
};
if (!payload.message) {
msg.textContent = t("gebuehren.feedback.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/tools/gebuehrentabellen/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("gebuehren.feedback.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("gebuehren.feedback.success");
msg.className = "form-msg form-msg-success";
setTimeout(() => { modal.style.display = "none"; }, 1500);
} catch {
msg.textContent = t("gebuehren.feedback.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initTabs();
initLookup();
initFeedback();
onLangChange(() => {
renderAll();
showLookupResult();
});
loadData();
});

View File

@@ -17,6 +17,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossar",
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
"nav.logout": "Abmelden",
// Footer
@@ -211,6 +212,56 @@ const translations: Record<Lang, Record<string, string>> = {
"glossar.suggest.error.generic": "Fehler beim Senden. Bitte versuchen Sie es erneut.",
"glossar.feedback.title": "Korrektur vorschlagen",
"glossar.feedback.tooltip": "Korrektur vorschlagen",
// Geb\u00fchrentabellen
"gebuehren.title": "Geb\u00fchrentabellen \u2014 patHoLo",
"gebuehren.heading": "Geb\u00fchrentabellen",
"gebuehren.subtitle": "Interaktive Geb\u00fchrentabellen f\u00fcr GKG, RVG, UPC, EPA und PatKostG.",
"gebuehren.streitwert": "Streitwert (EUR)",
"gebuehren.streitwert.placeholder": "z.B. 1.000.000",
"gebuehren.lookup": "Nachschlagen",
"gebuehren.current": "Aktuell",
"gebuehren.col.streitwert": "Streitwert (EUR)",
"gebuehren.col.fee": "1,0 Geb\u00fchr (EUR)",
"gebuehren.col.maxvalue": "bis Streitwert (EUR)",
"gebuehren.col.courtfee": "Gerichtsgeb\u00fchr (EUR)",
"gebuehren.upc.valuebased": "Streitwertabh\u00e4ngige Geb\u00fchren",
"gebuehren.upc.recoverable": "Erstattungsf\u00e4hige Kosten (Obergrenze)",
"gebuehren.upc.ceiling": "Obergrenze (EUR)",
"gebuehren.epa.proceeding": "Verfahren",
"gebuehren.epa.fee": "Geb\u00fchr (EUR)",
"gebuehren.epa.smefee": "KMU-Geb\u00fchr (EUR)",
"gebuehren.multipliers.title": "Geb\u00fchren-Multiplikatoren",
"gebuehren.multipliers.desc": "Faktoren f\u00fcr die Berechnung von Gerichts- und Anwaltskosten je Instanz.",
"gebuehren.multipliers.instance": "Instanz",
"gebuehren.multipliers.courtfee": "Gericht",
"gebuehren.multipliers.factor": "Faktor",
"gebuehren.multipliers.basis": "Grundlage",
"gebuehren.patkostg.court": "Gerichtskosten (BPatG / BGH)",
"gebuehren.patkostg.dpma": "DPMA-Geb\u00fchren",
"gebuehren.patkostg.annual": "Jahresgeb\u00fchren (Patent)",
"gebuehren.patkostg.year": "Patentjahr",
"gebuehren.patkostg.note": "Hinweis",
"gebuehren.patkostg.item": "Geb\u00fchrentatbestand",
"gebuehren.feedback.btn": "Feedback",
"gebuehren.feedback.title": "Feedback zur Geb\u00fchrentabelle",
"gebuehren.feedback.type": "Art",
"gebuehren.feedback.error": "Fehler gefunden",
"gebuehren.feedback.missing": "Fehlende Daten",
"gebuehren.feedback.suggestion": "Verbesserungsvorschlag",
"gebuehren.feedback.other": "Sonstiges",
"gebuehren.feedback.schedule": "Betrifft",
"gebuehren.feedback.general": "Allgemein",
"gebuehren.feedback.message": "Nachricht",
"gebuehren.feedback.submit": "Absenden",
"gebuehren.feedback.cancel": "Abbrechen",
"gebuehren.feedback.success": "Danke f\u00fcr Ihr Feedback!",
"gebuehren.feedback.error.required": "Bitte geben Sie eine Nachricht ein.",
"gebuehren.feedback.error.generic": "Fehler beim Senden. Bitte versuchen Sie es erneut.",
// 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.",
},
en: {
@@ -221,6 +272,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossary",
"nav.gebuehrentabellen": "Fee Schedules",
"nav.logout": "Sign Out",
// Footer
@@ -415,6 +467,56 @@ const translations: Record<Lang, Record<string, string>> = {
"glossar.suggest.error.generic": "Error submitting. Please try again.",
"glossar.feedback.title": "Suggest a correction",
"glossar.feedback.tooltip": "Suggest a correction",
// Geb\u00fchrentabellen
"gebuehren.title": "Fee Schedules \u2014 patHoLo",
"gebuehren.heading": "Fee Schedules",
"gebuehren.subtitle": "Interactive fee schedules for GKG, RVG, UPC, EPO, and PatKostG.",
"gebuehren.streitwert": "Dispute Value (EUR)",
"gebuehren.streitwert.placeholder": "e.g. 1,000,000",
"gebuehren.lookup": "Look up",
"gebuehren.current": "Current",
"gebuehren.col.streitwert": "Dispute Value (EUR)",
"gebuehren.col.fee": "1.0 Fee (EUR)",
"gebuehren.col.maxvalue": "up to value (EUR)",
"gebuehren.col.courtfee": "Court Fee (EUR)",
"gebuehren.upc.valuebased": "Value-based Fees",
"gebuehren.upc.recoverable": "Recoverable Costs (Ceiling)",
"gebuehren.upc.ceiling": "Ceiling (EUR)",
"gebuehren.epa.proceeding": "Proceeding",
"gebuehren.epa.fee": "Fee (EUR)",
"gebuehren.epa.smefee": "SME Fee (EUR)",
"gebuehren.multipliers.title": "Fee Multipliers",
"gebuehren.multipliers.desc": "Factors for calculating court and attorney fees per instance.",
"gebuehren.multipliers.instance": "Instance",
"gebuehren.multipliers.courtfee": "Court",
"gebuehren.multipliers.factor": "Factor",
"gebuehren.multipliers.basis": "Basis",
"gebuehren.patkostg.court": "Court Fees (BPatG / BGH)",
"gebuehren.patkostg.dpma": "DPMA Fees",
"gebuehren.patkostg.annual": "Annual Renewal Fees (Patent)",
"gebuehren.patkostg.year": "Patent Year",
"gebuehren.patkostg.note": "Note",
"gebuehren.patkostg.item": "Fee Item",
"gebuehren.feedback.btn": "Feedback",
"gebuehren.feedback.title": "Fee Schedule Feedback",
"gebuehren.feedback.type": "Type",
"gebuehren.feedback.error": "Error found",
"gebuehren.feedback.missing": "Missing data",
"gebuehren.feedback.suggestion": "Improvement suggestion",
"gebuehren.feedback.other": "Other",
"gebuehren.feedback.schedule": "Regarding",
"gebuehren.feedback.general": "General",
"gebuehren.feedback.message": "Message",
"gebuehren.feedback.submit": "Submit",
"gebuehren.feedback.cancel": "Cancel",
"gebuehren.feedback.success": "Thank you for your feedback!",
"gebuehren.feedback.error.required": "Please enter a message.",
"gebuehren.feedback.error.generic": "Error submitting. Please try again.",
// 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.",
},
};

View File

@@ -6,6 +6,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_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_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>';
@@ -43,6 +44,7 @@ export function Sidebar({ currentPath }: SidebarProps): string {
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
{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("/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

@@ -0,0 +1,259 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderGebuehrentabellen(): 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="gebuehren.title">Geb&uuml;hrentabellen &mdash; patHoLo</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/tools/gebuehrentabellen" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="gebuehren-header-row">
<div>
<h1 data-i18n="gebuehren.heading">Geb&uuml;hrentabellen</h1>
<p className="tool-subtitle" data-i18n="gebuehren.subtitle">
Interaktive Geb&uuml;hrentabellen f&uuml;r GKG, RVG, UPC, EPA und PatKostG.
</p>
</div>
<button className="btn-suggest" id="btn-feedback" type="button">
<span data-i18n="gebuehren.feedback.btn">Feedback</span>
</button>
</div>
</div>
{/* Streitwert lookup */}
<div className="gebuehren-lookup">
<label htmlFor="streitwert-input" data-i18n="gebuehren.streitwert">Streitwert (EUR)</label>
<div className="gebuehren-lookup-row">
<span className="gebuehren-currency">EUR</span>
<input
type="text"
id="streitwert-input"
className="gebuehren-input"
placeholder="z.B. 1.000.000"
data-i18n-placeholder="gebuehren.streitwert.placeholder"
autocomplete="off"
/>
<button type="button" id="btn-lookup" className="btn-lookup" data-i18n="gebuehren.lookup">
Nachschlagen
</button>
</div>
</div>
{/* Lookup result */}
<div className="gebuehren-lookup-result" id="lookup-result" style="display:none" />
{/* Tabs */}
<div className="gebuehren-tabs" id="gebuehren-tabs">
<button className="gebuehren-tab active" data-tab="gkg" type="button">GKG</button>
<button className="gebuehren-tab" data-tab="rvg" type="button">RVG</button>
<button className="gebuehren-tab" data-tab="upc" type="button">UPC</button>
<button className="gebuehren-tab" data-tab="epa" type="button">EPA</button>
<button className="gebuehren-tab" data-tab="patkostg" type="button">PatKostG</button>
</div>
{/* GKG panel */}
<div id="tab-gkg" className="gebuehren-panel">
<div className="gebuehren-versions" id="gkg-versions" />
<div className="gebuehren-table-wrap">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.col.streitwert">Streitwert (EUR)</th>
<th data-i18n="gebuehren.col.fee">1,0 Geb&uuml;hr (EUR)</th>
</tr>
</thead>
<tbody id="gkg-body" />
</table>
</div>
</div>
{/* RVG panel */}
<div id="tab-rvg" className="gebuehren-panel" style="display:none">
<div className="gebuehren-versions" id="rvg-versions" />
<div className="gebuehren-table-wrap">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.col.streitwert">Streitwert (EUR)</th>
<th data-i18n="gebuehren.col.fee">1,0 Geb&uuml;hr (EUR)</th>
</tr>
</thead>
<tbody id="rvg-body" />
</table>
</div>
</div>
{/* UPC panel */}
<div id="tab-upc" className="gebuehren-panel" style="display:none">
<div className="gebuehren-versions" id="upc-versions" />
<div className="gebuehren-upc-summary" id="upc-summary" />
<h3 className="gebuehren-section-title" data-i18n="gebuehren.upc.valuebased">Streitwertabh&auml;ngige Geb&uuml;hren</h3>
<div className="gebuehren-table-wrap">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.col.maxvalue">bis Streitwert (EUR)</th>
<th data-i18n="gebuehren.col.courtfee">Gerichtsgeb&uuml;hr (EUR)</th>
</tr>
</thead>
<tbody id="upc-value-body" />
</table>
</div>
<h3 className="gebuehren-section-title" data-i18n="gebuehren.upc.recoverable">Erstattungsf&auml;hige Kosten (Obergrenze)</h3>
<div className="gebuehren-table-wrap gebuehren-table-wrap-short">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.col.maxvalue">bis Streitwert (EUR)</th>
<th data-i18n="gebuehren.upc.ceiling">Obergrenze (EUR)</th>
</tr>
</thead>
<tbody id="upc-recover-body" />
</table>
</div>
</div>
{/* EPA panel */}
<div id="tab-epa" className="gebuehren-panel" style="display:none">
<div className="gebuehren-table-wrap">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.epa.proceeding">Verfahren</th>
<th data-i18n="gebuehren.epa.fee">Geb&uuml;hr (EUR)</th>
<th data-i18n="gebuehren.epa.smefee">KMU-Geb&uuml;hr (EUR)</th>
</tr>
</thead>
<tbody id="epa-body" />
</table>
</div>
</div>
{/* PatKostG panel */}
<div id="tab-patkostg" className="gebuehren-panel" style="display:none">
<h3 className="gebuehren-section-title" data-i18n="gebuehren.patkostg.court">Gerichtskosten (BPatG / BGH)</h3>
<div className="gebuehren-table-wrap gebuehren-table-wrap-short">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.multipliers.instance">Instanz</th>
<th data-i18n="gebuehren.multipliers.factor">Faktor</th>
<th data-i18n="gebuehren.patkostg.note">Hinweis</th>
</tr>
</thead>
<tbody id="patkostg-court-body" />
</table>
</div>
<h3 className="gebuehren-section-title" data-i18n="gebuehren.patkostg.dpma">DPMA-Geb&uuml;hren</h3>
<div className="gebuehren-table-wrap gebuehren-table-wrap-short">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.patkostg.item">Geb&uuml;hrentatbestand</th>
<th data-i18n="gebuehren.epa.fee">Geb&uuml;hr (EUR)</th>
</tr>
</thead>
<tbody id="patkostg-dpma-body" />
</table>
</div>
<h3 className="gebuehren-section-title" data-i18n="gebuehren.patkostg.annual">Jahresgeb&uuml;hren (Patent)</h3>
<div className="gebuehren-table-wrap gebuehren-table-wrap-short">
<table className="gebuehren-table">
<thead>
<tr>
<th data-i18n="gebuehren.patkostg.year">Patentjahr</th>
<th data-i18n="gebuehren.epa.fee">Geb&uuml;hr (EUR)</th>
</tr>
</thead>
<tbody id="patkostg-annual-body" />
</table>
</div>
</div>
{/* Multipliers reference */}
<div id="multipliers-section" className="gebuehren-multipliers">
<h3 data-i18n="gebuehren.multipliers.title">Geb&uuml;hren-Multiplikatoren</h3>
<p className="gebuehren-multipliers-desc" data-i18n="gebuehren.multipliers.desc">
Faktoren f&uuml;r die Berechnung von Gerichts- und Anwaltskosten je Instanz.
</p>
<div className="gebuehren-table-wrap">
<table className="gebuehren-table gebuehren-table-compact">
<thead>
<tr>
<th data-i18n="gebuehren.multipliers.instance">Instanz</th>
<th data-i18n="gebuehren.multipliers.courtfee">Gericht</th>
<th data-i18n="gebuehren.multipliers.basis">Grundlage</th>
<th>RA VG</th>
<th>RA TG</th>
<th>PA VG</th>
<th>PA TG</th>
</tr>
</thead>
<tbody id="multipliers-body" />
</table>
</div>
</div>
</div>
</section>
</main>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="gebuehren.feedback.title">Feedback zur Geb&uuml;hrentabelle</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="gebuehren.feedback.type">Art</label>
<select id="feedback-type" required>
<option value="error" data-i18n="gebuehren.feedback.error">Fehler gefunden</option>
<option value="missing" data-i18n="gebuehren.feedback.missing">Fehlende Daten</option>
<option value="suggestion" data-i18n="gebuehren.feedback.suggestion">Verbesserungsvorschlag</option>
<option value="other" data-i18n="gebuehren.feedback.other">Sonstiges</option>
</select>
</div>
<div className="form-field">
<label htmlFor="feedback-schedule" data-i18n="gebuehren.feedback.schedule">Betrifft</label>
<select id="feedback-schedule">
<option value="gkg">GKG</option>
<option value="rvg">RVG</option>
<option value="upc">UPC</option>
<option value="epa">EPA</option>
<option value="patkostg">PatKostG</option>
<option value="general" data-i18n="gebuehren.feedback.general">Allgemein</option>
</select>
</div>
<div className="form-field">
<label htmlFor="feedback-message" data-i18n="gebuehren.feedback.message">Nachricht</label>
<textarea id="feedback-message" rows={3} required />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="gebuehren.feedback.cancel">Abbrechen</button>
<button type="submit" className="btn-submit" data-i18n="gebuehren.feedback.submit">Absenden</button>
</div>
<p className="form-msg" id="feedback-msg" />
</form>
</div>
</div>
<Footer />
<script src="/assets/gebuehrentabellen.js"></script>
</body>
</html>
);
}

View File

@@ -9,6 +9,7 @@ const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
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>';
export function renderIndex(): string {
return "<!DOCTYPE html>" + (
@@ -77,6 +78,12 @@ export function renderIndex(): string {
<h2 data-i18n="index.glossar.title">Patentglossar</h2>
<p data-i18n="index.glossar.desc">Zweisprachiges DE/EN-Glossar der wichtigsten Begriffe im Patentrecht. Durchsuchbar nach Kategorien.</p>
</a>
<a href="/tools/gebuehrentabellen" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<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>
</div>
</div>
</section>

View File

@@ -2283,3 +2283,330 @@ input[type="range"]::-moz-range-thumb {
border: none;
}
}
/* --- Geb\u00fchrentabellen --- */
.gebuehren-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.gebuehren-lookup {
margin-bottom: 1.5rem;
}
.gebuehren-lookup label {
display: block;
font-weight: 500;
margin-bottom: 0.375rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.gebuehren-lookup-row {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 480px;
}
.gebuehren-currency {
color: var(--color-text-muted);
font-weight: 500;
font-size: 0.875rem;
}
.gebuehren-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 0.9rem;
background: var(--color-surface);
transition: border-color 0.15s;
}
.gebuehren-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(101, 163, 13, 0.15);
}
.btn-lookup {
padding: 0.5rem 1rem;
background: var(--color-accent);
color: #fff;
border: none;
border-radius: var(--radius);
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.btn-lookup:hover {
background: var(--color-accent-light);
}
/* Lookup result cards */
.gebuehren-lookup-result {
margin-bottom: 1.5rem;
}
.gebuehren-lookup-cards {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.gebuehren-lookup-card {
background: var(--color-surface);
border: 1px solid var(--color-accent);
border-radius: var(--radius);
padding: 0.75rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 160px;
}
.gebuehren-lookup-label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.gebuehren-lookup-value {
font-family: var(--font-mono);
font-weight: 600;
font-size: 1.05rem;
color: var(--color-accent);
}
/* Tabs */
.gebuehren-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border);
margin-bottom: 1.5rem;
overflow-x: auto;
}
.gebuehren-tab {
padding: 0.625rem 1.25rem;
background: none;
border: none;
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.gebuehren-tab:hover {
color: var(--color-text);
}
.gebuehren-tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
/* Panels */
.gebuehren-panel {
min-height: 200px;
}
.gebuehren-versions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.gebuehren-section-title {
font-size: 0.95rem;
font-weight: 600;
margin: 1.5rem 0 0.75rem;
color: var(--color-text);
}
.gebuehren-section-title:first-child {
margin-top: 0;
}
/* Fee tables */
.gebuehren-table-wrap {
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: auto;
max-height: 520px;
}
.gebuehren-table-wrap-short {
max-height: none;
}
.gebuehren-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.gebuehren-table th,
.gebuehren-table td {
padding: 0.5rem 0.875rem;
text-align: right;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.gebuehren-table th {
background: var(--color-bg);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted);
}
.gebuehren-table th:first-child,
.gebuehren-table td:first-child {
text-align: left;
}
.gebuehren-table tbody tr:hover {
background: rgba(101, 163, 13, 0.04);
}
.gebuehren-table tbody tr.highlight {
background: rgba(101, 163, 13, 0.12);
font-weight: 600;
}
.gebuehren-table tbody tr.highlight td {
border-bottom-color: var(--color-accent);
}
.gebuehren-table-compact th,
.gebuehren-table-compact td {
padding: 0.375rem 0.625rem;
font-size: 0.8rem;
}
/* UPC summary cards */
.gebuehren-upc-cards {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.gebuehren-upc-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.75rem 1.25rem;
min-width: 160px;
flex: 1;
}
.gebuehren-upc-card-label {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.gebuehren-upc-card-value {
font-family: var(--font-mono);
font-weight: 600;
font-size: 1.1rem;
color: var(--color-text);
}
/* Multipliers section */
.gebuehren-multipliers {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.gebuehren-multipliers h3 {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.gebuehren-multipliers-desc {
font-size: 0.825rem;
color: var(--color-text-muted);
margin-bottom: 1rem;
}
.text-muted {
color: var(--color-text-muted);
}
/* Responsive */
@media (max-width: 640px) {
.gebuehren-lookup-row {
flex-wrap: wrap;
max-width: 100%;
}
.gebuehren-input {
min-width: 0;
}
.gebuehren-lookup-cards {
flex-direction: column;
}
.gebuehren-upc-cards {
flex-direction: column;
}
.gebuehren-tab {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.gebuehren-table th,
.gebuehren-table td {
padding: 0.375rem 0.5rem;
font-size: 0.8rem;
}
}
@media print {
.gebuehren-lookup,
.gebuehren-tabs,
.gebuehren-versions,
.btn-suggest,
.gebuehren-lookup-result,
.modal-overlay {
display: none !important;
}
.gebuehren-panel {
display: block !important;
}
.gebuehren-table-wrap {
max-height: none;
overflow: visible;
border: 1px solid #ccc;
}
}

View File

@@ -0,0 +1,425 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/calc"
)
// Standard Streitwerte for precomputed table rows.
var standardStreitwerte = []float64{
500, 1000, 2000, 3000, 5000,
10000, 15000, 20000, 25000,
30000, 40000, 50000,
75000, 100000, 150000, 200000,
250000, 300000, 400000, 500000,
750000, 1000000, 1500000, 2000000,
3000000, 4000000, 5000000,
7500000, 10000000, 15000000, 20000000,
25000000, 30000000, 50000000,
}
// feeVersionOrder defines the display order for GKG/RVG versions (newest first).
var feeVersionOrder = []string{"2025", "2021", "2013", "2005"}
// --- Response types ---
type FeeTableResponse struct {
GKG []FeeVersionTable `json:"gkg"`
RVG []FeeVersionTable `json:"rvg"`
UPC []UPCScheduleInfo `json:"upc"`
EPA []EPAFeeInfo `json:"epa"`
Multipliers []MultiplierInfo `json:"multipliers"`
PatKostG PatKostGInfo `json:"patkostg"`
}
type FeeVersionTable struct {
Version string `json:"version"`
Label string `json:"label"`
ValidFrom string `json:"validFrom"`
IsCurrent bool `json:"isCurrent"`
Rows []FeeTableRow `json:"rows"`
}
type FeeTableRow struct {
Streitwert float64 `json:"streitwert"`
Fee float64 `json:"fee"`
}
type UPCScheduleInfo struct {
Version string `json:"version"`
Label string `json:"label"`
FixedInfringement float64 `json:"fixedInfringement"`
FixedRevocation float64 `json:"fixedRevocation"`
SMEReduction float64 `json:"smeReduction"`
ValueBased []UPCValueRow `json:"valueBased"`
RecoverableCosts []UPCRecoverRow `json:"recoverableCosts"`
}
type UPCValueRow struct {
MaxValue *float64 `json:"maxValue"`
Fee float64 `json:"fee"`
}
type UPCRecoverRow struct {
MaxValue *float64 `json:"maxValue"`
Ceiling float64 `json:"ceiling"`
}
type EPAFeeInfo struct {
Key string `json:"key"`
Label string `json:"label"`
LabelEN string `json:"labelEN"`
Fee float64 `json:"fee"`
SMEFee float64 `json:"smeFee"`
}
type MultiplierInfo struct {
Key string `json:"key"`
Label string `json:"label"`
LabelEN string `json:"labelEN"`
CourtFeeFactor float64 `json:"courtFeeFactor"`
FeeBasis string `json:"feeBasis"`
RAVGFactor float64 `json:"raVGFactor"`
RATGFactor float64 `json:"raTGFactor"`
PAVGFactor float64 `json:"paVGFactor"`
PATGFactor float64 `json:"paTGFactor"`
}
type PatKostGInfo struct {
CourtFees []PatKostGCourtFee `json:"courtFees"`
DPMAFees []DPMAFee `json:"dpmaFees"`
AnnualFees []DPMAAnnualFee `json:"annualFees"`
}
type PatKostGCourtFee struct {
Key string `json:"key"`
Label string `json:"label"`
LabelEN string `json:"labelEN"`
Factor float64 `json:"factor"`
Note string `json:"note"`
NoteEN string `json:"noteEN"`
}
type DPMAFee struct {
Label string `json:"label"`
LabelEN string `json:"labelEN"`
Fee float64 `json:"fee"`
}
type DPMAAnnualFee struct {
Year int `json:"year"`
Fee float64 `json:"fee"`
}
// --- Lookup response ---
type LookupResponse struct {
Streitwert float64 `json:"streitwert"`
GKG map[string]float64 `json:"gkg"`
RVG map[string]float64 `json:"rvg"`
UPC map[string]UPCLookupResult `json:"upc"`
}
type UPCLookupResult struct {
FixedInfringement float64 `json:"fixedInfringement"`
ValueBasedFee float64 `json:"valueBasedFee"`
Total float64 `json:"total"`
SMETotal float64 `json:"smeTotal"`
RecoverableCeiling float64 `json:"recoverableCeiling"`
}
// --- Feedback ---
type GebuehrenFeedback struct {
FeedbackType string `json:"feedback_type"`
Message string `json:"message"`
Schedule string `json:"schedule"`
}
func handleGebuehrentabellenPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/gebuehrentabellen.html")
}
func handleGebuehrentabellenAPI(w http.ResponseWriter, r *http.Request) {
resp := buildFeeTableResponse()
writeJSON(w, http.StatusOK, resp)
}
func handleGebuehrentabellenLookup(w http.ResponseWriter, r *http.Request) {
sw := r.URL.Query().Get("streitwert")
if sw == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "streitwert parameter required"})
return
}
streitwert, err := strconv.ParseFloat(sw, 64)
if err != nil || streitwert <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid streitwert"})
return
}
result := LookupResponse{
Streitwert: streitwert,
GKG: make(map[string]float64),
RVG: make(map[string]float64),
UPC: make(map[string]UPCLookupResult),
}
for _, v := range feeVersionOrder {
gkgFee, _ := calc.ComputeBaseFee(streitwert, false, v)
rvgFee, _ := calc.ComputeBaseFee(streitwert, true, v)
result.GKG[v] = math.Round(gkgFee*100) / 100
result.RVG[v] = math.Round(rvgFee*100) / 100
}
for key, schedule := range calc.UPCFeeSchedules {
valueFee := upcValueFee(streitwert, schedule.ValueBased)
total := schedule.FixedInfringement + valueFee
smeTotal := math.Round(total * (1 - schedule.SMEReduction))
recov := upcRecoverableCeiling(streitwert, schedule.RecoverableCosts)
result.UPC[key] = UPCLookupResult{
FixedInfringement: schedule.FixedInfringement,
ValueBasedFee: valueFee,
Total: total,
SMETotal: smeTotal,
RecoverableCeiling: recov,
}
}
writeJSON(w, http.StatusOK, result)
}
func upcValueFee(streitwert float64, brackets []calc.UPCFeeBracket) float64 {
for _, b := range brackets {
if b.MaxValue == nil || streitwert <= *b.MaxValue {
return b.Fee
}
}
return brackets[len(brackets)-1].Fee
}
func upcRecoverableCeiling(streitwert float64, table []calc.UPCRecoverableCost) float64 {
for _, e := range table {
if e.MaxValue == nil || streitwert <= *e.MaxValue {
return e.Ceiling
}
}
return table[len(table)-1].Ceiling
}
func buildFeeTableResponse() FeeTableResponse {
var resp FeeTableResponse
// GKG tables
for _, v := range feeVersionOrder {
schedule := calc.FeeSchedules[v]
table := FeeVersionTable{
Version: v,
Label: schedule.Label,
ValidFrom: schedule.ValidFrom,
IsCurrent: v == "2025",
}
for _, sw := range standardStreitwerte {
fee, _ := calc.ComputeBaseFee(sw, false, v)
table.Rows = append(table.Rows, FeeTableRow{
Streitwert: sw,
Fee: math.Round(fee*100) / 100,
})
}
resp.GKG = append(resp.GKG, table)
}
// RVG tables
for _, v := range feeVersionOrder {
schedule := calc.FeeSchedules[v]
table := FeeVersionTable{
Version: v,
Label: schedule.Label,
ValidFrom: schedule.ValidFrom,
IsCurrent: v == "2025",
}
for _, sw := range standardStreitwerte {
fee, _ := calc.ComputeBaseFee(sw, true, v)
table.Rows = append(table.Rows, FeeTableRow{
Streitwert: sw,
Fee: math.Round(fee*100) / 100,
})
}
resp.RVG = append(resp.RVG, table)
}
// UPC schedules (2026 first, then pre2026)
for _, key := range []string{"2026", "pre2026"} {
schedule := calc.UPCFeeSchedules[key]
info := UPCScheduleInfo{
Version: key,
Label: schedule.Label,
FixedInfringement: schedule.FixedInfringement,
FixedRevocation: schedule.FixedRevocation,
SMEReduction: schedule.SMEReduction,
}
for _, b := range schedule.ValueBased {
info.ValueBased = append(info.ValueBased, UPCValueRow{
MaxValue: b.MaxValue,
Fee: b.Fee,
})
}
for _, r := range schedule.RecoverableCosts {
info.RecoverableCosts = append(info.RecoverableCosts, UPCRecoverRow{
MaxValue: r.MaxValue,
Ceiling: r.Ceiling,
})
}
resp.UPC = append(resp.UPC, info)
}
// EPA fees
for _, key := range []string{"EPA_OPPOSITION", "EPA_APPEAL"} {
epa := calc.EPAFees[key]
resp.EPA = append(resp.EPA, EPAFeeInfo{
Key: epa.Key,
Label: epa.Label,
LabelEN: epa.LabelEN,
Fee: epa.Fee,
SMEFee: epa.SMEFee,
})
}
// Additional EPA fees not in calc (grant-related)
resp.EPA = append(resp.EPA,
EPAFeeInfo{Key: "EPA_GRANT", Label: "Erteilungs- und Druckgebühr", LabelEN: "Grant and printing fee", Fee: 1040, SMEFee: 1040},
EPAFeeInfo{Key: "EPA_EXAMINATION", Label: "Prüfungsgebühr", LabelEN: "Examination fee", Fee: 1970, SMEFee: 1970},
EPAFeeInfo{Key: "EPA_SEARCH", Label: "Recherchengebühr", LabelEN: "Search fee", Fee: 1520, SMEFee: 1520},
EPAFeeInfo{Key: "EPA_FILING", Label: "Anmeldegebühr", LabelEN: "Filing fee", Fee: 140, SMEFee: 140},
EPAFeeInfo{Key: "EPA_DESIGNATION", Label: "Benennungsgebühr (pauschal)", LabelEN: "Designation fee (flat)", Fee: 660, SMEFee: 660},
)
// Multipliers
for _, inst := range calc.AllDEInstances() {
resp.Multipliers = append(resp.Multipliers, MultiplierInfo{
Key: inst.Key,
Label: inst.Label,
LabelEN: inst.LabelEN,
CourtFeeFactor: inst.CourtFeeFactor,
FeeBasis: inst.FeeBasis,
RAVGFactor: inst.RAVGFactor,
RATGFactor: inst.RATGFactor,
PAVGFactor: inst.PAVGFactor,
PATGFactor: inst.PATGFactor,
})
}
// PatKostG
resp.PatKostG = PatKostGInfo{
CourtFees: []PatKostGCourtFee{
{
Key: "BPatG", Label: "BPatG (Nichtigkeitsverfahren)", LabelEN: "Federal Patent Court (Nullity)",
Factor: 4.5, Note: "4,5-fache GKG-Gebühr", NoteEN: "4.5x GKG fee",
},
{
Key: "BGH_NULLITY", Label: "BGH (Nichtigkeitsberufung)", LabelEN: "Federal Court of Justice (Nullity Appeal)",
Factor: 6.0, Note: "6,0-fache GKG-Gebühr", NoteEN: "6.0x GKG fee",
},
},
DPMAFees: []DPMAFee{
{Label: "Patentanmeldung (elektronisch)", LabelEN: "Patent application (electronic)", Fee: 40},
{Label: "Patentanmeldung (Papier)", LabelEN: "Patent application (paper)", Fee: 60},
{Label: "Prüfungsantrag", LabelEN: "Examination request", Fee: 350},
{Label: "Erteilungsgebühr (bis 10 Ansprüche)", LabelEN: "Grant fee (up to 10 claims)", Fee: 125},
{Label: "je weiterer Anspruch ab Nr. 11", LabelEN: "per additional claim from 11th", Fee: 30},
{Label: "Gebrauchsmusteranmeldung (elektronisch)", LabelEN: "Utility model application (electronic)", Fee: 30},
{Label: "Gebrauchsmusteranmeldung (Papier)", LabelEN: "Utility model application (paper)", Fee: 40},
},
AnnualFees: []DPMAAnnualFee{
{Year: 3, Fee: 70}, {Year: 4, Fee: 70}, {Year: 5, Fee: 90},
{Year: 6, Fee: 130}, {Year: 7, Fee: 180}, {Year: 8, Fee: 240},
{Year: 9, Fee: 290}, {Year: 10, Fee: 350}, {Year: 11, Fee: 470},
{Year: 12, Fee: 620}, {Year: 13, Fee: 760}, {Year: 14, Fee: 910},
{Year: 15, Fee: 1060}, {Year: 16, Fee: 1230}, {Year: 17, Fee: 1410},
{Year: 18, Fee: 1590}, {Year: 19, Fee: 1790}, {Year: 20, Fee: 2030},
},
}
return resp
}
func handleGebuehrentabellenFeedback(w http.ResponseWriter, r *http.Request) {
var feedback GebuehrenFeedback
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.Message = strings.TrimSpace(feedback.Message)
feedback.Schedule = strings.TrimSpace(feedback.Schedule)
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,
"message": feedback.Message,
"schedule": feedback.Schedule,
"submitted_by": email,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
log.Printf("gebuehren feedback marshal error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Interner Fehler."})
return
}
endpoint := fmt.Sprintf("%s/rest/v1/gebuehrentabellen_feedback", authClient.URL)
req2, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody))
if err != nil {
log.Printf("gebuehren 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("gebuehren 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("gebuehren 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

@@ -43,6 +43,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string) {
protected.HandleFunc("POST /api/links/suggest", handleLinkSuggest)
protected.HandleFunc("POST /api/links/feedback", handleLinkFeedback)
protected.HandleFunc("GET /api/links/suggestions/count", handleSuggestionCount)
protected.HandleFunc("GET /tools/gebuehrentabellen", handleGebuehrentabellenPage)
protected.HandleFunc("GET /api/tools/gebuehrentabellen", handleGebuehrentabellenAPI)
protected.HandleFunc("GET /api/tools/gebuehrentabellen/lookup", handleGebuehrentabellenLookup)
protected.HandleFunc("POST /api/tools/gebuehrentabellen/feedback", handleGebuehrentabellenFeedback)
mux.Handle("/", client.Middleware(protected))
}