diff --git a/app/RELEASE_NOTES.md b/app/RELEASE_NOTES.md index 0fdbf99..34aef6c 100644 --- a/app/RELEASE_NOTES.md +++ b/app/RELEASE_NOTES.md @@ -1,4 +1,12 @@ # Release Notes +## 1.0.0 - 2026-03-05 +- OpenAI-compatible API proxy for forwarding requests. +- Web UI for creating, managing, and deactivating API keys. +- Per-key usage tracking with filtering, sorting, and reporting groups. +- Admin usage and cost dashboards with range filters and charts. +- Release notes page linked from the sidebar with in-app version display. + ## 0.1.0 - 2026-03-04 - Initial release notes tracking. +- Added reporting groups management and usage/cost reporting views with charts and sortable tables. diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts index 4124c77..1e5c5f0 100644 --- a/app/drizzle/schema.ts +++ b/app/drizzle/schema.ts @@ -114,6 +114,84 @@ export const users = pgTable( ], ); +export const reportingGroups = pgTable( + "reporting_groups", + { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ + name: "reporting_groups_id_seq", + startWith: 1, + increment: 1, + minValue: 1, + // biome-ignore lint/correctness/noPrecisionLoss: matches Postgres BIGINT max. + maxValue: 9223372036854775807, + cache: 1, + }), + title: varchar({ length: 255 }).notNull(), + createdBy: varchar("created_by", { length: 255 }).notNull(), + createdAt: timestamp("created_at", { + withTimezone: true, + mode: "string", + }) + .defaultNow() + .notNull(), + }, + (table) => [ + foreignKey({ + columns: [table.createdBy], + foreignColumns: [users.id], + name: "reporting_groups_created_by_fkey", + }), + ], +); + +export const reportingGroupMembers = pgTable( + "reporting_group_members", + { + groupId: bigint("group_id", { mode: "number" }).notNull(), + userId: varchar("user_id", { length: 255 }).notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.groupId, table.userId], + name: "reporting_group_members_pkey", + }), + foreignKey({ + columns: [table.groupId], + foreignColumns: [reportingGroups.id], + name: "reporting_group_members_group_id_fkey", + }), + foreignKey({ + columns: [table.userId], + foreignColumns: [users.id], + name: "reporting_group_members_user_id_fkey", + }), + ], +); + +export const reportingGroupViewers = pgTable( + "reporting_group_viewers", + { + groupId: bigint("group_id", { mode: "number" }).notNull(), + userId: varchar("user_id", { length: 255 }).notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.groupId, table.userId], + name: "reporting_group_viewers_pkey", + }), + foreignKey({ + columns: [table.groupId], + foreignColumns: [reportingGroups.id], + name: "reporting_group_viewers_group_id_fkey", + }), + foreignKey({ + columns: [table.userId], + foreignColumns: [users.id], + name: "reporting_group_viewers_user_id_fkey", + }), + ], +); + export const models = pgTable("models", { id: varchar({ length: 255 }).primaryKey().notNull(), }); diff --git a/app/package-lock.json b/app/package-lock.json index 13749d1..23d382f 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "analytics-app", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "analytics-app", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@auth/drizzle-adapter": "^1.7.2", "@base-ui/react": "^1.2.0", diff --git a/app/package.json b/app/package.json index 836d4e0..8c577bd 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "analytics-app", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { diff --git a/app/src/app/release-notes/page.tsx b/app/src/app/release-notes/page.tsx index ae661e8..e82209f 100644 --- a/app/src/app/release-notes/page.tsx +++ b/app/src/app/release-notes/page.tsx @@ -11,8 +11,8 @@ export default async function ReleaseNotesPage() { return (
Version
-Version
+
{notes}
diff --git a/app/src/app/reporting/create/page.tsx b/app/src/app/reporting/create/page.tsx
new file mode 100644
index 0000000..e91492e
--- /dev/null
+++ b/app/src/app/reporting/create/page.tsx
@@ -0,0 +1,25 @@
+import { redirect } from "next/navigation";
+
+import { auth } from "~/server/auth";
+import { ReportingCreateClient } from "./reporting-create-client";
+
+export default async function ReportingCreatePage() {
+ const session = await auth();
+ if (!session?.user) {
+ redirect("/");
+ }
+
+ return (
+
+
+ Bericht erstellen
+
+ Auswertung nach Gruppe und Zeitraum inklusive Kostenübersicht.
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/app/reporting/create/reporting-create-client.tsx b/app/src/app/reporting/create/reporting-create-client.tsx
new file mode 100644
index 0000000..5d66364
--- /dev/null
+++ b/app/src/app/reporting/create/reporting-create-client.tsx
@@ -0,0 +1,909 @@
+"use client";
+
+import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react";
+import { Fragment, useEffect, useMemo, useState } from "react";
+import {
+ Area,
+ AreaChart,
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Pie,
+ PieChart,
+ Scatter,
+ ScatterChart,
+ XAxis,
+ YAxis,
+ ZAxis,
+} from "recharts";
+
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "~/components/ui/chart";
+import { Input } from "~/components/ui/input";
+import {
+ NativeSelect,
+ NativeSelectOption,
+} from "~/components/ui/native-select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "~/components/ui/table";
+import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import { api } from "~/trpc/react";
+
+const chartColors = [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ "var(--chart-3)",
+ "var(--chart-4)",
+ "var(--chart-5)",
+];
+
+type Timeframe = "daily" | "monthly" | "quarterly" | "yearly";
+
+type SortKey =
+ | "name"
+ | "inputTokens"
+ | "cachedInputTokens"
+ | "outputTokens"
+ | "totalCost";
+
+type SortState = { id: SortKey; direction: "asc" | "desc" } | null;
+
+const timeframeOptions = [
+ { value: "daily", label: "Täglich" },
+ { value: "monthly", label: "Monatlich" },
+ { value: "quarterly", label: "Quartal" },
+ { value: "yearly", label: "Jährlich" },
+] as const;
+
+const pad = (value: number) => String(value).padStart(2, "0");
+
+export function ReportingCreateClient({ isAdmin }: { isAdmin: boolean }) {
+ const { data: groups = [], isLoading: groupsLoading } =
+ api.reporting.listGroups.useQuery();
+
+ const now = useMemo(() => new Date(), []);
+ const currentYear = now.getFullYear();
+ const currentMonth = now.getMonth() + 1;
+ const currentDay = now.getDate();
+ const currentQuarter = Math.floor((now.getMonth() + 3) / 3);
+
+ const [timeframe, setTimeframe] = useState("monthly");
+ const [dailyDate, setDailyDate] = useState(
+ `${currentYear}-${pad(currentMonth)}-${pad(currentDay)}`,
+ );
+ const [monthValue, setMonthValue] = useState(
+ `${currentYear}-${pad(currentMonth)}`,
+ );
+ const [quarterYear, setQuarterYear] = useState(currentYear);
+ const [quarterValue, setQuarterValue] = useState(currentQuarter);
+ const [yearValue, setYearValue] = useState(currentYear);
+
+ const availableGroups = useMemo(() => {
+ const list = groups.map((group) => ({
+ id: group.id,
+ title: group.title,
+ }));
+ return isAdmin ? [{ id: "all", title: "Alle" }, ...list] : list;
+ }, [groups, isAdmin]);
+
+ const [selectedGroupId, setSelectedGroupId] = useState("");
+
+ useEffect(() => {
+ if (selectedGroupId) return;
+ if (isAdmin) {
+ setSelectedGroupId("all");
+ return;
+ }
+ if (availableGroups.length) {
+ setSelectedGroupId(availableGroups[0]?.id ?? "");
+ }
+ }, [availableGroups, isAdmin, selectedGroupId]);
+
+ const reportRange = useMemo(() => {
+ switch (timeframe) {
+ case "daily":
+ return { type: "daily" as const, date: dailyDate };
+ case "monthly":
+ return { type: "monthly" as const, month: monthValue };
+ case "quarterly":
+ return {
+ type: "quarterly" as const,
+ year: Number(quarterYear),
+ quarter: Number(quarterValue),
+ };
+ case "yearly":
+ return { type: "yearly" as const, year: Number(yearValue) };
+ default:
+ return { type: "monthly" as const, month: monthValue };
+ }
+ }, [dailyDate, monthValue, quarterValue, quarterYear, timeframe, yearValue]);
+
+ const selectedPeriodEnd = useMemo(() => {
+ switch (timeframe) {
+ case "daily": {
+ const [year, month, day] = dailyDate.split("-").map(Number);
+ if (!year || !month || !day) return null;
+ return new Date(year, month - 1, day + 1);
+ }
+ case "monthly": {
+ const [year, month] = monthValue.split("-").map(Number);
+ if (!year || !month) return null;
+ return new Date(year, month, 1);
+ }
+ case "quarterly": {
+ const startMonth = (quarterValue - 1) * 3;
+ return new Date(quarterYear, startMonth + 3, 1);
+ }
+ case "yearly": {
+ return new Date(yearValue + 1, 0, 1);
+ }
+ default:
+ return null;
+ }
+ }, [dailyDate, monthValue, quarterValue, quarterYear, timeframe, yearValue]);
+
+ const isPeriodOpen = useMemo(() => {
+ if (!selectedPeriodEnd) return false;
+ return selectedPeriodEnd.getTime() > now.getTime();
+ }, [now, selectedPeriodEnd]);
+
+ const reportEnabled = Boolean(selectedGroupId);
+ const { data: report, isLoading: reportLoading } =
+ api.reporting.getReport.useQuery(
+ {
+ groupId: selectedGroupId,
+ range: reportRange,
+ },
+ {
+ enabled: reportEnabled && (isAdmin || groups.length > 0),
+ },
+ );
+
+ const numberFormatter = useMemo(() => new Intl.NumberFormat("de-DE"), []);
+ const costFormatter = useMemo(
+ () =>
+ new Intl.NumberFormat("de-DE", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }),
+ [],
+ );
+
+ const formatCost = (value: number | null, currency: string | null) => {
+ if (value === null || Number.isNaN(value)) return "—";
+ const symbol = currency === "EUR" ? "€" : currency === "USD" ? "$" : "";
+ return `${costFormatter.format(value)}${symbol}`;
+ };
+
+ const summary = report?.summary;
+ const modelUsage = report?.modelUsage ?? [];
+ const users = report?.users ?? [];
+ const cumulativeCosts = report?.cumulativeCosts ?? [];
+ const hourlyTokens = report?.hourlyTokens ?? [];
+ const hourlyOutputByDay = report?.hourlyOutputByDay ?? [];
+ const costsUsed = report?.costsUsed ?? [];
+
+ const [sorting, setSorting] = useState(null);
+ const [expandedUsers, setExpandedUsers] = useState>(
+ () => new Set(),
+ );
+
+ const sortedUsers = useMemo(() => {
+ if (!sorting) return users;
+ const sorted = [...users];
+ const direction = sorting.direction === "asc" ? 1 : -1;
+ sorted.sort((a, b) => {
+ switch (sorting.id) {
+ case "name":
+ return direction * a.name.localeCompare(b.name);
+ case "inputTokens":
+ return direction * (a.inputTokens - b.inputTokens);
+ case "cachedInputTokens":
+ return direction * (a.cachedInputTokens - b.cachedInputTokens);
+ case "outputTokens":
+ return direction * (a.outputTokens - b.outputTokens);
+ case "totalCost":
+ return direction * ((a.totalCost ?? -1) - (b.totalCost ?? -1));
+ default:
+ return 0;
+ }
+ });
+ return sorted;
+ }, [sorting, users]);
+
+ const toggleSorting = (id: SortKey) => {
+ setSorting((prev) => {
+ if (!prev || prev.id !== id) {
+ return { id, direction: "asc" };
+ }
+ return prev.direction === "asc" ? { id, direction: "desc" } : null;
+ });
+ };
+
+ const toggleExpandedUser = (id: string) => {
+ setExpandedUsers((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ };
+
+ const chartData = modelUsage.map((item, index) => {
+ const key = `model-${index}`;
+ return {
+ key,
+ model: item.model,
+ outputTokens: item.outputTokens,
+ fill: `var(--color-${key})`,
+ };
+ });
+
+ const chartConfig = chartData.reduce((acc, item, index) => {
+ acc[item.key] = {
+ label: item.model,
+ color: chartColors[index % chartColors.length],
+ };
+ return acc;
+ }, {});
+
+ const costChartConfig: ChartConfig = {
+ cumulativeCost: {
+ label: "Kumulierte Kosten",
+ color: chartColors[1],
+ },
+ };
+
+ const hourlyChartConfig: ChartConfig = {
+ avgTokens: {
+ label: "Ø Tokens pro Stunde",
+ color: chartColors[2],
+ },
+ };
+
+ const heatmapChartConfig: ChartConfig = {
+ outputTokens: {
+ label: "Ø Output-Tokens",
+ color: chartColors[3],
+ },
+ };
+
+ const dateFormatter = useMemo(
+ () =>
+ new Intl.DateTimeFormat("de-DE", {
+ dateStyle: "medium",
+ }),
+ [],
+ );
+
+ const formatDate = (value: string | null) => {
+ if (!value) return "—";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "—";
+ return dateFormatter.format(date);
+ };
+
+ const shortDateFormatter = useMemo(
+ () =>
+ new Intl.DateTimeFormat("de-DE", {
+ month: "short",
+ day: "2-digit",
+ }),
+ [],
+ );
+
+ const cumulativeCostAvailable = useMemo(
+ () => cumulativeCosts.some((item) => item.cumulativeCost !== null),
+ [cumulativeCosts],
+ );
+
+ const hourlyTokensTotal = useMemo(
+ () => hourlyTokens.reduce((acc, item) => acc + item.avgTokens, 0),
+ [hourlyTokens],
+ );
+
+ const dayLabelByIndex = useMemo(() => {
+ const map = new Map();
+ for (const item of hourlyOutputByDay) {
+ map.set(item.dayIndex, item.dayLabel);
+ }
+ return map;
+ }, [hourlyOutputByDay]);
+
+ const heatmapData = useMemo(
+ () => hourlyOutputByDay.filter((item) => item.outputTokens > 0),
+ [hourlyOutputByDay],
+ );
+
+ const maxHeatmapTokens = useMemo(() => {
+ let max = 0;
+ for (const item of heatmapData) {
+ max = Math.max(max, item.outputTokens);
+ }
+ return max;
+ }, [heatmapData]);
+
+ if (!isAdmin && !groupsLoading && availableGroups.length === 0) {
+ return (
+
+ Du bist für keine Abrechnungsgruppe berechtigt, sie einzusehen.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ setSelectedGroupId(event.target.value)}
+ value={selectedGroupId}
+ >
+ {availableGroups.map((group) => (
+
+ {group.title}
+
+ ))}
+
+
+
+
+
+ Zeitraum
+
+ setTimeframe(value as Timeframe)}
+ value={timeframe}
+ >
+
+ {timeframeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {timeframe === "daily" ? (
+ setDailyDate(event.target.value)}
+ type="date"
+ value={dailyDate}
+ />
+ ) : null}
+ {timeframe === "monthly" ? (
+ setMonthValue(event.target.value)}
+ type="month"
+ value={monthValue}
+ />
+ ) : null}
+ {timeframe === "quarterly" ? (
+ <>
+
+ setQuarterValue(Number(event.target.value))
+ }
+ value={String(quarterValue)}
+ >
+ {[1, 2, 3, 4].map((quarter) => (
+
+ Q{quarter}
+
+ ))}
+
+
+ setQuarterYear(Number(event.target.value))
+ }
+ type="number"
+ value={quarterYear}
+ />
+ >
+ ) : null}
+ {timeframe === "yearly" ? (
+ setYearValue(Number(event.target.value))}
+ type="number"
+ value={yearValue}
+ />
+ ) : null}
+ {isPeriodOpen ? (
+
+ Hinweis: Der ausgewählte Zeitraum ist noch nicht
+ abgeschlossen.
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Token-Übersicht
+
+
+
+ Input:{" "}
+
+ {numberFormatter.format(summary?.inputTokens ?? 0)}
+
+
+
+ Cache:{" "}
+
+ {numberFormatter.format(summary?.cachedInputTokens ?? 0)}
+
+
+
+ Output:{" "}
+
+ {numberFormatter.format(summary?.outputTokens ?? 0)}
+
+
+
+
+
+
+ Gesamtkosten
+
+
+ {formatCost(
+ summary?.totalCost ?? null,
+ summary?.currency ?? "EUR",
+ )}
+
+
+
+ {reportLoading ? (
+
+ Bericht wird geladen ...
+
+ ) : null}
+
+
+
+
+
+ Modelle nach Output-Tokens
+
+
+ {chartData.length ? (
+
+
+ }
+ />
+
+ } />
+
+
+ ) : (
+
+ Keine Modelle im ausgewählten Zeitraum.
+
+ )}
+
+
+
+
+
+ Kumulierte Kosten
+
+
+ {cumulativeCostAvailable ? (
+
+
+
+
+ shortDateFormatter.format(new Date(value))
+ }
+ />
+
+ costFormatter.format(Number(value))
+ }
+ width={56}
+ />
+
+ value === null
+ ? "—"
+ : formatCost(
+ Number(value),
+ summary?.currency ?? "EUR",
+ )
+ }
+ labelFormatter={(value) =>
+ dateFormatter.format(new Date(value as string))
+ }
+ />
+ }
+ />
+
+
+
+ ) : (
+
+ Keine Kostendaten für den Zeitraum verfügbar.
+
+ )}
+
+
+
+
+
+ Stündliche Token-Verteilung (Ø)
+
+
+ {hourlyTokensTotal > 0 ? (
+
+
+
+ `${value}:00`}
+ />
+
+ numberFormatter.format(Number(value))
+ }
+ width={44}
+ />
+
+ numberFormatter.format(Number(value))
+ }
+ labelFormatter={(value) => `${value}:00`}
+ />
+ }
+ />
+
+
+
+ ) : (
+
+ Keine Tokens für den Zeitraum gefunden.
+
+ )}
+
+
+
+
+
+ Output-Tokens nach Wochentag & Stunde
+
+
+ {heatmapData.length ? (
+
+
+
+ `${value}:00`}
+ ticks={[0, 4, 8, 12, 16, 20, 23]}
+ type="number"
+ />
+
+ dayLabelByIndex.get(Number(value)) ?? String(value)
+ }
+ ticks={[0, 1, 2, 3, 4, 5, 6]}
+ type="number"
+ width={40}
+ />
+
+
+ `${numberFormatter.format(Number(value))} Tokens`
+ }
+ labelFormatter={(_, payload) => {
+ const item = payload?.[0]?.payload;
+ if (!item) return "";
+ return `${item.dayLabel}, ${item.hour} Uhr`;
+ }}
+ />
+ }
+ />
+
+
+
+ ) : (
+
+ Keine Daten für den Zeitraum gefunden.
+
+ )}
+
+
+
+
+
+
+ Nutzung nach Benutzer
+
+
+
+
+
+
+ toggleSorting("name")}
+ >
+ Benutzer / Modell
+
+
+
+ toggleSorting("inputTokens")}
+ >
+ Input-Tokens
+
+
+
+ toggleSorting("cachedInputTokens")}
+ >
+ Cache-Tokens
+
+
+
+ toggleSorting("outputTokens")}
+ >
+ Output-Tokens
+
+
+
+ toggleSorting("totalCost")}
+ >
+ Gesamtkosten
+
+
+
+
+
+ {sortedUsers.map((user) => (
+
+
+
+
+
+
+ {numberFormatter.format(user.inputTokens)}
+
+
+ {numberFormatter.format(user.cachedInputTokens)}
+
+
+ {numberFormatter.format(user.outputTokens)}
+
+
+ {formatCost(user.totalCost, user.currency)}
+
+
+ {expandedUsers.has(user.id)
+ ? user.models.map((model) => (
+
+
+ ↳ {model.model}
+
+
+ {numberFormatter.format(model.inputTokens)} (
+ {formatCost(model.inputCost, model.currency)})
+
+
+ {numberFormatter.format(model.cachedInputTokens)} (
+ {formatCost(model.cachedCost, model.currency)})
+
+
+ {numberFormatter.format(model.outputTokens)} (
+ {formatCost(model.outputCost, model.currency)})
+
+
+ {formatCost(model.totalCost, model.currency)}
+
+
+ ))
+ : null}
+
+ ))}
+ {sortedUsers.length === 0 ? (
+
+
+ Keine Benutzer gefunden.
+
+
+ ) : null}
+
+
+
+
+
+
+
+ Kostenbasis der Berechnung
+
+
+
+
+
+ Modell
+
+ Token-Typ
+
+ Preis
+ Einheit
+ Währung
+
+ Gültig ab
+
+
+
+
+ {costsUsed.map((cost) => (
+
+
+ {cost.model}
+
+ {cost.tokenType}
+
+ {costFormatter.format(cost.price / 100)}
+
+
+ {cost.unit ?? "—"}
+
+
+ {cost.currency ?? "—"}
+
+
+ {formatDate(cost.validFrom)}
+
+
+ ))}
+ {costsUsed.length === 0 ? (
+
+
+ Keine Kostenbasis verfügbar.
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+function SortButton({
+ children,
+ active,
+ onClick,
+}: {
+ children: React.ReactNode;
+ active: "asc" | "desc" | null;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/app/src/app/reporting/groups/page.tsx b/app/src/app/reporting/groups/page.tsx
new file mode 100644
index 0000000..4d5d3fa
--- /dev/null
+++ b/app/src/app/reporting/groups/page.tsx
@@ -0,0 +1,25 @@
+import { redirect } from "next/navigation";
+
+import { auth } from "~/server/auth";
+import { ReportingGroupsClient } from "./reporting-groups-client";
+
+export default async function ReportingGroupsPage() {
+ const session = await auth();
+ if (!session?.user?.isAdmin) {
+ redirect("/");
+ }
+
+ return (
+
+
+ Abrechnungsgruppen
+
+ Gruppen erstellen, Benutzer zuweisen und Sichtbarkeiten steuern.
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/app/reporting/groups/reporting-groups-client.tsx b/app/src/app/reporting/groups/reporting-groups-client.tsx
new file mode 100644
index 0000000..c5d07dc
--- /dev/null
+++ b/app/src/app/reporting/groups/reporting-groups-client.tsx
@@ -0,0 +1,437 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "~/components/ui/button";
+import {
+ Combobox,
+ ComboboxChip,
+ ComboboxChips,
+ ComboboxChipsInput,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ useComboboxAnchor,
+} from "~/components/ui/combobox";
+import { Input } from "~/components/ui/input";
+import { api } from "~/trpc/react";
+
+export function ReportingGroupsClient() {
+ const utils = api.useUtils();
+ const { data: users = [], isLoading: usersLoading } =
+ api.reporting.listUsers.useQuery();
+ const { data: groups = [], isLoading: groupsLoading } =
+ api.reporting.listGroupDetails.useQuery();
+
+ const createGroup = api.reporting.createGroup.useMutation({
+ onSuccess: async () => {
+ await utils.reporting.listGroupDetails.invalidate();
+ await utils.reporting.listGroups.invalidate();
+ toast.success("Gruppe erstellt.");
+ },
+ onError: () => {
+ toast.error("Gruppe konnte nicht erstellt werden.");
+ },
+ });
+ const updateGroup = api.reporting.updateGroup.useMutation({
+ onSuccess: async () => {
+ await utils.reporting.listGroupDetails.invalidate();
+ await utils.reporting.listGroups.invalidate();
+ toast.success("Gruppe aktualisiert.");
+ },
+ onError: () => {
+ toast.error("Gruppe konnte nicht aktualisiert werden.");
+ },
+ });
+ const deleteGroup = api.reporting.deleteGroup.useMutation({
+ onSuccess: async () => {
+ await utils.reporting.listGroupDetails.invalidate();
+ await utils.reporting.listGroups.invalidate();
+ toast.success("Gruppe gelöscht.");
+ },
+ onError: () => {
+ toast.error("Gruppe konnte nicht gelöscht werden.");
+ },
+ });
+
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [newTitle, setNewTitle] = useState("");
+ const [newMemberIds, setNewMemberIds] = useState([]);
+ const [newViewerIds, setNewViewerIds] = useState([]);
+ const [drafts, setDrafts] = useState<
+ Record
+ >({});
+
+ useEffect(() => {
+ const next: Record<
+ string,
+ { title: string; memberIds: string[]; viewerIds: string[] }
+ > = {};
+ for (const group of groups) {
+ next[group.id] = {
+ title: group.title,
+ memberIds: group.memberIds,
+ viewerIds: group.viewerIds,
+ };
+ }
+
+ setDrafts((prev) => {
+ const prevKeys = Object.keys(prev);
+ const nextKeys = Object.keys(next);
+ if (prevKeys.length !== nextKeys.length) return next;
+ for (const key of nextKeys) {
+ const prevEntry = prev[key];
+ const nextEntry = next[key];
+ if (!prevEntry || !nextEntry) return next;
+ if (prevEntry.title !== nextEntry.title) return next;
+ if (
+ prevEntry.memberIds.length !== nextEntry.memberIds.length ||
+ prevEntry.viewerIds.length !== nextEntry.viewerIds.length
+ ) {
+ return next;
+ }
+ if (
+ [...prevEntry.memberIds].sort().join("|") !==
+ [...nextEntry.memberIds].sort().join("|") ||
+ [...prevEntry.viewerIds].sort().join("|") !==
+ [...nextEntry.viewerIds].sort().join("|")
+ ) {
+ return next;
+ }
+ }
+ return prev;
+ });
+ }, [groups]);
+
+ const usersReady = !usersLoading && users.length > 0;
+ const groupsReady = !groupsLoading && groups.length > 0;
+
+ const canCreate = newTitle.trim().length > 0;
+
+ const userMap = useMemo(() => {
+ const map = new Map();
+ for (const user of users) {
+ map.set(user.id, user.name ?? user.id);
+ }
+ return map;
+ }, [users]);
+
+ return (
+
+
+
+ Neue Abrechnungsgruppe
+
+ Lege neue Gruppen für das Reporting an.
+
+
+
+
+ {dialogOpen ? (
+
+
+
+
+ setNewTitle(event.target.value)}
+ placeholder="z.B. Produktteam"
+ value={newTitle}
+ />
+
+
+
+
+
+
+
+
+
+ {createGroup.error ? (
+
+ Gruppe konnte nicht erstellt werden.
+
+ ) : null}
+
+
+ ) : null}
+
+
+ {groupsReady ? (
+ groups.map((group) => {
+ const draft = drafts[group.id] ?? {
+ title: group.title,
+ memberIds: group.memberIds,
+ viewerIds: group.viewerIds,
+ };
+ const canSave = draft.title.trim().length > 0;
+ const memberNames = draft.memberIds
+ .map((id) => userMap.get(id) ?? id)
+ .join(", ");
+ const viewerNames = draft.viewerIds
+ .map((id) => userMap.get(id) ?? id)
+ .join(", ");
+
+ return (
+
+
+
+ Gruppe
+ {group.title}
+
+
+
+
+
+
+
+
+
+
+ setDrafts((prev) => ({
+ ...prev,
+ [group.id]: {
+ ...draft,
+ title: event.target.value,
+ },
+ }))
+ }
+ value={draft.title}
+ />
+
+ Mitglieder: {memberNames || "—"}
+
+
+ Berechtigte: {viewerNames || "—"}
+
+
+
+
+ setDrafts((prev) => ({
+ ...prev,
+ [group.id]: {
+ ...draft,
+ memberIds: next,
+ },
+ }))
+ }
+ selected={draft.memberIds}
+ users={users}
+ />
+
+ setDrafts((prev) => ({
+ ...prev,
+ [group.id]: {
+ ...draft,
+ viewerIds: next,
+ },
+ }))
+ }
+ selected={draft.viewerIds}
+ users={users}
+ />
+
+
+
+ );
+ })
+ ) : groupsLoading ? (
+
+ Abrechnungsgruppen werden geladen ...
+
+ ) : (
+
+ Noch keine Abrechnungsgruppen vorhanden.
+
+ )}
+
+ {!usersReady ? (
+
+ Benutzer werden geladen ...
+
+ ) : null}
+
+ );
+}
+
+function UserCombobox({
+ label,
+ description,
+ users,
+ selected,
+ onChange,
+}: {
+ label: string;
+ description: string;
+ users: Array<{ id: string; name: string | null }>;
+ selected: string[];
+ onChange: (ids: string[]) => void;
+}) {
+ const anchorRef = useComboboxAnchor();
+ const [inputValue, setInputValue] = useState("");
+ const userMap = useMemo(() => {
+ const map = new Map();
+ for (const user of users) {
+ map.set(user.id, user.name ?? user.id);
+ }
+ return map;
+ }, [users]);
+ const filteredUsers = useMemo(() => {
+ const query = inputValue.trim().toLowerCase();
+ if (!query) return users;
+ return users.filter((user) => {
+ const displayName = (user.name ?? user.id).toLowerCase();
+ return (
+ displayName.includes(query) || user.id.toLowerCase().includes(query)
+ );
+ });
+ }, [inputValue, users]);
+
+ return (
+
+ {label}
+ {description}
+
+ ({
+ value: user.id,
+ label: user.name ?? user.id,
+ }))}
+ itemToStringValue={(value) =>
+ userMap.get(String(value)) ?? String(value)
+ }
+ multiple
+ onInputValueChange={(value) => {
+ const next =
+ typeof value === "string"
+ ? value
+ : (value as { inputValue?: string } | null)?.inputValue;
+ setInputValue(String(next ?? ""));
+ }}
+ onValueChange={(value) => {
+ onChange(Array.isArray(value) ? value : []);
+ setInputValue("");
+ }}
+ value={selected}
+ >
+
+ {selected.map((id) => (
+ {userMap.get(id) ?? id}
+ ))}
+ setInputValue(event.target.value)}
+ placeholder="Benutzer auswählen"
+ value={inputValue}
+ />
+
+
+
+ {(item) => (
+
+ {item.label ?? item.value}
+
+ )}
+
+ Keine Benutzer gefunden.
+
+
+
+
+ );
+}
diff --git a/app/src/components/app-sidebar.tsx b/app/src/components/app-sidebar.tsx
index 2fe2056..1ee44f9 100644
--- a/app/src/components/app-sidebar.tsx
+++ b/app/src/components/app-sidebar.tsx
@@ -17,10 +17,10 @@ export async function AppSidebar() {
return (
-
- OpenAI API Proxy
+
+ OpenAI API Proxy
v{version}
diff --git a/app/src/components/sidebar-nav.tsx b/app/src/components/sidebar-nav.tsx
index 569750a..e769de9 100644
--- a/app/src/components/sidebar-nav.tsx
+++ b/app/src/components/sidebar-nav.tsx
@@ -1,6 +1,6 @@
"use client";
-import { BarChart3, Coins, KeyRound } from "lucide-react";
+import { BarChart3, Coins, FileText, KeyRound, Users } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -34,6 +34,20 @@ const adminItems = [
},
];
+const reportingItems = [
+ {
+ href: "/reporting/create",
+ label: "Bericht erstellen",
+ icon: FileText,
+ },
+ {
+ href: "/reporting/groups",
+ label: "Abrechnungsgruppen",
+ icon: Users,
+ adminOnly: true,
+ },
+];
+
export function SidebarNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
@@ -92,6 +106,35 @@ export function SidebarNav({ isAdmin }: { isAdmin: boolean }) {
) : null}
+
+ Reporting
+
+
+ {reportingItems
+ .filter((item) => !item.adminOnly || isAdmin)
+ .map((item) => {
+ const isActive = pathname === item.href;
+ const Icon = item.icon;
+ return (
+
+
+
+
+ {item.label}
+
+
+
+ );
+ })}
+
+
+
>
);
}
diff --git a/app/src/components/ui/combobox.tsx b/app/src/components/ui/combobox.tsx
new file mode 100644
index 0000000..75e4eeb
--- /dev/null
+++ b/app/src/components/ui/combobox.tsx
@@ -0,0 +1,299 @@
+"use client";
+
+import { Combobox as ComboboxPrimitive } from "@base-ui/react";
+import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
+import * as React from "react";
+import { Button } from "~/components/ui/button";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+} from "~/components/ui/input-group";
+import { cn } from "~/lib/utils";
+
+const Combobox = ComboboxPrimitive.Root;
+
+function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
+ return ;
+}
+
+function ComboboxTrigger({
+ className,
+ children,
+ ...props
+}: ComboboxPrimitive.Trigger.Props) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
+ return (
+ }
+ {...props}
+ >
+
+
+ );
+}
+
+function ComboboxInput({
+ className,
+ children,
+ disabled = false,
+ showTrigger = true,
+ showClear = false,
+ ...props
+}: ComboboxPrimitive.Input.Props & {
+ showTrigger?: boolean;
+ showClear?: boolean;
+}) {
+ return (
+
+ }
+ {...props}
+ />
+
+ {showTrigger && (
+ }
+ size="icon-xs"
+ variant="ghost"
+ />
+ )}
+ {showClear && }
+
+ {children}
+
+ );
+}
+
+function ComboboxContent({
+ className,
+ side = "bottom",
+ sideOffset = 6,
+ align = "start",
+ alignOffset = 0,
+ anchor,
+ ...props
+}: ComboboxPrimitive.Popup.Props &
+ Pick<
+ ComboboxPrimitive.Positioner.Props,
+ "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
+ >) {
+ return (
+
+
+
+
+
+ );
+}
+
+function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
+ return (
+
+ );
+}
+
+function ComboboxItem({
+ className,
+ children,
+ ...props
+}: ComboboxPrimitive.Item.Props) {
+ return (
+
+ {children}
+
+ }
+ >
+
+
+
+ );
+}
+
+function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
+ return (
+
+ );
+}
+
+function ComboboxLabel({
+ className,
+ ...props
+}: ComboboxPrimitive.GroupLabel.Props) {
+ return (
+
+ );
+}
+
+function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
+ return (
+
+ );
+}
+
+function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
+ return (
+
+ );
+}
+
+function ComboboxSeparator({
+ className,
+ ...props
+}: ComboboxPrimitive.Separator.Props) {
+ return (
+
+ );
+}
+
+function ComboboxChips({
+ className,
+ ...props
+}: React.ComponentPropsWithRef &
+ ComboboxPrimitive.Chips.Props) {
+ return (
+
+ );
+}
+
+function ComboboxChip({
+ className,
+ children,
+ showRemove = true,
+ ...props
+}: ComboboxPrimitive.Chip.Props & {
+ showRemove?: boolean;
+}) {
+ return (
+
+ {children}
+ {showRemove && (
+ }
+ >
+
+
+ )}
+
+ );
+}
+
+function ComboboxChipsInput({
+ className,
+ ...props
+}: ComboboxPrimitive.Input.Props) {
+ return (
+
+ );
+}
+
+function useComboboxAnchor() {
+ return React.useRef(null);
+}
+
+export {
+ Combobox,
+ ComboboxInput,
+ ComboboxContent,
+ ComboboxList,
+ ComboboxItem,
+ ComboboxGroup,
+ ComboboxLabel,
+ ComboboxCollection,
+ ComboboxEmpty,
+ ComboboxSeparator,
+ ComboboxChips,
+ ComboboxChip,
+ ComboboxChipsInput,
+ ComboboxTrigger,
+ ComboboxValue,
+ useComboboxAnchor,
+};
diff --git a/app/src/components/ui/input-group.tsx b/app/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..f015222
--- /dev/null
+++ b/app/src/components/ui/input-group.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Textarea } from "~/components/ui/textarea";
+import { cn } from "~/lib/utils";
+
+function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+