From 2f342e4449231531c44d2a4d49085294aed922a5 Mon Sep 17 00:00:00 2001 From: Robin Wloka Date: Thu, 5 Mar 2026 12:42:08 +0100 Subject: [PATCH 1/8] Aktueller stand --- app/RELEASE_NOTES.md | 1 + app/drizzle/schema.ts | 78 ++ app/src/app/release-notes/page.tsx | 4 +- app/src/app/reporting/create/page.tsx | 25 + .../create/reporting-create-client.tsx | 538 ++++++++++++++ app/src/app/reporting/groups/page.tsx | 25 + .../groups/reporting-groups-client.tsx | 304 ++++++++ app/src/components/app-sidebar.tsx | 6 +- app/src/components/sidebar-nav.tsx | 45 +- app/src/server/api/root.ts | 2 + app/src/server/api/routers/reporting.ts | 673 ++++++++++++++++++ app/src/server/db/schema.ts | 78 ++ .../20260305130000_reporting_groups.sql | 46 ++ db/migrations/atlas.sum | 3 +- 14 files changed, 1821 insertions(+), 7 deletions(-) create mode 100644 app/src/app/reporting/create/page.tsx create mode 100644 app/src/app/reporting/create/reporting-create-client.tsx create mode 100644 app/src/app/reporting/groups/page.tsx create mode 100644 app/src/app/reporting/groups/reporting-groups-client.tsx create mode 100644 app/src/server/api/routers/reporting.ts create mode 100644 db/migrations/20260305130000_reporting_groups.sql diff --git a/app/RELEASE_NOTES.md b/app/RELEASE_NOTES.md index 0fdbf99..6da148b 100644 --- a/app/RELEASE_NOTES.md +++ b/app/RELEASE_NOTES.md @@ -2,3 +2,4 @@ ## 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/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

-

v{version}

+

Version

+

v{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..c95d371 --- /dev/null +++ b/app/src/app/reporting/create/reporting-create-client.tsx @@ -0,0 +1,538 @@ +"use client"; + +import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import { Pie, PieChart } 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 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 [sorting, setSorting] = useState(null); + + 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 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; + }, {}); + + 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} +
+
+
+
+ +
+
+
+
+ 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. +
+ )} +
+
+ +
+
+ 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) => ( + + + + {user.name} + + + {numberFormatter.format(user.inputTokens)} + + + {numberFormatter.format(user.cachedInputTokens)} + + + {numberFormatter.format(user.outputTokens)} + + + {formatCost(user.totalCost, user.currency)} + + + {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)} + + + ))} + + ))} + {sortedUsers.length === 0 ? ( + + + Keine Benutzer gefunden. + + + ) : 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..aee4204 --- /dev/null +++ b/app/src/app/reporting/groups/reporting-groups-client.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { api } from "~/trpc/react"; + +const toggleValue = (values: string[], value: string) => + values.includes(value) + ? values.filter((item) => item !== value) + : [...values, value]; + +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(); + }, + }); + const updateGroup = api.reporting.updateGroup.useMutation({ + onSuccess: async () => { + await utils.reporting.listGroupDetails.invalidate(); + await utils.reporting.listGroups.invalidate(); + }, + }); + const deleteGroup = api.reporting.deleteGroup.useMutation({ + onSuccess: async () => { + await utils.reporting.listGroupDetails.invalidate(); + await utils.reporting.listGroups.invalidate(); + }, + }); + + 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(next); + }, [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

+
+
+ + setNewTitle(event.target.value)} + placeholder="z.B. Produktteam" + value={newTitle} + /> +
+ +
+
+
+ + setNewMemberIds((prev) => toggleValue(prev, id)) + } + selected={newMemberIds} + users={users} + /> + + setNewViewerIds((prev) => toggleValue(prev, id)) + } + selected={newViewerIds} + users={users} + /> +
+
+
+ +
+ {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: toggleValue(draft.memberIds, id), + }, + })) + } + selected={draft.memberIds} + users={users} + /> + + setDrafts((prev) => ({ + ...prev, + [group.id]: { + ...draft, + viewerIds: toggleValue(draft.viewerIds, id), + }, + })) + } + selected={draft.viewerIds} + users={users} + /> +
+
+
+ ); + }) + ) : groupsLoading ? ( +
+ Abrechnungsgruppen werden geladen ... +
+ ) : ( +
+ Noch keine Abrechnungsgruppen vorhanden. +
+ )} +
+ {!usersReady ? ( +
+ Benutzer werden geladen ... +
+ ) : null} +
+ ); +} + +function UserChecklist({ + label, + description, + users, + selected, + onToggle, +}: { + label: string; + description: string; + users: Array<{ id: string; name: string | null }>; + selected: string[]; + onToggle: (id: string) => void; +}) { + return ( +
+
{label}
+

{description}

+
+ {users.map((user) => ( + + ))} + {!users.length ? ( +
+ Keine Benutzer verfügbar. +
+ ) : null} +
+
+ ); +} 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/server/api/root.ts b/app/src/server/api/root.ts index 7110955..59fcadd 100644 --- a/app/src/server/api/root.ts +++ b/app/src/server/api/root.ts @@ -1,6 +1,7 @@ import { adminRouter } from "~/server/api/routers/admin"; import { apiKeyRouter } from "~/server/api/routers/api-key"; import { postRouter } from "~/server/api/routers/post"; +import { reportingRouter } from "~/server/api/routers/reporting"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; /** @@ -12,6 +13,7 @@ export const appRouter = createTRPCRouter({ admin: adminRouter, post: postRouter, apiKey: apiKeyRouter, + reporting: reportingRouter, }); // export type definition of API diff --git a/app/src/server/api/routers/reporting.ts b/app/src/server/api/routers/reporting.ts new file mode 100644 index 0000000..bc33ebe --- /dev/null +++ b/app/src/server/api/routers/reporting.ts @@ -0,0 +1,673 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { + apikeys, + costs, + reportingGroupMembers, + reportingGroups, + reportingGroupViewers, + requests, + users, +} from "~/server/db/schema"; + +const adminProcedure = protectedProcedure.use(({ ctx, next }) => { + if (!ctx.session.user.isAdmin) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return next({ + ctx: { + session: ctx.session, + }, + }); +}); + +const reportRangeInput = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("daily"), + date: z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}$/), + }), + z.object({ + type: z.literal("monthly"), + month: z + .string() + .trim() + .regex(/^\d{4}-\d{2}$/), + }), + z.object({ + type: z.literal("quarterly"), + year: z.number().int().min(2000).max(2200), + quarter: z.number().int().min(1).max(4), + }), + z.object({ + type: z.literal("yearly"), + year: z.number().int().min(2000).max(2200), + }), +]); + +const normalize = (value: string | null | undefined) => + (value ?? "").trim().toLowerCase(); + +const tokenAliases = { + input: ["input", "prompt", "input_tokens", "prompt_tokens"], + cached: [ + "cached_input", + "cached", + "cache", + "input_cached", + "cached_input_tokens", + ], + output: ["output", "completion", "output_tokens", "completion_tokens"], +}; + +const unitDivisor = (unit: "1M" | "1K" | null) => { + if (unit === "1K") return 1_000; + return 1_000_000; +}; + +const priceToCurrency = (price: number) => price / 100; + +function getDateRange(input: z.infer) { + switch (input.type) { + case "daily": { + const parts = input.date.split("-").map(Number); + const year = parts[0]; + const month = parts[1]; + const day = parts[2]; + if ( + year === undefined || + month === undefined || + day === undefined || + !Number.isFinite(year) || + !Number.isFinite(month) || + !Number.isFinite(day) + ) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + const start = new Date(Date.UTC(year, month - 1, day)); + const end = new Date(Date.UTC(year, month - 1, day + 1)); + return { start, end }; + } + case "monthly": { + const parts = input.month.split("-").map(Number); + const year = parts[0]; + const month = parts[1]; + if ( + year === undefined || + month === undefined || + !Number.isFinite(year) || + !Number.isFinite(month) + ) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + const start = new Date(Date.UTC(year, month - 1, 1)); + const end = new Date(Date.UTC(year, month, 1)); + return { start, end }; + } + case "quarterly": { + const startMonth = (input.quarter - 1) * 3; + const start = new Date(Date.UTC(input.year, startMonth, 1)); + const end = new Date(Date.UTC(input.year, startMonth + 3, 1)); + return { start, end }; + } + case "yearly": { + const start = new Date(Date.UTC(input.year, 0, 1)); + const end = new Date(Date.UTC(input.year + 1, 0, 1)); + return { start, end }; + } + default: + return { start: new Date(0), end: new Date() }; + } +} + +export const reportingRouter = createTRPCRouter({ + listGroups: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id; + if (!userId) return []; + + const baseSelect = { + id: reportingGroups.id, + title: reportingGroups.title, + memberCount: + sql`coalesce(count(${reportingGroupMembers.userId}), 0)`.as( + "memberCount", + ), + }; + + if (ctx.session.user.isAdmin) { + const rows = await ctx.db + .select(baseSelect) + .from(reportingGroups) + .leftJoin( + reportingGroupMembers, + eq(reportingGroupMembers.groupId, reportingGroups.id), + ) + .groupBy(reportingGroups.id, reportingGroups.title) + .orderBy(reportingGroups.title); + + return rows.map((row) => ({ + id: String(row.id), + title: row.title, + memberCount: Number(row.memberCount ?? 0), + })); + } + + const rows = await ctx.db + .select(baseSelect) + .from(reportingGroups) + .innerJoin( + reportingGroupViewers, + and( + eq(reportingGroupViewers.groupId, reportingGroups.id), + eq(reportingGroupViewers.userId, userId), + ), + ) + .leftJoin( + reportingGroupMembers, + eq(reportingGroupMembers.groupId, reportingGroups.id), + ) + .groupBy(reportingGroups.id, reportingGroups.title) + .orderBy(reportingGroups.title); + + return rows.map((row) => ({ + id: String(row.id), + title: row.title, + memberCount: Number(row.memberCount ?? 0), + })); + }), + listUsers: adminProcedure.query(async ({ ctx }) => { + const rows = await ctx.db + .select({ id: users.id, name: users.name }) + .from(users) + .orderBy(sql`coalesce(${users.name}, ${users.id})`); + + return rows.map((row) => ({ id: row.id, name: row.name ?? row.id })); + }), + listGroupDetails: adminProcedure.query(async ({ ctx }) => { + const groups = await ctx.db + .select({ + id: reportingGroups.id, + title: reportingGroups.title, + createdBy: reportingGroups.createdBy, + createdAt: reportingGroups.createdAt, + }) + .from(reportingGroups) + .orderBy(reportingGroups.title); + + const memberRows = await ctx.db + .select({ + groupId: reportingGroupMembers.groupId, + userId: reportingGroupMembers.userId, + }) + .from(reportingGroupMembers); + + const viewerRows = await ctx.db + .select({ + groupId: reportingGroupViewers.groupId, + userId: reportingGroupViewers.userId, + }) + .from(reportingGroupViewers); + + const membersByGroup = new Map(); + for (const row of memberRows) { + const groupId = Number(row.groupId ?? 0); + const list = membersByGroup.get(groupId) ?? []; + list.push(row.userId); + membersByGroup.set(groupId, list); + } + + const viewersByGroup = new Map(); + for (const row of viewerRows) { + const groupId = Number(row.groupId ?? 0); + const list = viewersByGroup.get(groupId) ?? []; + list.push(row.userId); + viewersByGroup.set(groupId, list); + } + + return groups.map((group) => { + const id = Number(group.id ?? 0); + return { + id: String(id), + title: group.title, + createdBy: group.createdBy, + createdAt: group.createdAt, + memberIds: membersByGroup.get(id) ?? [], + viewerIds: viewersByGroup.get(id) ?? [], + }; + }); + }), + createGroup: adminProcedure + .input( + z.object({ + title: z.string().trim().min(1).max(255), + memberIds: z.array(z.string().trim().min(1)).default([]), + viewerIds: z.array(z.string().trim().min(1)).default([]), + }), + ) + .mutation(async ({ ctx, input }) => { + const [group] = await ctx.db + .insert(reportingGroups) + .values({ + title: input.title, + createdBy: ctx.session.user.id, + }) + .returning({ id: reportingGroups.id }); + + const groupId = Number(group?.id ?? 0); + if (!groupId) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + + const memberIds = Array.from(new Set(input.memberIds)); + if (memberIds.length) { + await ctx.db.insert(reportingGroupMembers).values( + memberIds.map((userId) => ({ + groupId, + userId, + })), + ); + } + + const viewerIds = Array.from(new Set(input.viewerIds)); + if (viewerIds.length) { + await ctx.db.insert(reportingGroupViewers).values( + viewerIds.map((userId) => ({ + groupId, + userId, + })), + ); + } + + return { id: String(groupId) }; + }), + updateGroup: adminProcedure + .input( + z.object({ + id: z.string().trim().min(1), + title: z.string().trim().min(1).max(255), + memberIds: z.array(z.string().trim().min(1)).default([]), + viewerIds: z.array(z.string().trim().min(1)).default([]), + }), + ) + .mutation(async ({ ctx, input }) => { + const groupId = Number(input.id); + if (!groupId) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + + await ctx.db + .update(reportingGroups) + .set({ title: input.title }) + .where(eq(reportingGroups.id, groupId)); + + await ctx.db + .delete(reportingGroupMembers) + .where(eq(reportingGroupMembers.groupId, groupId)); + await ctx.db + .delete(reportingGroupViewers) + .where(eq(reportingGroupViewers.groupId, groupId)); + + const memberIds = Array.from(new Set(input.memberIds)); + if (memberIds.length) { + await ctx.db.insert(reportingGroupMembers).values( + memberIds.map((userId) => ({ + groupId, + userId, + })), + ); + } + + const viewerIds = Array.from(new Set(input.viewerIds)); + if (viewerIds.length) { + await ctx.db.insert(reportingGroupViewers).values( + viewerIds.map((userId) => ({ + groupId, + userId, + })), + ); + } + }), + deleteGroup: adminProcedure + .input(z.object({ id: z.string().trim().min(1) })) + .mutation(async ({ ctx, input }) => { + const groupId = Number(input.id); + if (!groupId) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + + await ctx.db + .delete(reportingGroups) + .where(eq(reportingGroups.id, groupId)); + }), + getReport: protectedProcedure + .input( + z.object({ + groupId: z.string().trim().min(1), + range: reportRangeInput, + }), + ) + .query(async ({ ctx, input }) => { + const isAdmin = ctx.session.user.isAdmin; + const viewerId = ctx.session.user.id; + const groupId = input.groupId; + const groupIdNumber = groupId === "all" ? null : Number(groupId); + + if (groupId === "all") { + if (!isAdmin) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + } else { + if (!Number.isFinite(groupIdNumber)) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + } + + if (groupId !== "all" && !isAdmin) { + const safeGroupId = groupIdNumber as number; + const viewer = await ctx.db + .select({ userId: reportingGroupViewers.userId }) + .from(reportingGroupViewers) + .where( + and( + eq(reportingGroupViewers.groupId, safeGroupId), + eq(reportingGroupViewers.userId, viewerId), + ), + ) + .limit(1); + if (!viewer.length) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + } + + const { start, end } = getDateRange(input.range); + const timeFilter = sql`${requests.requestTime} >= ${start.toISOString()} AND ${requests.requestTime} < ${end.toISOString()}`; + + let scopedUserIds: string[] | null = null; + if (groupId !== "all") { + const safeGroupId = groupIdNumber as number; + const members = await ctx.db + .select({ userId: reportingGroupMembers.userId }) + .from(reportingGroupMembers) + .where(eq(reportingGroupMembers.groupId, safeGroupId)); + scopedUserIds = members.map((row) => row.userId); + if (!scopedUserIds.length) { + return { + summary: { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + totalCost: 0, + currency: "EUR", + }, + modelUsage: [], + users: [], + }; + } + } + + const usersBase = ctx.db + .select({ + userId: users.id, + name: users.name, + model: requests.model, + inputTokens: + sql`coalesce(sum(${requests.inputTokenCount} - ${requests.cachedInputTokenCount}), 0)`.as( + "inputTokens", + ), + cachedInputTokens: + sql`coalesce(sum(${requests.cachedInputTokenCount}), 0)`.as( + "cachedInputTokens", + ), + outputTokens: + sql`coalesce(sum(${requests.outputTokenCount}), 0)`.as( + "outputTokens", + ), + }) + .from(users) + .leftJoin(apikeys, eq(apikeys.owner, users.id)) + .leftJoin( + requests, + and(eq(requests.apiKeyId, apikeys.uuid), timeFilter), + ); + + const usageRows = await (scopedUserIds + ? usersBase.where(inArray(users.id, scopedUserIds)) + : usersBase + ).groupBy(users.id, users.name, requests.model); + + const costRows = await ctx.db + .select({ + model: costs.model, + price: costs.price, + validFrom: costs.validFrom, + tokenType: costs.tokenType, + unitOfMessure: costs.unitOfMessure, + currency: costs.currency, + }) + .from(costs); + + const costIndex = new Map< + string, + Map< + string, + { + price: number; + validFrom: string | null; + unit: "1M" | "1K" | null; + currency: string | null; + } + > + >(); + + for (const row of costRows) { + const modelKey = normalize(row.model); + const tokenKey = normalize(row.tokenType); + if (!modelKey || !tokenKey) continue; + const modelMap = costIndex.get(modelKey) ?? new Map(); + const existing = modelMap.get(tokenKey); + const currentValidFrom = row.validFrom + ? new Date(row.validFrom).getTime() + : 0; + const existingValidFrom = existing?.validFrom + ? new Date(existing.validFrom).getTime() + : 0; + if (!existing || currentValidFrom >= existingValidFrom) { + modelMap.set(tokenKey, { + price: Number(row.price ?? 0), + validFrom: row.validFrom ?? null, + unit: (row.unitOfMessure ?? null) as "1M" | "1K" | null, + currency: row.currency ? row.currency.trim().toUpperCase() : null, + }); + } + costIndex.set(modelKey, modelMap); + } + + const calcTokenCost = ( + model: string, + tokens: number, + aliases: string[], + ) => { + if (tokens <= 0) return { cost: 0, missing: false, currency: null }; + const modelKey = normalize(model); + const modelMap = costIndex.get(modelKey); + if (!modelMap) return { cost: 0, missing: true, currency: null }; + let entry: + | { + price: number; + validFrom: string | null; + unit: "1M" | "1K" | null; + currency: string | null; + } + | undefined; + for (const alias of aliases) { + entry = modelMap.get(alias); + if (entry) break; + } + if (!entry) return { cost: 0, missing: true, currency: null }; + return { + cost: + (tokens / unitDivisor(entry.unit)) * priceToCurrency(entry.price), + missing: false, + currency: entry.currency, + }; + }; + + const usersMap = new Map< + string, + { + id: string; + name: string; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalCost: number | null; + currency: string | null; + models: Array<{ + model: string; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + inputCost: number | null; + cachedCost: number | null; + outputCost: number | null; + totalCost: number | null; + currency: string | null; + }>; + } + >(); + + const modelTotals = new Map(); + let totalInputTokens = 0; + let totalCachedTokens = 0; + let totalOutputTokens = 0; + let totalCost: number | null = 0; + let totalCurrency: string | null = null; + + for (const row of usageRows) { + const id = row.userId; + const entry = usersMap.get(id) ?? { + id, + name: row.name ?? id, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + totalCost: 0, + currency: null, + models: [], + }; + + const inputTokens = Number(row.inputTokens ?? 0); + const cachedTokens = Number(row.cachedInputTokens ?? 0); + const outputTokens = Number(row.outputTokens ?? 0); + + entry.inputTokens += inputTokens; + entry.cachedInputTokens += cachedTokens; + entry.outputTokens += outputTokens; + + totalInputTokens += inputTokens; + totalCachedTokens += cachedTokens; + totalOutputTokens += outputTokens; + + const model = row.model ?? null; + if (model && inputTokens + cachedTokens + outputTokens > 0) { + const inputCost = calcTokenCost( + model, + inputTokens, + tokenAliases.input, + ); + const cachedCost = calcTokenCost( + model, + cachedTokens, + tokenAliases.cached, + ); + const outputCost = calcTokenCost( + model, + outputTokens, + tokenAliases.output, + ); + + const modelMissing = + inputCost.missing || cachedCost.missing || outputCost.missing; + const currencies = [ + inputCost.currency, + cachedCost.currency, + outputCost.currency, + ].filter((value): value is string => Boolean(value)); + const modelCurrency = + currencies.length > 0 && + currencies.every((value) => value === currencies[0]) + ? (currencies[0] ?? null) + : null; + const modelCost = modelMissing + ? null + : inputCost.cost + cachedCost.cost + outputCost.cost; + + entry.models.push({ + model, + inputTokens, + cachedInputTokens: cachedTokens, + outputTokens, + inputCost: modelMissing ? null : inputCost.cost, + cachedCost: modelMissing ? null : cachedCost.cost, + outputCost: modelMissing ? null : outputCost.cost, + totalCost: modelCost, + currency: modelCost === null ? null : modelCurrency, + }); + + if (entry.totalCost !== null) { + entry.totalCost = + modelCost === null ? null : entry.totalCost + modelCost; + if (entry.totalCost === null) { + entry.currency = null; + } else if (entry.currency === null) { + entry.currency = modelCurrency; + } else if (modelCurrency && entry.currency !== modelCurrency) { + entry.currency = null; + } + } + + modelTotals.set(model, (modelTotals.get(model) ?? 0) + outputTokens); + + if (totalCost !== null) { + totalCost = modelCost === null ? null : totalCost + modelCost; + if (totalCost === null) { + totalCurrency = null; + } else if (totalCurrency === null) { + totalCurrency = modelCurrency; + } else if (modelCurrency && totalCurrency !== modelCurrency) { + totalCurrency = null; + } + } + } + + usersMap.set(id, entry); + } + + const usersData = Array.from(usersMap.values()).map((user) => ({ + ...user, + models: user.models.sort((a, b) => a.model.localeCompare(b.model)), + })); + + return { + summary: { + inputTokens: totalInputTokens, + cachedInputTokens: totalCachedTokens, + outputTokens: totalOutputTokens, + totalCost: totalCost ?? null, + currency: totalCurrency ?? null, + }, + modelUsage: Array.from(modelTotals.entries()) + .map(([model, outputTokens]) => ({ + model, + outputTokens, + })) + .sort((a, b) => b.outputTokens - a.outputTokens), + users: usersData, + }; + }), +}); diff --git a/app/src/server/db/schema.ts b/app/src/server/db/schema.ts index 4124c77..1e5c5f0 100644 --- a/app/src/server/db/schema.ts +++ b/app/src/server/db/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/db/migrations/20260305130000_reporting_groups.sql b/db/migrations/20260305130000_reporting_groups.sql new file mode 100644 index 0000000..f4cac4d --- /dev/null +++ b/db/migrations/20260305130000_reporting_groups.sql @@ -0,0 +1,46 @@ +-- Create "reporting_groups" table +CREATE TABLE "reporting_groups" ( + "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "title" character varying(255) NOT NULL, + "created_by" character varying(255) NOT NULL, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +ALTER TABLE "reporting_groups" + ADD CONSTRAINT "reporting_groups_created_by_fkey" + FOREIGN KEY ("created_by") REFERENCES "users" ("id") + ON DELETE CASCADE; + +-- Create "reporting_group_members" table +CREATE TABLE "reporting_group_members" ( + "group_id" bigint NOT NULL, + "user_id" character varying(255) NOT NULL, + CONSTRAINT "reporting_group_members_pkey" PRIMARY KEY ("group_id", "user_id") +); + +ALTER TABLE "reporting_group_members" + ADD CONSTRAINT "reporting_group_members_group_id_fkey" + FOREIGN KEY ("group_id") REFERENCES "reporting_groups" ("id") + ON DELETE CASCADE; + +ALTER TABLE "reporting_group_members" + ADD CONSTRAINT "reporting_group_members_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE; + +-- Create "reporting_group_viewers" table +CREATE TABLE "reporting_group_viewers" ( + "group_id" bigint NOT NULL, + "user_id" character varying(255) NOT NULL, + CONSTRAINT "reporting_group_viewers_pkey" PRIMARY KEY ("group_id", "user_id") +); + +ALTER TABLE "reporting_group_viewers" + ADD CONSTRAINT "reporting_group_viewers_group_id_fkey" + FOREIGN KEY ("group_id") REFERENCES "reporting_groups" ("id") + ON DELETE CASCADE; + +ALTER TABLE "reporting_group_viewers" + ADD CONSTRAINT "reporting_group_viewers_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE; diff --git a/db/migrations/atlas.sum b/db/migrations/atlas.sum index b1e8c07..74de2f3 100644 --- a/db/migrations/atlas.sum +++ b/db/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:+Jcxxt6EE6VEESFQkavn4qL8+2U+o398fd1q8C+zvGM= +h1:2j8AtX2vpcoTEslqnLHx0+BTn7Boa199ELRee20giEg= 20240924151008_init.sql h1:rTjsfTqruGXjIUITaR6Pqt0n4lm+4EcdvESuYXgmyP4= 20240924154252_request_monitoring.sql h1:VWH6kcc2DWS7L9tBQ8lgS858/9f5HP7PMYZo0fZlCX8= 20241001124048_id_datatype.sql h1:Nb1OpAzcVshPzgEqQ3w/sCFjorO4h/GTOBeuISanu4k= @@ -17,3 +17,4 @@ h1:+Jcxxt6EE6VEESFQkavn4qL8+2U+o398fd1q8C+zvGM= 20260301123000_seed_costs_for_models.sql h1:hsK8C6vW5xBHSJuNM4wDGylKu4t127cHWD0T2cx3eug= 20260301130000_costs_unit_enum.sql h1:fSMut8VD8W/VHlaPQdjFaLT7kN6UuUfw/yqgdmWY+Pc= 20260304120000_add_apikeys_deactivated.sql h1:CyRdhz3axkbej6xCqb0SngFkuXN7dI6Fox0WBfpLMCY= +20260305130000_reporting_groups.sql h1:HS3rs7B/55oLaP6s4WWVuzS4MxhyCJfQPndXN2p3uEw= From b1bcd2b7ac710e765ae46efb6bb07cd774b0d6a9 Mon Sep 17 00:00:00 2001 From: Robin Wloka Date: Thu, 5 Mar 2026 13:37:51 +0100 Subject: [PATCH 2/8] Aktueller stand --- .../groups/reporting-groups-client.tsx | 280 +++++++++++----- app/src/components/ui/combobox.tsx | 299 ++++++++++++++++++ app/src/components/ui/input-group.tsx | 148 +++++++++ app/src/components/ui/textarea.tsx | 18 ++ 4 files changed, 663 insertions(+), 82 deletions(-) create mode 100644 app/src/components/ui/combobox.tsx create mode 100644 app/src/components/ui/input-group.tsx create mode 100644 app/src/components/ui/textarea.tsx diff --git a/app/src/app/reporting/groups/reporting-groups-client.tsx b/app/src/app/reporting/groups/reporting-groups-client.tsx index aee4204..8b192a2 100644 --- a/app/src/app/reporting/groups/reporting-groups-client.tsx +++ b/app/src/app/reporting/groups/reporting-groups-client.tsx @@ -3,14 +3,20 @@ import { useEffect, useMemo, useState } from "react"; 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"; -const toggleValue = (values: string[], value: string) => - values.includes(value) - ? values.filter((item) => item !== value) - : [...values, value]; - export function ReportingGroupsClient() { const utils = api.useUtils(); const { data: users = [], isLoading: usersLoading } = @@ -37,6 +43,7 @@ export function ReportingGroupsClient() { }, }); + const [dialogOpen, setDialogOpen] = useState(false); const [newTitle, setNewTitle] = useState(""); const [newMemberIds, setNewMemberIds] = useState([]); const [newViewerIds, setNewViewerIds] = useState([]); @@ -56,7 +63,33 @@ export function ReportingGroupsClient() { viewerIds: group.viewerIds, }; } - setDrafts(next); + + 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; @@ -74,62 +107,100 @@ export function ReportingGroupsClient() { return (
-
-

Neue Abrechnungsgruppe

-
-
- - setNewTitle(event.target.value)} - placeholder="z.B. Produktteam" - value={newTitle} - /> -
- +
+ {dialogOpen ? ( +
+
+
+ + setNewTitle(event.target.value)} + placeholder="z.B. Produktteam" + value={newTitle} + /> +
+
+ +
-
- - setNewMemberIds((prev) => toggleValue(prev, id)) - } - selected={newMemberIds} - users={users} - /> - - setNewViewerIds((prev) => toggleValue(prev, id)) - } - selected={newViewerIds} - users={users} - /> +
+ + + {createGroup.error ? ( + + Gruppe konnte nicht erstellt werden. + + ) : null}
-
+ ) : null}
{groupsReady ? ( @@ -211,30 +282,30 @@ export function ReportingGroupsClient() {
- + onChange={(next) => setDrafts((prev) => ({ ...prev, [group.id]: { ...draft, - memberIds: toggleValue(draft.memberIds, id), + memberIds: next, }, })) } selected={draft.memberIds} users={users} /> - + onChange={(next) => setDrafts((prev) => ({ ...prev, [group.id]: { ...draft, - viewerIds: toggleValue(draft.viewerIds, id), + viewerIds: next, }, })) } @@ -265,39 +336,84 @@ export function ReportingGroupsClient() { ); } -function UserChecklist({ +function UserCombobox({ label, description, users, selected, - onToggle, + onChange, }: { label: string; description: string; users: Array<{ id: string; name: string | null }>; selected: string[]; - onToggle: (id: string) => void; + 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}

-
- {users.map((user) => ( - - ))} - {!users.length ? ( -
- Keine Benutzer verfügbar. -
- ) : null} +
+ ({ + value: user.id, + label: user.name ?? user.id, + }))} + itemToStringLabel={(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} + ))} + + + + + {(item) => ( + + {item.label ?? item.value} + + )} + + Keine Benutzer gefunden. + +
); 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 ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-1 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className, + )} + data-slot="input-group" + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 font-medium text-muted-foreground text-xs group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-none [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + }, +); + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-xs shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-none px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": "size-6 rounded-none p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + }, +); + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset"; + }) { + return ( +