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,
|
Calendar,
|
||||||
Brain,
|
Brain,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Calculator,
|
||||||
Settings,
|
Settings,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
@@ -29,6 +30,7 @@ const allNavigation: NavItem[] = [
|
|||||||
{ name: "Fristen", href: "/fristen", icon: Clock },
|
{ name: "Fristen", href: "/fristen", icon: Clock },
|
||||||
{ name: "Termine", href: "/termine", icon: Calendar },
|
{ name: "Termine", href: "/termine", icon: Calendar },
|
||||||
{ name: "Berichte", href: "/berichte", icon: BarChart3 },
|
{ 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: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
|
||||||
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
|
{ 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;
|
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> {
|
export interface PaginatedResponse<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user