diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..8ca9e52 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -0,0 +1,14 @@ +// app/(app)/dashboard/page.tsx + +export default function DashboardPage() { + return ( + <> +

+ Geräte-Inventar +

+

+ Hier könntest du gleich als Nächstes eine Übersicht deiner Geräte einbauen. +

+ + ); +} diff --git a/app/(app)/devices/page.tsx b/app/(app)/devices/page.tsx new file mode 100644 index 0000000..02fe1a7 --- /dev/null +++ b/app/(app)/devices/page.tsx @@ -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[] = [ + { + 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 */} +
+
+

+ Geräte +

+

+ Übersicht aller erfassten Geräte im Inventar. +

+
+ + +
+ + {/* Tabelle */} +
+ + data={devices} + columns={columns} + getRowId={(row) => row.id} + selectable + actionsHeader="" + renderActions={(row) => ( +
+ {/* Desktop: drei Icon-Buttons nebeneinander */} +
+
+ + {/* Mobile / kleine Screens: kompaktes Dropdown mit Ellipsis-Trigger */} +
+ , + onClick: () => console.log('Details', row.id), + }, + { + label: 'Bearbeiten', + icon: , + onClick: () => console.log('Bearbeiten', row.id), + }, + { + label: 'Löschen', + icon: , + tone: 'danger', + onClick: () => console.log('Löschen', row.id), + }, + ], + }, + ]} + /> +
+
+ )} + /> +
+ + ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..a2641a9 --- /dev/null +++ b/app/(app)/layout.tsx @@ -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) { + 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 ( + + + + Open user menu + + {/* Avatar mit Zufallsfarbe & Initial */} +
+ {avatarInitial} +
+ + + + +
+ + {userNavigation.map((item) => ( + + {item.name === 'Abmelden' ? ( + + ) : ( + + {item.name} + + )} + + ))} + +
+ ); +} + +/* ───────── 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 ( +
+ {/* Mobile Sidebar */} + + + +
+ + +
+ +
+
+ + {/* Sidebar mobil */} +
+
+ Inventar +
+ +
+
+
+
+ + {/* Sidebar Desktop */} +
+
+
+ Inventar +
+ +
+
+ + {/* Topbar + Inhalt */} +
+
+ + +