updated
This commit is contained in:
parent
73607d2605
commit
5e6f7e872d
@ -1,14 +1,177 @@
|
||||
// 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 (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Geräte-Inventar
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Hier könntest du gleich als Nächstes eine Übersicht deiner Geräte einbauen.
|
||||
</p>
|
||||
{/* 🔴 Überfällige Geräte (rot) */}
|
||||
{hasOverdue && (
|
||||
<div className="mb-4">
|
||||
<Alerts
|
||||
tone="error"
|
||||
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"> →</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"> →</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -44,8 +44,6 @@ type DeviceDetailsGridProps = {
|
||||
function DeviceDetailsGrid({
|
||||
device,
|
||||
onStartLoan,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: DeviceDetailsGridProps) {
|
||||
|
||||
const [activeSection, setActiveSection] =
|
||||
@ -114,6 +112,7 @@ function DeviceDetailsGrid({
|
||||
{ id: 'info', label: 'Stammdaten' },
|
||||
{ id: 'zubehoer', label: 'Zubehör' },
|
||||
]}
|
||||
variant='pillsBrand'
|
||||
value={activeSection}
|
||||
onChange={(id) =>
|
||||
setActiveSection(id as 'info' | 'zubehoer')
|
||||
@ -142,9 +141,11 @@ function DeviceDetailsGrid({
|
||||
Status
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
{/* linke „Spalte“: nur inhaltsbreit */}
|
||||
<div className="flex w-auto shrink-0 flex-col gap-1">
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Zeile 1: Badge + Buttons nebeneinander */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Badge */}
|
||||
<div className="flex w-auto shrink-0">
|
||||
<span
|
||||
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
|
||||
>
|
||||
@ -153,35 +154,6 @@ function DeviceDetailsGrid({
|
||||
/>
|
||||
<span>{statusLabel}</span>
|
||||
</span>
|
||||
|
||||
{device.loanedTo && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* rechte Seite: Buttons */}
|
||||
@ -193,19 +165,36 @@ function DeviceDetailsGrid({
|
||||
>
|
||||
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
|
||||
</Button>
|
||||
|
||||
{canEdit && onEdit && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="soft"
|
||||
tone="indigo"
|
||||
onClick={onEdit}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Trenner nach Verleihstatus */}
|
||||
@ -531,17 +520,35 @@ export default function DeviceDetailModal({
|
||||
}}
|
||||
headerExtras={
|
||||
device && (
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-end">
|
||||
{/* Mobile: Tabs im Header */}
|
||||
<div className="sm:hidden">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'history', label: 'Änderungsverlauf' },
|
||||
]}
|
||||
variant='pillsBrand'
|
||||
value={activeTab}
|
||||
onChange={(id) => setActiveTab(id as 'details' | 'history')}
|
||||
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>
|
||||
)
|
||||
}
|
||||
sidebar={
|
||||
@ -550,14 +557,14 @@ export default function DeviceDetailModal({
|
||||
{/* 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="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} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[14px] text-gray-500">
|
||||
<p className="text-[13px] font-mono tracking-wide text-gray-100">
|
||||
{device.inventoryNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 dark:border-white/10 mx-1" />
|
||||
|
||||
@ -606,7 +613,7 @@ export default function DeviceDetailModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */}
|
||||
{/* Desktop */}
|
||||
<div className="hidden sm:block pr-2">
|
||||
<DeviceDetailsGrid
|
||||
device={device}
|
||||
|
||||
@ -399,6 +399,7 @@ export default function DeviceEditModal({
|
||||
{ id: 'fields', label: 'Stammdaten' },
|
||||
{ id: 'relations', label: 'Zubehör' },
|
||||
]}
|
||||
variant='pillsBrand'
|
||||
value={activeTab}
|
||||
onChange={(id) => setActiveTab(id as 'fields' | 'relations')}
|
||||
ariaLabel="Bearbeitungsansicht wählen"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// app/(app)/devices/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Table, { TableColumn } from '@/components/ui/Table';
|
||||
import { Dropdown } from '@/components/ui/Dropdown';
|
||||
@ -18,6 +18,7 @@ import type { TagOption } from '@/components/ui/TagMultiCombobox';
|
||||
import DeviceEditModal from './DeviceEditModal';
|
||||
import DeviceDetailModal from './DeviceDetailModal';
|
||||
import DeviceCreateModal from './DeviceCreateModal';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
|
||||
export type AccessorySummary = {
|
||||
inventoryNumber: string;
|
||||
@ -54,8 +55,11 @@ export type DeviceDetail = {
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
type PrimaryTab = 'main' | 'accessories' | 'all';
|
||||
type StatusTab = 'all' | 'loaned' | 'dueToday' | 'overdue';
|
||||
|
||||
function formatDate(iso: string | null | undefined) {
|
||||
if (!iso) return '–'; // oder '' wenn du es leer willst
|
||||
if (!iso) return '–';
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
@ -125,8 +129,28 @@ const columns: TableColumn<DeviceDetail>[] = [
|
||||
header: 'Tags',
|
||||
sortable: false,
|
||||
canHide: true,
|
||||
render: (row) =>
|
||||
row.tags && row.tags.length > 0 ? row.tags.join(', ') : '',
|
||||
cellClassName: 'whitespace-normal max-w-xs',
|
||||
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',
|
||||
@ -138,6 +162,8 @@ const columns: TableColumn<DeviceDetail>[] = [
|
||||
];
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [devices, setDevices] = useState<DeviceDetail[]>([]);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
@ -151,21 +177,15 @@ export default function DevicesPage() {
|
||||
const searchParams = useSearchParams();
|
||||
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
|
||||
const canEditDevices = currentUserGroups.includes('INVENTAR_ADMIN');
|
||||
const canEditDevices = Boolean(
|
||||
(session?.user as any)?.groupCanEditDevices,
|
||||
);
|
||||
|
||||
// 🔹 Tab-Filter: Hauptgeräte / Zubehör / Alle
|
||||
const [activeTab, setActiveTab] =
|
||||
useState<'main' | 'accessories' | 'all'>('main');
|
||||
|
||||
// 🔹 Counters für Badges
|
||||
const mainCount = devices.filter((d) => !d.parentInventoryNumber).length;
|
||||
const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length;
|
||||
const allCount = devices.length;
|
||||
// 🔹 Oberste Tabs: Hauptgeräte / Zubehör / Alle Geräte
|
||||
const [primaryTab, setPrimaryTab] = useState<PrimaryTab>('all');
|
||||
// 🔹 Untere Tabs: Leihstatus
|
||||
const [statusTab, setStatusTab] = useState<StatusTab>('all');
|
||||
|
||||
/* ───────── Geräte-Liste laden ───────── */
|
||||
|
||||
@ -210,7 +230,7 @@ export default function DevicesPage() {
|
||||
}, [loadDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams) return; // TS happy
|
||||
if (!searchParams) return;
|
||||
|
||||
const fromDevice = searchParams.get('device');
|
||||
const fromInventory =
|
||||
@ -317,8 +337,6 @@ export default function DevicesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistisch aus lokaler Liste entfernen
|
||||
// (zusätzlich kommt noch der Socket-Event device:deleted)
|
||||
setDevices((prev) =>
|
||||
prev.filter((d) => d.inventoryNumber !== inventoryNumber),
|
||||
);
|
||||
@ -346,15 +364,12 @@ export default function DevicesPage() {
|
||||
setDetailInventoryNumber(null);
|
||||
|
||||
if (!searchParams) {
|
||||
// Fallback: einfach auf /devices ohne Query
|
||||
router.replace('/devices', { scroll: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// ReadonlyURLSearchParams → string → URLSearchParams kopieren
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// alle möglichen Detail-Parameter entfernen
|
||||
params.delete('device');
|
||||
params.delete('inventoryNumber');
|
||||
params.delete('inv');
|
||||
@ -367,28 +382,78 @@ export default function DevicesPage() {
|
||||
|
||||
const handleEditFromDetail = useCallback(
|
||||
(inventoryNumber: string) => {
|
||||
// Detail-Modal schließen + URL /device-Query aufräumen
|
||||
closeDetailModal();
|
||||
// danach Edit-Modal öffnen
|
||||
setEditInventoryNumber(inventoryNumber);
|
||||
},
|
||||
[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) => {
|
||||
if (activeTab === 'main') {
|
||||
// Hauptgeräte: kein parent → eigenständig
|
||||
return !d.parentInventoryNumber;
|
||||
}
|
||||
if (activeTab === 'accessories') {
|
||||
// Zubehör: hat ein Hauptgerät
|
||||
return !!d.parentInventoryNumber;
|
||||
}
|
||||
// "all"
|
||||
// Counts für oberste Tabs (immer über alle Geräte)
|
||||
const mainCount = devices.filter((d) => !d.parentInventoryNumber).length;
|
||||
const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
/* ───────── Render ───────── */
|
||||
@ -421,10 +486,17 @@ export default function DevicesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */}
|
||||
<div className="mt-6">
|
||||
{/* 🔹 Tabs: oben Gerätetyp, darunter Leihstatus */}
|
||||
<div className="mt-6 space-y-3">
|
||||
{/* Oberste Ebene */}
|
||||
<Tabs
|
||||
variant='pillsBrand'
|
||||
tabs={[
|
||||
{
|
||||
id: 'all',
|
||||
label: 'Alle Geräte',
|
||||
count: allCount,
|
||||
},
|
||||
{
|
||||
id: 'main',
|
||||
label: 'Hauptgeräte',
|
||||
@ -435,26 +507,43 @@ export default function DevicesPage() {
|
||||
label: 'Zubehör',
|
||||
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',
|
||||
label: 'Alle Geräte',
|
||||
count: allCount,
|
||||
label: 'Alle',
|
||||
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}
|
||||
onChange={(id) =>
|
||||
setActiveTab(id as 'main' | 'accessories' | 'all')
|
||||
}
|
||||
ariaLabel="Geräteliste filtern"
|
||||
value={statusTab}
|
||||
onChange={(id) => setStatusTab(id as StatusTab)}
|
||||
ariaLabel="Leihstatus filtern"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{listLoading && (
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Geräte werden geladen …
|
||||
</p>
|
||||
)}
|
||||
|
||||
{listError && (
|
||||
<p className="mt-4 text-sm text-red-600 dark:text-red-400">
|
||||
{listError}
|
||||
@ -469,6 +558,7 @@ export default function DevicesPage() {
|
||||
getRowId={(row) => row.inventoryNumber}
|
||||
selectable
|
||||
actionsHeader=""
|
||||
isLoading={listLoading}
|
||||
renderActions={(row) => (
|
||||
<div className="flex justify-end">
|
||||
{/* Desktop: drei Icon-Buttons nebeneinander */}
|
||||
@ -482,6 +572,7 @@ export default function DevicesPage() {
|
||||
onClick={() => handleDetails(row.inventoryNumber)}
|
||||
/>
|
||||
|
||||
{canEditDevices && (
|
||||
<Button
|
||||
variant="soft"
|
||||
tone="gray"
|
||||
@ -490,6 +581,7 @@ export default function DevicesPage() {
|
||||
aria-label={`Gerät ${row.inventoryNumber} bearbeiten`}
|
||||
onClick={() => handleEdit(row.inventoryNumber)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="soft"
|
||||
|
||||
@ -45,11 +45,6 @@ const navigation = [
|
||||
{ name: 'Personen', href: '/users', icon: UserIcon },
|
||||
];
|
||||
|
||||
const userNavigation = [
|
||||
{ name: 'Your profile', href: '#' },
|
||||
{ name: 'Abmelden', href: '#' },
|
||||
];
|
||||
|
||||
function classNames(...classes: Array<string | boolean | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@ -72,7 +67,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
const displayName = 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 trimmed = code.trim();
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
// app/(app)/users/EditUserModal.tsx
|
||||
'use client';
|
||||
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Switch from '@/components/ui/Switch'; // 👈 Neu
|
||||
import type { UserWithAvatar } from './types';
|
||||
|
||||
type EditUserModalProps = {
|
||||
@ -15,6 +17,8 @@ type EditUserModalProps = {
|
||||
onLastNameChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
/** Abgeleitet aus der Gruppe: darf dieser Benutzer Geräte bearbeiten? */
|
||||
canEditDevices: boolean; // 👈 Neu
|
||||
};
|
||||
|
||||
export default function EditUserModal({
|
||||
@ -29,6 +33,7 @@ export default function EditUserModal({
|
||||
onLastNameChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
canEditDevices, // 👈 Neu
|
||||
}: EditUserModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
@ -56,7 +61,7 @@ export default function EditUserModal({
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
className="space-y-3 text-sm"
|
||||
className="space-y-4 text-sm"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
@ -109,6 +114,27 @@ export default function EditUserModal({
|
||||
/>
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@ -684,6 +684,7 @@ export default function UsersTablesClient({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Tabs
|
||||
tabs={mainTabs}
|
||||
variant='pillsBrand'
|
||||
value={safeActiveMainTab}
|
||||
onChange={setActiveMainTab}
|
||||
ariaLabel="Usergruppen (Cluster) auswählen"
|
||||
@ -710,6 +711,7 @@ export default function UsersTablesClient({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Tabs
|
||||
tabs={subTabs}
|
||||
variant='pillsBrand'
|
||||
value={safeActiveSubTab}
|
||||
onChange={setActiveSubTab}
|
||||
ariaLabel="Untergruppen auswählen"
|
||||
@ -803,6 +805,10 @@ export default function UsersTablesClient({
|
||||
onLastNameChange={setEditLastName}
|
||||
onClose={() => setEditUser(null)}
|
||||
onSubmit={handleSaveEdit}
|
||||
canEditDevices={(() => {
|
||||
const group = allGroups.find((g) => g.id === editUser.groupId);
|
||||
return !!group?.canEditDevices;
|
||||
})()}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
120
app/api/profile/avatar/route.ts
Normal file
120
app/api/profile/avatar/route.ts
Normal 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 });
|
||||
}
|
||||
@ -1,23 +1,30 @@
|
||||
// /app/layout.tsx
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Providers from "./providers";
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import Providers from './providers';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: '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({
|
||||
@ -26,7 +33,10 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
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
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-x-hidden`}
|
||||
>
|
||||
|
||||
@ -41,7 +41,7 @@ export function DeviceQrCode({ inventoryNumber, size = 180 }: DeviceQrCodeProps)
|
||||
value={qrValue}
|
||||
size={size}
|
||||
level="M"
|
||||
includeMargin
|
||||
marginSize={2}
|
||||
bgColor="#FFFFFF"
|
||||
fgColor="#000000"
|
||||
/>
|
||||
|
||||
184
components/ProfileAvatarModal.tsx
Normal file
184
components/ProfileAvatarModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -3,8 +3,9 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 ProfileAvatarModal from '@/components/ProfileAvatarModal';
|
||||
|
||||
export type UserMenuProps = {
|
||||
displayName: string;
|
||||
@ -13,7 +14,7 @@ export type UserMenuProps = {
|
||||
};
|
||||
|
||||
const userNavigation = [
|
||||
{ name: 'Your profile', href: '#' },
|
||||
{ name: 'Profilbild ändern', href: '#' },
|
||||
{ name: 'Abmelden', href: '#' },
|
||||
];
|
||||
|
||||
@ -23,6 +24,11 @@ export default function UserMenu({
|
||||
avatarUrl,
|
||||
}: UserMenuProps) {
|
||||
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 menuRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -66,15 +72,61 @@ export default function UserMenu({
|
||||
const handleItemClick = (itemName: string) => {
|
||||
setOpen(false);
|
||||
|
||||
if (itemName === 'Profilbild ändern') {
|
||||
setAvatarModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemName === 'Abmelden') {
|
||||
void signOut({ callbackUrl: '/login' });
|
||||
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 (
|
||||
<>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
@ -88,8 +140,7 @@ export default function UserMenu({
|
||||
<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" />
|
||||
<PersonAvatar name={avatarName} avatarUrl={currentAvatarUrl} size="md" />
|
||||
|
||||
<span className="hidden lg:flex lg:items-center">
|
||||
<span
|
||||
@ -127,5 +178,16 @@ export default function UserMenu({
|
||||
</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
239
components/ui/Alerts.tsx
Normal 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;
|
||||
@ -260,7 +260,7 @@ export default function AppCombobox<T>({
|
||||
|
||||
<div className="relative mt-2">
|
||||
<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}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
|
||||
39
components/ui/LoadingSpinner.tsx
Normal file
39
components/ui/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner'; // 👈 Neu
|
||||
|
||||
function classNames(...classes: Array<string | boolean | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
@ -18,7 +19,7 @@ export type TableColumn<T> = {
|
||||
/** Kann die Spalte sortiert werden? */
|
||||
sortable?: boolean;
|
||||
/** Kann die Spalte ausgeblendet werden? */
|
||||
canHide?: boolean,
|
||||
canHide?: boolean;
|
||||
/** Optional eigene Klassen für die TH-Zelle */
|
||||
headerClassName?: string;
|
||||
/** Optional eigene Klassen für die TD-Zelle */
|
||||
@ -44,6 +45,8 @@ export interface TableProps<T> {
|
||||
defaultSortKey?: keyof T;
|
||||
/** Optional: Standard-Sortierrichtung */
|
||||
defaultSortDirection?: SortDirection;
|
||||
/** Optional: Wenn true, wird statt der Zeilen ein LoadingSpinner angezeigt */
|
||||
isLoading?: boolean; // 👈 Neu
|
||||
}
|
||||
|
||||
type SortState<T> = {
|
||||
@ -62,6 +65,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
actionsHeader = '',
|
||||
defaultSortKey,
|
||||
defaultSortDirection = 'asc',
|
||||
isLoading = false, // 👈 Neu
|
||||
} = props;
|
||||
|
||||
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 (vb == null) return sort.direction === 'asc' ? 1 : -1;
|
||||
|
||||
// Reine Numbers
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sort.direction === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
|
||||
// Numerische Strings wie "1", "123", "42"
|
||||
if (typeof va === 'string' && typeof vb === 'string') {
|
||||
const na = Number(va);
|
||||
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 sb = vb instanceof Date ? vb.getTime() : String(vb);
|
||||
|
||||
@ -130,20 +131,20 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onSelectionChange) return;
|
||||
const selectedRows = sortedData.filter((row) => selectedIds.includes(getRowId(row)));
|
||||
const selectedRows = sortedData.filter((row) =>
|
||||
selectedIds.includes(getRowId(row)),
|
||||
);
|
||||
onSelectionChange(selectedRows);
|
||||
}, [selectedIds, sortedData, getRowId, onSelectionChange]);
|
||||
|
||||
function toggleSort(key: keyof T) {
|
||||
setSort((prev) => {
|
||||
if (prev.key === key) {
|
||||
// gleiche Spalte -> Richtung flippen
|
||||
return {
|
||||
key,
|
||||
direction: prev.direction === 'asc' ? 'desc' : 'asc',
|
||||
};
|
||||
}
|
||||
// neue Spalte -> asc
|
||||
return { key, direction: 'asc' };
|
||||
});
|
||||
}
|
||||
@ -151,7 +152,8 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
function toggleAll() {
|
||||
if (!selectable) return;
|
||||
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);
|
||||
}
|
||||
@ -163,13 +165,13 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
);
|
||||
}
|
||||
|
||||
const colSpan =
|
||||
columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0);
|
||||
|
||||
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">
|
||||
{/* Wichtig: auf kleinen Screens overflow-x-visible, erst ab lg overflow-x-auto */}
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm dark:divide-white/10"
|
||||
>
|
||||
<table 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">
|
||||
<tr>
|
||||
{selectable && (
|
||||
@ -239,7 +241,9 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
'size-4',
|
||||
isSorted && sort.direction === 'desc' && 'rotate-180',
|
||||
isSorted &&
|
||||
sort.direction === 'desc' &&
|
||||
'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@ -261,8 +265,34 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-900/40">
|
||||
{sortedData.map((row) => {
|
||||
{isLoading ? (
|
||||
// 🔹 Loading-State: Spinner-Zeile statt Daten
|
||||
<tr>
|
||||
<td
|
||||
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"
|
||||
>
|
||||
Keine Einträge vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
// 🔹 Normale Zeilen
|
||||
sortedData.map((row) => {
|
||||
const id = getRowId(row);
|
||||
const isSelected = selectedIds.includes(id);
|
||||
|
||||
@ -328,17 +358,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{sortedData.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0)}
|
||||
className="px-2 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Keine Einträge vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// components/ui/Tabs.tsx
|
||||
'use client';
|
||||
|
||||
import { ChevronDownIcon } from '@heroicons/react/16/solid';
|
||||
@ -5,11 +6,21 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
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 = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** optional: Anzahl (z.B. Personen in der Gruppe) */
|
||||
count?: number;
|
||||
/** optional: Anzahl / Badge, z.B. "52" */
|
||||
count?: number | string;
|
||||
/** optional: Icon (z.B. <UsersIcon className="size-5" />) */
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
type TabsProps = {
|
||||
@ -18,6 +29,7 @@ type TabsProps = {
|
||||
onChange: (id: string) => void;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
variant?: TabsVariant;
|
||||
};
|
||||
|
||||
export default function Tabs({
|
||||
@ -26,15 +38,247 @@ export default function Tabs({
|
||||
onChange,
|
||||
className,
|
||||
ariaLabel = 'Ansicht auswählen',
|
||||
variant = 'underline',
|
||||
}: 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 (
|
||||
<div className={className}>
|
||||
{/* Mobile: Select + Chevron */}
|
||||
{/* Mobile: Select + Chevron (für alle Varianten gleich) */}
|
||||
<div className="grid grid-cols-1 sm:hidden">
|
||||
<select
|
||||
value={current?.id}
|
||||
value={currentId}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
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"
|
||||
@ -53,34 +297,8 @@ export default function Tabs({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Underline-Tabs */}
|
||||
<div className="hidden sm:block">
|
||||
<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>
|
||||
{/* Desktop: abhängig von variant */}
|
||||
<div className="hidden sm:block">{renderDesktopTabs()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,12 +23,15 @@ function getAvatarColor(seed: string) {
|
||||
return AVATAR_COLORS[index];
|
||||
}
|
||||
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
type Size = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: 'h-6 w-6 text-[10px]',
|
||||
md: 'h-8 w-8 text-xs',
|
||||
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 = {
|
||||
@ -49,16 +52,28 @@ export default function UserAvatar({
|
||||
const initial = displayName.charAt(0)?.toUpperCase() || '?';
|
||||
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 (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
src={avatarUrl!}
|
||||
alt={displayName || 'Avatar'}
|
||||
onError={() => setHasImageError(true)}
|
||||
className={clsx('rounded-full object-cover', sizeClasses[size])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: Initialen mit pseudo-zufälliger Hintergrundfarbe
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@ -33,7 +33,7 @@ export * from "./enums.ts"
|
||||
* 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 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
@ -80,12 +80,12 @@ export type PrismaVersion = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma Client JS version: 7.0.0
|
||||
* Query Engine version: 0c19ccc313cf9911a90d99d2ac2eb0280c76c513
|
||||
* Prisma Client JS version: 7.1.0
|
||||
* Query Engine version: ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
|
||||
*/
|
||||
export const prismaVersion: PrismaVersion = {
|
||||
client: "7.0.0",
|
||||
engine: "0c19ccc313cf9911a90d99d2ac2eb0280c76c513"
|
||||
client: "7.1.0",
|
||||
engine: "ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba"
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1126,7 +1126,8 @@ export const UserScalarFieldEnum = {
|
||||
passwordHash: 'passwordHash',
|
||||
groupId: 'groupId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
updatedAt: 'updatedAt',
|
||||
avatarUrl: 'avatarUrl'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
@ -1394,7 +1395,7 @@ export type PrismaClientOptions = ({
|
||||
* { 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)[]
|
||||
/**
|
||||
@ -1422,6 +1423,22 @@ export type PrismaClientOptions = ({
|
||||
* ```
|
||||
*/
|
||||
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 = {
|
||||
user?: Prisma.UserOmit
|
||||
|
||||
@ -87,7 +87,8 @@ export const UserScalarFieldEnum = {
|
||||
passwordHash: 'passwordHash',
|
||||
groupId: 'groupId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
updatedAt: 'updatedAt',
|
||||
avatarUrl: 'avatarUrl'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
|
||||
@ -34,6 +34,7 @@ export type UserMinAggregateOutputType = {
|
||||
groupId: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
export type UserMaxAggregateOutputType = {
|
||||
@ -46,6 +47,7 @@ export type UserMaxAggregateOutputType = {
|
||||
groupId: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
export type UserCountAggregateOutputType = {
|
||||
@ -58,6 +60,7 @@ export type UserCountAggregateOutputType = {
|
||||
groupId: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
avatarUrl: number
|
||||
_all: number
|
||||
}
|
||||
|
||||
@ -72,6 +75,7 @@ export type UserMinAggregateInputType = {
|
||||
groupId?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
avatarUrl?: true
|
||||
}
|
||||
|
||||
export type UserMaxAggregateInputType = {
|
||||
@ -84,6 +88,7 @@ export type UserMaxAggregateInputType = {
|
||||
groupId?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
avatarUrl?: true
|
||||
}
|
||||
|
||||
export type UserCountAggregateInputType = {
|
||||
@ -96,6 +101,7 @@ export type UserCountAggregateInputType = {
|
||||
groupId?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
avatarUrl?: true
|
||||
_all?: true
|
||||
}
|
||||
|
||||
@ -181,6 +187,7 @@ export type UserGroupByOutputType = {
|
||||
groupId: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
avatarUrl: string | null
|
||||
_count: UserCountAggregateOutputType | null
|
||||
_min: UserMinAggregateOutputType | null
|
||||
_max: UserMaxAggregateOutputType | null
|
||||
@ -214,6 +221,7 @@ export type UserWhereInput = {
|
||||
groupId?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
avatarUrl?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
devicesCreated?: Prisma.DeviceListRelationFilter
|
||||
devicesUpdated?: Prisma.DeviceListRelationFilter
|
||||
historyEntries?: Prisma.DeviceHistoryListRelationFilter
|
||||
@ -231,6 +239,7 @@ export type UserOrderByWithRelationInput = {
|
||||
groupId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
avatarUrl?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
devicesCreated?: Prisma.DeviceOrderByRelationAggregateInput
|
||||
devicesUpdated?: Prisma.DeviceOrderByRelationAggregateInput
|
||||
historyEntries?: Prisma.DeviceHistoryOrderByRelationAggregateInput
|
||||
@ -251,6 +260,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
groupId?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
avatarUrl?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
devicesCreated?: Prisma.DeviceListRelationFilter
|
||||
devicesUpdated?: Prisma.DeviceListRelationFilter
|
||||
historyEntries?: Prisma.DeviceHistoryListRelationFilter
|
||||
@ -268,6 +278,7 @@ export type UserOrderByWithAggregationInput = {
|
||||
groupId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
avatarUrl?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
_count?: Prisma.UserCountOrderByAggregateInput
|
||||
_max?: Prisma.UserMaxOrderByAggregateInput
|
||||
_min?: Prisma.UserMinOrderByAggregateInput
|
||||
@ -286,6 +297,7 @@ export type UserScalarWhereWithAggregatesInput = {
|
||||
groupId?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||
avatarUrl?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
}
|
||||
|
||||
export type UserCreateInput = {
|
||||
@ -297,6 +309,7 @@ export type UserCreateInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
|
||||
@ -314,6 +327,7 @@ export type UserUncheckedCreateInput = {
|
||||
groupId?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
|
||||
@ -329,6 +343,7 @@ export type UserUpdateInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
|
||||
@ -346,6 +361,7 @@ export type UserUncheckedUpdateInput = {
|
||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
|
||||
@ -362,6 +378,7 @@ export type UserCreateManyInput = {
|
||||
groupId?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdateManyMutationInput = {
|
||||
@ -373,6 +390,7 @@ export type UserUpdateManyMutationInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateManyInput = {
|
||||
@ -385,6 +403,7 @@ export type UserUncheckedUpdateManyInput = {
|
||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
}
|
||||
|
||||
export type UserCountOrderByAggregateInput = {
|
||||
@ -397,6 +416,7 @@ export type UserCountOrderByAggregateInput = {
|
||||
groupId?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
avatarUrl?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type UserMaxOrderByAggregateInput = {
|
||||
@ -409,6 +429,7 @@ export type UserMaxOrderByAggregateInput = {
|
||||
groupId?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
avatarUrl?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type UserMinOrderByAggregateInput = {
|
||||
@ -421,6 +442,7 @@ export type UserMinOrderByAggregateInput = {
|
||||
groupId?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
avatarUrl?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type UserScalarRelationFilter = {
|
||||
@ -568,6 +590,7 @@ export type UserCreateWithoutRolesInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
|
||||
@ -584,6 +607,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
|
||||
groupId?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
|
||||
@ -614,6 +638,7 @@ export type UserUpdateWithoutRolesInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
|
||||
@ -630,6 +655,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
|
||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
|
||||
@ -644,6 +670,7 @@ export type UserCreateWithoutGroupInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
|
||||
@ -659,6 +686,7 @@ export type UserUncheckedCreateWithoutGroupInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
|
||||
@ -704,6 +732,7 @@ export type UserScalarWhereInput = {
|
||||
groupId?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
avatarUrl?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
}
|
||||
|
||||
export type UserCreateWithoutDevicesCreatedInput = {
|
||||
@ -715,6 +744,7 @@ export type UserCreateWithoutDevicesCreatedInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
|
||||
group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput
|
||||
@ -731,6 +761,7 @@ export type UserUncheckedCreateWithoutDevicesCreatedInput = {
|
||||
groupId?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
|
||||
roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput
|
||||
@ -750,6 +781,7 @@ export type UserCreateWithoutDevicesUpdatedInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryCreateNestedManyWithoutChangedByInput
|
||||
group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput
|
||||
@ -766,6 +798,7 @@ export type UserUncheckedCreateWithoutDevicesUpdatedInput = {
|
||||
groupId?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutChangedByInput
|
||||
roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput
|
||||
@ -796,6 +829,7 @@ export type UserUpdateWithoutDevicesCreatedInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
|
||||
group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput
|
||||
@ -812,6 +846,7 @@ export type UserUncheckedUpdateWithoutDevicesCreatedInput = {
|
||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
|
||||
roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput
|
||||
@ -837,6 +872,7 @@ export type UserUpdateWithoutDevicesUpdatedInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
|
||||
group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput
|
||||
@ -853,6 +889,7 @@ export type UserUncheckedUpdateWithoutDevicesUpdatedInput = {
|
||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
|
||||
roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput
|
||||
@ -867,6 +904,7 @@ export type UserCreateWithoutHistoryEntriesInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceCreateNestedManyWithoutUpdatedByInput
|
||||
group?: Prisma.UserGroupCreateNestedOneWithoutUsersInput
|
||||
@ -883,6 +921,7 @@ export type UserUncheckedCreateWithoutHistoryEntriesInput = {
|
||||
groupId?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedCreateNestedManyWithoutUpdatedByInput
|
||||
roles?: Prisma.UserRoleUncheckedCreateNestedManyWithoutUserInput
|
||||
@ -913,6 +952,7 @@ export type UserUpdateWithoutHistoryEntriesInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
|
||||
group?: Prisma.UserGroupUpdateOneWithoutUsersNestedInput
|
||||
@ -929,6 +969,7 @@ export type UserUncheckedUpdateWithoutHistoryEntriesInput = {
|
||||
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
|
||||
roles?: Prisma.UserRoleUncheckedUpdateManyWithoutUserNestedInput
|
||||
@ -943,6 +984,7 @@ export type UserCreateManyGroupInput = {
|
||||
passwordHash?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdateWithoutGroupInput = {
|
||||
@ -954,6 +996,7 @@ export type UserUpdateWithoutGroupInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUpdateManyWithoutChangedByNestedInput
|
||||
@ -969,6 +1012,7 @@ export type UserUncheckedUpdateWithoutGroupInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
avatarUrl?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
devicesCreated?: Prisma.DeviceUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
devicesUpdated?: Prisma.DeviceUncheckedUpdateManyWithoutUpdatedByNestedInput
|
||||
historyEntries?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutChangedByNestedInput
|
||||
@ -984,6 +1028,7 @@ export type UserUncheckedUpdateManyWithoutGroupInput = {
|
||||
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: 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
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
avatarUrl?: boolean
|
||||
devicesCreated?: boolean | Prisma.User$devicesCreatedArgs<ExtArgs>
|
||||
devicesUpdated?: boolean | Prisma.User$devicesUpdatedArgs<ExtArgs>
|
||||
historyEntries?: boolean | Prisma.User$historyEntriesArgs<ExtArgs>
|
||||
@ -1072,6 +1118,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
groupId?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
avatarUrl?: boolean
|
||||
group?: boolean | Prisma.User$groupArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["user"]>
|
||||
|
||||
@ -1085,6 +1132,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
groupId?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
avatarUrl?: boolean
|
||||
group?: boolean | Prisma.User$groupArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["user"]>
|
||||
|
||||
@ -1098,9 +1146,10 @@ export type UserSelectScalar = {
|
||||
groupId?: boolean
|
||||
createdAt?: 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> = {
|
||||
devicesCreated?: boolean | Prisma.User$devicesCreatedArgs<ExtArgs>
|
||||
devicesUpdated?: boolean | Prisma.User$devicesUpdatedArgs<ExtArgs>
|
||||
@ -1135,6 +1184,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
groupId: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
avatarUrl: string | null
|
||||
}, ExtArgs["result"]["user"]>
|
||||
composites: {}
|
||||
}
|
||||
@ -1572,6 +1622,7 @@ export interface UserFieldRefs {
|
||||
readonly groupId: Prisma.FieldRef<"User", 'String'>
|
||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly avatarUrl: Prisma.FieldRef<"User", 'String'>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -55,11 +59,15 @@ export const authOptions: NextAuthOptions = {
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.email ?? user.nwkennung ?? 'Unbekannt');
|
||||
|
||||
const canEditDevices = !!user.group?.canEditDevices;
|
||||
|
||||
return {
|
||||
id: user.nwkennung,
|
||||
name: displayName,
|
||||
email: user.email,
|
||||
nwkennung: user.nwkennung,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
groupCanEditDevices: canEditDevices, // <-- hier mitgeben
|
||||
} as any;
|
||||
},
|
||||
}),
|
||||
@ -69,23 +77,39 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
// Login wird standardmäßig gemerkt (hier explizit: 30 Tage)
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
},
|
||||
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) {
|
||||
token.id = (user as any).id;
|
||||
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;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (session.user && 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;
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
132
package-lock.json
generated
132
package-lock.json
generated
@ -12,7 +12,7 @@
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
||||
"@prisma/adapter-pg": "^7.0.0",
|
||||
"@prisma/client": "^7.0.1",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.0.3",
|
||||
@ -36,7 +36,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"prisma": "^7.0.1",
|
||||
"prisma": "^7.1.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
@ -1095,9 +1095,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.14.2.tgz",
|
||||
"integrity": "sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==",
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz",
|
||||
"integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -1923,12 +1923,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.0.1.tgz",
|
||||
"integrity": "sha512-O74T6xcfaGAq5gXwCAvfTLvI6fmC3and2g5yLRMkNjri1K8mSpEgclDNuUWs9xj5AwNEMQ88NeD3asI+sovm1g==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.1.0.tgz",
|
||||
"integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/client-runtime-utils": "7.0.1"
|
||||
"@prisma/client-runtime-utils": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19 || ^22.12 || >=24.0"
|
||||
@ -1947,15 +1947,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client-runtime-utils": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.0.1.tgz",
|
||||
"integrity": "sha512-R26BVX9D/iw4toUmZKZf3jniM/9pMGHHdZN5LVP2L7HNiCQKNQQx/9LuMtjepbgRqSqQO3oHN0yzojHLnKTGEw==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.1.0.tgz",
|
||||
"integrity": "sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.0.1.tgz",
|
||||
"integrity": "sha512-MacIjXdo+hNKxPvtMzDXykIIc8HCRWoyjQ2nguJTFqLDzJBD5L6QRaANGTLOqbGtJ3sFvLRmfXhrFg3pWoK1BA==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.1.0.tgz",
|
||||
"integrity": "sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@ -1972,28 +1972,28 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/dev": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.13.0.tgz",
|
||||
"integrity": "sha512-QMmF6zFeUF78yv1HYbHvod83AQnl7u6NtKyDhTRZOJup3h1icWs8R7RUVxBJZvM2tBXNAMpLQYYM/8kPlOPegA==",
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.15.0.tgz",
|
||||
"integrity": "sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@electric-sql/pglite": "0.3.2",
|
||||
"@electric-sql/pglite-socket": "0.0.6",
|
||||
"@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",
|
||||
"@prisma/get-platform": "6.8.2",
|
||||
"@prisma/query-plan-executor": "6.18.0",
|
||||
"foreground-child": "3.3.1",
|
||||
"get-port-please": "3.1.2",
|
||||
"hono": "4.7.10",
|
||||
"hono": "4.10.6",
|
||||
"http-status-codes": "2.3.0",
|
||||
"pathe": "2.0.3",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"remeda": "2.21.3",
|
||||
"std-env": "3.9.0",
|
||||
"valibot": "1.1.0",
|
||||
"valibot": "1.2.0",
|
||||
"zeptomatch": "2.0.2"
|
||||
}
|
||||
},
|
||||
@ -2007,70 +2007,70 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.0.1.tgz",
|
||||
"integrity": "sha512-f+D/vdKeImqUHysd5Bgv8LQ1whl4sbLepHyYMQQMK61cp4WjwJVryophleLUrfEJRpBLGTBI/7fnLVENxxMFPQ==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.1.0.tgz",
|
||||
"integrity": "sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.0.1",
|
||||
"@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6",
|
||||
"@prisma/fetch-engine": "7.0.1",
|
||||
"@prisma/get-platform": "7.0.1"
|
||||
"@prisma/debug": "7.1.0",
|
||||
"@prisma/engines-version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
|
||||
"@prisma/fetch-engine": "7.1.0",
|
||||
"@prisma/get-platform": "7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6.tgz",
|
||||
"integrity": "sha512-RA7pShKvijHib4USRB3YuLTQamHKJPkTRDc45AwxfahUQngiGVMlIj4ix4emUxkrum4o/jwn82WIwlG57EtgiQ==",
|
||||
"version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba.tgz",
|
||||
"integrity": "sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines/node_modules/@prisma/debug": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz",
|
||||
"integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz",
|
||||
"integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz",
|
||||
"integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz",
|
||||
"integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.0.1"
|
||||
"@prisma/debug": "7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.0.1.tgz",
|
||||
"integrity": "sha512-5DnSairYIYU7dcv/9pb1KCwIRHZfhVOd34855d01lUI5QdF9rdCkMywPQbBM67YP7iCgQoEZO0/COtOMpR4i9A==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.1.0.tgz",
|
||||
"integrity": "sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.0.1",
|
||||
"@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6",
|
||||
"@prisma/get-platform": "7.0.1"
|
||||
"@prisma/debug": "7.1.0",
|
||||
"@prisma/engines-version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba",
|
||||
"@prisma/get-platform": "7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz",
|
||||
"integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz",
|
||||
"integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz",
|
||||
"integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz",
|
||||
"integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.0.1"
|
||||
"@prisma/debug": "7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
@ -5539,9 +5539,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/grammex": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.11.tgz",
|
||||
"integrity": "sha512-HNwLkgRg9SqTAd1N3Uh/MnKwTBTzwBxTOPbXQ8pb0tpwydjk90k4zRE8JUn9fMUiRwKtXFZ1TWFmms3dZHN+Fg==",
|
||||
"version": "3.1.12",
|
||||
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
|
||||
"integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -5664,9 +5664,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.7.10",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.7.10.tgz",
|
||||
"integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==",
|
||||
"version": "4.10.6",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz",
|
||||
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -7726,16 +7726,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.0.1.tgz",
|
||||
"integrity": "sha512-zp93MdFMSU1IHPEXbUHVUuD8wauh2BUm14OVxhxGrWJQQpXpda0rW4VSST2bci4raoldX64/wQxHKkl/wqDskQ==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.1.0.tgz",
|
||||
"integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "7.0.1",
|
||||
"@prisma/dev": "0.13.0",
|
||||
"@prisma/engines": "7.0.1",
|
||||
"@prisma/config": "7.1.0",
|
||||
"@prisma/dev": "0.15.0",
|
||||
"@prisma/engines": "7.1.0",
|
||||
"@prisma/studio-core": "0.8.2",
|
||||
"mysql2": "3.15.3",
|
||||
"postgres": "3.4.7"
|
||||
@ -9366,9 +9366,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz",
|
||||
"integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
||||
"@prisma/adapter-pg": "^7.0.0",
|
||||
"@prisma/client": "^7.0.1",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.0.3",
|
||||
@ -42,7 +42,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"prisma": "^7.0.1",
|
||||
"prisma": "^7.1.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT;
|
||||
@ -24,6 +24,7 @@ model User {
|
||||
groupId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
avatarUrl String?
|
||||
|
||||
devicesCreated Device[] @relation("DeviceCreatedBy")
|
||||
devicesUpdated Device[] @relation("DeviceUpdatedBy")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user