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:
@@ -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/");
|
||||
}
|
||||
|
||||
578
frontend/src/client/gebuehrentabellen.ts
Normal file
578
frontend/src/client/gebuehrentabellen.ts
Normal 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();
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
259
frontend/src/gebuehrentabellen.tsx
Normal file
259
frontend/src/gebuehrentabellen.tsx
Normal 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ührentabellen — 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ührentabellen</h1>
|
||||
<p className="tool-subtitle" data-i18n="gebuehren.subtitle">
|
||||
Interaktive Gebührentabellen fü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ü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ü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ängige Gebü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ühr (EUR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="upc-value-body" />
|
||||
</table>
|
||||
</div>
|
||||
<h3 className="gebuehren-section-title" data-i18n="gebuehren.upc.recoverable">Erstattungsfä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ühr (EUR)</th>
|
||||
<th data-i18n="gebuehren.epa.smefee">KMU-Gebü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ühren</h3>
|
||||
<div className="gebuehren-table-wrap gebuehren-table-wrap-short">
|
||||
<table className="gebuehren-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="gebuehren.patkostg.item">Gebührentatbestand</th>
|
||||
<th data-i18n="gebuehren.epa.fee">Gebühr (EUR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="patkostg-dpma-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="gebuehren-section-title" data-i18n="gebuehren.patkostg.annual">Jahresgebü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ü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ühren-Multiplikatoren</h3>
|
||||
<p className="gebuehren-multipliers-desc" data-i18n="gebuehren.multipliers.desc">
|
||||
Faktoren fü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ührentabelle</h2>
|
||||
<button className="modal-close" id="modal-close" type="button">×</button>
|
||||
</div>
|
||||
<form id="feedback-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="feedback-type" data-i18n="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>
|
||||
);
|
||||
}
|
||||
@@ -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ührentabellen</h2>
|
||||
<p data-i18n="index.gebuehren.desc">Interaktive Gebührentabellen für GKG, RVG, UPC, EPA und PatKostG. Streitwert eingeben, Gebühr ablesen.</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
425
internal/handlers/gebuehrentabellen.go
Normal file
425
internal/handlers/gebuehrentabellen.go
Normal 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"})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user