geraete/app/(app)/layout.tsx
2025-11-18 14:44:36 +01:00

490 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /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, useRouter } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react';
import { Skeleton } from '@/components/ui/Skeleton';
import ScanModal from '@/components/ScanModal';
import DeviceDetailModal from './devices/DeviceDetailModal';
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 [scanOpen, setScanOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [detailInventoryNumber, setDetailInventoryNumber] = useState<string | null>(null);
const pathname = usePathname();
const router = useRouter();
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);
const handleScanResult = (code: string) => {
const trimmed = code.trim();
if (!trimmed) return;
// 1) Versuch: QR-Code ist eine URL
try {
const url = new URL(trimmed);
const isSameOrigin =
typeof window !== 'undefined' &&
url.origin === window.location.origin;
if (isSameOrigin) {
// Beispiel: /devices/123456
const parts = url.pathname.split('/').filter(Boolean);
const idx = parts.indexOf('devices');
if (idx >= 0 && parts[idx + 1]) {
const inv = decodeURIComponent(parts[idx + 1]);
setDetailInventoryNumber(inv);
setDetailOpen(true);
return;
}
}
// Andere Domain → im Browser extern öffnen
window.open(trimmed, '_blank', 'noopener,noreferrer');
return;
} catch {
// Kein gültiger URL → weiter unten behandeln
}
// 2) Kein URL → pure Inventarnummer in der Webapp
setDetailInventoryNumber(trimmed);
setDetailOpen(true);
};
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"
>
<div className="relative">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
type="search"
name="search"
placeholder="Suchen…"
aria-label="Suchen"
className="block w-full rounded-xl border-0 bg-gray-50 py-1.5 pl-10 pr-3 text-sm text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:placeholder:text-gray-500"
/>
</div>
</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"
onClick={() => setScanOpen(true)}
>
<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>
<ScanModal
open={scanOpen}
onClose={() => setScanOpen(false)}
onResult={handleScanResult}
/>
<DeviceDetailModal
open={detailOpen}
inventoryNumber={detailInventoryNumber}
onClose={() => setDetailOpen(false)}
/>
</div>
</div>
);
}