updated
This commit is contained in:
parent
73419f468e
commit
4dd63ca3bb
14
app/(app)/dashboard/page.tsx
Normal file
14
app/(app)/dashboard/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// app/(app)/dashboard/page.tsx
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
app/(app)/devices/page.tsx
Normal file
251
app/(app)/devices/page.tsx
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
// app/(app)/devices/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Table, { TableColumn } from '@/components/ui/Table';
|
||||||
|
import { Dropdown } from '@/components/ui/Dropdown';
|
||||||
|
import {
|
||||||
|
BookOpenIcon,
|
||||||
|
PencilIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
type DeviceRow = {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// Fachliche Felder (entsprechend deinem Prisma-Model)
|
||||||
|
name: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
inventoryNumber: string;
|
||||||
|
serialNumber?: string | null;
|
||||||
|
productNumber?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
|
||||||
|
// optionale Netzwerk-/Zugangs-Felder
|
||||||
|
ipv4Address?: string | null;
|
||||||
|
ipv6Address?: string | null;
|
||||||
|
macAddress?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
|
||||||
|
// Beziehungen (als einfache Strings für die Tabelle)
|
||||||
|
group?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: später per Prisma laden
|
||||||
|
const mockDevices: DeviceRow[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Dienstrechner Sachbearbeitung 1',
|
||||||
|
manufacturer: 'Dell',
|
||||||
|
model: 'OptiPlex 7010',
|
||||||
|
inventoryNumber: 'INV-00123',
|
||||||
|
serialNumber: 'SN-ABC-123',
|
||||||
|
productNumber: 'PN-4711',
|
||||||
|
group: 'Dienstrechner',
|
||||||
|
location: 'Raum 1.12',
|
||||||
|
comment: 'Steht am Fensterplatz',
|
||||||
|
ipv4Address: '10.0.0.12',
|
||||||
|
ipv6Address: null,
|
||||||
|
macAddress: '00-11-22-33-44-55',
|
||||||
|
username: 'sachb1',
|
||||||
|
updatedAt: '2025-01-10T09:15:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Monitor Lager 27"',
|
||||||
|
manufacturer: 'Samsung',
|
||||||
|
model: 'S27F350',
|
||||||
|
inventoryNumber: 'INV-00124',
|
||||||
|
serialNumber: 'SN-DEF-456',
|
||||||
|
productNumber: 'PN-0815',
|
||||||
|
group: 'Monitore',
|
||||||
|
location: 'Lager Keller',
|
||||||
|
comment: null,
|
||||||
|
ipv4Address: null,
|
||||||
|
ipv6Address: null,
|
||||||
|
macAddress: null,
|
||||||
|
username: null,
|
||||||
|
updatedAt: '2025-01-08T14:30:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn<DeviceRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Bezeichnung',
|
||||||
|
sortable: true,
|
||||||
|
canHide: true,
|
||||||
|
headerClassName: 'min-w-48',
|
||||||
|
cellClassName: 'font-medium text-gray-900 dark:text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inventoryNumber',
|
||||||
|
header: 'Inventar-Nr.',
|
||||||
|
sortable: true,
|
||||||
|
canHide: false,
|
||||||
|
headerClassName: 'min-w-32',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manufacturer',
|
||||||
|
header: 'Hersteller',
|
||||||
|
sortable: true,
|
||||||
|
canHide: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'model',
|
||||||
|
header: 'Modell',
|
||||||
|
sortable: true,
|
||||||
|
canHide: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'serialNumber',
|
||||||
|
header: 'Seriennummer',
|
||||||
|
sortable: true,
|
||||||
|
canHide: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'productNumber',
|
||||||
|
header: 'Produktnummer',
|
||||||
|
sortable: true,
|
||||||
|
canHide: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'group',
|
||||||
|
header: 'Gruppe',
|
||||||
|
sortable: true,
|
||||||
|
canHide: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'location',
|
||||||
|
header: 'Standort / Raum',
|
||||||
|
sortable: true,
|
||||||
|
canHide: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comment',
|
||||||
|
header: 'Kommentar',
|
||||||
|
sortable: false,
|
||||||
|
canHide: true,
|
||||||
|
cellClassName: 'whitespace-normal max-w-xs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updatedAt',
|
||||||
|
header: 'Geändert am',
|
||||||
|
sortable: true,
|
||||||
|
canHide: true,
|
||||||
|
render: (row) => formatDate(row.updatedAt),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const devices = mockDevices;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header über der Tabelle */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Geräte
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Übersicht aller erfassten Geräte im Inventar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
||||||
|
>
|
||||||
|
Neues Gerät anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabelle */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<Table<DeviceRow>
|
||||||
|
data={devices}
|
||||||
|
columns={columns}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
selectable
|
||||||
|
actionsHeader=""
|
||||||
|
renderActions={(row) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* Desktop: drei Icon-Buttons nebeneinander */}
|
||||||
|
<div className="hidden gap-2 lg:flex">
|
||||||
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
tone="indigo"
|
||||||
|
size="md"
|
||||||
|
icon={<BookOpenIcon className="size-5" />}
|
||||||
|
aria-label={`Details zu ${row.inventoryNumber}`}
|
||||||
|
onClick={() => console.log('Details', row.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
tone="gray"
|
||||||
|
size="md"
|
||||||
|
icon={<PencilIcon className="size-5" />}
|
||||||
|
aria-label={`Gerät ${row.inventoryNumber} bearbeiten`}
|
||||||
|
onClick={() => console.log('Bearbeiten', row.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
tone="rose"
|
||||||
|
size="md"
|
||||||
|
icon={<TrashIcon className="size-5" />}
|
||||||
|
aria-label={`Gerät ${row.inventoryNumber} löschen`}
|
||||||
|
onClick={() => console.log('Löschen', row.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile / kleine Screens: kompaktes Dropdown mit Ellipsis-Trigger */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<Dropdown
|
||||||
|
triggerVariant="icon"
|
||||||
|
ariaLabel={`Aktionen für ${row.inventoryNumber}`}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Details',
|
||||||
|
icon: <BookOpenIcon className="size-4" />,
|
||||||
|
onClick: () => console.log('Details', row.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bearbeiten',
|
||||||
|
icon: <PencilIcon className="size-4" />,
|
||||||
|
onClick: () => console.log('Bearbeiten', row.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Löschen',
|
||||||
|
icon: <TrashIcon className="size-4" />,
|
||||||
|
tone: 'danger',
|
||||||
|
onClick: () => console.log('Löschen', row.id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
app/(app)/layout.tsx
Normal file
426
app/(app)/layout.tsx
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
// /app/(app)/layout.tsx
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogPanel,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuItems,
|
||||||
|
TransitionChild,
|
||||||
|
} from '@headlessui/react';
|
||||||
|
import {
|
||||||
|
Bars3Icon,
|
||||||
|
BellIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
CameraIcon,
|
||||||
|
ChartPieIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
FolderIcon,
|
||||||
|
ComputerDesktopIcon,
|
||||||
|
HomeIcon,
|
||||||
|
UsersIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useSession, signOut } from 'next-auth/react';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
||||||
|
{ name: 'Geräte', href: '/devices', icon: ComputerDesktopIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const teams = [
|
||||||
|
{ id: 1, name: 'Heroicons', href: '#', initial: 'H' },
|
||||||
|
{ id: 2, name: 'Tailwind Labs', href: '#', initial: 'T' },
|
||||||
|
{ id: 3, name: 'Workcation', href: '#', initial: 'W' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const userNavigation = [
|
||||||
|
{ name: 'Your profile', href: '#' },
|
||||||
|
{ name: 'Abmelden', href: '#' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | boolean | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// feste Liste an erlaubten Tailwind-Hintergrundfarben
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'bg-indigo-500',
|
||||||
|
'bg-sky-500',
|
||||||
|
'bg-emerald-500',
|
||||||
|
'bg-amber-500',
|
||||||
|
'bg-rose-500',
|
||||||
|
'bg-purple-500',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getInitial(name: string) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return trimmed.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// deterministische "zufällige" Farbe auf Basis des Namens
|
||||||
|
function getAvatarColor(name: string) {
|
||||||
|
if (!name) return AVATAR_COLORS[0];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = (hash + name.charCodeAt(i)) % AVATAR_COLORS.length;
|
||||||
|
}
|
||||||
|
return AVATAR_COLORS[hash];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── User-Menü, wenn Session geladen ───────── */
|
||||||
|
|
||||||
|
type UserMenuProps = {
|
||||||
|
displayName: string;
|
||||||
|
avatarInitial: string;
|
||||||
|
avatarColorClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function UserMenu({ displayName, avatarInitial, avatarColorClass }: UserMenuProps) {
|
||||||
|
return (
|
||||||
|
<Menu as="div" className="relative">
|
||||||
|
<MenuButton className="relative flex items-center">
|
||||||
|
<span className="absolute -inset-1.5" />
|
||||||
|
<span className="sr-only">Open user menu</span>
|
||||||
|
|
||||||
|
{/* Avatar mit Zufallsfarbe & Initial */}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'flex size-8 items-center justify-center rounded-full text-sm font-semibold text-white shadow-inner',
|
||||||
|
avatarColorClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{avatarInitial}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="hidden lg:flex lg:items-center">
|
||||||
|
<span aria-hidden="true" className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="ml-2 size-5 text-gray-400 dark:text-gray-500" />
|
||||||
|
</span>
|
||||||
|
</MenuButton>
|
||||||
|
<MenuItems
|
||||||
|
transition
|
||||||
|
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline-1 outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||||
|
>
|
||||||
|
{userNavigation.map((item) => (
|
||||||
|
<MenuItem key={item.name}>
|
||||||
|
{item.name === 'Abmelden' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
|
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuItems>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Layout ───────── */
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
|
const rawName =
|
||||||
|
status === 'authenticated'
|
||||||
|
? (session?.user?.name ?? session?.user?.email ?? '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const displayName = rawName;
|
||||||
|
const avatarInitial = getInitial(rawName);
|
||||||
|
const avatarColorClass = getAvatarColor(rawName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||||
|
{/* Mobile Sidebar */}
|
||||||
|
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 flex">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||||
|
>
|
||||||
|
<TransitionChild>
|
||||||
|
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
|
||||||
|
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||||
|
<span className="sr-only">Close sidebar</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
{/* Sidebar mobil */}
|
||||||
|
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 pb-4 dark:bg-gray-900 dark:ring dark:ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
||||||
|
<div className="relative flex h-16 shrink-0 items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">Inventar</span>
|
||||||
|
</div>
|
||||||
|
<nav className="relative flex flex-1 flex-col">
|
||||||
|
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||||
|
<li>
|
||||||
|
<ul role="list" className="-mx-2 space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isCurrent = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<li key={item.name}>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
className={classNames(
|
||||||
|
isCurrent
|
||||||
|
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||||
|
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
isCurrent
|
||||||
|
? 'text-indigo-600 dark:text-white'
|
||||||
|
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
||||||
|
'size-6 shrink-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
|
||||||
|
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||||
|
{teams.map((team) => (
|
||||||
|
<li key={team.name}>
|
||||||
|
<a
|
||||||
|
href={team.href}
|
||||||
|
className="group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-[0.625rem] font-medium text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:group-hover:border-white/20 dark:group-hover:text-white">
|
||||||
|
{team.initial}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{team.name}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mt-auto">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<Cog6ToothIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white"
|
||||||
|
/>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Sidebar Desktop */}
|
||||||
|
<div className="hidden bg-gray-900 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
||||||
|
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4 dark:border-white/10 dark:bg-black/10">
|
||||||
|
<div className="flex h-16 shrink-0 items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">Inventar</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-1 flex-col">
|
||||||
|
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||||
|
<li>
|
||||||
|
<ul role="list" className="-mx-2 space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isCurrent = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<li key={item.name}>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
className={classNames(
|
||||||
|
isCurrent
|
||||||
|
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||||
|
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
isCurrent
|
||||||
|
? 'text-indigo-600 dark:text-white'
|
||||||
|
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
||||||
|
'size-6 shrink-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
|
||||||
|
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||||
|
{teams.map((team) => (
|
||||||
|
<li key={team.name}>
|
||||||
|
<a
|
||||||
|
href={team.href}
|
||||||
|
className="group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-[0.625rem] font-medium text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:group-hover:border-white/20 dark:group-hover:text-white">
|
||||||
|
{team.initial}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{team.name}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mt-auto">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<Cog6ToothIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white"
|
||||||
|
/>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Topbar + Inhalt */}
|
||||||
|
<div className="lg:pl-72">
|
||||||
|
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900 dark:shadow-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="-m-2.5 p-2.5 text-gray-700 hover:text-gray-900 lg:hidden dark:text-gray-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Open sidebar</span>
|
||||||
|
<Bars3Icon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div aria-hidden="true" className="h-6 w-px bg-gray-200 lg:hidden dark:bg-white/10" />
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center gap-x-4 self-stretch lg:gap-x-6">
|
||||||
|
{/* Suche */}
|
||||||
|
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
||||||
|
<input
|
||||||
|
name="search"
|
||||||
|
placeholder="Suchen..."
|
||||||
|
aria-label="Suchen..."
|
||||||
|
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
<MagnifyingGlassIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||||
|
{/* Trennstrich zwischen Suche und Kamera – nur mobil */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-6 w-px bg-gray-200 dark:bg-white/10 lg:hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Kamera-Button – nur mobil */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white lg:hidden"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Gerät mit Kamera erfassen</span>
|
||||||
|
<CameraIcon aria-hidden="true" className="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Trennstrich zwischen Kamera und Notifications – nur mobil */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-6 w-px bg-gray-200 dark:bg-white/10 lg:hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Notifications – immer sichtbar */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">View notifications</span>
|
||||||
|
<BellIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Trennstrich vor User-Menü – nur Desktop */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200 dark:lg:bg-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hier je nach Status: Skeleton oder echtes Menü */}
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Skeleton
|
||||||
|
primaryLabel="Benutzer"
|
||||||
|
secondaryLabel="Profildaten werden geladen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'authenticated' && rawName && (
|
||||||
|
<UserMenu
|
||||||
|
displayName={displayName}
|
||||||
|
avatarInitial={avatarInitial}
|
||||||
|
avatarColorClass={avatarColorClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* hier kommt der Seiteninhalt rein */}
|
||||||
|
<main className="py-10">
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /app/layout.tsx
|
||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|||||||
@ -1,21 +1,24 @@
|
|||||||
// app/login/page.tsx (oder eine Client-Komponente)
|
// app/login/page.tsx
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import LoginForm from '@/components/auth/LoginForm';
|
import LoginForm from '@/components/auth/LoginForm';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const callbackUrl = searchParams?.get('callbackUrl') ?? '/dashboard';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
showSocialLogin={false}
|
showSocialLogin={false}
|
||||||
onSubmit={async ({ email, password }) => {
|
onSubmit={async ({ email, password }) => {
|
||||||
// NextAuth kümmert sich um Redirect (inkl. callbackUrl)
|
await signIn('credentials', {
|
||||||
await signIn('credentials', {
|
email,
|
||||||
email,
|
password,
|
||||||
password,
|
callbackUrl,
|
||||||
// callbackUrl: '/', // optional, sonst nimmt er den ursprünglichen Pfad
|
});
|
||||||
});
|
}}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
336
app/page.tsx
336
app/page.tsx
@ -1,336 +0,0 @@
|
|||||||
// /app/page.tsx
|
|
||||||
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogBackdrop,
|
|
||||||
DialogPanel,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuItem,
|
|
||||||
MenuItems,
|
|
||||||
TransitionChild,
|
|
||||||
} from '@headlessui/react'
|
|
||||||
import {
|
|
||||||
Bars3Icon,
|
|
||||||
BellIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
ChartPieIcon,
|
|
||||||
Cog6ToothIcon,
|
|
||||||
DocumentDuplicateIcon,
|
|
||||||
FolderIcon,
|
|
||||||
HomeIcon,
|
|
||||||
UsersIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{ name: 'Dashboard', href: '#', icon: HomeIcon, current: true },
|
|
||||||
{ name: 'Team', href: '#', icon: UsersIcon, current: false },
|
|
||||||
{ name: 'Projects', href: '#', icon: FolderIcon, current: false },
|
|
||||||
{ name: 'Calendar', href: '#', icon: CalendarIcon, current: false },
|
|
||||||
{ name: 'Documents', href: '#', icon: DocumentDuplicateIcon, current: false },
|
|
||||||
{ name: 'Reports', href: '#', icon: ChartPieIcon, current: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
const teams = [
|
|
||||||
{ id: 1, name: 'Heroicons', href: '#', initial: 'H', current: false },
|
|
||||||
{ id: 2, name: 'Tailwind Labs', href: '#', initial: 'T', current: false },
|
|
||||||
{ id: 3, name: 'Workcation', href: '#', initial: 'W', current: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
const userNavigation = [
|
|
||||||
{ name: 'Your profile', href: '#' },
|
|
||||||
{ name: 'Sign out', href: '#' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function classNames(...classes: Array<string | boolean | null | undefined>) {
|
|
||||||
return classes.filter(Boolean).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
|
||||||
{/* Mobile Sidebar */}
|
|
||||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
|
|
||||||
<DialogBackdrop
|
|
||||||
transition
|
|
||||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 flex">
|
|
||||||
<DialogPanel
|
|
||||||
transition
|
|
||||||
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-closed:-translate-x-full"
|
|
||||||
>
|
|
||||||
<TransitionChild>
|
|
||||||
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
|
|
||||||
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
|
||||||
<span className="sr-only">Close sidebar</span>
|
|
||||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TransitionChild>
|
|
||||||
|
|
||||||
{/* Sidebar mobil */}
|
|
||||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 pb-4 dark:bg-gray-900 dark:ring dark:ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
|
||||||
<div className="relative flex h-16 shrink-0 items-center">
|
|
||||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">Inventar</span>
|
|
||||||
</div>
|
|
||||||
<nav className="relative flex flex-1 flex-col">
|
|
||||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
|
||||||
<li>
|
|
||||||
<ul role="list" className="-mx-2 space-y-1">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<li key={item.name}>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
className={classNames(
|
|
||||||
item.current
|
|
||||||
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
|
|
||||||
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
|
||||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
aria-hidden="true"
|
|
||||||
className={classNames(
|
|
||||||
item.current
|
|
||||||
? 'text-indigo-600 dark:text-white'
|
|
||||||
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
|
||||||
'size-6 shrink-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
|
|
||||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
|
||||||
{teams.map((team) => (
|
|
||||||
<li key={team.name}>
|
|
||||||
<a
|
|
||||||
href={team.href}
|
|
||||||
className={classNames(
|
|
||||||
team.current
|
|
||||||
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
|
|
||||||
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
|
||||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
team.current
|
|
||||||
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
|
|
||||||
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
|
|
||||||
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{team.initial}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{team.name}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li className="mt-auto">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
<Cog6ToothIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white"
|
|
||||||
/>
|
|
||||||
Settings
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Sidebar Desktop */}
|
|
||||||
<div className="hidden bg-gray-900 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
|
||||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4 dark:border-white/10 dark:bg-black/10">
|
|
||||||
<div className="flex h-16 shrink-0 items-center">
|
|
||||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">Inventar</span>
|
|
||||||
</div>
|
|
||||||
<nav className="flex flex-1 flex-col">
|
|
||||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
|
||||||
<li>
|
|
||||||
<ul role="list" className="-mx-2 space-y-1">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<li key={item.name}>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
className={classNames(
|
|
||||||
item.current
|
|
||||||
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
|
|
||||||
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
|
||||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
aria-hidden="true"
|
|
||||||
className={classNames(
|
|
||||||
item.current
|
|
||||||
? 'text-indigo-600 dark:text-white'
|
|
||||||
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
|
||||||
'size-6 shrink-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
|
|
||||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
|
||||||
{teams.map((team) => (
|
|
||||||
<li key={team.name}>
|
|
||||||
<a
|
|
||||||
href={team.href}
|
|
||||||
className={classNames(
|
|
||||||
team.current
|
|
||||||
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
|
|
||||||
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
|
||||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
team.current
|
|
||||||
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
|
|
||||||
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
|
|
||||||
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{team.initial}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{team.name}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li className="mt-auto">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
<Cog6ToothIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white"
|
|
||||||
/>
|
|
||||||
Settings
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Topbar + Content */}
|
|
||||||
<div className="lg:pl-72">
|
|
||||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900 dark:shadow-none">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
className="-m-2.5 p-2.5 text-gray-700 hover:text-gray-900 lg:hidden dark:text-gray-400 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Open sidebar</span>
|
|
||||||
<Bars3Icon aria-hidden="true" className="size-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div aria-hidden="true" className="h-6 w-px bg-gray-200 lg:hidden dark:bg-white/10" />
|
|
||||||
|
|
||||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
|
||||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
|
||||||
<input
|
|
||||||
name="search"
|
|
||||||
placeholder="Search"
|
|
||||||
aria-label="Search"
|
|
||||||
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
|
|
||||||
/>
|
|
||||||
<MagnifyingGlassIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
<span className="sr-only">View notifications</span>
|
|
||||||
<BellIcon aria-hidden="true" className="size-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div aria-hidden="true" className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200 dark:lg:bg-white/10" />
|
|
||||||
|
|
||||||
{/* Profile dropdown */}
|
|
||||||
<Menu as="div" className="relative">
|
|
||||||
<MenuButton className="relative flex items-center">
|
|
||||||
<span className="absolute -inset-1.5" />
|
|
||||||
<span className="sr-only">Open user menu</span>
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
|
||||||
className="size-8 rounded-full bg-gray-50 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
|
||||||
/>
|
|
||||||
<span className="hidden lg:flex lg:items-center">
|
|
||||||
<span aria-hidden="true" className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white">
|
|
||||||
Tom Cook
|
|
||||||
</span>
|
|
||||||
<ChevronDownIcon aria-hidden="true" className="ml-2 size-5 text-gray-400 dark:text-gray-500" />
|
|
||||||
</span>
|
|
||||||
</MenuButton>
|
|
||||||
<MenuItems
|
|
||||||
transition
|
|
||||||
className="absolute right-0 z-10 mt-2.5 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg outline-1 outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
|
||||||
>
|
|
||||||
{userNavigation.map((item) => (
|
|
||||||
<MenuItem key={item.name}>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</MenuItems>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="py-10">
|
|
||||||
<div className="px-4 sm:px-6 lg:px-8">
|
|
||||||
{/* Hier kommt dein eigentlicher Inhalt hin */}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,12 +1,11 @@
|
|||||||
// /components/ui/Button.tsx
|
// /components/ui/Button.tsx
|
||||||
|
|
||||||
// src/components/Button.tsx
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'soft';
|
export type ButtonVariant = 'primary' | 'secondary' | 'soft';
|
||||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
export type ButtonShape = 'default' | 'pill' | 'circle';
|
export type ButtonShape = 'default' | 'pill' | 'circle';
|
||||||
|
export type ButtonTone = 'indigo' | 'gray' | 'rose';
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
@ -16,24 +15,58 @@ export interface ButtonProps
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
iconPosition?: 'leading' | 'trailing';
|
iconPosition?: 'leading' | 'trailing';
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
tone?: ButtonTone; // NEU: Farbton
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
'inline-flex items-center justify-center font-semibold shadow-xs ' +
|
'inline-flex items-center justify-center font-semibold shadow-xs ' +
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500 ' +
|
'focus-visible:outline-2 focus-visible:outline-offset-2 ' +
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150';
|
'disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150';
|
||||||
|
|
||||||
// Farben / Styles wie in deinen Beispielen
|
// Farb-Kombinationen pro Variant + Tone
|
||||||
const variantClasses: Record<ButtonVariant, string> = {
|
const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
||||||
primary:
|
primary: {
|
||||||
'bg-indigo-600 text-white hover:bg-indigo-500 ' +
|
indigo:
|
||||||
'dark:bg-indigo-500 dark:text-white dark:hover:bg-indigo-400 dark:shadow-none',
|
'bg-indigo-600 text-white hover:bg-indigo-500 ' +
|
||||||
secondary:
|
'focus-visible:outline-indigo-600 ' +
|
||||||
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
'dark:bg-indigo-500 dark:text-white dark:hover:bg-indigo-400 dark:shadow-none dark:focus-visible:outline-indigo-500',
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
gray:
|
||||||
soft:
|
'bg-gray-900 text-white hover:bg-gray-800 ' +
|
||||||
'bg-indigo-50 text-indigo-600 hover:bg-indigo-100 ' +
|
'focus-visible:outline-gray-900 ' +
|
||||||
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
|
'dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:shadow-none dark:focus-visible:outline-gray-700',
|
||||||
|
rose:
|
||||||
|
'bg-rose-600 text-white hover:bg-rose-500 ' +
|
||||||
|
'focus-visible:outline-rose-600 ' +
|
||||||
|
'dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400 dark:shadow-none dark:focus-visible:outline-rose-500',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
indigo:
|
||||||
|
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
||||||
|
'focus-visible:outline-indigo-600 ' +
|
||||||
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20 dark:focus-visible:outline-indigo-500',
|
||||||
|
gray:
|
||||||
|
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
||||||
|
'focus-visible:outline-gray-900 ' +
|
||||||
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20 dark:focus-visible:outline-gray-700',
|
||||||
|
rose:
|
||||||
|
'bg-white text-rose-600 inset-ring inset-ring-rose-200 hover:bg-rose-50 ' +
|
||||||
|
'focus-visible:outline-rose-600 ' +
|
||||||
|
'dark:bg-white/10 dark:text-rose-300 dark:shadow-none dark:inset-ring-rose-500/40 dark:hover:bg-rose-500/10 dark:focus-visible:outline-rose-500',
|
||||||
|
},
|
||||||
|
soft: {
|
||||||
|
indigo:
|
||||||
|
'bg-indigo-50 text-indigo-600 hover:bg-indigo-100 ' +
|
||||||
|
'focus-visible:outline-indigo-600 ' +
|
||||||
|
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30 dark:focus-visible:outline-indigo-500',
|
||||||
|
gray:
|
||||||
|
'bg-gray-100 text-gray-800 hover:bg-gray-200 ' +
|
||||||
|
'focus-visible:outline-gray-900 ' +
|
||||||
|
'dark:bg-gray-700/40 dark:text-gray-100 dark:shadow-none dark:hover:bg-gray-700/60 dark:focus-visible:outline-gray-600',
|
||||||
|
rose:
|
||||||
|
'bg-rose-50 text-rose-600 hover:bg-rose-100 ' +
|
||||||
|
'focus-visible:outline-rose-600 ' +
|
||||||
|
'dark:bg-rose-500/20 dark:text-rose-300 dark:shadow-none dark:hover:bg-rose-500/30 dark:focus-visible:outline-rose-500',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Größen wie in deinen Snippets (rectangular)
|
// Größen wie in deinen Snippets (rectangular)
|
||||||
@ -78,6 +111,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
|
tone = 'indigo', // NEU: Default
|
||||||
size = 'md',
|
size = 'md',
|
||||||
shape = 'default',
|
shape = 'default',
|
||||||
icon,
|
icon,
|
||||||
@ -106,7 +140,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
baseClasses,
|
baseClasses,
|
||||||
variantClasses[variant],
|
variantToneClasses[variant][tone],
|
||||||
getSizeClasses(size, shape),
|
getSizeClasses(size, shape),
|
||||||
gapClasses,
|
gapClasses,
|
||||||
fullWidth && 'w-full',
|
fullWidth && 'w-full',
|
||||||
|
|||||||
198
components/ui/Dropdown.tsx
Normal file
198
components/ui/Dropdown.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
// components/ui/Dropdown.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
} from '@heroicons/react/20/solid';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
type DropdownTone = 'default' | 'danger';
|
||||||
|
|
||||||
|
export type DropdownItem = {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
tone?: DropdownTone;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DropdownSection = {
|
||||||
|
id?: string;
|
||||||
|
items: DropdownItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DropdownTriggerVariant = 'button' | 'icon';
|
||||||
|
|
||||||
|
export interface DropdownProps {
|
||||||
|
/** Button-Label (für Trigger "button") */
|
||||||
|
label?: string;
|
||||||
|
/** aria-label für Trigger "icon" */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Ausrichtung des Menüs */
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
/** Darstellung des Triggers (normaler Button oder nur Icon) */
|
||||||
|
triggerVariant?: DropdownTriggerVariant;
|
||||||
|
/** Optionaler Header im Dropdown (z.B. "Signed in as …") */
|
||||||
|
header?: React.ReactNode;
|
||||||
|
/** Sektionen (werden bei >1 Sektion automatisch mit Divider getrennt) */
|
||||||
|
sections: DropdownSection[];
|
||||||
|
|
||||||
|
/** Optional: zusätzliche Klassen für den Trigger-Button */
|
||||||
|
triggerClassName?: string;
|
||||||
|
/** Optional: zusätzliche Klassen für das MenuItems-Panel */
|
||||||
|
menuClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── interne Helfer ───────── */
|
||||||
|
|
||||||
|
const itemBaseClasses =
|
||||||
|
'block px-4 py-2 text-sm text-gray-700 ' +
|
||||||
|
'data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden ' +
|
||||||
|
'dark:text-gray-300 dark:data-focus:bg-white/5 dark:data-focus:text-white';
|
||||||
|
|
||||||
|
const itemWithIconClasses =
|
||||||
|
'group flex items-center gap-x-3 px-4 py-2 text-sm text-gray-700 ' +
|
||||||
|
'data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden ' +
|
||||||
|
'dark:text-gray-300 dark:data-focus:bg-white/5 dark:data-focus:text-white';
|
||||||
|
|
||||||
|
const iconClasses =
|
||||||
|
'flex size-5 shrink-0 items-center justify-center text-gray-400 group-data-focus:text-gray-500 ' +
|
||||||
|
'dark:text-gray-500 dark:group-data-focus:text-white';
|
||||||
|
|
||||||
|
const toneClasses: Record<DropdownTone, string> = {
|
||||||
|
default: '',
|
||||||
|
danger:
|
||||||
|
'text-rose-600 data-focus:text-rose-700 dark:text-rose-400 dark:data-focus:text-rose-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderItemContent(item: DropdownItem) {
|
||||||
|
const hasIcon = !!item.icon;
|
||||||
|
|
||||||
|
if (hasIcon) {
|
||||||
|
return (
|
||||||
|
<span className={clsx(itemWithIconClasses, item.tone && toneClasses[item.tone])}>
|
||||||
|
<span aria-hidden="true" className={iconClasses}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(itemBaseClasses, item.tone && toneClasses[item.tone])}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Dropdown-Komponente ───────── */
|
||||||
|
|
||||||
|
export function Dropdown({
|
||||||
|
label = 'Options',
|
||||||
|
ariaLabel = 'Open options',
|
||||||
|
align = 'right',
|
||||||
|
triggerVariant = 'button',
|
||||||
|
header,
|
||||||
|
sections,
|
||||||
|
triggerClassName,
|
||||||
|
menuClassName,
|
||||||
|
}: DropdownProps) {
|
||||||
|
const hasDividers = sections.length > 1;
|
||||||
|
|
||||||
|
const alignmentClasses =
|
||||||
|
align === 'left'
|
||||||
|
? 'left-0 origin-top-left'
|
||||||
|
: 'right-0 origin-top-right';
|
||||||
|
|
||||||
|
const triggerIsButton = triggerVariant === 'button';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu as="div" className="relative inline-block">
|
||||||
|
<MenuButton
|
||||||
|
className={clsx(
|
||||||
|
triggerIsButton
|
||||||
|
? 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20'
|
||||||
|
: 'flex items-center rounded-full text-gray-400 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:text-gray-300 dark:focus-visible:outline-indigo-500',
|
||||||
|
triggerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{triggerIsButton ? (
|
||||||
|
<>
|
||||||
|
{label}
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="-mr-1 size-5 text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="sr-only">{ariaLabel}</span>
|
||||||
|
<EllipsisVerticalIcon aria-hidden="true" className="size-5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuItems
|
||||||
|
transition
|
||||||
|
className={clsx(
|
||||||
|
'absolute z-20 mt-2 w-56 rounded-md bg-white shadow-lg outline-1 outline-black/5 ' +
|
||||||
|
'transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 ' +
|
||||||
|
'data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in ' +
|
||||||
|
'dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
|
||||||
|
hasDividers && 'divide-y divide-gray-100 dark:divide-white/10',
|
||||||
|
alignmentClasses,
|
||||||
|
menuClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Optionaler Header (z.B. "Signed in as …") */}
|
||||||
|
{header && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sektionen mit/ohne Divider */}
|
||||||
|
{sections.map((section, sectionIndex) => (
|
||||||
|
<div key={section.id ?? sectionIndex} className="py-1">
|
||||||
|
{section.items.map((item, itemIndex) => {
|
||||||
|
const key = item.id ?? `${sectionIndex}-${itemIndex}`;
|
||||||
|
const commonProps = {
|
||||||
|
className: itemBaseClasses,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem key={key} disabled={item.disabled}>
|
||||||
|
{item.href ? (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
onClick={item.onClick}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
{renderItemContent(item)}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={item.onClick}
|
||||||
|
className="block w-full text-left"
|
||||||
|
>
|
||||||
|
{renderItemContent(item)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</MenuItems>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
67
components/ui/Skeleton.tsx
Normal file
67
components/ui/Skeleton.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// components/ui/Skeleton.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | boolean | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkeletonProps = {
|
||||||
|
/** Zusätzliche Klassen für den Wrapper */
|
||||||
|
className?: string;
|
||||||
|
/** Beschriftung für Screenreader – z.B. "Benutzer" */
|
||||||
|
primaryLabel?: string;
|
||||||
|
/** Zweite Zeile für Screenreader – z.B. "Profildaten werden geladen" */
|
||||||
|
secondaryLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilfsfunktion: wähle eine Tailwind-Breite basierend auf der Textlänge.
|
||||||
|
* Wenn kein Text -> keine Breitenklasse (keine Simulation).
|
||||||
|
*/
|
||||||
|
function getWidthClass(label?: string) {
|
||||||
|
const len = (label ?? '').length;
|
||||||
|
|
||||||
|
if (len === 0) return ''; // keine Breite, wenn kein Text
|
||||||
|
if (len <= 6) return 'w-16'; // kurz
|
||||||
|
if (len <= 12) return 'w-24'; // mittel
|
||||||
|
if (len <= 20) return 'w-32'; // länger
|
||||||
|
return 'w-40'; // sehr lang
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton-Placeholder für das User-Menü (Avatar + Text).
|
||||||
|
* Es werden nur Balken gerendert, wenn auch Labels übergeben wurden.
|
||||||
|
*/
|
||||||
|
export function Skeleton({
|
||||||
|
className,
|
||||||
|
primaryLabel,
|
||||||
|
secondaryLabel,
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const primaryWidthClass = getWidthClass(primaryLabel);
|
||||||
|
const secondaryWidthClass = getWidthClass(secondaryLabel);
|
||||||
|
|
||||||
|
const srText = [primaryLabel, secondaryLabel].filter(Boolean).join(' – ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('flex animate-pulse items-center space-x-4', className)}>
|
||||||
|
{/* Nur für Screenreader, wenn überhaupt Text vorhanden */}
|
||||||
|
{srText && <span className="sr-only">{srText}</span>}
|
||||||
|
|
||||||
|
<div className="size-8 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* erste Zeile – nur wenn primaryLabel gesetzt ist */}
|
||||||
|
{primaryLabel && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'h-2 rounded bg-gray-200 dark:bg-gray-700',
|
||||||
|
primaryWidthClass,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
328
components/ui/Table.tsx
Normal file
328
components/ui/Table.tsx
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
// components/ui/Table.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | boolean | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export type TableColumn<T> = {
|
||||||
|
/** Schlüssel im Datensatz, z.B. 'inventoryNumber' */
|
||||||
|
key: keyof T;
|
||||||
|
/** Spaltenüberschrift */
|
||||||
|
header: string;
|
||||||
|
/** Kann die Spalte sortiert werden? */
|
||||||
|
sortable?: boolean;
|
||||||
|
/** Kann die Spalte ausgeblendet werden? */
|
||||||
|
canHide?: boolean,
|
||||||
|
/** Optional eigene Klassen für die TH-Zelle */
|
||||||
|
headerClassName?: string;
|
||||||
|
/** Optional eigene Klassen für die TD-Zelle */
|
||||||
|
cellClassName?: string;
|
||||||
|
/** Custom Renderer für Zellen (z.B. formatDate) */
|
||||||
|
render?: (row: T) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TableProps<T> {
|
||||||
|
data: T[];
|
||||||
|
columns: TableColumn<T>[];
|
||||||
|
/** Eindeutige ID pro Zeile */
|
||||||
|
getRowId: (row: T) => string;
|
||||||
|
/** Checkboxen + Auswahl aktivieren */
|
||||||
|
selectable?: boolean;
|
||||||
|
/** Callback bei Änderung der Auswahl */
|
||||||
|
onSelectionChange?: (selected: T[]) => void;
|
||||||
|
/** Optional: Actions in der letzten Spalte rendern */
|
||||||
|
renderActions?: (row: T) => React.ReactNode;
|
||||||
|
/** Optional: Header-Text für die Actions-Spalte */
|
||||||
|
actionsHeader?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortState<T> = {
|
||||||
|
key: keyof T | null;
|
||||||
|
direction: SortDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Table<T>(props: TableProps<T>) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getRowId,
|
||||||
|
selectable = false,
|
||||||
|
onSelectionChange,
|
||||||
|
renderActions,
|
||||||
|
actionsHeader = '',
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [sort, setSort] = React.useState<SortState<T>>({
|
||||||
|
key: null,
|
||||||
|
direction: 'asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
|
const headerCheckboxRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Sortierte Daten
|
||||||
|
const sortedData = React.useMemo(() => {
|
||||||
|
if (!sort.key) return data;
|
||||||
|
|
||||||
|
const col = columns.find((c) => c.key === sort.key);
|
||||||
|
if (!col) return data;
|
||||||
|
|
||||||
|
const sorted = [...data].sort((a, b) => {
|
||||||
|
const va = (a as any)[sort.key!];
|
||||||
|
const vb = (b as any)[sort.key!];
|
||||||
|
|
||||||
|
if (va == null && vb == null) return 0;
|
||||||
|
if (va == null) return sort.direction === 'asc' ? -1 : 1;
|
||||||
|
if (vb == null) return sort.direction === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
if (typeof va === 'number' && typeof vb === 'number') {
|
||||||
|
return sort.direction === 'asc' ? va - vb : vb - va;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date / ISO-String
|
||||||
|
const sa = va instanceof Date ? va.getTime() : String(va);
|
||||||
|
const sb = vb instanceof Date ? vb.getTime() : String(vb);
|
||||||
|
|
||||||
|
if (sa < sb) return sort.direction === 'asc' ? -1 : 1;
|
||||||
|
if (sa > sb) return sort.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}, [data, columns, sort]);
|
||||||
|
|
||||||
|
// Selection / Tri-State Header Checkbox
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!selectable || !headerCheckboxRef.current) return;
|
||||||
|
|
||||||
|
const allIds = sortedData.map((row) => getRowId(row));
|
||||||
|
const checkedCount = allIds.filter((id) => selectedIds.includes(id)).length;
|
||||||
|
const isAll = checkedCount === allIds.length && allIds.length > 0;
|
||||||
|
const isNone = checkedCount === 0;
|
||||||
|
const isIndeterminate = !isAll && !isNone;
|
||||||
|
|
||||||
|
headerCheckboxRef.current.checked = isAll;
|
||||||
|
headerCheckboxRef.current.indeterminate = isIndeterminate;
|
||||||
|
}, [sortedData, selectedIds, selectable, getRowId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
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' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if (!selectable) return;
|
||||||
|
const allIds = sortedData.map((row) => getRowId(row));
|
||||||
|
const allSelected = allIds.length > 0 && allIds.every((id) => selectedIds.includes(id));
|
||||||
|
|
||||||
|
setSelectedIds(allSelected ? [] : allIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(id: string) {
|
||||||
|
if (!selectable) return;
|
||||||
|
setSelectedIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="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-visible lg:overflow-x-auto">
|
||||||
|
<table className="min-w-full 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 && (
|
||||||
|
<th scope="col" className="w-12 px-4">
|
||||||
|
<div className="group grid size-4 grid-cols-1">
|
||||||
|
<input
|
||||||
|
ref={headerCheckboxRef}
|
||||||
|
type="checkbox"
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="opacity-0 group-has-checked:opacity-100"
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-0 group-has-indeterminate:opacity-100"
|
||||||
|
d="M3 7H11"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{columns.map((col) => {
|
||||||
|
const isSorted = sort.key === col.key;
|
||||||
|
const isSortable = col.sortable;
|
||||||
|
const isHideable = col.canHide;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={String(col.key)}
|
||||||
|
scope="col"
|
||||||
|
className={classNames(
|
||||||
|
'py-3.5 px-4 text-left text-sm font-semibold text-gray-900 dark:text-white',
|
||||||
|
col.headerClassName,
|
||||||
|
isHideable
|
||||||
|
? 'hidden lg:table-cell'
|
||||||
|
: '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSortable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSort(col.key)}
|
||||||
|
className="group inline-flex items-center"
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'ml-2 flex-none rounded-sm',
|
||||||
|
isSorted
|
||||||
|
? 'bg-gray-100 text-gray-900 group-hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:group-hover:bg-gray-700'
|
||||||
|
: 'invisible text-gray-400 group-hover:visible group-focus:visible dark:text-gray-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
'size-4',
|
||||||
|
isSorted && sort.direction === 'desc' && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
col.header
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{renderActions && (
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="py-3.5 px-4 text-sm font-semibold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{actionsHeader}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-900/40">
|
||||||
|
{sortedData.map((row) => {
|
||||||
|
const id = getRowId(row);
|
||||||
|
const isSelected = selectedIds.includes(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={id}
|
||||||
|
className={classNames(isSelected && 'bg-gray-50 dark:bg-gray-800/60')}
|
||||||
|
>
|
||||||
|
{selectable && (
|
||||||
|
<td className="px-4">
|
||||||
|
<div className="group grid size-4 grid-cols-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleRow(id)}
|
||||||
|
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="opacity-0 group-has-checked:opacity-100"
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-0 group-has-indeterminate:opacity-100"
|
||||||
|
d="M3 7H11"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={String(col.key)}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
|
||||||
|
col.cellClassName,
|
||||||
|
col.canHide && 'hidden lg:table-cell', // <-- HIER
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{col.render ? col.render(row) : String((row as any)[col.key] ?? '—')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{renderActions && (
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
|
||||||
|
{renderActions(row)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{sortedData.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0)}
|
||||||
|
className="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Keine Daten vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +0,0 @@
|
|||||||
// middleware.ts
|
|
||||||
export { default } from 'next-auth/middleware';
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
// alles schützen, außer:
|
|
||||||
// - /api/auth (NextAuth selbst)
|
|
||||||
// - /login (Login-Seite)
|
|
||||||
// - /_next (Next.js Assets)
|
|
||||||
// - /favicon.ico usw.
|
|
||||||
'/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `manufacturer` to the `Device` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `model` to the `Device` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `name` to the `Device` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Device" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"manufacturer" TEXT NOT NULL,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"inventoryNumber" TEXT NOT NULL,
|
||||||
|
"serialNumber" TEXT,
|
||||||
|
"productNumber" TEXT,
|
||||||
|
"comment" TEXT,
|
||||||
|
"ipv4Address" TEXT,
|
||||||
|
"ipv6Address" TEXT,
|
||||||
|
"macAddress" TEXT,
|
||||||
|
"username" TEXT,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"groupId" TEXT,
|
||||||
|
"locationId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"updatedById" TEXT,
|
||||||
|
CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DeviceGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Device_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Device" ("comment", "createdAt", "createdById", "groupId", "id", "inventoryNumber", "locationId", "productNumber", "serialNumber", "updatedAt", "updatedById") SELECT "comment", "createdAt", "createdById", "groupId", "id", "inventoryNumber", "locationId", "productNumber", "serialNumber", "updatedAt", "updatedById" FROM "Device";
|
||||||
|
DROP TABLE "Device";
|
||||||
|
ALTER TABLE "new_Device" RENAME TO "Device";
|
||||||
|
CREATE UNIQUE INDEX "Device_inventoryNumber_key" ON "Device"("inventoryNumber");
|
||||||
|
CREATE UNIQUE INDEX "Device_ipv4Address_key" ON "Device"("ipv4Address");
|
||||||
|
CREATE UNIQUE INDEX "Device_ipv6Address_key" ON "Device"("ipv6Address");
|
||||||
|
CREATE UNIQUE INDEX "Device_macAddress_key" ON "Device"("macAddress");
|
||||||
|
CREATE UNIQUE INDEX "Device_username_key" ON "Device"("username");
|
||||||
|
CREATE UNIQUE INDEX "Device_passwordHash_key" ON "Device"("passwordHash");
|
||||||
|
CREATE INDEX "Device_inventoryNumber_idx" ON "Device"("inventoryNumber");
|
||||||
|
CREATE INDEX "Device_groupId_idx" ON "Device"("groupId");
|
||||||
|
CREATE INDEX "Device_locationId_idx" ON "Device"("locationId");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@ -78,11 +78,20 @@ model Device {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
// Fachliche Felder
|
// Fachliche Felder
|
||||||
|
name String // Anzeigename / Bezeichnung des Geräts
|
||||||
|
manufacturer String // Hersteller
|
||||||
|
model String // Modellbezeichnung
|
||||||
inventoryNumber String @unique // Inventar-Nummer
|
inventoryNumber String @unique // Inventar-Nummer
|
||||||
serialNumber String? // Seriennummer
|
serialNumber String? // Seriennummer
|
||||||
productNumber String? // Produktnummer
|
productNumber String? // Produktnummer
|
||||||
comment String?
|
comment String?
|
||||||
|
|
||||||
|
ipv4Address String? @unique // IPv4-Adresse
|
||||||
|
ipv6Address String? @unique // IPv6-Adresse
|
||||||
|
macAddress String? @unique // MAC-Adresse
|
||||||
|
username String? @unique // Benutzername
|
||||||
|
passwordHash String? @unique // Passwort
|
||||||
|
|
||||||
// Beziehungen
|
// Beziehungen
|
||||||
group DeviceGroup? @relation(fields: [groupId], references: [id])
|
group DeviceGroup? @relation(fields: [groupId], references: [id])
|
||||||
groupId String?
|
groupId String?
|
||||||
|
|||||||
47
proxy.ts
Normal file
47
proxy.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// proxy.ts
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
|
export default async function proxy(request: NextRequest) {
|
||||||
|
const { pathname, search } = request.nextUrl;
|
||||||
|
|
||||||
|
// Sachen, die NIE geschützt werden sollen:
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/api/auth') ||
|
||||||
|
pathname.startsWith('/login') ||
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname === '/favicon.ico'
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token aus NextAuth lesen
|
||||||
|
const token = await getToken({
|
||||||
|
req: request,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
// nicht eingeloggt -> auf /login umleiten
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
// nach Login wieder zurück zur ursprünglichen Seite
|
||||||
|
loginUrl.searchParams.set('callbackUrl', pathname + search);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eingeloggt und auf Root "/" -> nach /dashboard umleiten
|
||||||
|
if (pathname === '/') {
|
||||||
|
const dashboardUrl = new URL('/dashboard', request.url);
|
||||||
|
return NextResponse.redirect(dashboardUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// alles andere normal durchlassen
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
// alles abfangen, außer reinen Static-Kram
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -29,6 +29,6 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
, "app/page.tsx.bak" ],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user