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

-

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..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 ( +
[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 ( +