378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
// /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>
|
||
);
|
||
}
|