L14 Abrechnungsübersicht: Backoffice-Übersicht, Detail, Druckansicht, Meilensteine im Auftrag und mobil, API

/billing mit Tabs Offen/Abgerechnet/Storniert, Filtern und Mehrfachdruck; /billing/[id] mit Abrechnungsblatt und Aktionen (abgerechnet mit Rechnungsnummer, PDF, Rechnungsnummer ändern, stornieren); /billing/print; Auftrags-Tab „Meilensteine & Abrechnung“; mobiler Abschnitt „Erreicht melden“ (offline); Dashboard-Kachel „Bereit zur Abrechnung“; /api/v1/billing*, Meilenstein-Endpunkte und OpenAPI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:35:56 +02:00
co-authored by Claude Opus 5
parent ac2d857e26
commit b76c1bdbfd
32 changed files with 1402 additions and 16 deletions
@@ -0,0 +1,19 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { markBilled } from "@/server/services/billing/records";
/**
* POST /api/v1/billing/:id/billed — mark as billed (freeze statement, assign positions, PDF job).
* Body (optional): { invoiceNumber?: string, confirmPendingExcluded?: boolean }.
*/
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
const body = await readJsonObject(req, { allowEmpty: true });
const record = await markBilled(ctx, {
recordId: id,
invoiceNumber: typeof body.invoiceNumber === "string" ? body.invoiceNumber : null,
confirmPendingExcluded: body.confirmPendingExcluded === true,
});
return json({ id: record.id, status: record.status, invoiceNumber: record.invoiceNumber, billedAt: record.billedAt });
});
+10
View File
@@ -0,0 +1,10 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getBillingRecordDetail } from "@/server/services/billing/queries";
/** GET /api/v1/billing/:id — billing record with its statement (frozen snapshot once billed). */
export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:read");
const { id } = await params;
return json(await getBillingRecordDetail(ctx, id));
});
+12
View File
@@ -0,0 +1,12 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { voidBilling } from "@/server/services/billing/records";
/** POST /api/v1/billing/:id/void — void a billed record. Body: { reason: string } (required). */
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
const body = await readJsonObject(req);
const res = await voidBilling(ctx, { recordId: id, reason: typeof body.reason === "string" ? body.reason : "" });
return json({ id: res.voided.id, status: res.voided.status, reopenedId: res.reopened?.id ?? null });
});
+26
View File
@@ -0,0 +1,26 @@
import { requireApiContext } from "@/server/api/context";
import { paginated, parsePagination, withApi } from "@/server/api/respond";
import { listBillingRecords } from "@/server/services/billing/queries";
/**
* GET /api/v1/billing — billing overview (L14). Query: status (open|billed|voided, default open), kind,
* customerId, teamId, from/to (YYYY-MM-DD), q, page, pageSize. No prices or amounts.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("billing", "billing:read");
const url = new URL(req.url);
const { page, pageSize } = parsePagination(url);
const sp = url.searchParams;
const res = await listBillingRecords(ctx, {
status: (sp.get("status") ?? undefined) as never,
kind: (sp.get("kind") ?? undefined) as never,
customerId: sp.get("customerId") ?? undefined,
teamId: sp.get("teamId") ?? undefined,
from: sp.get("from") ?? undefined,
to: sp.get("to") ?? undefined,
q: sp.get("q") ?? undefined,
page,
pageSize,
});
return paginated(res.items, res.total, res.page, res.pageSize);
});
@@ -0,0 +1,10 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { confirmMilestone } from "@/server/services/billing/milestones";
/** POST /api/v1/milestones/:id/confirm — confirm a reported milestone → open billing record. */
export const POST = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
return json(await confirmMilestone(ctx, id));
});
@@ -0,0 +1,15 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { markMilestoneReached } from "@/server/services/billing/milestones";
/**
* POST /api/v1/milestones/:id/reach — report a milestone as reached (field:execute + order in scope,
* or work_order:write). Idempotent. Body (optional): { note?: string }. Offline: sync op `milestone.reach`.
*/
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing");
const { id } = await params;
const body = await readJsonObject(req, { allowEmpty: true });
const res = await markMilestoneReached(ctx, { milestoneId: id, note: typeof body.note === "string" ? body.note : null });
return json({ milestone: res.milestone, changed: res.changed });
});
@@ -0,0 +1,11 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { rejectMilestone } from "@/server/services/billing/milestones";
/** POST /api/v1/milestones/:id/reject — reject a reported milestone. Body: { reason: string } (required). */
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
const body = await readJsonObject(req);
return json(await rejectMilestone(ctx, { milestoneId: id, reason: typeof body.reason === "string" ? body.reason : "" }));
});
@@ -0,0 +1,19 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { createMilestone, listMilestones } from "@/server/services/billing/milestones";
/** GET /api/v1/work-orders/:id/milestones — milestones of a visible order (field roles: scope). */
export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing");
const { id } = await params;
return json({ data: await listMilestones(ctx, id) });
});
/** POST /api/v1/work-orders/:id/milestones — define a milestone. Body: { title, description? }. */
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "work_order:write");
const { id } = await params;
const body = await readJsonObject(req);
const m = await createMilestone(ctx, { workOrderId: id, title: typeof body.title === "string" ? body.title : "", description: typeof body.description === "string" ? body.description : null });
return json(m, { status: 201 });
});