Erstimport. Fachliche Grundlage ist die EIU-Ablagestruktur des Auftraggebers (2-EIU-Ablagestruktur.xlsx): 1.106 Zeilen, aufbereitet zu 159 Ordnern und 944 Soll-Unterlagen mit Fachbereichsrelevanz, Aufbewahrungsfrist, Medium und Übergabezeitpunkt. Module - Dokumentationsstand: Soll-Ist je Projekt, Fristen aus dem Übergabezeitpunkt und den Projektterminen, Ampel, CSV-Rückexport im Format der Ursprungsdatei - Dokumentenmanagement mit Versionen, Freigabelauf, SHA-256-Prüfsummen - Bautagebuch nach Ril 809.0301, nach Abschluss gesperrt - Meldungen und Mängel (Behinderung, Bedenken, Mangel, AvL, Unfall) - Verträge, Nachträge, Aufmaße, Stundenlohnzettel mit zeilenweiser Anerkennung - Rechnungsprüfung: automatischer Abgleich gegen Vertrag, anerkanntes Aufmaß, bestätigte Stundenzettel und Vorrechnungen; getrennte Bescheinigung sachlich richtig / rechnerisch richtig / Zahlungsfreigabe - Kommunikation mit Projektkanälen, Erwähnungen und E-Mail-Benachrichtigung - Revisionssicheres Protokoll aller Vorgänge Rechte Rollen nach dem Vorgabeprozess Bauüberwachung (PL, BHV, BÜB, FBÜ, kaufmännische Steuerung, Planung, Auftragnehmer, Prüfer, Betreiber), Zuordnung je Projekt. Auftragnehmer sehen ausschließlich Daten der eigenen Firma; diese Schranke hängt allein an der Rolle. Welche Bereiche ihnen überhaupt angezeigt werden, entscheidet der Bauherr je Projekt über Schalter in den Einstellungen. Betrieb Docker Compose mit Anwendung, PostgreSQL und Mailpit. Keine Named Volumes – alle persistenten Daten liegen als Bind-Mount unter data/ im Compose- Verzeichnis, damit ein Backup ein Kopiervorgang bleibt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
6.2 KiB
TypeScript
131 lines
6.2 KiB
TypeScript
import Link from "next/link";
|
||
import { AlertTriangle } from "lucide-react";
|
||
import { projektKontext } from "@/lib/auth";
|
||
import { prisma } from "@/lib/db";
|
||
import { canInProject } from "@/lib/rbac";
|
||
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
|
||
import { datum, eur } from "@/lib/format";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
export default async function Rechnungen({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
||
if (!canInProject(roles, "rechnung.lesen", optionen))
|
||
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
|
||
|
||
const rechnungen = await prisma.invoice.findMany({
|
||
where: {
|
||
projectId: id,
|
||
// Auftragnehmer sehen ausschließlich die eigenen Rechnungen
|
||
...(nurEigeneFirma ? { companyId: user.companyId ?? "__keine__" } : {}),
|
||
},
|
||
include: {
|
||
company: { select: { name: true, kreditorNr: true } },
|
||
contract: { select: { nummer: true } },
|
||
befunde: { select: { schwere: true, erledigt: true } },
|
||
},
|
||
orderBy: { eingangsdatum: "desc" },
|
||
});
|
||
|
||
const inPruefung = rechnungen.filter((r) => ["EINGEGANGEN", "IN_PRUEFUNG", "RUECKFRAGE", "SACHLICH_RICHTIG"].includes(r.status));
|
||
const kritisch = rechnungen.reduce((s, r) => s + r.befunde.filter((b) => b.schwere === "KRITISCH" && !b.erledigt).length, 0);
|
||
const gestellt = rechnungen.filter((r) => r.status !== "ZURUECKGEWIESEN").reduce((s, r) => s + Number(r.nettoBetrag), 0);
|
||
const gekuerzt = rechnungen.reduce((s, r) => s + Number(r.kuerzungNetto), 0);
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<Kachel titel="In Prüfung" wert={inPruefung.length} hinweis={`${rechnungen.length} Rechnungen insgesamt`} ton={inPruefung.length ? "warnung" : "gut"} />
|
||
<Kachel titel="Kritische Befunde" wert={kritisch} hinweis="offen, aus dem automatischen Abgleich" ton={kritisch ? "kritisch" : "gut"} />
|
||
<Kachel titel="Netto gestellt" wert={eur(gestellt)} />
|
||
<Kachel
|
||
titel="Kürzung nach Prüfung"
|
||
wert={eur(gekuerzt)}
|
||
hinweis={gestellt > 0 ? `${((gekuerzt / gestellt) * 100).toFixed(1)} % der gestellten Summe` : undefined}
|
||
ton={gekuerzt > 0 ? "warnung" : "gut"}
|
||
/>
|
||
</div>
|
||
|
||
<div className="card-pad text-xs leading-relaxed text-db-muted">
|
||
Jede eingehende Rechnung wird automatisch gegen Vertrag, anerkannte Aufmaße, bestätigte Stundenlohnzettel und
|
||
bereits abgerechnete Mengen geprüft. Der Prüfbericht ist ein Vorschlag – die Bescheinigung „sachlich richtig“
|
||
(Bauüberwachung) und „rechnerisch richtig“ (kaufmännische Steuerung) bleiben getrennte, personengebundene
|
||
Entscheidungen.
|
||
</div>
|
||
|
||
{rechnungen.length === 0 ? (
|
||
<Leer text="Für dieses Projekt liegen keine Rechnungen vor." />
|
||
) : (
|
||
<div className="card overflow-hidden">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-db-line">
|
||
<th className="th">Rechnung</th>
|
||
<th className="th">Auftragnehmer / Vertrag</th>
|
||
<th className="th">Zeitraum</th>
|
||
<th className="th text-right">Netto gestellt</th>
|
||
<th className="th text-right">anerkannt</th>
|
||
<th className="th text-right">Befunde</th>
|
||
<th className="th text-right">Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rechnungen.map((r) => {
|
||
const offen = r.befunde.filter((b) => !b.erledigt);
|
||
const krit = offen.filter((b) => b.schwere === "KRITISCH").length;
|
||
return (
|
||
<tr key={r.id} className="row">
|
||
<td className="td">
|
||
<Link href={`/projekte/${id}/rechnungen/${r.id}`} className="link font-medium">
|
||
{r.rechnungsNr}
|
||
</Link>
|
||
<div className="text-[11px] text-db-muted">
|
||
{r.typ.replace(/_/g, " ").toLowerCase()} · Eingang {datum(r.eingangsdatum)}
|
||
</div>
|
||
</td>
|
||
<td className="td text-slate-300">
|
||
{r.company.name}
|
||
<div className="text-[11px] text-db-muted">
|
||
{r.contract?.nummer ?? "ohne Vertragsbezug"}
|
||
{r.company.kreditorNr && ` · Kreditor ${r.company.kreditorNr}`}
|
||
</div>
|
||
</td>
|
||
<td className="td whitespace-nowrap text-xs text-db-muted">
|
||
{datum(r.leistungVon)} – {datum(r.leistungBis)}
|
||
</td>
|
||
<td className="td text-right tabular-nums text-slate-200">{eur(r.nettoBetrag)}</td>
|
||
<td className="td text-right tabular-nums">
|
||
{r.anerkanntNetto !== null ? (
|
||
<span className={Number(r.kuerzungNetto) > 0 ? "text-amber-400" : "text-emerald-400"}>
|
||
{eur(r.anerkanntNetto)}
|
||
</span>
|
||
) : (
|
||
<span className="text-db-muted">—</span>
|
||
)}
|
||
</td>
|
||
<td className="td text-right">
|
||
{offen.length > 0 ? (
|
||
<span className={`chip ${krit ? "border-red-800 bg-red-900/25 text-red-300" : "border-amber-700 bg-amber-900/25 text-amber-300"}`}>
|
||
<AlertTriangle size={11} /> {offen.length}
|
||
</span>
|
||
) : r.letzterPrueflauf ? (
|
||
<span className="chip border-emerald-700 bg-emerald-900/25 text-emerald-300">ohne Befund</span>
|
||
) : (
|
||
<span className="text-xs text-db-muted">nicht geprüft</span>
|
||
)}
|
||
</td>
|
||
<td className="td text-right">
|
||
<StatusChip status={r.status} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|