This commit is contained in:
Linrador 2025-11-14 17:03:26 +01:00
parent 73419f468e
commit 4dd63ca3bb
16 changed files with 1455 additions and 375 deletions

View 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
View 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
View 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>
);
}

View File

@ -1,3 +1,5 @@
// /app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

View File

@ -1,21 +1,24 @@
// app/login/page.tsx (oder eine Client-Komponente)
// app/login/page.tsx
'use client';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
import LoginForm from '@/components/auth/LoginForm';
export default function LoginPage() {
const searchParams = useSearchParams();
const callbackUrl = searchParams?.get('callbackUrl') ?? '/dashboard';
return (
<LoginForm
showSocialLogin={false}
onSubmit={async ({ email, password }) => {
// NextAuth kümmert sich um Redirect (inkl. callbackUrl)
await signIn('credentials', {
email,
password,
// callbackUrl: '/', // optional, sonst nimmt er den ursprünglichen Pfad
});
}}
showSocialLogin={false}
onSubmit={async ({ email, password }) => {
await signIn('credentials', {
email,
password,
callbackUrl,
});
}}
/>
);
}

View File

@ -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>
)
}

View File

@ -1,12 +1,11 @@
// /components/ui/Button.tsx
// src/components/Button.tsx
import * as React from 'react';
import clsx from 'clsx';
export type ButtonVariant = 'primary' | 'secondary' | 'soft';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type ButtonShape = 'default' | 'pill' | 'circle';
export type ButtonTone = 'indigo' | 'gray' | 'rose';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@ -16,24 +15,58 @@ export interface ButtonProps
icon?: React.ReactNode;
iconPosition?: 'leading' | 'trailing';
fullWidth?: boolean;
tone?: ButtonTone; // NEU: Farbton
}
const baseClasses =
'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';
// Farben / Styles wie in deinen Beispielen
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-indigo-600 text-white hover:bg-indigo-500 ' +
'dark:bg-indigo-500 dark:text-white dark:hover:bg-indigo-400 dark:shadow-none',
secondary:
'bg-white text-gray-900 inset-ring 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',
soft:
'bg-indigo-50 text-indigo-600 hover:bg-indigo-100 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
// Farb-Kombinationen pro Variant + Tone
const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
primary: {
indigo:
'bg-indigo-600 text-white hover:bg-indigo-500 ' +
'focus-visible:outline-indigo-600 ' +
'dark:bg-indigo-500 dark:text-white dark:hover:bg-indigo-400 dark:shadow-none dark:focus-visible:outline-indigo-500',
gray:
'bg-gray-900 text-white hover:bg-gray-800 ' +
'focus-visible:outline-gray-900 ' +
'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)
@ -78,6 +111,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
tone = 'indigo', // NEU: Default
size = 'md',
shape = 'default',
icon,
@ -106,7 +140,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
type={type}
className={clsx(
baseClasses,
variantClasses[variant],
variantToneClasses[variant][tone],
getSizeClasses(size, shape),
gapClasses,
fullWidth && 'w-full',

198
components/ui/Dropdown.tsx Normal file
View 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;

View 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
View 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>
);
}

View File

@ -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).*)',
],
};

Binary file not shown.

View File

@ -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;

View File

@ -78,11 +78,20 @@ model Device {
id String @id @default(cuid())
// Fachliche Felder
name String // Anzeigename / Bezeichnung des Geräts
manufacturer String // Hersteller
model String // Modellbezeichnung
inventoryNumber String @unique // Inventar-Nummer
serialNumber String? // Seriennummer
productNumber String? // Produktnummer
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
group DeviceGroup? @relation(fields: [groupId], references: [id])
groupId String?

47
proxy.ts Normal file
View 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).*)',
],
};

View File

@ -29,6 +29,6 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
, "app/page.tsx.bak" ],
"exclude": ["node_modules"]
}