This commit is contained in:
Linrador 2025-12-05 13:53:29 +01:00
parent 73607d2605
commit 5e6f7e872d
28 changed files with 1719 additions and 421 deletions

View File

@ -1,14 +1,177 @@
// app/(app)/dashboard/page.tsx // app/(app)/dashboard/page.tsx
import Alerts from '@/components/ui/Alerts';
import { prisma } from '@/lib/prisma';
const dtf = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
});
export default async function DashboardPage() {
const now = new Date();
// Start / Ende des heutigen Tages (ohne Uhrzeit)
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const startOfTomorrow = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + 1,
);
// 🔴 Überfällige Geräte: loanedUntil < heute
const overdueDevices = await prisma.device.findMany({
where: {
loanedTo: { not: null },
loanedUntil: { lt: startOfToday },
},
orderBy: { loanedUntil: 'asc' },
select: {
inventoryNumber: true,
name: true,
loanedTo: true,
loanedUntil: true,
},
});
// 🟡 Heute fällige Geräte: loanedUntil am heutigen Tag
const dueTodayDevices = await prisma.device.findMany({
where: {
loanedTo: { not: null },
loanedUntil: {
gte: startOfToday,
lt: startOfTomorrow,
},
},
orderBy: { loanedUntil: 'asc' },
select: {
inventoryNumber: true,
name: true,
loanedTo: true,
loanedUntil: true,
},
});
const hasOverdue = overdueDevices.length > 0;
const hasDueToday = dueTodayDevices.length > 0;
export default function DashboardPage() {
return ( return (
<> <>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white"> {/* 🔴 Überfällige Geräte (rot) */}
Geräte-Inventar {hasOverdue && (
</h1> <div className="mb-4">
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400"> <Alerts
Hier könntest du gleich als Nächstes eine Übersicht deiner Geräte einbauen. tone="error"
</p> title={
overdueDevices.length === 1
? 'Es gibt ein überfälliges Gerät'
: `Es gibt ${overdueDevices.length} überfällige Geräte`
}
description={
<div className="space-y-2">
<p>Diese Geräte haben das Rückgabedatum bereits überschritten:</p>
<ul className="list-disc space-y-1 pl-5 text-sm">
{overdueDevices.map((d) => (
<li key={d.inventoryNumber}>
<span className="font-mono">
{d.inventoryNumber}
</span>
{d.name && (
<>
{' '}
<a
href={`/devices?device=${encodeURIComponent(
d.inventoryNumber,
)}`}
className="font-medium underline text-red-800 hover:text-red-700 dark:text-red-200 dark:hover:text-red-100"
>
{d.name}
</a>
</>
)}
{d.loanedTo && <span> (an {d.loanedTo})</span>}
{d.loanedUntil && (
<span>
{' '}
fällig am {dtf.format(d.loanedUntil)}
</span>
)}
</li>
))}
</ul>
</div>
}
rightContent={
<a
href="/devices"
className="font-medium whitespace-nowrap text-red-800 hover:text-red-700 dark:text-red-200 dark:hover:text-red-100"
>
Zur Geräteliste
<span aria-hidden="true"> &rarr;</span>
</a>
}
/>
</div>
)}
{/* 🟡 Heute fällige Geräte (gelb) */}
{hasDueToday && (
<div className="mb-4">
<Alerts
tone="warning"
title={
dueTodayDevices.length === 1
? 'Ein Gerät ist heute fällig'
: `${dueTodayDevices.length} Geräte sind heute fällig`
}
description={
<div className="space-y-2">
<p>Diese Geräte sollten heute zurückgegeben werden:</p>
<ul className="list-disc space-y-1 pl-5 text-sm">
{dueTodayDevices.map((d) => (
<li key={d.inventoryNumber}>
<span className="font-mono">
{d.inventoryNumber}
</span>
{d.name && (
<>
{' '}
<a
href={`/devices?device=${encodeURIComponent(
d.inventoryNumber,
)}`}
className="font-medium underline text-yellow-800 hover:text-yellow-700 dark:text-yellow-200 dark:hover:text-yellow-100"
>
{d.name}
</a>
</>
)}
{d.loanedTo && <span> (an {d.loanedTo})</span>}
{d.loanedUntil && (
<span>
{' '}
fällig am {dtf.format(d.loanedUntil)}
</span>
)}
</li>
))}
</ul>
</div>
}
rightContent={
<a
href="/devices"
className="font-medium whitespace-nowrap text-yellow-800 hover:text-yellow-700 dark:text-yellow-200 dark:hover:text-yellow-100"
>
Zur Geräteliste
<span aria-hidden="true"> &rarr;</span>
</a>
}
/>
</div>
)}
</> </>
); );
} }

View File

@ -44,8 +44,6 @@ type DeviceDetailsGridProps = {
function DeviceDetailsGrid({ function DeviceDetailsGrid({
device, device,
onStartLoan, onStartLoan,
canEdit,
onEdit,
}: DeviceDetailsGridProps) { }: DeviceDetailsGridProps) {
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
@ -114,6 +112,7 @@ function DeviceDetailsGrid({
{ id: 'info', label: 'Stammdaten' }, { id: 'info', label: 'Stammdaten' },
{ id: 'zubehoer', label: 'Zubehör' }, { id: 'zubehoer', label: 'Zubehör' },
]} ]}
variant='pillsBrand'
value={activeSection} value={activeSection}
onChange={(id) => onChange={(id) =>
setActiveSection(id as 'info' | 'zubehoer') setActiveSection(id as 'info' | 'zubehoer')
@ -142,69 +141,59 @@ function DeviceDetailsGrid({
Status Status
</p> </p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> <div className="mt-2 space-y-2">
{/* linke „Spalte“: nur inhaltsbreit */} {/* Zeile 1: Badge + Buttons nebeneinander */}
<div className="flex w-auto shrink-0 flex-col gap-1"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span {/* Badge */}
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`} <div className="flex w-auto shrink-0">
>
<span <span
className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`} className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
/> >
<span>{statusLabel}</span> <span
</span> className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`}
/>
{device.loanedTo && ( <span>{statusLabel}</span>
<span className="text-xs text-gray-700 dark:text-gray-200">
an{' '}
<span className="font-semibold">
{device.loanedTo}
</span>
{device.loanedFrom && (
<>
{' '}
seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
)}
{device.loanedUntil && (
<>
{' '}
bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '}
- Hinweis: {device.loanComment}
</>
)}
</span> </span>
)} </div>
</div>
{/* rechte Seite: Buttons */} {/* rechte Seite: Buttons */}
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
</Button>
{canEdit && onEdit && (
<Button <Button
size="md" size="md"
variant="soft" variant="primary"
tone="indigo" onClick={onStartLoan}
onClick={onEdit}
> >
Bearbeiten {isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
</Button> </Button>
)} </div>
</div> </div>
{/* Zeile 2: Verleih-Details über volle Breite */}
{device.loanedTo && (
<p className="text-xs text-gray-700 dark:text-gray-200">
an{' '}
<span className="font-semibold">
{device.loanedTo}
</span>
{device.loanedFrom && (
<>
{' '}seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
)}
{device.loanedUntil && (
<>
{' '}bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '} Hinweis: {device.loanComment}
</>
)}
</p>
)}
</div> </div>
</div> </div>
@ -531,16 +520,34 @@ export default function DeviceDetailModal({
}} }}
headerExtras={ headerExtras={
device && ( device && (
<div className="sm:hidden"> <div className="flex items-center justify-between gap-3 sm:justify-end">
<Tabs {/* Mobile: Tabs im Header */}
tabs={[ <div className="sm:hidden">
{ id: 'details', label: 'Details' }, <Tabs
{ id: 'history', label: 'Änderungsverlauf' }, tabs={[
]} { id: 'details', label: 'Details' },
value={activeTab} { id: 'history', label: 'Änderungsverlauf' },
onChange={(id) => setActiveTab(id as 'details' | 'history')} ]}
ariaLabel="Ansicht wählen" variant='pillsBrand'
/> value={activeTab}
onChange={(id) =>
setActiveTab(id as 'details' | 'history')
}
ariaLabel="Ansicht wählen"
/>
</div>
{/* Rechts: Bearbeiten-Button nur wenn erlaubt */}
{canEdit && onEdit && (
<Button
size="sm"
variant="soft"
tone="indigo"
onClick={() => onEdit(device.inventoryNumber)}
>
Bearbeiten
</Button>
)}
</div> </div>
) )
} }
@ -550,13 +557,13 @@ export default function DeviceDetailModal({
{/* QR-Code oben, nicht scrollend */} {/* QR-Code oben, nicht scrollend */}
<div className="rounded-lg border border-gray-800 bg-gray-900/70 px-4 py-3 shadow-sm"> <div className="rounded-lg border border-gray-800 bg-gray-900/70 px-4 py-3 shadow-sm">
<div className="mt-2 flex justify-center"> <div className="mt-2 flex justify-center">
<div className="rounded-md bg-black/80 p-2"> <div className="rounded-md bg-black/80 px-3 py-3 flex flex-col items-center gap-2">
<DeviceQrCode inventoryNumber={device.inventoryNumber} /> <DeviceQrCode inventoryNumber={device.inventoryNumber} />
<p className="text-[13px] font-mono tracking-wide text-gray-100">
{device.inventoryNumber}
</p>
</div> </div>
</div> </div>
<p className="mt-2 text-center text-[14px] text-gray-500">
{device.inventoryNumber}
</p>
</div> </div>
<div className="border-t border-gray-800 dark:border-white/10 mx-1" /> <div className="border-t border-gray-800 dark:border-white/10 mx-1" />
@ -606,7 +613,7 @@ export default function DeviceDetailModal({
)} )}
</div> </div>
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */} {/* Desktop */}
<div className="hidden sm:block pr-2"> <div className="hidden sm:block pr-2">
<DeviceDetailsGrid <DeviceDetailsGrid
device={device} device={device}

View File

@ -399,6 +399,7 @@ export default function DeviceEditModal({
{ id: 'fields', label: 'Stammdaten' }, { id: 'fields', label: 'Stammdaten' },
{ id: 'relations', label: 'Zubehör' }, { id: 'relations', label: 'Zubehör' },
]} ]}
variant='pillsBrand'
value={activeTab} value={activeTab}
onChange={(id) => setActiveTab(id as 'fields' | 'relations')} onChange={(id) => setActiveTab(id as 'fields' | 'relations')}
ariaLabel="Bearbeitungsansicht wählen" ariaLabel="Bearbeitungsansicht wählen"

View File

@ -1,8 +1,8 @@
// app/(app)/devices/page.tsx
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Table, { TableColumn } from '@/components/ui/Table'; import Table, { TableColumn } from '@/components/ui/Table';
import { Dropdown } from '@/components/ui/Dropdown'; import { Dropdown } from '@/components/ui/Dropdown';
@ -18,6 +18,7 @@ import type { TagOption } from '@/components/ui/TagMultiCombobox';
import DeviceEditModal from './DeviceEditModal'; import DeviceEditModal from './DeviceEditModal';
import DeviceDetailModal from './DeviceDetailModal'; import DeviceDetailModal from './DeviceDetailModal';
import DeviceCreateModal from './DeviceCreateModal'; import DeviceCreateModal from './DeviceCreateModal';
import Badge from '@/components/ui/Badge';
export type AccessorySummary = { export type AccessorySummary = {
inventoryNumber: string; inventoryNumber: string;
@ -54,8 +55,11 @@ export type DeviceDetail = {
updatedAt: string | null; updatedAt: string | null;
}; };
type PrimaryTab = 'main' | 'accessories' | 'all';
type StatusTab = 'all' | 'loaned' | 'dueToday' | 'overdue';
function formatDate(iso: string | null | undefined) { function formatDate(iso: string | null | undefined) {
if (!iso) return ''; // oder '' wenn du es leer willst if (!iso) return '';
return new Intl.DateTimeFormat('de-DE', { return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short', dateStyle: 'short',
@ -125,8 +129,28 @@ const columns: TableColumn<DeviceDetail>[] = [
header: 'Tags', header: 'Tags',
sortable: false, sortable: false,
canHide: true, canHide: true,
render: (row) => cellClassName: 'whitespace-normal max-w-xs',
row.tags && row.tags.length > 0 ? row.tags.join(', ') : '', render: (row) => {
const tags = row.tags ?? [];
if (!tags.length) return null;
return (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
size="sm"
tone="indigo"
variant="flat"
shape="pill"
>
{tag}
</Badge>
))}
</div>
);
},
}, },
{ {
key: 'updatedAt', key: 'updatedAt',
@ -138,6 +162,8 @@ const columns: TableColumn<DeviceDetail>[] = [
]; ];
export default function DevicesPage() { export default function DevicesPage() {
const { data: session } = useSession();
const [devices, setDevices] = useState<DeviceDetail[]>([]); const [devices, setDevices] = useState<DeviceDetail[]>([]);
const [listLoading, setListLoading] = useState(false); const [listLoading, setListLoading] = useState(false);
const [listError, setListError] = useState<string | null>(null); const [listError, setListError] = useState<string | null>(null);
@ -151,21 +177,15 @@ export default function DevicesPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
// TODO: Ersetze das durch deinen echten User-/Gruppen-Mechanismus
// Beispiel: aktuelle Benutzergruppen (z.B. aus Context oder eigenem Hook)
const currentUserGroups: string[] = []; // Platzhalter
// Nur User in dieser Gruppe sollen Geräte bearbeiten dürfen // Nur User in dieser Gruppe sollen Geräte bearbeiten dürfen
const canEditDevices = currentUserGroups.includes('INVENTAR_ADMIN'); const canEditDevices = Boolean(
(session?.user as any)?.groupCanEditDevices,
);
// 🔹 Tab-Filter: Hauptgeräte / Zubehör / Alle // 🔹 Oberste Tabs: Hauptgeräte / Zubehör / Alle Geräte
const [activeTab, setActiveTab] = const [primaryTab, setPrimaryTab] = useState<PrimaryTab>('all');
useState<'main' | 'accessories' | 'all'>('main'); // 🔹 Untere Tabs: Leihstatus
const [statusTab, setStatusTab] = useState<StatusTab>('all');
// 🔹 Counters für Badges
const mainCount = devices.filter((d) => !d.parentInventoryNumber).length;
const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length;
const allCount = devices.length;
/* ───────── Geräte-Liste laden ───────── */ /* ───────── Geräte-Liste laden ───────── */
@ -210,7 +230,7 @@ export default function DevicesPage() {
}, [loadDevices]); }, [loadDevices]);
useEffect(() => { useEffect(() => {
if (!searchParams) return; // TS happy if (!searchParams) return;
const fromDevice = searchParams.get('device'); const fromDevice = searchParams.get('device');
const fromInventory = const fromInventory =
@ -317,8 +337,6 @@ export default function DevicesPage() {
return; return;
} }
// Optimistisch aus lokaler Liste entfernen
// (zusätzlich kommt noch der Socket-Event device:deleted)
setDevices((prev) => setDevices((prev) =>
prev.filter((d) => d.inventoryNumber !== inventoryNumber), prev.filter((d) => d.inventoryNumber !== inventoryNumber),
); );
@ -346,15 +364,12 @@ export default function DevicesPage() {
setDetailInventoryNumber(null); setDetailInventoryNumber(null);
if (!searchParams) { if (!searchParams) {
// Fallback: einfach auf /devices ohne Query
router.replace('/devices', { scroll: false }); router.replace('/devices', { scroll: false });
return; return;
} }
// ReadonlyURLSearchParams → string → URLSearchParams kopieren
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
// alle möglichen Detail-Parameter entfernen
params.delete('device'); params.delete('device');
params.delete('inventoryNumber'); params.delete('inventoryNumber');
params.delete('inv'); params.delete('inv');
@ -367,28 +382,78 @@ export default function DevicesPage() {
const handleEditFromDetail = useCallback( const handleEditFromDetail = useCallback(
(inventoryNumber: string) => { (inventoryNumber: string) => {
// Detail-Modal schließen + URL /device-Query aufräumen
closeDetailModal(); closeDetailModal();
// danach Edit-Modal öffnen
setEditInventoryNumber(inventoryNumber); setEditInventoryNumber(inventoryNumber);
}, },
[closeDetailModal], [closeDetailModal],
); );
/* ───────── Counter & Filter ───────── */
/* ───────── Filter nach Tab ───────── */ // Tag-Grenzen für "heute"
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(tomorrowStart.getDate() + 1);
const filteredDevices = devices.filter((d) => { // Counts für oberste Tabs (immer über alle Geräte)
if (activeTab === 'main') { const mainCount = devices.filter((d) => !d.parentInventoryNumber).length;
// Hauptgeräte: kein parent → eigenständig const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length;
return !d.parentInventoryNumber; const allCount = devices.length;
// Zuerst nach primaryTab filtern → Basis-Menge für Status-Tabs
const baseDevices = devices.filter((d) => {
const hasParent = !!d.parentInventoryNumber;
switch (primaryTab) {
case 'main':
return !hasParent;
case 'accessories':
return hasParent;
case 'all':
default:
return true;
} }
if (activeTab === 'accessories') { });
// Zubehör: hat ein Hauptgerät
return !!d.parentInventoryNumber; // Counts für Status-Tabs (abhängig vom gewählten primaryTab)
const loanedCount = baseDevices.filter((d) => !!d.loanedTo).length;
const overdueCount = baseDevices.filter((d) => {
if (!d.loanedTo || !d.loanedUntil) return false;
const until = new Date(d.loanedUntil);
return until < todayStart;
}).length;
const dueTodayCount = baseDevices.filter((d) => {
if (!d.loanedTo || !d.loanedUntil) return false;
const until = new Date(d.loanedUntil);
return until >= todayStart && until < tomorrowStart;
}).length;
// Endgültige Filterung nach StatusTab
const filteredDevices = baseDevices.filter((d) => {
const isLoaned = !!d.loanedTo;
const until = d.loanedUntil ? new Date(d.loanedUntil) : null;
switch (statusTab) {
case 'all':
return true;
case 'loaned':
return isLoaned;
case 'overdue':
return (
isLoaned &&
!!until &&
until < todayStart
);
case 'dueToday':
return (
isLoaned &&
!!until &&
until >= todayStart &&
until < tomorrowStart
);
default:
return true;
} }
// "all"
return true;
}); });
/* ───────── Render ───────── */ /* ───────── Render ───────── */
@ -421,10 +486,17 @@ export default function DevicesPage() {
)} )}
</div> </div>
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */} {/* 🔹 Tabs: oben Gerätetyp, darunter Leihstatus */}
<div className="mt-6"> <div className="mt-6 space-y-3">
{/* Oberste Ebene */}
<Tabs <Tabs
variant='pillsBrand'
tabs={[ tabs={[
{
id: 'all',
label: 'Alle Geräte',
count: allCount,
},
{ {
id: 'main', id: 'main',
label: 'Hauptgeräte', label: 'Hauptgeräte',
@ -435,26 +507,43 @@ export default function DevicesPage() {
label: 'Zubehör', label: 'Zubehör',
count: accessoriesCount, count: accessoriesCount,
}, },
]}
value={primaryTab}
onChange={(id) => setPrimaryTab(id as PrimaryTab)}
ariaLabel="Geräte-Typ filtern"
/>
{/* Untere Ebene: Leihstatus (abhängig von primaryTab, Counts basieren auf baseDevices) */}
<Tabs
variant='pillsBrand'
tabs={[
{ {
id: 'all', id: 'all',
label: 'Alle Geräte', label: 'Alle',
count: allCount, count: baseDevices.length,
},
{
id: 'loaned',
label: 'Verliehen',
count: loanedCount,
},
{
id: 'dueToday',
label: 'Heute fällig',
count: dueTodayCount,
},
{
id: 'overdue',
label: 'Überfällig',
count: overdueCount,
}, },
]} ]}
value={activeTab} value={statusTab}
onChange={(id) => onChange={(id) => setStatusTab(id as StatusTab)}
setActiveTab(id as 'main' | 'accessories' | 'all') ariaLabel="Leihstatus filtern"
}
ariaLabel="Geräteliste filtern"
/> />
</div> </div>
{listLoading && (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Geräte werden geladen
</p>
)}
{listError && ( {listError && (
<p className="mt-4 text-sm text-red-600 dark:text-red-400"> <p className="mt-4 text-sm text-red-600 dark:text-red-400">
{listError} {listError}
@ -469,6 +558,7 @@ export default function DevicesPage() {
getRowId={(row) => row.inventoryNumber} getRowId={(row) => row.inventoryNumber}
selectable selectable
actionsHeader="" actionsHeader=""
isLoading={listLoading}
renderActions={(row) => ( renderActions={(row) => (
<div className="flex justify-end"> <div className="flex justify-end">
{/* Desktop: drei Icon-Buttons nebeneinander */} {/* Desktop: drei Icon-Buttons nebeneinander */}
@ -482,14 +572,16 @@ export default function DevicesPage() {
onClick={() => handleDetails(row.inventoryNumber)} onClick={() => handleDetails(row.inventoryNumber)}
/> />
<Button {canEditDevices && (
variant="soft" <Button
tone="gray" variant="soft"
size="md" tone="gray"
icon={<PencilIcon className="size-5" />} size="md"
aria-label={`Gerät ${row.inventoryNumber} bearbeiten`} icon={<PencilIcon className="size-5" />}
onClick={() => handleEdit(row.inventoryNumber)} aria-label={`Gerät ${row.inventoryNumber} bearbeiten`}
/> onClick={() => handleEdit(row.inventoryNumber)}
/>
)}
<Button <Button
variant="soft" variant="soft"

View File

@ -45,11 +45,6 @@ const navigation = [
{ name: 'Personen', href: '/users', icon: UserIcon }, { name: 'Personen', href: '/users', icon: UserIcon },
]; ];
const userNavigation = [
{ name: 'Your profile', href: '#' },
{ name: 'Abmelden', href: '#' },
];
function classNames(...classes: Array<string | boolean | null | undefined>) { function classNames(...classes: Array<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' '); return classes.filter(Boolean).join(' ');
} }
@ -72,7 +67,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
const displayName = rawName; const displayName = rawName;
const avatarName = rawName; const avatarName = rawName;
const avatarUrl = session?.user?.image ?? null;
// Avatar-URL bevorzugt aus avatarUrl, sonst Fallback auf image
const avatarUrl =
status === 'authenticated'
? ((session?.user as any).avatarUrl ?? session?.user?.image ?? null)
: null;
const handleScanResult = (code: string) => { const handleScanResult = (code: string) => {
const trimmed = code.trim(); const trimmed = code.trim();

View File

@ -1,6 +1,8 @@
// app/(app)/users/EditUserModal.tsx
'use client'; 'use client';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import Switch from '@/components/ui/Switch'; // 👈 Neu
import type { UserWithAvatar } from './types'; import type { UserWithAvatar } from './types';
type EditUserModalProps = { type EditUserModalProps = {
@ -15,6 +17,8 @@ type EditUserModalProps = {
onLastNameChange: (value: string) => void; onLastNameChange: (value: string) => void;
onClose: () => void; onClose: () => void;
onSubmit: () => void; onSubmit: () => void;
/** Abgeleitet aus der Gruppe: darf dieser Benutzer Geräte bearbeiten? */
canEditDevices: boolean; // 👈 Neu
}; };
export default function EditUserModal({ export default function EditUserModal({
@ -29,6 +33,7 @@ export default function EditUserModal({
onLastNameChange, onLastNameChange,
onClose, onClose,
onSubmit, onSubmit,
canEditDevices, // 👈 Neu
}: EditUserModalProps) { }: EditUserModalProps) {
if (!open) return null; if (!open) return null;
@ -56,7 +61,7 @@ export default function EditUserModal({
e.preventDefault(); e.preventDefault();
onSubmit(); onSubmit();
}} }}
className="space-y-3 text-sm" className="space-y-4 text-sm"
> >
<div> <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
@ -109,6 +114,27 @@ export default function EditUserModal({
/> />
</div> </div>
</div> </div>
{/* 🔹 Info: Darf Geräte bearbeiten (über Gruppe gesteuert) */}
<div className="mt-2 flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
Darf Geräte bearbeiten
</span>
<span className="text-[11px] text-gray-500 dark:text-gray-400">
Dieser Status wird durch die zugewiesene Gruppe gesteuert.
</span>
</div>
<Switch
id="user-can-edit-devices"
name="user-can-edit-devices"
checked={canEditDevices}
onChange={() => { /* read-only, wird durch Gruppe bestimmt */ }}
disabled
ariaLabel="Benutzer darf Geräte bearbeiten (über Gruppe gesteuert)"
/>
</div>
</form> </form>
</Modal> </Modal>
); );

View File

@ -684,6 +684,7 @@ export default function UsersTablesClient({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<Tabs <Tabs
tabs={mainTabs} tabs={mainTabs}
variant='pillsBrand'
value={safeActiveMainTab} value={safeActiveMainTab}
onChange={setActiveMainTab} onChange={setActiveMainTab}
ariaLabel="Usergruppen (Cluster) auswählen" ariaLabel="Usergruppen (Cluster) auswählen"
@ -710,6 +711,7 @@ export default function UsersTablesClient({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<Tabs <Tabs
tabs={subTabs} tabs={subTabs}
variant='pillsBrand'
value={safeActiveSubTab} value={safeActiveSubTab}
onChange={setActiveSubTab} onChange={setActiveSubTab}
ariaLabel="Untergruppen auswählen" ariaLabel="Untergruppen auswählen"
@ -803,6 +805,10 @@ export default function UsersTablesClient({
onLastNameChange={setEditLastName} onLastNameChange={setEditLastName}
onClose={() => setEditUser(null)} onClose={() => setEditUser(null)}
onSubmit={handleSaveEdit} onSubmit={handleSaveEdit}
canEditDevices={(() => {
const group = allGroups.find((g) => g.id === editUser.groupId);
return !!group?.canEditDevices;
})()}
/> />
)} )}

View File

@ -0,0 +1,120 @@
// app/api/profile/avatar/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import fs from 'fs';
import path from 'path';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
const user = session?.user as any | undefined;
const nwkennung: string | undefined = user?.nwkennung;
if (!session || !nwkennung) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get('avatar');
if (!file || !(file instanceof Blob)) {
return NextResponse.json({ error: 'NO_FILE' }, { status: 400 });
}
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const mime = file.type || '';
if (!mime.startsWith('image/')) {
return NextResponse.json({ error: 'INVALID_TYPE' }, { status: 400 });
}
const size: number | undefined = file.size;
if (typeof size === 'number' && size > MAX_SIZE) {
return NextResponse.json(
{ error: 'TOO_LARGE', maxSizeBytes: MAX_SIZE },
{ status: 413 },
);
}
// Dateiendung bestimmen
let ext = '';
if ('name' in file) {
const name = (file as any).name as string;
ext = path.extname(name);
}
if (!ext) {
if (mime === 'image/jpeg') ext = '.jpg';
else if (mime === 'image/png') ext = '.png';
else if (mime === 'image/gif') ext = '.gif';
else ext = '.img';
}
const avatarsDir = path.join(process.cwd(), 'public', 'avatars');
await fs.promises.mkdir(avatarsDir, { recursive: true });
// alte Avatare löschen
const existingFiles = await fs.promises.readdir(avatarsDir);
await Promise.all(
existingFiles
.filter((f) => f.startsWith(`${nwkennung}-`))
.map((f) => fs.promises.unlink(path.join(avatarsDir, f))),
);
// Neuer, eindeutiger Dateiname
const timestamp = Date.now();
const fileName = `${nwkennung}-${timestamp}${ext.toLowerCase()}`;
const filePath = path.join(avatarsDir, fileName);
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await fs.promises.writeFile(filePath, buffer);
const avatarUrl = `/avatars/${fileName}`;
await prisma.user.update({
where: { nwkennung },
data: { avatarUrl },
});
return NextResponse.json({ ok: true, avatarUrl });
}
// 👇 Neu: Profilbild löschen
export async function DELETE(req: NextRequest) {
const session = await getServerSession(authOptions);
const user = session?.user as any | undefined;
const nwkennung: string | undefined = user?.nwkennung;
if (!session || !nwkennung) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const avatarsDir = path.join(process.cwd(), 'public', 'avatars');
// Dateien löschen (falls vorhanden)
try {
const existingFiles = await fs.promises.readdir(avatarsDir);
const userFiles = existingFiles.filter((f) =>
f.startsWith(`${nwkennung}-`),
);
await Promise.all(
userFiles.map((f) => fs.promises.unlink(path.join(avatarsDir, f))),
);
} catch (err: any) {
// Wenn es den Ordner nicht gibt, ignorieren
if (err?.code !== 'ENOENT') {
console.error('[DELETE /api/profile/avatar] cleanup error', err);
}
}
// avatarUrl in DB auf null setzen
await prisma.user.update({
where: { nwkennung },
data: { avatarUrl: null },
});
return NextResponse.json({ ok: true, avatarUrl: null });
}

View File

@ -1,23 +1,30 @@
// /app/layout.tsx // /app/layout.tsx
import type { Metadata, Viewport } from 'next';
import type { Metadata } from "next"; import { Geist, Geist_Mono } from 'next/font/google';
import { Geist, Geist_Mono } from "next/font/google"; import './globals.css';
import "./globals.css"; import Providers from './providers';
import Providers from "./providers";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: '--font-geist-sans',
subsets: ["latin"], subsets: ['latin'],
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: '--font-geist-mono',
subsets: ["latin"], subsets: ['latin'],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Create Next App',
description: "Generated by create next app", description: 'Generated by create next app',
};
// 👇 Neu
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false, // verhindert Pinch-Zoom
}; };
export default function RootLayout({ export default function RootLayout({
@ -26,7 +33,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className="bg-white dark:bg-gray-950 scheme-light dark:scheme-dark"> <html
lang="de"
className="bg-white dark:bg-gray-950 scheme-light dark:scheme-dark"
>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-x-hidden`} className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-x-hidden`}
> >

View File

@ -41,7 +41,7 @@ export function DeviceQrCode({ inventoryNumber, size = 180 }: DeviceQrCodeProps)
value={qrValue} value={qrValue}
size={size} size={size}
level="M" level="M"
includeMargin marginSize={2}
bgColor="#FFFFFF" bgColor="#FFFFFF"
fgColor="#000000" fgColor="#000000"
/> />

View File

@ -0,0 +1,184 @@
// components/ProfileAvatarModal.tsx
'use client';
import { useEffect, useState } from 'react';
import Modal from '@/components/ui/Modal';
import PersonAvatar from '@/components/ui/UserAvatar';
import Button from './ui/Button';
type ProfileAvatarModalProps = {
open: boolean;
onClose: () => void;
avatarName: string;
avatarUrl?: string | null;
/**
* Wird aufgerufen, wenn eine neue Datei gespeichert werden soll.
*/
onAvatarSelected?: (file: File) => Promise<void> | void;
/**
* Optional: wird aufgerufen, wenn der Nutzer das Profilbild löschen möchte.
* Erwartet, dass Backend + Session angepasst werden (Avatar auf null).
*/
onAvatarDelete?: () => Promise<void> | void;
};
export default function ProfileAvatarModal({
open,
onClose,
avatarName,
avatarUrl,
onAvatarSelected,
onAvatarDelete,
}: ProfileAvatarModalProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Wenn Modal geschlossen wird → State zurücksetzen
useEffect(() => {
if (!open) {
setSelectedFile(null);
setPreviewUrl(null);
setIsSaving(false);
}
}, [open]);
// Preview-URL für ausgewählte Datei erzeugen
useEffect(() => {
if (!selectedFile) {
setPreviewUrl(null);
return;
}
const url = URL.createObjectURL(selectedFile);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [selectedFile]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleSave = async () => {
if (!selectedFile) return;
try {
setIsSaving(true);
if (onAvatarSelected) {
await onAvatarSelected(selectedFile);
}
onClose();
} finally {
setIsSaving(false);
}
};
const handleDeleteClick = async () => {
if (!onAvatarDelete) return;
const sure = window.confirm('Profilbild wirklich entfernen?');
if (!sure) return;
try {
setIsSaving(true);
await onAvatarDelete();
// lokale Preview zurücksetzen
setSelectedFile(null);
setPreviewUrl(null);
onClose();
} finally {
setIsSaving(false);
}
};
const effectiveAvatarUrl = previewUrl ?? avatarUrl ?? undefined;
const hasDeletableAvatar = !!avatarUrl; // Button nur anzeigen, wenn ein Avatar existiert
return (
<Modal
open={open}
onClose={onClose}
title="Profilbild ändern"
tone="info"
size="sm"
primaryAction={{
label: 'Speichern',
onClick: handleSave,
disabled: !selectedFile || isSaving,
}}
secondaryAction={{
label: 'Abbrechen',
onClick: onClose,
variant: 'secondary',
disabled: isSaving,
}}
>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Wähle ein neues Profilbild aus oder entferne das aktuelle Bild.<br />
Unterstützte Formate: JPG, PNG, GIF.
</p>
{/* Aktuell vs. Vorschau */}
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="flex flex-col items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
Aktuell
</span>
<PersonAvatar
name={avatarName}
avatarUrl={avatarUrl ?? undefined}
size="2xl"
/>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
Vorschau
</span>
<PersonAvatar
name={avatarName}
avatarUrl={effectiveAvatarUrl}
size="2xl"
/>
</div>
</div>
{/* File-Input + ggf. Lösch-Button */}
<div className="mt-4 space-y-3">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="mt-2 block w-full text-sm text-gray-900
file:mr-4 file:rounded-md file:border-0
file:bg-indigo-50 file:px-3 file:py-1.5
file:text-sm file:font-semibold file:text-indigo-700
hover:file:bg-indigo-100
dark:text-gray-100
dark:file:bg-indigo-500/10 dark:file:text-indigo-200
dark:hover:file:bg-indigo-500/20"
/>
{/* Profilbild löschen nur, wenn wirklich eins vorhanden ist */}
{onAvatarDelete && hasDeletableAvatar && (
<Button
onClick={handleDeleteClick}
disabled={isSaving}
size='md'
variant='soft'
tone='rose'
className="w-full text-xs font-medium text-rose-600 hover:text-rose-700 dark:text-rose-400 dark:hover:text-rose-300"
>
Profilbild entfernen
</Button>
)}
</div>
</div>
</Modal>
);
}

View File

@ -3,8 +3,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { signOut } from 'next-auth/react'; import { signOut, useSession } from 'next-auth/react';
import PersonAvatar from '@/components/ui/UserAvatar'; import PersonAvatar from '@/components/ui/UserAvatar';
import ProfileAvatarModal from '@/components/ProfileAvatarModal';
export type UserMenuProps = { export type UserMenuProps = {
displayName: string; displayName: string;
@ -13,7 +14,7 @@ export type UserMenuProps = {
}; };
const userNavigation = [ const userNavigation = [
{ name: 'Your profile', href: '#' }, { name: 'Profilbild ändern', href: '#' },
{ name: 'Abmelden', href: '#' }, { name: 'Abmelden', href: '#' },
]; ];
@ -23,6 +24,11 @@ export default function UserMenu({
avatarUrl, avatarUrl,
}: UserMenuProps) { }: UserMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const { update } = useSession();
const [currentAvatarUrl, setCurrentAvatarUrl] = useState<string | null | undefined>(avatarUrl ?? null);
const buttonRef = useRef<HTMLButtonElement | null>(null); const buttonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@ -66,66 +72,122 @@ export default function UserMenu({
const handleItemClick = (itemName: string) => { const handleItemClick = (itemName: string) => {
setOpen(false); setOpen(false);
if (itemName === 'Profilbild ändern') {
setAvatarModalOpen(true);
return;
}
if (itemName === 'Abmelden') { if (itemName === 'Abmelden') {
void signOut({ callbackUrl: '/login' }); void signOut({ callbackUrl: '/login' });
return; return;
} }
};
// hier könntest du später noch Routing für "Your profile" o.ä. einbauen const handleAvatarSelected = async (file: File) => {
const formData = new FormData();
formData.append('avatar', file);
const res = await fetch('/api/profile/avatar', {
method: 'POST',
body: formData,
});
if (!res.ok) {
console.error('Avatar-Upload fehlgeschlagen');
return;
}
const data = await res.json();
const newUrl = data.avatarUrl as string | null;
// 1) Lokal sofort aktualisieren
setCurrentAvatarUrl(newUrl);
// 2) NextAuth-Session aktualisieren → layout.tsx bekommt neue avatarUrl
await update({ avatarUrl: newUrl });
};
const handleAvatarDelete = async () => {
const res = await fetch('/api/profile/avatar', {
method: 'DELETE',
});
if (!res.ok) {
console.error('Avatar-Löschung fehlgeschlagen');
return;
}
// 1) Lokal zurück auf null
setCurrentAvatarUrl(null);
// 2) Session updaten → überall Fallback-Initialen
await update({ avatarUrl: null });
}; };
return ( return (
<div className="relative"> <>
<button <div className="relative">
ref={buttonRef} <button
type="button" ref={buttonRef}
onClick={handleToggle} type="button"
className="relative flex items-center focus:outline-none" onClick={handleToggle}
aria-haspopup="menu" className="relative flex items-center focus:outline-none"
aria-expanded={open} aria-haspopup="menu"
aria-controls="user-menu-dropdown" aria-expanded={open}
> aria-controls="user-menu-dropdown"
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
{/* Avatar über gemeinsame Komponente */}
<PersonAvatar name={avatarName} avatarUrl={avatarUrl} size="md" />
<span className="hidden lg:flex lg:items-center">
<span
aria-hidden="true"
className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white"
>
{displayName}
</span>
<ChevronDownIcon
aria-hidden="true"
className="ml-2 size-5 text-gray-400 dark:text-gray-500"
/>
</span>
</button>
{open && (
<div
ref={menuRef}
id="user-menu-dropdown"
role="menu"
aria-orientation="vertical"
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline-1 outline-gray-900/5 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
> >
{userNavigation.map((item) => ( <span className="absolute -inset-1.5" />
<button <span className="sr-only">Open user menu</span>
key={item.name}
type="button" <PersonAvatar name={avatarName} avatarUrl={currentAvatarUrl} size="md" />
role="menuitem"
onClick={() => handleItemClick(item.name)} <span className="hidden lg:flex lg:items-center">
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 hover:bg-gray-50 focus:outline-none dark:text-white dark:hover:bg-white/5" <span
aria-hidden="true"
className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white"
> >
{item.name} {displayName}
</button> </span>
))} <ChevronDownIcon
</div> aria-hidden="true"
)} className="ml-2 size-5 text-gray-400 dark:text-gray-500"
</div> />
</span>
</button>
{open && (
<div
ref={menuRef}
id="user-menu-dropdown"
role="menu"
aria-orientation="vertical"
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline-1 outline-gray-900/5 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{userNavigation.map((item) => (
<button
key={item.name}
type="button"
role="menuitem"
onClick={() => handleItemClick(item.name)}
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 hover:bg-gray-50 focus:outline-none dark:text-white dark:hover:bg-white/5"
>
{item.name}
</button>
))}
</div>
)}
</div>
{/* Profilbild-Modal */}
<ProfileAvatarModal
open={avatarModalOpen}
onClose={() => setAvatarModalOpen(false)}
avatarName={avatarName}
avatarUrl={currentAvatarUrl ?? undefined}
onAvatarSelected={handleAvatarSelected}
onAvatarDelete={handleAvatarDelete}
/>
</>
); );
} }

239
components/ui/Alerts.tsx Normal file
View File

@ -0,0 +1,239 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import {
InformationCircleIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
XCircleIcon,
XMarkIcon,
} from '@heroicons/react/20/solid';
export type AlertTone = 'info' | 'success' | 'warning' | 'error';
export interface AlertProps {
/** Farbschema / Typ des Alerts */
tone?: AlertTone;
/** Überschrift (z.B. "Attention needed") */
title?: React.ReactNode;
/** Beschreibungstext (als string oder JSX) */
description?: React.ReactNode;
/** Bullet-List-Einträge wie im "With list"-Beispiel */
listItems?: React.ReactNode[];
/** Eigene Icon-Komponente (null = Icon komplett ausblenden) */
icon?: React.ReactNode | null;
/** Bereich für Buttons / Actions unter dem Text */
actions?: React.ReactNode;
/** Inhalt rechts (z.B. ein "Details →"-Link) */
rightContent?: React.ReactNode;
/** Linker Accent-Border (statt Outlines im Dark Mode) */
accent?: boolean;
/** Wenn gesetzt, wird ein Dismiss-X angezeigt und dieser Handler aufgerufen */
onDismiss?: () => void;
className?: string;
}
const toneConfig: Record<
AlertTone,
{
bg: string;
outline: string;
accentBorder: string;
title: string;
text: string;
icon: string;
dismissBtn: string;
}
> = {
info: {
bg: 'bg-blue-50 dark:bg-blue-500/10',
outline: 'dark:outline dark:outline-blue-500/20',
accentBorder: 'border-l-4 border-blue-400 dark:border-blue-500',
title: 'text-blue-800 dark:text-blue-200',
text: 'text-blue-700 dark:text-blue-300',
icon: 'text-blue-400 dark:text-blue-300',
dismissBtn:
'inline-flex rounded-md bg-blue-50 p-1.5 text-blue-500 hover:bg-blue-100 ' +
'focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-50 focus-visible:outline-none ' +
'dark:bg-transparent dark:text-blue-400 dark:hover:bg-blue-500/10 ' +
'dark:focus-visible:ring-blue-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-blue-900',
},
success: {
bg: 'bg-green-50 dark:bg-green-500/10',
outline: 'dark:outline dark:outline-green-500/20',
accentBorder: 'border-l-4 border-green-400 dark:border-green-500',
title: 'text-green-800 dark:text-green-200',
text: 'text-green-700 dark:text-green-200/85',
icon: 'text-green-400 dark:text-green-300',
dismissBtn:
'inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 ' +
'focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-none ' +
'dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 ' +
'dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900',
},
warning: {
bg: 'bg-yellow-50 dark:bg-yellow-500/10',
outline: 'dark:outline dark:outline-yellow-500/15',
accentBorder: 'border-l-4 border-yellow-400 dark:border-yellow-500',
title: 'text-yellow-800 dark:text-yellow-100',
text: 'text-yellow-700 dark:text-yellow-100/80',
icon: 'text-yellow-400 dark:text-yellow-300',
dismissBtn:
'inline-flex rounded-md bg-yellow-50 p-1.5 text-yellow-500 hover:bg-yellow-100 ' +
'focus-visible:ring-2 focus-visible:ring-yellow-600 focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-50 focus-visible:outline-none ' +
'dark:bg-transparent dark:text-yellow-400 dark:hover:bg-yellow-500/10 ' +
'dark:focus-visible:ring-yellow-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-yellow-900',
},
error: {
bg: 'bg-red-50 dark:bg-red-500/15',
outline: 'dark:outline dark:outline-red-500/25',
accentBorder: 'border-l-4 border-red-400 dark:border-red-500',
title: 'text-red-800 dark:text-red-200',
text: 'text-red-700 dark:text-red-200/80',
icon: 'text-red-400 dark:text-red-400',
dismissBtn:
'inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 ' +
'focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-2 focus-visible:ring-offset-red-50 focus-visible:outline-none ' +
'dark:bg-transparent dark:text-red-400 dark:hover:bg-red-500/10 ' +
'dark:focus-visible:ring-red-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-red-900',
},
};
function getDefaultIcon(tone: AlertTone) {
switch (tone) {
case 'info':
return (
<InformationCircleIcon
aria-hidden="true"
className="size-5"
/>
);
case 'success':
return (
<CheckCircleIcon
aria-hidden="true"
className="size-5"
/>
);
case 'warning':
return (
<ExclamationTriangleIcon
aria-hidden="true"
className="size-5"
/>
);
case 'error':
return (
<XCircleIcon
aria-hidden="true"
className="size-5"
/>
);
}
}
function Alerts({
tone = 'info',
title,
description,
listItems,
icon,
actions,
rightContent,
accent = false,
onDismiss,
className,
}: AlertProps) {
const cfg = toneConfig[tone];
const hasList = !!listItems && listItems.length > 0;
const showIcon = icon !== null;
return (
<div
role="alert"
className={clsx(
'p-4',
cfg.bg,
accent ? cfg.accentBorder : 'rounded-md ' + cfg.outline,
accent && 'rounded-none',
className,
)}
>
<div className="flex">
{showIcon && (
<div className="shrink-0">
<span className={cfg.icon}>
{icon ?? getDefaultIcon(tone)}
</span>
</div>
)}
<div
className={clsx(
'ml-3 flex-1',
rightContent || onDismiss
? 'md:flex md:justify-between md:items-start'
: undefined,
)}
>
<div className="text-sm">
{title && (
<h3 className={clsx('font-medium', cfg.title)}>{title}</h3>
)}
{description && (
<div className={clsx('mt-2', cfg.text)}>
{typeof description === 'string' ? (
<p>{description}</p>
) : (
description
)}
</div>
)}
{hasList && (
<div className={clsx(title || description ? 'mt-2' : undefined, cfg.text)}>
<ul role="list" className="list-disc space-y-1 pl-5">
{listItems!.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</div>
)}
{actions && <div className="mt-4">{actions}</div>}
</div>
{(rightContent || onDismiss) && (
<div className="mt-3 md:mt-0 md:ml-6 flex items-start gap-2">
{rightContent && <div className="text-sm">{rightContent}</div>}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className={cfg.dismissBtn}
>
<span className="sr-only">Dismiss</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</button>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default Alerts;

View File

@ -260,7 +260,7 @@ export default function AppCombobox<T>({
<div className="relative mt-2"> <div className="relative mt-2">
<ComboboxInput <ComboboxInput
className="block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500" className="block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-gray-900 dark:text-gray-100 dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder} placeholder={placeholder}

View File

@ -0,0 +1,39 @@
// src/components/ui/LoadingSpinner.tsx
'use client';
import * as React from 'react';
import clsx from 'clsx';
export type LoadingSpinnerProps = React.SVGProps<SVGSVGElement> & {
size?: 'xs' | 'sm' | 'md' | 'lg';
};
const sizeClasses: Record<NonNullable<LoadingSpinnerProps['size']>, string> = {
xs: 'size-3',
sm: 'size-4',
md: 'size-5', // entspricht deinem Beispiel
lg: 'size-6',
};
export default function LoadingSpinner({
size = 'md',
className,
...props
}: LoadingSpinnerProps) {
return (
<svg
fill="none"
viewBox="0 0 24 24"
className={clsx(
'animate-spin mr-3 -ml-1 text-gray-500 dark:text-gray-400',
sizeClasses[size],
className,
)}
aria-hidden={props['aria-label'] ? undefined : true}
{...props}
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
);
}

View File

@ -3,6 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { ChevronDownIcon } from '@heroicons/react/20/solid';
import LoadingSpinner from '@/components/ui/LoadingSpinner'; // 👈 Neu
function classNames(...classes: Array<string | boolean | null | undefined>) { function classNames(...classes: Array<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' '); return classes.filter(Boolean).join(' ');
@ -18,7 +19,7 @@ export type TableColumn<T> = {
/** Kann die Spalte sortiert werden? */ /** Kann die Spalte sortiert werden? */
sortable?: boolean; sortable?: boolean;
/** Kann die Spalte ausgeblendet werden? */ /** Kann die Spalte ausgeblendet werden? */
canHide?: boolean, canHide?: boolean;
/** Optional eigene Klassen für die TH-Zelle */ /** Optional eigene Klassen für die TH-Zelle */
headerClassName?: string; headerClassName?: string;
/** Optional eigene Klassen für die TD-Zelle */ /** Optional eigene Klassen für die TD-Zelle */
@ -44,6 +45,8 @@ export interface TableProps<T> {
defaultSortKey?: keyof T; defaultSortKey?: keyof T;
/** Optional: Standard-Sortierrichtung */ /** Optional: Standard-Sortierrichtung */
defaultSortDirection?: SortDirection; defaultSortDirection?: SortDirection;
/** Optional: Wenn true, wird statt der Zeilen ein LoadingSpinner angezeigt */
isLoading?: boolean; // 👈 Neu
} }
type SortState<T> = { type SortState<T> = {
@ -62,6 +65,7 @@ export default function Table<T>(props: TableProps<T>) {
actionsHeader = '', actionsHeader = '',
defaultSortKey, defaultSortKey,
defaultSortDirection = 'asc', defaultSortDirection = 'asc',
isLoading = false, // 👈 Neu
} = props; } = props;
const [sort, setSort] = React.useState<SortState<T>>({ const [sort, setSort] = React.useState<SortState<T>>({
@ -87,12 +91,10 @@ export default function Table<T>(props: TableProps<T>) {
if (va == null) return sort.direction === 'asc' ? -1 : 1; if (va == null) return sort.direction === 'asc' ? -1 : 1;
if (vb == null) return sort.direction === 'asc' ? 1 : -1; if (vb == null) return sort.direction === 'asc' ? 1 : -1;
// Reine Numbers
if (typeof va === 'number' && typeof vb === 'number') { if (typeof va === 'number' && typeof vb === 'number') {
return sort.direction === 'asc' ? va - vb : vb - va; return sort.direction === 'asc' ? va - vb : vb - va;
} }
// Numerische Strings wie "1", "123", "42"
if (typeof va === 'string' && typeof vb === 'string') { if (typeof va === 'string' && typeof vb === 'string') {
const na = Number(va); const na = Number(va);
const nb = Number(vb); const nb = Number(vb);
@ -102,7 +104,6 @@ export default function Table<T>(props: TableProps<T>) {
} }
} }
// Date / ISO-String
const sa = va instanceof Date ? va.getTime() : String(va); const sa = va instanceof Date ? va.getTime() : String(va);
const sb = vb instanceof Date ? vb.getTime() : String(vb); const sb = vb instanceof Date ? vb.getTime() : String(vb);
@ -130,20 +131,20 @@ export default function Table<T>(props: TableProps<T>) {
React.useEffect(() => { React.useEffect(() => {
if (!onSelectionChange) return; if (!onSelectionChange) return;
const selectedRows = sortedData.filter((row) => selectedIds.includes(getRowId(row))); const selectedRows = sortedData.filter((row) =>
selectedIds.includes(getRowId(row)),
);
onSelectionChange(selectedRows); onSelectionChange(selectedRows);
}, [selectedIds, sortedData, getRowId, onSelectionChange]); }, [selectedIds, sortedData, getRowId, onSelectionChange]);
function toggleSort(key: keyof T) { function toggleSort(key: keyof T) {
setSort((prev) => { setSort((prev) => {
if (prev.key === key) { if (prev.key === key) {
// gleiche Spalte -> Richtung flippen
return { return {
key, key,
direction: prev.direction === 'asc' ? 'desc' : 'asc', direction: prev.direction === 'asc' ? 'desc' : 'asc',
}; };
} }
// neue Spalte -> asc
return { key, direction: 'asc' }; return { key, direction: 'asc' };
}); });
} }
@ -151,7 +152,8 @@ export default function Table<T>(props: TableProps<T>) {
function toggleAll() { function toggleAll() {
if (!selectable) return; if (!selectable) return;
const allIds = sortedData.map((row) => getRowId(row)); const allIds = sortedData.map((row) => getRowId(row));
const allSelected = allIds.length > 0 && allIds.every((id) => selectedIds.includes(id)); const allSelected =
allIds.length > 0 && allIds.every((id) => selectedIds.includes(id));
setSelectedIds(allSelected ? [] : allIds); setSelectedIds(allSelected ? [] : allIds);
} }
@ -163,13 +165,13 @@ export default function Table<T>(props: TableProps<T>) {
); );
} }
const colSpan =
columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0);
return ( return (
<div className="relative overflow-visible rounded-lg border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/40"> <div className="relative overflow-visible rounded-lg border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/40">
{/* Wichtig: auf kleinen Screens overflow-x-visible, erst ab lg overflow-x-auto */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table <table className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm dark:divide-white/10">
className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm dark:divide-white/10"
>
<thead className="bg-gray-50 dark:bg-gray-800/60"> <thead className="bg-gray-50 dark:bg-gray-800/60">
<tr> <tr>
{selectable && ( {selectable && (
@ -239,7 +241,9 @@ export default function Table<T>(props: TableProps<T>) {
aria-hidden="true" aria-hidden="true"
className={classNames( className={classNames(
'size-4', 'size-4',
isSorted && sort.direction === 'desc' && 'rotate-180', isSorted &&
sort.direction === 'desc' &&
'rotate-180',
)} )}
/> />
</span> </span>
@ -261,84 +265,100 @@ export default function Table<T>(props: TableProps<T>) {
)} )}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-900/40"> <tbody className="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-900/40">
{sortedData.map((row) => { {isLoading ? (
const id = getRowId(row); // 🔹 Loading-State: Spinner-Zeile statt Daten
const isSelected = selectedIds.includes(id);
return (
<tr
key={id}
className={classNames(
isSelected && 'bg-gray-50 dark:bg-gray-800/60',
)}
>
{selectable && (
<td className="px-4">
<div className="group grid size-4 grid-cols-1">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(id)}
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500"
/>
<svg
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white"
viewBox="0 0 14 14"
fill="none"
>
<path
className="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</td>
)}
{columns.map((col) => (
<td
key={String(col.key)}
className={classNames(
'px-2 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
col.cellClassName,
col.canHide && 'hidden lg:table-cell',
)}
>
{col.render
? col.render(row)
: String((row as any)[col.key] ?? '—')}
</td>
))}
{renderActions && (
<td className="px-2 py-3 text-sm whitespace-nowrap text-right">
{renderActions(row)}
</td>
)}
</tr>
);
})}
{sortedData.length === 0 && (
<tr> <tr>
<td <td
colSpan={columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0)} colSpan={colSpan}
className="px-2 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
<div className="flex items-center justify-center gap-3">
<LoadingSpinner />
<span>Wird geladen </span>
</div>
</td>
</tr>
) : sortedData.length === 0 ? (
// 🔹 Empty-State
<tr>
<td
colSpan={colSpan}
className="px-2 py-6 text-center text-sm text-gray-500 dark:text-gray-400" className="px-2 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
> >
Keine Einträge vorhanden. Keine Einträge vorhanden.
</td> </td>
</tr> </tr>
) : (
// 🔹 Normale Zeilen
sortedData.map((row) => {
const id = getRowId(row);
const isSelected = selectedIds.includes(id);
return (
<tr
key={id}
className={classNames(
isSelected && 'bg-gray-50 dark:bg-gray-800/60',
)}
>
{selectable && (
<td className="px-4">
<div className="group grid size-4 grid-cols-1">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(id)}
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500"
/>
<svg
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white"
viewBox="0 0 14 14"
fill="none"
>
<path
className="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</td>
)}
{columns.map((col) => (
<td
key={String(col.key)}
className={classNames(
'px-2 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
col.cellClassName,
col.canHide && 'hidden lg:table-cell',
)}
>
{col.render
? col.render(row)
: String((row as any)[col.key] ?? '—')}
</td>
))}
{renderActions && (
<td className="px-2 py-3 text-sm whitespace-nowrap text-right">
{renderActions(row)}
</td>
)}
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@ -1,3 +1,4 @@
// components/ui/Tabs.tsx
'use client'; 'use client';
import { ChevronDownIcon } from '@heroicons/react/16/solid'; import { ChevronDownIcon } from '@heroicons/react/16/solid';
@ -5,11 +6,21 @@ import clsx from 'clsx';
import * as React from 'react'; import * as React from 'react';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
export type TabsVariant =
| 'underline' // Standard: Unterstrich, wie bisher (mit optionalem Count/Badge)
| 'underlineFull' // Full-width Tabs mit Unterstrich
| 'bar' // "Bar with underline" Variante
| 'pills' // Pills (neutral)
| 'pillsGray' // Pills auf grauem Hintergrund
| 'pillsBrand'; // Pills mit Brand-Farbe
export type TabItem = { export type TabItem = {
id: string; id: string;
label: string; label: string;
/** optional: Anzahl (z.B. Personen in der Gruppe) */ /** optional: Anzahl / Badge, z.B. "52" */
count?: number; count?: number | string;
/** optional: Icon (z.B. <UsersIcon className="size-5" />) */
icon?: React.ReactNode;
}; };
type TabsProps = { type TabsProps = {
@ -18,6 +29,7 @@ type TabsProps = {
onChange: (id: string) => void; onChange: (id: string) => void;
className?: string; className?: string;
ariaLabel?: string; ariaLabel?: string;
variant?: TabsVariant;
}; };
export default function Tabs({ export default function Tabs({
@ -26,15 +38,247 @@ export default function Tabs({
onChange, onChange,
className, className,
ariaLabel = 'Ansicht auswählen', ariaLabel = 'Ansicht auswählen',
variant = 'underline',
}: TabsProps) { }: TabsProps) {
const current = tabs.find((t) => t.id === value) ?? tabs[0]; if (!tabs || tabs.length === 0) return null;
const isValidValue = tabs.some((t) => t.id === value);
const currentId = isValidValue ? value : tabs[0].id;
const current = tabs.find((t) => t.id === currentId)!;
const renderDesktopTabs = () => {
switch (variant) {
case 'underline':
return (
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const isCurrent = tab.id === currentId;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
'border-b-2 px-1 py-3 text-sm font-medium whitespace-nowrap flex items-center gap-2',
)}
>
{tab.icon && (
<span className="mr-1 -ml-0.5 flex items-center">
{tab.icon}
</span>
)}
<span>{tab.label}</span>
{tab.count != null && (
<Badge tone="gray" variant="flat" size="sm">
{tab.count}
</Badge>
)}
</button>
);
})}
</nav>
</div>
);
case 'underlineFull':
return (
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className="-mb-px flex">
{tabs.map((tab) => {
const isCurrent = tab.id === currentId;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
'flex-1 border-b-2 px-1 py-3 text-center text-sm font-medium flex items-center justify-center gap-2',
)}
>
{tab.icon && (
<span className="mr-1 -ml-0.5 flex items-center">
{tab.icon}
</span>
)}
<span>{tab.label}</span>
{tab.count != null && (
<Badge tone="gray" variant="flat" size="sm">
{tab.count}
</Badge>
)}
</button>
);
})}
</nav>
</div>
);
case 'bar':
return (
<nav
aria-label={ariaLabel}
className="isolate flex divide-x divide-gray-200 rounded-lg bg-white shadow-sm dark:divide-white/10 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10"
>
{tabs.map((tab, tabIdx) => {
const isCurrent = tab.id === currentId;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'text-gray-900 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
tabIdx === 0 ? 'rounded-l-lg' : '',
tabIdx === tabs.length - 1 ? 'rounded-r-lg' : '',
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5 flex items-center justify-center gap-2',
)}
>
{tab.icon && (
<span className="mr-1 -ml-0.5 flex items-center">
{tab.icon}
</span>
)}
<span>{tab.label}</span>
{tab.count != null && (
<Badge tone="gray" variant="flat" size="sm">
{tab.count}
</Badge>
)}
<span
aria-hidden="true"
className={clsx(
isCurrent ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
'absolute inset-x-0 bottom-0 h-0.5',
)}
/>
</button>
);
})}
</nav>
);
case 'pills':
return (
<nav aria-label={ariaLabel} className="flex space-x-4">
{tabs.map((tab) => {
const isCurrent = tab.id === currentId;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
'rounded-md px-3 py-2 text-sm font-medium flex items-center gap-2',
)}
>
{tab.icon && (
<span className="mr-1 -ml-0.5 flex items-center">
{tab.icon}
</span>
)}
<span>{tab.label}</span>
{tab.count != null && (
<Badge tone="gray" variant="flat" size="sm">
{tab.count}
</Badge>
)}
</button>
);
})}
</nav>
);
case 'pillsGray':
return (
<nav aria-label={ariaLabel} className="flex space-x-4">
{tabs.map((tab) => {
const isCurrent = tab.id === currentId;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white',
'rounded-md px-3 py-2 text-sm font-medium flex items-center gap-2',
)}
>
{tab.icon && (
<span className="mr-1 -ml-0.5 flex items-center">
{tab.icon}
</span>
)}
<span>{tab.label}</span>
{tab.count != null && (
<Badge tone="gray" variant="flat" size="sm">
{tab.count}
</Badge>
)}
</button>
);
})}
</nav>
);
case 'pillsBrand':
return (
<nav aria-label={ariaLabel} className="flex space-x-4">
{tabs.map((tab) => {
const isCurrent = tab.id === currentId;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
'rounded-md px-3 py-2 text-sm font-medium flex items-center gap-2',
)}
>
{tab.icon && (
<span className="mr-1 -ml-0.5 flex items-center">
{tab.icon}
</span>
)}
<span>{tab.label}</span>
{tab.count != null && (
<Badge tone="gray" variant="flat" size="sm">
{tab.count}
</Badge>
)}
</button>
);
})}
</nav>
);
default:
return null;
}
};
return ( return (
<div className={className}> <div className={className}>
{/* Mobile: Select + Chevron */} {/* Mobile: Select + Chevron (für alle Varianten gleich) */}
<div className="grid grid-cols-1 sm:hidden"> <div className="grid grid-cols-1 sm:hidden">
<select <select
value={current?.id} value={currentId}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel} aria-label={ariaLabel}
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500" className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
@ -53,34 +297,8 @@ export default function Tabs({
/> />
</div> </div>
{/* Desktop: Underline-Tabs */} {/* Desktop: abhängig von variant */}
<div className="hidden sm:block"> <div className="hidden sm:block">{renderDesktopTabs()}</div>
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const isCurrent = tab.id === current?.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
'border-b-2 px-1 py-3 text-sm font-medium whitespace-nowrap flex items-center gap-2',
)}
>
<span>{tab.label}</span>
{typeof tab.count === 'number' && (
<Badge tone="gray" variant="flat" size="sm">{tab.count}</Badge>
)}
</button>
);
})}
</nav>
</div>
</div>
</div> </div>
); );
} }

View File

@ -23,12 +23,15 @@ function getAvatarColor(seed: string) {
return AVATAR_COLORS[index]; return AVATAR_COLORS[index];
} }
type Size = 'sm' | 'md' | 'lg'; type Size = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
const sizeClasses: Record<Size, string> = { const sizeClasses: Record<Size, string> = {
sm: 'h-6 w-6 text-[10px]', sm: 'h-6 w-6 text-[10px]',
md: 'h-8 w-8 text-xs', md: 'h-8 w-8 text-xs',
lg: 'h-10 w-10 text-sm', lg: 'h-10 w-10 text-sm',
xl: 'h-12 w-12 text-md',
'2xl': 'h-14 w-14 text-lg',
'3xl': 'h-16 w-16 text-xl',
}; };
export type UserAvatarProps = { export type UserAvatarProps = {
@ -49,16 +52,28 @@ export default function UserAvatar({
const initial = displayName.charAt(0)?.toUpperCase() || '?'; const initial = displayName.charAt(0)?.toUpperCase() || '?';
const colorClass = getAvatarColor(displayName || initial || 'x'); const colorClass = getAvatarColor(displayName || initial || 'x');
if (avatarUrl) { // Wenn Bild-URL gesetzt, aber das Laden fehlschlägt → Fallback auf Initialen
const [hasImageError, setHasImageError] = React.useState(false);
React.useEffect(() => {
// Wenn sich avatarUrl ändert, Fehlerzustand zurücksetzen
setHasImageError(false);
}, [avatarUrl]);
const showImage = !!avatarUrl && !hasImageError;
if (showImage) {
return ( return (
<img <img
src={avatarUrl} src={avatarUrl!}
alt={displayName || 'Avatar'} alt={displayName || 'Avatar'}
onError={() => setHasImageError(true)}
className={clsx('rounded-full object-cover', sizeClasses[size])} className={clsx('rounded-full object-cover', sizeClasses[size])}
/> />
); );
} }
// Fallback: Initialen mit pseudo-zufälliger Hintergrundfarbe
return ( return (
<div <div
className={clsx( className={clsx(

View File

@ -33,7 +33,7 @@ export * from "./enums.ts"
* const users = await prisma.user.findMany() * const users = await prisma.user.findMany()
* ``` * ```
* *
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client). * Read more in our [docs](https://pris.ly/d/client).
*/ */
export const PrismaClient = $Class.getPrismaClientClass() export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs> export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>

File diff suppressed because one or more lines are too long

View File

@ -80,12 +80,12 @@ export type PrismaVersion = {
} }
/** /**
* Prisma Client JS version: 7.0.0 * Prisma Client JS version: 7.1.0
* Query Engine version: 0c19ccc313cf9911a90d99d2ac2eb0280c76c513 * Query Engine version: ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
*/ */
export const prismaVersion: PrismaVersion = { export const prismaVersion: PrismaVersion = {
client: "7.0.0", client: "7.1.0",
engine: "0c19ccc313cf9911a90d99d2ac2eb0280c76c513" engine: "ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba"
} }
/** /**
@ -1126,7 +1126,8 @@ export const UserScalarFieldEnum = {
passwordHash: 'passwordHash', passwordHash: 'passwordHash',
groupId: 'groupId', groupId: 'groupId',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt',
avatarUrl: 'avatarUrl'
} as const } as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum] export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
@ -1394,7 +1395,7 @@ export type PrismaClientOptions = ({
* { emit: 'stdout', level: 'error' } * { emit: 'stdout', level: 'error' }
* *
* ``` * ```
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/logging#the-log-option). * Read more in our [docs](https://pris.ly/d/logging).
*/ */
log?: (LogLevel | LogDefinition)[] log?: (LogLevel | LogDefinition)[]
/** /**
@ -1422,6 +1423,22 @@ export type PrismaClientOptions = ({
* ``` * ```
*/ */
omit?: GlobalOmitConfig omit?: GlobalOmitConfig
/**
* SQL commenter plugins that add metadata to SQL queries as comments.
* Comments follow the sqlcommenter format: https://google.github.io/sqlcommenter/
*
* @example
* ```
* const prisma = new PrismaClient({
* adapter,
* comments: [
* traceContext(),
* queryInsights(),
* ],
* })
* ```
*/
comments?: runtime.SqlCommenterPlugin[]
} }
export type GlobalOmitConfig = { export type GlobalOmitConfig = {
user?: Prisma.UserOmit user?: Prisma.UserOmit

View File

@ -87,7 +87,8 @@ export const UserScalarFieldEnum = {
passwordHash: 'passwordHash', passwordHash: 'passwordHash',
groupId: 'groupId', groupId: 'groupId',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt',
avatarUrl: 'avatarUrl'
} as const } as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum] export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]

View File

@ -34,6 +34,7 @@ export type UserMinAggregateOutputType = {
groupId: string | null groupId: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
avatarUrl: string | null
} }
export type UserMaxAggregateOutputType = { export type UserMaxAggregateOutputType = {
@ -46,6 +47,7 @@ export type UserMaxAggregateOutputType = {
groupId: string | null groupId: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
avatarUrl: string | null
} }
export type UserCountAggregateOutputType = { export type UserCountAggregateOutputType = {
@ -58,6 +60,7 @@ export type UserCountAggregateOutputType = {
groupId: number groupId: number
createdAt: number createdAt: number
updatedAt: number updatedAt: number
avatarUrl: number
_all: number _all: number
} }
@ -72,6 +75,7 @@ export type UserMinAggregateInputType = {
groupId?: true groupId?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
avatarUrl?: true
} }
export type UserMaxAggregateInputType = { export type UserMaxAggregateInputType = {
@ -84,6 +88,7 @@ export type UserMaxAggregateInputType = {
groupId?: true groupId?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
avatarUrl?: true
} }
export type UserCountAggregateInputType = { export type UserCountAggregateInputType = {
@ -96,6 +101,7 @@ export type UserCountAggregateInputType = {
groupId?: true groupId?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
avatarUrl?: true
_all?: true _all?: true
} }
@ -181,6 +187,7 @@ export type UserGroupByOutputType = {
groupId: string | null groupId: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
avatarUrl: string | null
_count: UserCountAggregateOutputType | null _count: UserCountAggregateOutputType | null
_min: UserMinAggregateOutputType | null _min: UserMinAggregateOutputType | null
_max: UserMaxAggregateOutputType | null _max: UserMaxAggregateOutputType | null
@ -214,6 +221,7 @@ export type UserWhereInput = {
groupId?: Prisma.StringNullableFilter<"User"> | string | null groupId?: Prisma.StringNullableFilter<"User"> | string | null
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
avatarUrl?: Prisma.StringNullableFilter<"User"> | string | null
devicesCreated?: Prisma.DeviceListRelationFilter devicesCreated?: Prisma.DeviceListRelationFilter
devicesUpdated?: Prisma.DeviceListRelationFilter devicesUpdated?: Prisma.DeviceListRelationFilter
historyEntries?: Prisma.DeviceHistoryListRelationFilter historyEntries?: Prisma.DeviceHistoryListRelationFilter
@ -231,6 +239,7 @@ export type UserOrderByWithRelationInput = {
groupId?: Prisma.SortOrderInput | Prisma.SortOrder groupId?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
avatarUrl?: Prisma.SortOrderInput | Prisma.SortOrder
devicesCreated?: Prisma.DeviceOrderByRelationAggregateInput devicesCreated?: Prisma.DeviceOrderByRelationAggregateInput
devicesUpdated?: Prisma.DeviceOrderByRelationAggregateInput devicesUpdated?: Prisma.DeviceOrderByRelationAggregateInput
historyEntries?: Prisma.DeviceHistoryOrderByRelationAggregateInput historyEntries?: Prisma.DeviceHistoryOrderByRelationAggregateInput
@ -251,6 +260,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
groupId?: Prisma.StringNullableFilter<"User"> | string | null groupId?: Prisma.StringNullableFilter<"User"> | string | null
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
avatarUrl?: Prisma.StringNullableFilter<"User"> | string | null
devicesCreated?: Prisma.DeviceListRelationFilter devicesCreated?: Prisma.DeviceListRelationFilter
devicesUpdated?: Prisma.DeviceListRelationFilter devicesUpdated?: Prisma.DeviceListRelationFilter
historyEntries?: Prisma.DeviceHistoryListRelationFilter historyEntries?: Prisma.DeviceHistoryListRelationFilter
@ -268,6 +278,7 @@ export type UserOrderByWithAggregationInput = {
groupId?: Prisma.SortOrderInput | Prisma.SortOrder groupId?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
avatarUrl?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.UserCountOrderByAggregateInput _count?: Prisma.UserCountOrderByAggregateInput
_max?: Prisma.UserMaxOrderByAggregateInput _max?: Prisma.UserMaxOrderByAggregateInput
_min?: Prisma.UserMinOrderByAggregateInput _min?: Prisma.UserMinOrderByAggregateInput
@ -286,6 +297,7 @@ export type UserScalarWhereWithAggregatesInput = {
groupId?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null groupId?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
avatarUrl?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
} }
export type UserCreateInput = { export type UserCreateInput = {
@ -297,6 +309,7 @@ export type UserCreateInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
@ -314,6 +327,7 @@ export type UserUncheckedCreateInput = {
groupId?: string | null groupId?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
@ -329,6 +343,7 @@ export type UserUpdateInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
@ -346,6 +361,7 @@ export type UserUncheckedUpdateInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
@ -362,6 +378,7 @@ export type UserCreateManyInput = {
groupId?: string | null groupId?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
} }
export type UserUpdateManyMutationInput = { export type UserUpdateManyMutationInput = {
@ -373,6 +390,7 @@ export type UserUpdateManyMutationInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
} }
export type UserUncheckedUpdateManyInput = { export type UserUncheckedUpdateManyInput = {
@ -385,6 +403,7 @@ export type UserUncheckedUpdateManyInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
} }
export type UserCountOrderByAggregateInput = { export type UserCountOrderByAggregateInput = {
@ -397,6 +416,7 @@ export type UserCountOrderByAggregateInput = {
groupId?: Prisma.SortOrder groupId?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
avatarUrl?: Prisma.SortOrder
} }
export type UserMaxOrderByAggregateInput = { export type UserMaxOrderByAggregateInput = {
@ -409,6 +429,7 @@ export type UserMaxOrderByAggregateInput = {
groupId?: Prisma.SortOrder groupId?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
avatarUrl?: Prisma.SortOrder
} }
export type UserMinOrderByAggregateInput = { export type UserMinOrderByAggregateInput = {
@ -421,6 +442,7 @@ export type UserMinOrderByAggregateInput = {
groupId?: Prisma.SortOrder groupId?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
avatarUrl?: Prisma.SortOrder
} }
export type UserScalarRelationFilter = { export type UserScalarRelationFilter = {
@ -568,6 +590,7 @@ export type UserCreateWithoutRolesInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
@ -584,6 +607,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
groupId?: string | null groupId?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
@ -614,6 +638,7 @@ export type UserUpdateWithoutRolesInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
@ -630,6 +655,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
@ -644,6 +670,7 @@ export type UserCreateWithoutGroupInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
@ -659,6 +686,7 @@ export type UserUncheckedCreateWithoutGroupInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
@ -704,6 +732,7 @@ export type UserScalarWhereInput = {
groupId?: Prisma.StringNullableFilter<"User"> | string | null groupId?: Prisma.StringNullableFilter<"User"> | string | null
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
avatarUrl?: Prisma.StringNullableFilter<"User"> | string | null
} }
export type UserCreateWithoutDevicesCreatedInput = { export type UserCreateWithoutDevicesCreatedInput = {
@ -715,6 +744,7 @@ export type UserCreateWithoutDevicesCreatedInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput
@ -731,6 +761,7 @@ export type UserUncheckedCreateWithoutDevicesCreatedInput = {
groupId?: string | null groupId?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput
@ -750,6 +781,7 @@ export type UserCreateWithoutDevicesUpdatedInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput
@ -766,6 +798,7 @@ export type UserUncheckedCreateWithoutDevicesUpdatedInput = {
groupId?: string | null groupId?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput
@ -796,6 +829,7 @@ export type UserUpdateWithoutDevicesCreatedInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput
@ -812,6 +846,7 @@ export type UserUncheckedUpdateWithoutDevicesCreatedInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput
@ -837,6 +872,7 @@ export type UserUpdateWithoutDevicesUpdatedInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput
@ -853,6 +889,7 @@ export type UserUncheckedUpdateWithoutDevicesUpdatedInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput
@ -867,6 +904,7 @@ export type UserCreateWithoutHistoryEntriesInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput
@ -883,6 +921,7 @@ export type UserUncheckedCreateWithoutHistoryEntriesInput = {
groupId?: string | null groupId?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput
@ -913,6 +952,7 @@ export type UserUpdateWithoutHistoryEntriesInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput
@ -929,6 +969,7 @@ export type UserUncheckedUpdateWithoutHistoryEntriesInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput
@ -943,6 +984,7 @@ export type UserCreateManyGroupInput = {
passwordHash?: string | null passwordHash?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
avatarUrl?: string | null
} }
export type UserUpdateWithoutGroupInput = { export type UserUpdateWithoutGroupInput = {
@ -954,6 +996,7 @@ export type UserUpdateWithoutGroupInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
@ -969,6 +1012,7 @@ export type UserUncheckedUpdateWithoutGroupInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
@ -984,6 +1028,7 @@ export type UserUncheckedUpdateManyWithoutGroupInput = {
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
} }
@ -1054,6 +1099,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
groupId?: boolean groupId?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
avatarUrl?: boolean
devicesCreated?: boolean | Prisma.User$devicesCreatedArgs<ExtArgs> devicesCreated?: boolean | Prisma.User$devicesCreatedArgs<ExtArgs>
devicesUpdated?: boolean | Prisma.User$devicesUpdatedArgs<ExtArgs> devicesUpdated?: boolean | Prisma.User$devicesUpdatedArgs<ExtArgs>
historyEntries?: boolean | Prisma.User$historyEntriesArgs<ExtArgs> historyEntries?: boolean | Prisma.User$historyEntriesArgs<ExtArgs>
@ -1072,6 +1118,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
groupId?: boolean groupId?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
avatarUrl?: boolean
group?: boolean | Prisma.User$groupArgs<ExtArgs> group?: boolean | Prisma.User$groupArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, ExtArgs["result"]["user"]>
@ -1085,6 +1132,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
groupId?: boolean groupId?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
avatarUrl?: boolean
group?: boolean | Prisma.User$groupArgs<ExtArgs> group?: boolean | Prisma.User$groupArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, ExtArgs["result"]["user"]>
@ -1098,9 +1146,10 @@ export type UserSelectScalar = {
groupId?: boolean groupId?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
avatarUrl?: boolean
} }
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"nwkennung" | "email" | "arbeitsname" | "firstName" | "lastName" | "passwordHash" | "groupId" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]> export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"nwkennung" | "email" | "arbeitsname" | "firstName" | "lastName" | "passwordHash" | "groupId" | "createdAt" | "updatedAt" | "avatarUrl", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
devicesCreated?: boolean | Prisma.User$devicesCreatedArgs<ExtArgs> devicesCreated?: boolean | Prisma.User$devicesCreatedArgs<ExtArgs>
devicesUpdated?: boolean | Prisma.User$devicesUpdatedArgs<ExtArgs> devicesUpdated?: boolean | Prisma.User$devicesUpdatedArgs<ExtArgs>
@ -1135,6 +1184,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
groupId: string | null groupId: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
avatarUrl: string | null
}, ExtArgs["result"]["user"]> }, ExtArgs["result"]["user"]>
composites: {} composites: {}
} }
@ -1572,6 +1622,7 @@ export interface UserFieldRefs {
readonly groupId: Prisma.FieldRef<"User", 'String'> readonly groupId: Prisma.FieldRef<"User", 'String'>
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'> readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
readonly avatarUrl: Prisma.FieldRef<"User", 'String'>
} }

View File

@ -42,6 +42,10 @@ export const authOptions: NextAuthOptions = {
}, },
], ],
}, },
include: {
group: true, // <-- wichtig, damit user.group da ist
// roles: { include: { role: true } }, // falls du das auch brauchst
},
}); });
if (!user || !user.passwordHash) return null; if (!user || !user.passwordHash) return null;
@ -55,11 +59,15 @@ export const authOptions: NextAuthOptions = {
? `${user.firstName} ${user.lastName}` ? `${user.firstName} ${user.lastName}`
: user.email ?? user.nwkennung ?? 'Unbekannt'); : user.email ?? user.nwkennung ?? 'Unbekannt');
const canEditDevices = !!user.group?.canEditDevices;
return { return {
id: user.nwkennung, id: user.nwkennung,
name: displayName, name: displayName,
email: user.email, email: user.email,
nwkennung: user.nwkennung, nwkennung: user.nwkennung,
avatarUrl: user.avatarUrl ?? null,
groupCanEditDevices: canEditDevices, // <-- hier mitgeben
} as any; } as any;
}, },
}), }),
@ -69,23 +77,39 @@ export const authOptions: NextAuthOptions = {
}, },
session: { session: {
strategy: 'jwt', strategy: 'jwt',
// Login wird standardmäßig gemerkt (hier explizit: 30 Tage)
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30,
}, },
callbacks: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user, trigger, session }) {
// Wenn wir clientseitig `update({ avatarUrl })` aufrufen,
// kommt das hier als `trigger === 'update'` an.
if (trigger === 'update' && session) {
if ('avatarUrl' in session) {
(token as any).avatarUrl = (session as any).avatarUrl ?? null;
}
}
// Login-Fall
if (user) { if (user) {
token.id = (user as any).id; token.id = (user as any).id;
token.nwkennung = (user as any).nwkennung; token.nwkennung = (user as any).nwkennung;
(token as any).avatarUrl = (user as any).avatarUrl ?? null;
(token as any).groupCanEditDevices =
(user as any).groupCanEditDevices ?? false;
} }
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
if (session.user && token.id) { if (session.user && token.id) {
(session.user as any).id = token.id; (session.user as any).id = token.id;
(session.user as any).nwkennung = token.nwkennung; (session.user as any).nwkennung = (token as any).nwkennung;
(session.user as any).avatarUrl = (token as any).avatarUrl ?? null;
(session.user as any).groupCanEditDevices =
(token as any).groupCanEditDevices ?? false;
} }
return session; return session;
}, },
}, }
}; };

132
package-lock.json generated
View File

@ -12,7 +12,7 @@
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@prisma/adapter-better-sqlite3": "^7.0.0", "@prisma/adapter-better-sqlite3": "^7.0.0",
"@prisma/adapter-pg": "^7.0.0", "@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.1", "@prisma/client": "^7.1.0",
"@zxing/browser": "^0.1.5", "@zxing/browser": "^0.1.5",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"next": "16.0.3", "next": "16.0.3",
@ -36,7 +36,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.3", "eslint-config-next": "16.0.3",
"prisma": "^7.0.1", "prisma": "^7.1.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"
@ -1095,9 +1095,9 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.14.2", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.14.2.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz",
"integrity": "sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==", "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1923,12 +1923,12 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.1.0.tgz",
"integrity": "sha512-O74T6xcfaGAq5gXwCAvfTLvI6fmC3and2g5yLRMkNjri1K8mSpEgclDNuUWs9xj5AwNEMQ88NeD3asI+sovm1g==", "integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/client-runtime-utils": "7.0.1" "@prisma/client-runtime-utils": "7.1.0"
}, },
"engines": { "engines": {
"node": "^20.19 || ^22.12 || >=24.0" "node": "^20.19 || ^22.12 || >=24.0"
@ -1947,15 +1947,15 @@
} }
}, },
"node_modules/@prisma/client-runtime-utils": { "node_modules/@prisma/client-runtime-utils": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.1.0.tgz",
"integrity": "sha512-R26BVX9D/iw4toUmZKZf3jniM/9pMGHHdZN5LVP2L7HNiCQKNQQx/9LuMtjepbgRqSqQO3oHN0yzojHLnKTGEw==", "integrity": "sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.1.0.tgz",
"integrity": "sha512-MacIjXdo+hNKxPvtMzDXykIIc8HCRWoyjQ2nguJTFqLDzJBD5L6QRaANGTLOqbGtJ3sFvLRmfXhrFg3pWoK1BA==", "integrity": "sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -1972,28 +1972,28 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/dev": { "node_modules/@prisma/dev": {
"version": "0.13.0", "version": "0.15.0",
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.15.0.tgz",
"integrity": "sha512-QMmF6zFeUF78yv1HYbHvod83AQnl7u6NtKyDhTRZOJup3h1icWs8R7RUVxBJZvM2tBXNAMpLQYYM/8kPlOPegA==", "integrity": "sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==",
"devOptional": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@electric-sql/pglite": "0.3.2", "@electric-sql/pglite": "0.3.2",
"@electric-sql/pglite-socket": "0.0.6", "@electric-sql/pglite-socket": "0.0.6",
"@electric-sql/pglite-tools": "0.2.7", "@electric-sql/pglite-tools": "0.2.7",
"@hono/node-server": "1.14.2", "@hono/node-server": "1.19.6",
"@mrleebo/prisma-ast": "0.12.1", "@mrleebo/prisma-ast": "0.12.1",
"@prisma/get-platform": "6.8.2", "@prisma/get-platform": "6.8.2",
"@prisma/query-plan-executor": "6.18.0", "@prisma/query-plan-executor": "6.18.0",
"foreground-child": "3.3.1", "foreground-child": "3.3.1",
"get-port-please": "3.1.2", "get-port-please": "3.1.2",
"hono": "4.7.10", "hono": "4.10.6",
"http-status-codes": "2.3.0", "http-status-codes": "2.3.0",
"pathe": "2.0.3", "pathe": "2.0.3",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"remeda": "2.21.3", "remeda": "2.21.3",
"std-env": "3.9.0", "std-env": "3.9.0",
"valibot": "1.1.0", "valibot": "1.2.0",
"zeptomatch": "2.0.2" "zeptomatch": "2.0.2"
} }
}, },
@ -2007,70 +2007,70 @@
} }
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.1.0.tgz",
"integrity": "sha512-f+D/vdKeImqUHysd5Bgv8LQ1whl4sbLepHyYMQQMK61cp4WjwJVryophleLUrfEJRpBLGTBI/7fnLVENxxMFPQ==", "integrity": "sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.0.1", "@prisma/debug": "7.1.0",
"@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6", "@prisma/engines-version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
"@prisma/fetch-engine": "7.0.1", "@prisma/fetch-engine": "7.1.0",
"@prisma/get-platform": "7.0.1" "@prisma/get-platform": "7.1.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6", "version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba.tgz",
"integrity": "sha512-RA7pShKvijHib4USRB3YuLTQamHKJPkTRDc45AwxfahUQngiGVMlIj4ix4emUxkrum4o/jwn82WIwlG57EtgiQ==", "integrity": "sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines/node_modules/@prisma/debug": { "node_modules/@prisma/engines/node_modules/@prisma/debug": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz",
"integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==", "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz",
"integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==", "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.0.1" "@prisma/debug": "7.1.0"
} }
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.1.0.tgz",
"integrity": "sha512-5DnSairYIYU7dcv/9pb1KCwIRHZfhVOd34855d01lUI5QdF9rdCkMywPQbBM67YP7iCgQoEZO0/COtOMpR4i9A==", "integrity": "sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.0.1", "@prisma/debug": "7.1.0",
"@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6", "@prisma/engines-version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
"@prisma/get-platform": "7.0.1" "@prisma/get-platform": "7.1.0"
} }
}, },
"node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz",
"integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==", "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz",
"integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==", "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.0.1" "@prisma/debug": "7.1.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
@ -5539,9 +5539,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/grammex": { "node_modules/grammex": {
"version": "3.1.11", "version": "3.1.12",
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.11.tgz", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
"integrity": "sha512-HNwLkgRg9SqTAd1N3Uh/MnKwTBTzwBxTOPbXQ8pb0tpwydjk90k4zRE8JUn9fMUiRwKtXFZ1TWFmms3dZHN+Fg==", "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
@ -5664,9 +5664,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.7.10", "version": "4.10.6",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.7.10.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz",
"integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==", "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -7726,16 +7726,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "7.0.1", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.0.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.1.0.tgz",
"integrity": "sha512-zp93MdFMSU1IHPEXbUHVUuD8wauh2BUm14OVxhxGrWJQQpXpda0rW4VSST2bci4raoldX64/wQxHKkl/wqDskQ==", "integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/config": "7.0.1", "@prisma/config": "7.1.0",
"@prisma/dev": "0.13.0", "@prisma/dev": "0.15.0",
"@prisma/engines": "7.0.1", "@prisma/engines": "7.1.0",
"@prisma/studio-core": "0.8.2", "@prisma/studio-core": "0.8.2",
"mysql2": "3.15.3", "mysql2": "3.15.3",
"postgres": "3.4.7" "postgres": "3.4.7"
@ -9366,9 +9366,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/valibot": { "node_modules/valibot": {
"version": "1.1.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
"integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {

View File

@ -18,7 +18,7 @@
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@prisma/adapter-better-sqlite3": "^7.0.0", "@prisma/adapter-better-sqlite3": "^7.0.0",
"@prisma/adapter-pg": "^7.0.0", "@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.1", "@prisma/client": "^7.1.0",
"@zxing/browser": "^0.1.5", "@zxing/browser": "^0.1.5",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"next": "16.0.3", "next": "16.0.3",
@ -42,7 +42,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.3", "eslint-config-next": "16.0.3",
"prisma": "^7.0.1", "prisma": "^7.1.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT;

View File

@ -24,6 +24,7 @@ model User {
groupId String? groupId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
avatarUrl String?
devicesCreated Device[] @relation("DeviceCreatedBy") devicesCreated Device[] @relation("DeviceCreatedBy")
devicesUpdated Device[] @relation("DeviceUpdatedBy") devicesUpdated Device[] @relation("DeviceUpdatedBy")