geraete/app/(app)/layout.tsx
2025-12-05 13:53:29 +01:00

378 lines
14 KiB
TypeScript
Raw Permalink 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, useEffect } from 'react';
import {
Dialog,
DialogBackdrop,
DialogPanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
} from '@headlessui/react';
import {
Bars3Icon,
BellIcon,
CalendarIcon,
CameraIcon,
ChartPieIcon,
Cog6ToothIcon,
DocumentDuplicateIcon,
FolderIcon,
ComputerDesktopIcon,
UserIcon,
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';
import PersonAvatar from '@/components/ui/UserAvatar';
import UserMenu from '@/components/UserMenu';
import GlobalSearch from '@/components/GlobalSearch';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Geräte', href: '/devices', icon: ComputerDesktopIcon },
{ name: 'Personen', href: '/users', icon: UserIcon },
];
function classNames(...classes: Array<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
/* ───────── 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 avatarName = rawName;
// Avatar-URL bevorzugt aus avatarUrl, sonst Fallback auf image
const avatarUrl =
status === 'authenticated'
? ((session?.user as any).avatarUrl ?? session?.user?.image ?? null)
: null;
const handleScanResult = (code: string) => {
const trimmed = code.trim();
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);
};
// Automatisches Logout, wenn unser eigenes Ablaufdatum erreicht ist
useEffect(() => {
if (status !== 'authenticated') return;
const s = session as any;
const expiresAt: number | undefined = s?.customExpires;
// Falls aus irgendeinem Grund noch nicht gesetzt → nichts tun
if (!expiresAt) return;
const msUntilExpire = expiresAt - Date.now();
if (msUntilExpire <= 0) {
// Session abgelaufen → ausloggen
signOut({ callbackUrl: '/login' });
return;
}
const timeoutId = window.setTimeout(() => {
signOut({ callbackUrl: '/login' });
}, msUntilExpire);
return () => window.clearTimeout(timeoutId);
}, [session, status]);
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 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 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 */}
<div className="grid flex-1 grid-cols-1">
<GlobalSearch
onDeviceSelected={(inv) => {
setDetailInventoryNumber(inv);
setDetailOpen(true);
}}
/>
</div>
<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}
avatarName={avatarName}
avatarUrl={avatarUrl}
/>
)}
</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>
);
}