feat: Patentprozesskostenrechner — frontend UI at /kosten/rechner
This commit is contained in:
24
frontend/src/app/(app)/kosten/rechner/page.tsx
Normal file
24
frontend/src/app/(app)/kosten/rechner/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { CostCalculator } from "@/components/costs/CostCalculator";
|
||||
import { Calculator } from "lucide-react";
|
||||
|
||||
export default function KostenrechnerPage() {
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5 text-neutral-400" />
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Patentprozesskostenrechner
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
Berechnung der Verfahrenskosten für deutsche Patentverfahren und UPC
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CostCalculator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
frontend/src/components/costs/CostCalculator.tsx
Normal file
323
frontend/src/components/costs/CostCalculator.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import type {
|
||||
DEInstance,
|
||||
UPCInstance,
|
||||
InstanceConfig,
|
||||
UPCConfig,
|
||||
VatRate,
|
||||
Jurisdiction,
|
||||
CalculatorResult,
|
||||
} from "@/lib/costs/types";
|
||||
import { computeDEInstance, computeUPCInstance } from "@/lib/costs/calculator";
|
||||
import {
|
||||
DE_INFRINGEMENT_INSTANCES,
|
||||
DE_NULLITY_INSTANCES,
|
||||
} from "@/lib/costs/fee-tables";
|
||||
import { InstanceCard } from "./InstanceCard";
|
||||
import { UPCCard } from "./UPCCard";
|
||||
import { CostSummary } from "./CostSummary";
|
||||
import { CostComparison } from "./CostComparison";
|
||||
|
||||
const DEFAULT_DE_CONFIG: InstanceConfig = {
|
||||
enabled: false,
|
||||
feeVersion: "Aktuell",
|
||||
numAttorneys: 1,
|
||||
numPatentAttorneys: 1,
|
||||
oralHearing: true,
|
||||
numClients: 1,
|
||||
};
|
||||
|
||||
const DEFAULT_UPC_CONFIG: UPCConfig = {
|
||||
enabled: false,
|
||||
feeVersion: "2026",
|
||||
isSME: false,
|
||||
includeRevocation: false,
|
||||
};
|
||||
|
||||
function makeDefaultDEInstances(): Record<DEInstance, InstanceConfig> {
|
||||
return {
|
||||
LG: { ...DEFAULT_DE_CONFIG, enabled: true },
|
||||
OLG: { ...DEFAULT_DE_CONFIG },
|
||||
BGH_NZB: { ...DEFAULT_DE_CONFIG },
|
||||
BGH_REV: { ...DEFAULT_DE_CONFIG },
|
||||
BPatG: { ...DEFAULT_DE_CONFIG },
|
||||
BGH_NULLITY: { ...DEFAULT_DE_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
function makeDefaultUPCInstances(): Record<UPCInstance, UPCConfig> {
|
||||
return {
|
||||
UPC_FIRST: { ...DEFAULT_UPC_CONFIG, enabled: true },
|
||||
UPC_APPEAL: { ...DEFAULT_UPC_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
const STREITWERT_PRESETS = [
|
||||
100_000, 250_000, 500_000, 1_000_000, 3_000_000, 5_000_000, 10_000_000, 30_000_000,
|
||||
];
|
||||
|
||||
export function CostCalculator() {
|
||||
const [streitwert, setStreitwert] = useState(1_000_000);
|
||||
const [streitwertInput, setStreitwertInput] = useState("1.000.000");
|
||||
const [vatRate, setVatRate] = useState<VatRate>(0.19);
|
||||
const [jurisdiction, setJurisdiction] = useState<Jurisdiction>("DE");
|
||||
const [deInstances, setDEInstances] = useState(makeDefaultDEInstances);
|
||||
const [upcInstances, setUPCInstances] = useState(makeDefaultUPCInstances);
|
||||
|
||||
// Parse formatted number input
|
||||
function handleStreitwertInput(raw: string) {
|
||||
setStreitwertInput(raw);
|
||||
const cleaned = raw.replace(/\./g, "").replace(/,/g, ".");
|
||||
const num = parseFloat(cleaned);
|
||||
if (!isNaN(num) && num >= 500 && num <= 30_000_000) {
|
||||
setStreitwert(num);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreitwertBlur() {
|
||||
setStreitwertInput(
|
||||
new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }).format(streitwert),
|
||||
);
|
||||
}
|
||||
|
||||
function handleSliderChange(value: number) {
|
||||
setStreitwert(value);
|
||||
setStreitwertInput(
|
||||
new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }).format(value),
|
||||
);
|
||||
}
|
||||
|
||||
// All DE metadata (infringement + nullity)
|
||||
const allDEMeta = useMemo(
|
||||
() => [...DE_INFRINGEMENT_INSTANCES, ...DE_NULLITY_INSTANCES],
|
||||
[],
|
||||
);
|
||||
|
||||
// Compute results reactively
|
||||
const results: CalculatorResult = useMemo(() => {
|
||||
const deResults = allDEMeta.map((meta) => {
|
||||
const key = meta.key as DEInstance;
|
||||
return computeDEInstance(streitwert, deInstances[key], meta, vatRate);
|
||||
});
|
||||
|
||||
const upcFirst = computeUPCInstance(
|
||||
streitwert,
|
||||
upcInstances.UPC_FIRST,
|
||||
"UPC_FIRST",
|
||||
upcInstances.UPC_FIRST.includeRevocation,
|
||||
);
|
||||
const upcAppeal = computeUPCInstance(
|
||||
streitwert,
|
||||
upcInstances.UPC_APPEAL,
|
||||
"UPC_APPEAL",
|
||||
upcInstances.UPC_APPEAL.includeRevocation,
|
||||
);
|
||||
const upcResults = [upcFirst, upcAppeal];
|
||||
|
||||
const deTotal = deResults.reduce((sum, r) => sum + r.instanceTotal, 0);
|
||||
const upcTotal = upcResults.reduce((sum, r) => sum + r.instanceTotal, 0);
|
||||
|
||||
return {
|
||||
deResults,
|
||||
upcResults,
|
||||
deTotal: Math.round(deTotal * 100) / 100,
|
||||
upcTotal: Math.round(upcTotal * 100) / 100,
|
||||
grandTotal: Math.round((deTotal + upcTotal) * 100) / 100,
|
||||
};
|
||||
}, [streitwert, vatRate, deInstances, upcInstances, allDEMeta]);
|
||||
|
||||
const showDE = jurisdiction === "DE" || jurisdiction === "UPC";
|
||||
const showUPC = jurisdiction === "UPC";
|
||||
const showComparison =
|
||||
showDE &&
|
||||
showUPC &&
|
||||
results.deResults.some((r) => r.enabled) &&
|
||||
results.upcResults.some((r) => r.enabled);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||
{/* Left: Inputs */}
|
||||
<div className="space-y-6">
|
||||
{/* Global inputs card */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<h2 className="mb-4 text-sm font-semibold text-neutral-900">
|
||||
Grundeinstellungen
|
||||
</h2>
|
||||
|
||||
{/* Streitwert */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Streitwert (EUR)
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={streitwertInput}
|
||||
onChange={(e) => handleStreitwertInput(e.target.value)}
|
||||
onBlur={handleStreitwertBlur}
|
||||
className="w-40 rounded-md border border-neutral-200 bg-white px-3 py-2 text-right text-sm font-medium text-neutral-900 tabular-nums focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
<span className="text-xs text-neutral-400">EUR</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={500}
|
||||
max={30_000_000}
|
||||
step={10000}
|
||||
value={streitwert}
|
||||
onChange={(e) => handleSliderChange(parseInt(e.target.value))}
|
||||
className="mt-2 w-full accent-neutral-700"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-neutral-400">
|
||||
<span>500</span>
|
||||
<span>30 Mio.</span>
|
||||
</div>
|
||||
{/* Presets */}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{STREITWERT_PRESETS.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => handleSliderChange(v)}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs transition-colors ${
|
||||
streitwert === v
|
||||
? "bg-neutral-900 text-white"
|
||||
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{v >= 1_000_000
|
||||
? `${(v / 1_000_000).toFixed(0)} Mio.`
|
||||
: new Intl.NumberFormat("de-DE").format(v)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VAT + Jurisdiction row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Umsatzsteuer
|
||||
</label>
|
||||
<select
|
||||
value={vatRate}
|
||||
onChange={(e) => setVatRate(parseFloat(e.target.value) as VatRate)}
|
||||
className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-2 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
<option value={0.19}>19%</option>
|
||||
<option value={0.16}>16%</option>
|
||||
<option value={0}>0% (netto)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Gerichtsbarkeit
|
||||
</label>
|
||||
<div className="flex rounded-md border border-neutral-200">
|
||||
<button
|
||||
onClick={() => setJurisdiction("DE")}
|
||||
className={`flex-1 rounded-l-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
jurisdiction === "DE"
|
||||
? "bg-neutral-900 text-white"
|
||||
: "bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
DE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setJurisdiction("UPC")}
|
||||
className={`flex-1 rounded-r-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
jurisdiction === "UPC"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
DE + UPC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DE instances */}
|
||||
{showDE && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Verletzungsverfahren
|
||||
</h3>
|
||||
{DE_INFRINGEMENT_INSTANCES.map((meta) => (
|
||||
<InstanceCard
|
||||
key={meta.key}
|
||||
meta={meta}
|
||||
config={deInstances[meta.key as DEInstance]}
|
||||
onChange={(c) =>
|
||||
setDEInstances((prev) => ({ ...prev, [meta.key as DEInstance]: c }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Nichtigkeitsverfahren
|
||||
</h3>
|
||||
{DE_NULLITY_INSTANCES.map((meta) => (
|
||||
<InstanceCard
|
||||
key={meta.key}
|
||||
meta={meta}
|
||||
config={deInstances[meta.key as DEInstance]}
|
||||
onChange={(c) =>
|
||||
setDEInstances((prev) => ({ ...prev, [meta.key as DEInstance]: c }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UPC instances */}
|
||||
{showUPC && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-blue-600">
|
||||
Einheitliches Patentgericht (UPC)
|
||||
</h3>
|
||||
<UPCCard
|
||||
label="UPC (1. Instanz)"
|
||||
config={upcInstances.UPC_FIRST}
|
||||
onChange={(c) =>
|
||||
setUPCInstances((prev) => ({ ...prev, UPC_FIRST: c }))
|
||||
}
|
||||
showRevocation
|
||||
/>
|
||||
<UPCCard
|
||||
label="UPC (Berufung)"
|
||||
config={upcInstances.UPC_APPEAL}
|
||||
onChange={(c) =>
|
||||
setUPCInstances((prev) => ({ ...prev, UPC_APPEAL: c }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Results */}
|
||||
<div className="space-y-4 xl:sticky xl:top-6 xl:self-start">
|
||||
<CostSummary
|
||||
deResults={showDE ? results.deResults : []}
|
||||
upcResults={showUPC ? results.upcResults : []}
|
||||
deTotal={showDE ? results.deTotal : 0}
|
||||
upcTotal={showUPC ? results.upcTotal : 0}
|
||||
/>
|
||||
|
||||
{showComparison && (
|
||||
<CostComparison deTotal={results.deTotal} upcTotal={results.upcTotal} />
|
||||
)}
|
||||
|
||||
{/* Print note */}
|
||||
<p className="text-center text-[10px] text-neutral-300 print:hidden">
|
||||
Alle Angaben ohne Gewähr. Berechnung basiert auf GKG/RVG/PatKostG bzw. UPC-Gebührenordnung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/costs/CostComparison.tsx
Normal file
83
frontend/src/components/costs/CostComparison.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { formatEUR } from "@/lib/costs/calculator";
|
||||
|
||||
interface Props {
|
||||
deTotal: number;
|
||||
upcTotal: number;
|
||||
deLabel?: string;
|
||||
upcLabel?: string;
|
||||
}
|
||||
|
||||
export function CostComparison({
|
||||
deTotal,
|
||||
upcTotal,
|
||||
deLabel = "Deutsche Gerichte",
|
||||
upcLabel = "UPC",
|
||||
}: Props) {
|
||||
if (deTotal === 0 && upcTotal === 0) return null;
|
||||
|
||||
const maxValue = Math.max(deTotal, upcTotal);
|
||||
const dePercent = maxValue > 0 ? (deTotal / maxValue) * 100 : 0;
|
||||
const upcPercent = maxValue > 0 ? (upcTotal / maxValue) * 100 : 0;
|
||||
const diff = upcTotal - deTotal;
|
||||
const diffPercent = deTotal > 0 ? ((diff / deTotal) * 100).toFixed(0) : "—";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-neutral-900">
|
||||
Kostenvergleich DE vs. UPC
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* DE bar */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-xs font-medium text-neutral-600">{deLabel}</span>
|
||||
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
|
||||
{formatEUR(deTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-full overflow-hidden rounded-md bg-neutral-100">
|
||||
<div
|
||||
className="h-full rounded-md bg-neutral-700 transition-all duration-500"
|
||||
style={{ width: `${Math.max(dePercent, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UPC bar */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-xs font-medium text-blue-600">{upcLabel}</span>
|
||||
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
|
||||
{formatEUR(upcTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-full overflow-hidden rounded-md bg-blue-50">
|
||||
<div
|
||||
className="h-full rounded-md bg-blue-600 transition-all duration-500"
|
||||
style={{ width: `${Math.max(upcPercent, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Difference */}
|
||||
{deTotal > 0 && upcTotal > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md bg-neutral-50 px-3 py-2 text-xs">
|
||||
<span className="text-neutral-500">Differenz</span>
|
||||
<span
|
||||
className={`font-medium tabular-nums ${
|
||||
diff > 0 ? "text-red-600" : diff < 0 ? "text-green-600" : "text-neutral-600"
|
||||
}`}
|
||||
>
|
||||
{diff > 0 ? "+" : ""}
|
||||
{formatEUR(diff)} ({diff > 0 ? "+" : ""}
|
||||
{diffPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
frontend/src/components/costs/CostSummary.tsx
Normal file
229
frontend/src/components/costs/CostSummary.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import type { InstanceResult, UPCInstanceResult } from "@/lib/costs/types";
|
||||
import { formatEUR } from "@/lib/costs/calculator";
|
||||
|
||||
interface Props {
|
||||
deResults: InstanceResult[];
|
||||
upcResults: UPCInstanceResult[];
|
||||
deTotal: number;
|
||||
upcTotal: number;
|
||||
}
|
||||
|
||||
function DEInstanceBreakdown({ result }: { result: InstanceResult }) {
|
||||
if (!result.enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-neutral-900">{result.label}</h4>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{/* Court fees */}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600">Gerichtskosten</span>
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.gerichtskosten)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Attorney fees */}
|
||||
{result.perAttorney && (
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600">
|
||||
Rechtsanwaltskosten
|
||||
{result.attorneyTotal !== result.perAttorney.totalBrutto && (
|
||||
<span className="text-neutral-400">
|
||||
{" "}
|
||||
({Math.round(result.attorneyTotal / result.perAttorney.totalBrutto)}x)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.attorneyTotal)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Detail breakdown */}
|
||||
<div className="ml-4 mt-1 space-y-0.5 text-xs text-neutral-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Verfahrensgebühr</span>
|
||||
<span className="tabular-nums">{formatEUR(result.perAttorney.verfahrensgebuehr)}</span>
|
||||
</div>
|
||||
{result.perAttorney.erhoehungsgebuehr > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>Erhöhungsgebühr</span>
|
||||
<span className="tabular-nums">{formatEUR(result.perAttorney.erhoehungsgebuehr)}</span>
|
||||
</div>
|
||||
)}
|
||||
{result.perAttorney.terminsgebuehr > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>Terminsgebühr</span>
|
||||
<span className="tabular-nums">{formatEUR(result.perAttorney.terminsgebuehr)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>Auslagenpauschale</span>
|
||||
<span className="tabular-nums">{formatEUR(result.perAttorney.auslagenpauschale)}</span>
|
||||
</div>
|
||||
{result.perAttorney.vat > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>USt.</span>
|
||||
<span className="tabular-nums">{formatEUR(result.perAttorney.vat)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patent attorney fees */}
|
||||
{result.perPatentAttorney && (
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600">
|
||||
Patentanwaltskosten
|
||||
{result.patentAttorneyTotal !== result.perPatentAttorney.totalBrutto && (
|
||||
<span className="text-neutral-400">
|
||||
{" "}
|
||||
({Math.round(result.patentAttorneyTotal / result.perPatentAttorney.totalBrutto)}x)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.patentAttorneyTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instance total */}
|
||||
<div className="flex justify-between border-t border-neutral-100 pt-2">
|
||||
<span className="font-medium text-neutral-900">Zwischensumme</span>
|
||||
<span className="font-semibold text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.instanceTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UPCInstanceBreakdown({ result }: { result: UPCInstanceResult }) {
|
||||
if (!result.enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/30 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-neutral-900">{result.label}</h4>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600">Festgebühr</span>
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.fixedFee)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.valueBasedFee > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600">Streitwertabhängige Gebühr</span>
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.valueBasedFee)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600">Gerichtskosten gesamt</span>
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.courtFeesTotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.courtFeesSME !== result.courtFeesTotal && (
|
||||
<div className="flex justify-between text-blue-700">
|
||||
<span>Gerichtskosten (KMU)</span>
|
||||
<span className="font-medium tabular-nums">{formatEUR(result.courtFeesSME)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600">Erstattungsfähige Kosten (Deckel)</span>
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.recoverableCostsCeiling)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-blue-100 pt-2">
|
||||
<span className="font-medium text-neutral-900">Gesamtkostenrisiko</span>
|
||||
<span className="font-semibold text-neutral-900 tabular-nums">
|
||||
{formatEUR(result.instanceTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CostSummary({ deResults, upcResults, deTotal, upcTotal }: Props) {
|
||||
const hasDE = deResults.some((r) => r.enabled);
|
||||
const hasUPC = upcResults.some((r) => r.enabled);
|
||||
|
||||
if (!hasDE && !hasUPC) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-8">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Mindestens eine Instanz aktivieren, um Kosten zu berechnen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* DE results */}
|
||||
{hasDE && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wide">
|
||||
Deutsche Gerichte
|
||||
</h3>
|
||||
{deResults.map((r) => (
|
||||
<DEInstanceBreakdown key={r.instance} result={r} />
|
||||
))}
|
||||
{deResults.filter((r) => r.enabled).length > 1 && (
|
||||
<div className="flex justify-between rounded-lg bg-neutral-900 px-4 py-3 text-white">
|
||||
<span className="text-sm font-medium">Gesamtkosten DE</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{formatEUR(deTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UPC results */}
|
||||
{hasUPC && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-blue-600 uppercase tracking-wide">
|
||||
Einheitliches Patentgericht (UPC)
|
||||
</h3>
|
||||
{upcResults.map((r) => (
|
||||
<UPCInstanceBreakdown key={r.instance} result={r} />
|
||||
))}
|
||||
{upcResults.filter((r) => r.enabled).length > 1 && (
|
||||
<div className="flex justify-between rounded-lg bg-blue-900 px-4 py-3 text-white">
|
||||
<span className="text-sm font-medium">Gesamtkosten UPC</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{formatEUR(upcTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grand total when both */}
|
||||
{hasDE && hasUPC && (
|
||||
<div className="mt-2 flex justify-between rounded-lg border-2 border-neutral-900 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-neutral-900">Gesamtkosten</span>
|
||||
<span className="text-sm font-bold text-neutral-900 tabular-nums">
|
||||
{formatEUR(deTotal + upcTotal)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/costs/InstanceCard.tsx
Normal file
168
frontend/src/components/costs/InstanceCard.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { InstanceConfig, FeeScheduleVersion, InstanceMeta } from "@/lib/costs/types";
|
||||
import { FEE_SCHEDULES } from "@/lib/costs/fee-tables";
|
||||
|
||||
interface Props {
|
||||
meta: InstanceMeta;
|
||||
config: InstanceConfig;
|
||||
onChange: (config: InstanceConfig) => void;
|
||||
}
|
||||
|
||||
const VERSION_OPTIONS: { value: FeeScheduleVersion; label: string }[] = Object.entries(
|
||||
FEE_SCHEDULES,
|
||||
).map(([key, entry]) => ({
|
||||
value: key as FeeScheduleVersion,
|
||||
label: entry.label,
|
||||
}));
|
||||
|
||||
export function InstanceCard({ meta, config, onChange }: Props) {
|
||||
const [expanded, setExpanded] = useState(config.enabled);
|
||||
|
||||
function update(patch: Partial<InstanceConfig>) {
|
||||
onChange({ ...config, ...patch });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
config.enabled
|
||||
? "border-neutral-200 bg-white"
|
||||
: "border-neutral-100 bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => {
|
||||
update({ enabled: e.target.checked });
|
||||
if (e.target.checked) setExpanded(true);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-500"
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
config.enabled ? "text-neutral-900" : "text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
</label>
|
||||
{config.enabled && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="ml-auto rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
{config.enabled && expanded && (
|
||||
<div className="border-t border-neutral-100 px-4 pb-4 pt-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
{/* Fee version */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-500">
|
||||
Gebührentabelle
|
||||
</label>
|
||||
<select
|
||||
value={config.feeVersion}
|
||||
onChange={(e) =>
|
||||
update({ feeVersion: e.target.value as FeeScheduleVersion })
|
||||
}
|
||||
className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
{VERSION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Number of attorneys */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-500">
|
||||
Rechtsanwälte
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={config.numAttorneys}
|
||||
onChange={(e) =>
|
||||
update({ numAttorneys: Math.max(0, parseInt(e.target.value) || 0) })
|
||||
}
|
||||
className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Number of patent attorneys */}
|
||||
{meta.hasPatentAttorneys && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-500">
|
||||
Patentanwälte
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={config.numPatentAttorneys}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
numPatentAttorneys: Math.max(0, parseInt(e.target.value) || 0),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Oral hearing */}
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 pb-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.oralHearing}
|
||||
onChange={(e) => update({ oralHearing: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">
|
||||
Mündliche Verhandlung
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Number of clients */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-500">
|
||||
Mandanten
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={config.numClients}
|
||||
onChange={(e) =>
|
||||
update({ numClients: Math.max(1, parseInt(e.target.value) || 1) })
|
||||
}
|
||||
className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/costs/UPCCard.tsx
Normal file
115
frontend/src/components/costs/UPCCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { UPCConfig } from "@/lib/costs/types";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
config: UPCConfig;
|
||||
onChange: (config: UPCConfig) => void;
|
||||
showRevocation?: boolean;
|
||||
}
|
||||
|
||||
export function UPCCard({ label, config, onChange, showRevocation }: Props) {
|
||||
const [expanded, setExpanded] = useState(config.enabled);
|
||||
|
||||
function update(patch: Partial<UPCConfig>) {
|
||||
onChange({ ...config, ...patch });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
config.enabled
|
||||
? "border-blue-200 bg-blue-50/30"
|
||||
: "border-neutral-100 bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => {
|
||||
update({ enabled: e.target.checked });
|
||||
if (e.target.checked) setExpanded(true);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
config.enabled ? "text-neutral-900" : "text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
{config.enabled && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="ml-auto rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.enabled && expanded && (
|
||||
<div className="border-t border-blue-100 px-4 pb-4 pt-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-neutral-500">
|
||||
Gebührenordnung
|
||||
</label>
|
||||
<select
|
||||
value={config.feeVersion}
|
||||
onChange={(e) =>
|
||||
update({ feeVersion: e.target.value as "pre2026" | "2026" })
|
||||
}
|
||||
className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
<option value="2026">UPC (ab 2026)</option>
|
||||
<option value="pre2026">UPC (vor 2026)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 pb-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.isSME}
|
||||
onChange={(e) => update({ isSME: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">KMU-Ermäßigung</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showRevocation && (
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 pb-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.includeRevocation}
|
||||
onChange={(e) =>
|
||||
update({ includeRevocation: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">
|
||||
Nichtigkeitswiderklage
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Calendar,
|
||||
Brain,
|
||||
BarChart3,
|
||||
Calculator,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
@@ -29,6 +30,7 @@ const allNavigation: NavItem[] = [
|
||||
{ name: "Fristen", href: "/fristen", icon: Clock },
|
||||
{ name: "Termine", href: "/termine", icon: Calendar },
|
||||
{ name: "Berichte", href: "/berichte", icon: BarChart3 },
|
||||
{ name: "Kostenrechner", href: "/kosten/rechner", icon: Calculator },
|
||||
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
|
||||
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
|
||||
];
|
||||
|
||||
276
frontend/src/lib/costs/calculator.ts
Normal file
276
frontend/src/lib/costs/calculator.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type {
|
||||
FeeScheduleVersion,
|
||||
FeeBracket,
|
||||
InstanceConfig,
|
||||
InstanceMeta,
|
||||
InstanceResult,
|
||||
AttorneyBreakdown,
|
||||
VatRate,
|
||||
UPCConfig,
|
||||
UPCInstance,
|
||||
UPCInstanceResult,
|
||||
UPCFeeBracket,
|
||||
UPCRecoverableCost,
|
||||
} from "./types";
|
||||
import { isAlias } from "./types";
|
||||
import { FEE_SCHEDULES, CONSTANTS, UPC_FEES } from "./fee-tables";
|
||||
|
||||
/** Resolve alias to actual fee schedule brackets */
|
||||
function resolveBrackets(version: FeeScheduleVersion): FeeBracket[] {
|
||||
const entry = FEE_SCHEDULES[version];
|
||||
if (isAlias(entry)) {
|
||||
return resolveBrackets(entry.aliasOf);
|
||||
}
|
||||
return entry.brackets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the base fee (1.0x) using the step-based accumulator algorithm.
|
||||
* @param isRVG - true for RVG (attorney), false for GKG (court)
|
||||
*/
|
||||
export function computeBaseFee(
|
||||
streitwert: number,
|
||||
isRVG: boolean,
|
||||
version: FeeScheduleVersion,
|
||||
): number {
|
||||
const brackets = resolveBrackets(version);
|
||||
let remaining = streitwert;
|
||||
let fee = 0;
|
||||
let lowerBound = 0;
|
||||
|
||||
for (const bracket of brackets) {
|
||||
const [upperBound, stepSize, gkgInc, rvgInc] = bracket;
|
||||
const increment = isRVG ? rvgInc : gkgInc;
|
||||
const bracketSize = upperBound === Infinity ? remaining : upperBound - lowerBound;
|
||||
const portionInBracket = Math.min(remaining, bracketSize);
|
||||
|
||||
if (portionInBracket <= 0) break;
|
||||
|
||||
// First bracket: the base fee comes from the first step
|
||||
if (lowerBound === 0) {
|
||||
// The minimum fee is the increment for the first bracket
|
||||
fee += increment;
|
||||
const stepsAfterFirst = Math.max(0, Math.ceil((portionInBracket - stepSize) / stepSize));
|
||||
fee += stepsAfterFirst * increment;
|
||||
} else {
|
||||
const steps = Math.ceil(portionInBracket / stepSize);
|
||||
fee += steps * increment;
|
||||
}
|
||||
|
||||
remaining -= portionInBracket;
|
||||
lowerBound = upperBound;
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
|
||||
return fee;
|
||||
}
|
||||
|
||||
/** Compute attorney fees for a single attorney */
|
||||
function computeAttorneyFees(
|
||||
streitwert: number,
|
||||
version: FeeScheduleVersion,
|
||||
vgFactor: number,
|
||||
tgFactor: number,
|
||||
oralHearing: boolean,
|
||||
numClients: number,
|
||||
vatRate: VatRate,
|
||||
): AttorneyBreakdown {
|
||||
const baseRVG = computeBaseFee(streitwert, true, version);
|
||||
|
||||
const verfahrensgebuehr = vgFactor * baseRVG;
|
||||
const erhoehungsgebuehr =
|
||||
numClients > 1
|
||||
? Math.min((numClients - 1) * CONSTANTS.erhoehungsfaktor, CONSTANTS.erhoehungsfaktorMax) *
|
||||
baseRVG
|
||||
: 0;
|
||||
const terminsgebuehr = oralHearing ? tgFactor * baseRVG : 0;
|
||||
const auslagenpauschale = CONSTANTS.auslagenpauschale;
|
||||
|
||||
const subtotalNetto =
|
||||
verfahrensgebuehr + erhoehungsgebuehr + terminsgebuehr + auslagenpauschale;
|
||||
const vat = subtotalNetto * vatRate;
|
||||
const totalBrutto = subtotalNetto + vat;
|
||||
|
||||
return {
|
||||
verfahrensgebuehr: Math.round(verfahrensgebuehr * 100) / 100,
|
||||
erhoehungsgebuehr: Math.round(erhoehungsgebuehr * 100) / 100,
|
||||
terminsgebuehr: Math.round(terminsgebuehr * 100) / 100,
|
||||
auslagenpauschale,
|
||||
subtotalNetto: Math.round(subtotalNetto * 100) / 100,
|
||||
vat: Math.round(vat * 100) / 100,
|
||||
totalBrutto: Math.round(totalBrutto * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute all costs for a single DE instance */
|
||||
export function computeDEInstance(
|
||||
streitwert: number,
|
||||
config: InstanceConfig,
|
||||
meta: InstanceMeta,
|
||||
vatRate: VatRate,
|
||||
): InstanceResult {
|
||||
if (!config.enabled) {
|
||||
return {
|
||||
instance: meta.key,
|
||||
label: meta.label,
|
||||
enabled: false,
|
||||
gerichtskosten: 0,
|
||||
perAttorney: null,
|
||||
attorneyTotal: 0,
|
||||
perPatentAttorney: null,
|
||||
patentAttorneyTotal: 0,
|
||||
instanceTotal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Court fees: base fee × factor
|
||||
// PatKostG (BPatG nullity) uses the same step-based lookup from the GKG column
|
||||
const baseFee = computeBaseFee(streitwert, false, config.feeVersion);
|
||||
const gerichtskosten =
|
||||
meta.feeBasis === "fixed"
|
||||
? meta.fixedCourtFee ?? 0
|
||||
: Math.round(meta.courtFeeFactor * baseFee * 100) / 100;
|
||||
|
||||
// Attorney (RA) fees
|
||||
const perAttorney =
|
||||
config.numAttorneys > 0
|
||||
? computeAttorneyFees(
|
||||
streitwert,
|
||||
config.feeVersion,
|
||||
meta.raVGFactor,
|
||||
meta.raTGFactor,
|
||||
config.oralHearing,
|
||||
config.numClients,
|
||||
vatRate,
|
||||
)
|
||||
: null;
|
||||
const attorneyTotal = perAttorney
|
||||
? Math.round(perAttorney.totalBrutto * config.numAttorneys * 100) / 100
|
||||
: 0;
|
||||
|
||||
// Patent attorney (PA) fees
|
||||
const perPatentAttorney =
|
||||
meta.hasPatentAttorneys && config.numPatentAttorneys > 0
|
||||
? computeAttorneyFees(
|
||||
streitwert,
|
||||
config.feeVersion,
|
||||
meta.paVGFactor,
|
||||
meta.paTGFactor,
|
||||
config.oralHearing,
|
||||
config.numClients,
|
||||
vatRate,
|
||||
)
|
||||
: null;
|
||||
const patentAttorneyTotal = perPatentAttorney
|
||||
? Math.round(perPatentAttorney.totalBrutto * config.numPatentAttorneys * 100) / 100
|
||||
: 0;
|
||||
|
||||
const instanceTotal =
|
||||
Math.round((gerichtskosten + attorneyTotal + patentAttorneyTotal) * 100) / 100;
|
||||
|
||||
return {
|
||||
instance: meta.key,
|
||||
label: meta.label,
|
||||
enabled: true,
|
||||
gerichtskosten,
|
||||
perAttorney,
|
||||
attorneyTotal,
|
||||
perPatentAttorney,
|
||||
patentAttorneyTotal,
|
||||
instanceTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/** Look up a value-based fee from UPC bracket table */
|
||||
function lookupUPCValueFee(streitwert: number, brackets: readonly UPCFeeBracket[]): number {
|
||||
for (const bracket of brackets) {
|
||||
if (bracket.maxValue === null || streitwert <= bracket.maxValue) {
|
||||
return bracket.fee;
|
||||
}
|
||||
}
|
||||
return brackets[brackets.length - 1].fee;
|
||||
}
|
||||
|
||||
/** Look up recoverable costs ceiling */
|
||||
function lookupRecoverableCosts(
|
||||
streitwert: number,
|
||||
table: readonly UPCRecoverableCost[],
|
||||
): number {
|
||||
for (const entry of table) {
|
||||
if (entry.maxValue === null || streitwert <= entry.maxValue) {
|
||||
return entry.ceiling;
|
||||
}
|
||||
}
|
||||
return table[table.length - 1].ceiling;
|
||||
}
|
||||
|
||||
/** Compute UPC instance costs */
|
||||
export function computeUPCInstance(
|
||||
streitwert: number,
|
||||
config: UPCConfig,
|
||||
instance: UPCInstance,
|
||||
includeRevocation: boolean,
|
||||
): UPCInstanceResult {
|
||||
const label =
|
||||
instance === "UPC_FIRST" ? "UPC (1. Instanz)" : "UPC (Berufung)";
|
||||
|
||||
if (!config.enabled) {
|
||||
return {
|
||||
instance,
|
||||
label,
|
||||
enabled: false,
|
||||
fixedFee: 0,
|
||||
valueBasedFee: 0,
|
||||
courtFeesTotal: 0,
|
||||
courtFeesSME: 0,
|
||||
recoverableCostsCeiling: 0,
|
||||
instanceTotal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const feeData = UPC_FEES[config.feeVersion];
|
||||
|
||||
// Fixed fee for infringement
|
||||
let fixedFee = feeData.fixedFees.infringement;
|
||||
|
||||
// Add revocation fee if counterclaim included
|
||||
if (includeRevocation) {
|
||||
fixedFee += feeData.fixedFees.revocation;
|
||||
}
|
||||
|
||||
// Value-based fee (only for infringement-type actions with Streitwert > 500k)
|
||||
const valueBasedFee = lookupUPCValueFee(streitwert, feeData.valueBased);
|
||||
|
||||
const courtFeesTotal = fixedFee + valueBasedFee;
|
||||
const courtFeesSME = Math.round(courtFeesTotal * (1 - feeData.smReduction));
|
||||
|
||||
const recoverableCostsCeiling = lookupRecoverableCosts(
|
||||
streitwert,
|
||||
feeData.recoverableCosts,
|
||||
);
|
||||
|
||||
// Total cost risk = court fees + recoverable costs ceiling
|
||||
const instanceTotal = (config.isSME ? courtFeesSME : courtFeesTotal) + recoverableCostsCeiling;
|
||||
|
||||
return {
|
||||
instance,
|
||||
label,
|
||||
enabled: true,
|
||||
fixedFee,
|
||||
valueBasedFee,
|
||||
courtFeesTotal,
|
||||
courtFeesSME,
|
||||
recoverableCostsCeiling,
|
||||
instanceTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a number as EUR with thousand separators */
|
||||
export function formatEUR(value: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
239
frontend/src/lib/costs/fee-tables.ts
Normal file
239
frontend/src/lib/costs/fee-tables.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
FeeScheduleVersion,
|
||||
FeeScheduleEntry,
|
||||
InstanceMeta,
|
||||
UPCFeeBracket,
|
||||
UPCRecoverableCost,
|
||||
} from "./types";
|
||||
|
||||
// Fee schedules: [upperBound, stepSize, gkgIncrement, rvgIncrement]
|
||||
export const FEE_SCHEDULES: Record<FeeScheduleVersion, FeeScheduleEntry> = {
|
||||
"2005": {
|
||||
label: "GKG/RVG 2006-09-01",
|
||||
validFrom: "2006-09-01",
|
||||
brackets: [
|
||||
[300, 300, 25, 25],
|
||||
[1500, 300, 10, 20],
|
||||
[5000, 500, 8, 28],
|
||||
[10000, 1000, 15, 37],
|
||||
[25000, 3000, 23, 40],
|
||||
[50000, 5000, 29, 72],
|
||||
[200000, 15000, 100, 77],
|
||||
[500000, 30000, 150, 118],
|
||||
[Infinity, 50000, 150, 150],
|
||||
],
|
||||
},
|
||||
"2013": {
|
||||
label: "GKG/RVG 2013-08-01",
|
||||
validFrom: "2013-08-01",
|
||||
brackets: [
|
||||
[500, 300, 35, 45],
|
||||
[2000, 500, 18, 35],
|
||||
[10000, 1000, 19, 51],
|
||||
[25000, 3000, 26, 46],
|
||||
[50000, 5000, 35, 75],
|
||||
[200000, 15000, 120, 85],
|
||||
[500000, 30000, 179, 120],
|
||||
[Infinity, 50000, 180, 150],
|
||||
],
|
||||
},
|
||||
"2021": {
|
||||
label: "GKG/RVG 2021-01-01",
|
||||
validFrom: "2021-01-01",
|
||||
brackets: [
|
||||
[500, 300, 38, 49],
|
||||
[2000, 500, 20, 39],
|
||||
[10000, 1000, 21, 56],
|
||||
[25000, 3000, 29, 52],
|
||||
[50000, 5000, 38, 81],
|
||||
[200000, 15000, 132, 94],
|
||||
[500000, 30000, 198, 132],
|
||||
[Infinity, 50000, 198, 165],
|
||||
],
|
||||
},
|
||||
"2025": {
|
||||
label: "GKG/RVG 2025-06-01",
|
||||
validFrom: "2025-06-01",
|
||||
brackets: [
|
||||
[500, 300, 40, 51.5],
|
||||
[2000, 500, 21, 41.5],
|
||||
[10000, 1000, 22.5, 59.5],
|
||||
[25000, 3000, 30.5, 55],
|
||||
[50000, 5000, 40.5, 86],
|
||||
[200000, 15000, 140, 99.5],
|
||||
[500000, 30000, 210, 140],
|
||||
[Infinity, 50000, 210, 175],
|
||||
],
|
||||
},
|
||||
Aktuell: {
|
||||
label: "Aktuell (= 2025-06-01)",
|
||||
validFrom: "2025-06-01",
|
||||
aliasOf: "2025",
|
||||
},
|
||||
};
|
||||
|
||||
export const CONSTANTS = {
|
||||
erhoehungsfaktor: 0.3,
|
||||
erhoehungsfaktorMax: 2.0,
|
||||
auslagenpauschale: 20,
|
||||
} as const;
|
||||
|
||||
// DE instance metadata with fee multipliers
|
||||
export const DE_INFRINGEMENT_INSTANCES: InstanceMeta[] = [
|
||||
{
|
||||
key: "LG",
|
||||
label: "LG (Verletzung 1. Instanz)",
|
||||
courtFeeFactor: 3.0,
|
||||
feeBasis: "GKG",
|
||||
raVGFactor: 1.3,
|
||||
raTGFactor: 1.2,
|
||||
paVGFactor: 1.3,
|
||||
paTGFactor: 1.2,
|
||||
hasPatentAttorneys: true,
|
||||
},
|
||||
{
|
||||
key: "OLG",
|
||||
label: "OLG (Berufung)",
|
||||
courtFeeFactor: 4.0,
|
||||
feeBasis: "GKG",
|
||||
raVGFactor: 1.6,
|
||||
raTGFactor: 1.2,
|
||||
paVGFactor: 1.6,
|
||||
paTGFactor: 1.2,
|
||||
hasPatentAttorneys: true,
|
||||
},
|
||||
{
|
||||
key: "BGH_NZB",
|
||||
label: "BGH (Nichtzulassungsbeschwerde)",
|
||||
courtFeeFactor: 2.0,
|
||||
feeBasis: "GKG",
|
||||
raVGFactor: 2.3,
|
||||
raTGFactor: 1.2,
|
||||
paVGFactor: 1.6,
|
||||
paTGFactor: 1.2,
|
||||
hasPatentAttorneys: true,
|
||||
},
|
||||
{
|
||||
key: "BGH_REV",
|
||||
label: "BGH (Revision)",
|
||||
courtFeeFactor: 5.0,
|
||||
feeBasis: "GKG",
|
||||
raVGFactor: 2.3,
|
||||
raTGFactor: 1.5,
|
||||
paVGFactor: 1.6,
|
||||
paTGFactor: 1.5,
|
||||
hasPatentAttorneys: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DE_NULLITY_INSTANCES: InstanceMeta[] = [
|
||||
{
|
||||
key: "BPatG",
|
||||
label: "BPatG (Nichtigkeitsverfahren)",
|
||||
courtFeeFactor: 4.5,
|
||||
feeBasis: "PatKostG",
|
||||
raVGFactor: 1.3,
|
||||
raTGFactor: 1.2,
|
||||
paVGFactor: 1.3,
|
||||
paTGFactor: 1.2,
|
||||
hasPatentAttorneys: true,
|
||||
},
|
||||
{
|
||||
key: "BGH_NULLITY",
|
||||
label: "BGH (Nichtigkeitsberufung)",
|
||||
courtFeeFactor: 6.0,
|
||||
feeBasis: "GKG",
|
||||
raVGFactor: 1.6,
|
||||
raTGFactor: 1.5,
|
||||
paVGFactor: 1.6,
|
||||
paTGFactor: 1.5,
|
||||
hasPatentAttorneys: true,
|
||||
},
|
||||
];
|
||||
|
||||
// UPC fee data
|
||||
export const UPC_FEES = {
|
||||
pre2026: {
|
||||
label: "UPC (vor 2026)",
|
||||
fixedFees: {
|
||||
infringement: 11000,
|
||||
revocation: 20000,
|
||||
},
|
||||
valueBased: [
|
||||
{ maxValue: 500000, fee: 0 },
|
||||
{ maxValue: 750000, fee: 2500 },
|
||||
{ maxValue: 1000000, fee: 4000 },
|
||||
{ maxValue: 1500000, fee: 8000 },
|
||||
{ maxValue: 2000000, fee: 13000 },
|
||||
{ maxValue: 3000000, fee: 20000 },
|
||||
{ maxValue: 4000000, fee: 26000 },
|
||||
{ maxValue: 5000000, fee: 32000 },
|
||||
{ maxValue: 6000000, fee: 39000 },
|
||||
{ maxValue: 7000000, fee: 46000 },
|
||||
{ maxValue: 8000000, fee: 52000 },
|
||||
{ maxValue: 9000000, fee: 58000 },
|
||||
{ maxValue: 10000000, fee: 65000 },
|
||||
{ maxValue: 15000000, fee: 75000 },
|
||||
{ maxValue: 20000000, fee: 100000 },
|
||||
{ maxValue: 25000000, fee: 125000 },
|
||||
{ maxValue: 30000000, fee: 150000 },
|
||||
{ maxValue: 50000000, fee: 250000 },
|
||||
{ maxValue: null, fee: 325000 },
|
||||
] as UPCFeeBracket[],
|
||||
recoverableCosts: [
|
||||
{ maxValue: 250000, ceiling: 38000 },
|
||||
{ maxValue: 500000, ceiling: 56000 },
|
||||
{ maxValue: 1000000, ceiling: 112000 },
|
||||
{ maxValue: 2000000, ceiling: 200000 },
|
||||
{ maxValue: 4000000, ceiling: 400000 },
|
||||
{ maxValue: 8000000, ceiling: 600000 },
|
||||
{ maxValue: 16000000, ceiling: 800000 },
|
||||
{ maxValue: 30000000, ceiling: 1200000 },
|
||||
{ maxValue: 50000000, ceiling: 1500000 },
|
||||
{ maxValue: null, ceiling: 2000000 },
|
||||
] as UPCRecoverableCost[],
|
||||
smReduction: 0.4,
|
||||
},
|
||||
"2026": {
|
||||
label: "UPC (ab 2026)",
|
||||
fixedFees: {
|
||||
infringement: 14600,
|
||||
revocation: 26500,
|
||||
},
|
||||
// Estimated ~32% increase on pre-2026 values
|
||||
valueBased: [
|
||||
{ maxValue: 500000, fee: 0 },
|
||||
{ maxValue: 750000, fee: 3300 },
|
||||
{ maxValue: 1000000, fee: 5300 },
|
||||
{ maxValue: 1500000, fee: 10600 },
|
||||
{ maxValue: 2000000, fee: 17200 },
|
||||
{ maxValue: 3000000, fee: 26400 },
|
||||
{ maxValue: 4000000, fee: 34300 },
|
||||
{ maxValue: 5000000, fee: 42200 },
|
||||
{ maxValue: 6000000, fee: 51500 },
|
||||
{ maxValue: 7000000, fee: 60700 },
|
||||
{ maxValue: 8000000, fee: 68600 },
|
||||
{ maxValue: 9000000, fee: 76600 },
|
||||
{ maxValue: 10000000, fee: 85800 },
|
||||
{ maxValue: 15000000, fee: 99000 },
|
||||
{ maxValue: 20000000, fee: 132000 },
|
||||
{ maxValue: 25000000, fee: 165000 },
|
||||
{ maxValue: 30000000, fee: 198000 },
|
||||
{ maxValue: 50000000, fee: 330000 },
|
||||
{ maxValue: null, fee: 429000 },
|
||||
] as UPCFeeBracket[],
|
||||
recoverableCosts: [
|
||||
{ maxValue: 250000, ceiling: 38000 },
|
||||
{ maxValue: 500000, ceiling: 56000 },
|
||||
{ maxValue: 1000000, ceiling: 112000 },
|
||||
{ maxValue: 2000000, ceiling: 200000 },
|
||||
{ maxValue: 4000000, ceiling: 400000 },
|
||||
{ maxValue: 8000000, ceiling: 600000 },
|
||||
{ maxValue: 16000000, ceiling: 800000 },
|
||||
{ maxValue: 30000000, ceiling: 1200000 },
|
||||
{ maxValue: 50000000, ceiling: 1500000 },
|
||||
{ maxValue: null, ceiling: 2000000 },
|
||||
] as UPCRecoverableCost[],
|
||||
smReduction: 0.5,
|
||||
},
|
||||
} as const;
|
||||
132
frontend/src/lib/costs/types.ts
Normal file
132
frontend/src/lib/costs/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Fee calculation types for the Patentprozesskostenrechner
|
||||
|
||||
export type FeeScheduleVersion = "2005" | "2013" | "2021" | "2025" | "Aktuell";
|
||||
|
||||
export type Jurisdiction = "DE" | "UPC";
|
||||
|
||||
export type VatRate = 0 | 0.16 | 0.19;
|
||||
|
||||
/** [upperBound, stepSize, gkgIncrement, rvgIncrement] */
|
||||
export type FeeBracket = [number, number, number, number];
|
||||
|
||||
export interface FeeSchedule {
|
||||
label: string;
|
||||
validFrom: string;
|
||||
brackets: FeeBracket[];
|
||||
}
|
||||
|
||||
export interface FeeScheduleAlias {
|
||||
label: string;
|
||||
validFrom: string;
|
||||
aliasOf: FeeScheduleVersion;
|
||||
}
|
||||
|
||||
export type FeeScheduleEntry = FeeSchedule | FeeScheduleAlias;
|
||||
|
||||
export function isAlias(entry: FeeScheduleEntry): entry is FeeScheduleAlias {
|
||||
return "aliasOf" in entry;
|
||||
}
|
||||
|
||||
// DE instance types
|
||||
export type DEInfringementInstance = "LG" | "OLG" | "BGH_NZB" | "BGH_REV";
|
||||
export type DENullityInstance = "BPatG" | "BGH_NULLITY";
|
||||
export type DEInstance = DEInfringementInstance | DENullityInstance;
|
||||
|
||||
// UPC instance types
|
||||
export type UPCInstance = "UPC_FIRST" | "UPC_APPEAL";
|
||||
|
||||
export type Instance = DEInstance | UPCInstance;
|
||||
|
||||
export interface InstanceConfig {
|
||||
enabled: boolean;
|
||||
feeVersion: FeeScheduleVersion;
|
||||
numAttorneys: number;
|
||||
numPatentAttorneys: number;
|
||||
oralHearing: boolean;
|
||||
numClients: number;
|
||||
}
|
||||
|
||||
export interface UPCConfig {
|
||||
enabled: boolean;
|
||||
feeVersion: "pre2026" | "2026";
|
||||
isSME: boolean;
|
||||
includeRevocation: boolean;
|
||||
}
|
||||
|
||||
export interface CalculatorInputs {
|
||||
streitwert: number;
|
||||
vatRate: VatRate;
|
||||
jurisdiction: Jurisdiction;
|
||||
// DE instances
|
||||
deInstances: Record<DEInstance, InstanceConfig>;
|
||||
// UPC instances
|
||||
upcInstances: Record<UPCInstance, UPCConfig>;
|
||||
}
|
||||
|
||||
// Output types
|
||||
|
||||
export interface AttorneyBreakdown {
|
||||
verfahrensgebuehr: number;
|
||||
erhoehungsgebuehr: number;
|
||||
terminsgebuehr: number;
|
||||
auslagenpauschale: number;
|
||||
subtotalNetto: number;
|
||||
vat: number;
|
||||
totalBrutto: number;
|
||||
}
|
||||
|
||||
export interface InstanceResult {
|
||||
instance: Instance;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
gerichtskosten: number;
|
||||
perAttorney: AttorneyBreakdown | null;
|
||||
attorneyTotal: number;
|
||||
perPatentAttorney: AttorneyBreakdown | null;
|
||||
patentAttorneyTotal: number;
|
||||
instanceTotal: number;
|
||||
}
|
||||
|
||||
export interface UPCInstanceResult {
|
||||
instance: UPCInstance;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
fixedFee: number;
|
||||
valueBasedFee: number;
|
||||
courtFeesTotal: number;
|
||||
courtFeesSME: number;
|
||||
recoverableCostsCeiling: number;
|
||||
instanceTotal: number;
|
||||
}
|
||||
|
||||
export interface CalculatorResult {
|
||||
deResults: InstanceResult[];
|
||||
upcResults: UPCInstanceResult[];
|
||||
deTotal: number;
|
||||
upcTotal: number;
|
||||
grandTotal: number;
|
||||
}
|
||||
|
||||
// Instance metadata for display and calculation
|
||||
export interface InstanceMeta {
|
||||
key: Instance;
|
||||
label: string;
|
||||
courtFeeFactor: number;
|
||||
feeBasis: "GKG" | "PatKostG" | "fixed";
|
||||
fixedCourtFee?: number;
|
||||
raVGFactor: number;
|
||||
raTGFactor: number;
|
||||
paVGFactor: number;
|
||||
paTGFactor: number;
|
||||
hasPatentAttorneys: boolean;
|
||||
}
|
||||
|
||||
export interface UPCFeeBracket {
|
||||
maxValue: number | null;
|
||||
fee: number;
|
||||
}
|
||||
|
||||
export interface UPCRecoverableCost {
|
||||
maxValue: number | null;
|
||||
ceiling: number;
|
||||
}
|
||||
@@ -295,6 +295,44 @@ export interface ApiError {
|
||||
status: number;
|
||||
}
|
||||
|
||||
// Fee calculation types (Patentprozesskostenrechner)
|
||||
|
||||
export interface FeeCalculateRequest {
|
||||
streitwert: number;
|
||||
vat_rate: number;
|
||||
fee_version: string;
|
||||
instances: FeeInstanceInput[];
|
||||
}
|
||||
|
||||
export interface FeeInstanceInput {
|
||||
instance_type: string;
|
||||
enabled: boolean;
|
||||
num_attorneys: number;
|
||||
num_patent_attorneys: number;
|
||||
oral_hearing: boolean;
|
||||
num_clients: number;
|
||||
}
|
||||
|
||||
export interface FeeCalculateResponse {
|
||||
instances: FeeInstanceOutput[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FeeInstanceOutput {
|
||||
instance_type: string;
|
||||
label: string;
|
||||
court_fees: number;
|
||||
attorney_fees: number;
|
||||
patent_attorney_fees: number;
|
||||
instance_total: number;
|
||||
}
|
||||
|
||||
export interface FeeScheduleInfo {
|
||||
version: string;
|
||||
label: string;
|
||||
valid_from: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
|
||||
Reference in New Issue
Block a user