This commit is contained in:
Linrador 2026-01-29 11:47:28 +01:00
parent 5e6f7e872d
commit e2a71e4fa5
9 changed files with 442 additions and 863 deletions

View File

@ -143,21 +143,21 @@ function DeviceDetailsGrid({
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{/* Zeile 1: Badge + Buttons nebeneinander */} {/* Zeile 1: Badge + Buttons nebeneinander */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex items-center justify-between gap-2">
{/* Badge */} {/* Badge */}
<div className="flex w-auto shrink-0"> <div className="min-w-0">
<span <span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`} className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
> >
<span <span
className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`} className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`}
/> />
<span>{statusLabel}</span> <span className="truncate">{statusLabel}</span>
</span> </span>
</div> </div>
{/* rechte Seite: Buttons */} {/* rechte Seite: Buttons */}
<div className="flex flex-row gap-2"> <div className="shrink-0 flex flex-row gap-2">
<Button <Button
size="md" size="md"
variant="primary" variant="primary"
@ -528,7 +528,7 @@ export default function DeviceDetailModal({
{ id: 'details', label: 'Details' }, { id: 'details', label: 'Details' },
{ id: 'history', label: 'Änderungsverlauf' }, { id: 'history', label: 'Änderungsverlauf' },
]} ]}
variant='pillsBrand' variant="pillsBrand"
value={activeTab} value={activeTab}
onChange={(id) => onChange={(id) =>
setActiveTab(id as 'details' | 'history') setActiveTab(id as 'details' | 'history')
@ -553,11 +553,11 @@ export default function DeviceDetailModal({
} }
sidebar={ sidebar={
device ? ( device ? (
<div className="hidden w-full h-full sm:flex sm:flex-col sm:gap-4"> <div className="hidden h-full w-full sm:flex sm:flex-col sm:gap-4">
{/* QR-Code oben, nicht scrollend */} {/* QR-Code oben, nicht scrollend */}
<div className="rounded-lg border border-gray-800 bg-gray-900/70 px-4 py-3 shadow-sm"> <div className="rounded-lg border border-gray-800 bg-gray-900/70 px-4 py-3 shadow-sm">
<div className="mt-2 flex justify-center"> <div className="mt-2 flex justify-center">
<div className="rounded-md bg-black/80 px-3 py-3 flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2 rounded-md bg-black/80 px-3 py-3">
<DeviceQrCode inventoryNumber={device.inventoryNumber} /> <DeviceQrCode inventoryNumber={device.inventoryNumber} />
<p className="text-[13px] font-mono tracking-wide text-gray-100"> <p className="text-[13px] font-mono tracking-wide text-gray-100">
{device.inventoryNumber} {device.inventoryNumber}
@ -566,10 +566,10 @@ export default function DeviceDetailModal({
</div> </div>
</div> </div>
<div className="border-t border-gray-800 dark:border-white/10 mx-1" /> <div className="mx-1 border-t border-gray-800 dark:border-white/10" />
{/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */} {/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */}
<div className="flex-1 min-h-0 overflow-hidden"> <div className="min-h-0 flex-1 overflow-hidden">
<DeviceHistorySidebar <DeviceHistorySidebar
key={device.updatedAt} key={device.updatedAt}
inventoryNumber={device.inventoryNumber} inventoryNumber={device.inventoryNumber}
@ -581,51 +581,61 @@ export default function DeviceDetailModal({
) : undefined ) : undefined
} }
> >
{loading && ( {/* 👇 Nur der Body ist scrollbar, Header & Footer bleiben fix */}
<p className="text-sm text-gray-500 dark:text-gray-400"> <div className="max-h-[70vh] sm:max-h-[75vh] overflow-y-auto pr-2">
Gerätedaten werden geladen {loading && (
</p> <p className="text-sm text-gray-500 dark:text-gray-400">
)} Gerätedaten werden geladen
</p>
)}
{error && ( {error && (
<p className="text-sm text-red-600 dark:text-red-400"> <p className="text-sm text-red-600 dark:text-red-400">
{error} {error}
</p> </p>
)} )}
{!loading && !error && device && ( {!loading && !error && device && (
<> <>
{/* Mobile-Inhalt (Tabs steuern Ansicht) */} {/* Mobile-Inhalt (Tabs steuern Ansicht) */}
<div className="sm:hidden pr-2"> <div className="sm:hidden pr-2">
{activeTab === 'details' ? ( {activeTab === 'details' ? (
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
canEdit={canEdit}
onEdit={
onEdit
? () => onEdit(device.inventoryNumber)
: undefined
}
/>
) : (
<DeviceHistorySidebar
key={device.updatedAt + '-mobile'}
inventoryNumber={device.inventoryNumber}
refreshToken={historyRefresh}
/>
)}
</div>
{/* Desktop */}
<div className="hidden sm:block pr-2">
<DeviceDetailsGrid <DeviceDetailsGrid
device={device} device={device}
onStartLoan={handleStartLoan} onStartLoan={handleStartLoan}
canEdit={canEdit} canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined} onEdit={
onEdit ? () => onEdit(device.inventoryNumber) : undefined
}
/> />
) : ( </div>
<DeviceHistorySidebar </>
key={device.updatedAt + '-mobile'} )}
inventoryNumber={device.inventoryNumber} </div>
refreshToken={historyRefresh}
/>
)}
</div>
{/* Desktop */}
<div className="hidden sm:block pr-2">
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
/>
</div>
</>
)}
</Modal> </Modal>
{device && ( {device && (
<LoanDeviceModal <LoanDeviceModal
open={loanModalOpen} open={loanModalOpen}

View File

@ -577,17 +577,30 @@ export default function UsersTablesClient({
header: 'Arbeitsname', header: 'Arbeitsname',
sortable: true, sortable: true,
render: (row) => { render: (row) => {
const isCurrent = const isCurrent = !!currentUserId && row.nwkennung === currentUserId;
!!currentUserId && row.nwkennung === currentUserId;
const fullName = [row.firstName, row.lastName].filter(Boolean).join(' ');
const group = allGroups.find((g) => g.id === row.groupId);
const canEdit = !!group?.canEditDevices;
return ( return (
<div className="flex items-center gap-2"> <div className="min-w-0">
<span>{row.arbeitsname}</span> <div className="flex items-center gap-2 min-w-0">
{isCurrent && ( <span className="truncate">{row.arbeitsname}</span>
<Badge size="sm" tone="green" variant="flat"> {isCurrent && (
Du <Badge size="sm" tone="green" variant="flat">
</Badge> Du
)} </Badge>
)}
</div>
{/* Mobile-Detailszeile: Vor-/Nachname + Rechte kompakt */}
<div className="sm:hidden mt-0.5 flex flex-wrap gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
{fullName && <span className="truncate">{fullName}</span>}
<span className="before:content-['·'] before:px-1">
Geräte: {canEdit ? 'Ja' : 'Nein'}
</span>
</div>
</div> </div>
); );
}, },
@ -596,20 +609,22 @@ export default function UsersTablesClient({
key: 'lastName', key: 'lastName',
header: 'Nachname', header: 'Nachname',
sortable: true, sortable: true,
headerClassName: 'hidden sm:table-cell',
cellClassName: 'hidden sm:table-cell',
}, },
{ {
key: 'firstName', key: 'firstName',
header: 'Vorname', header: 'Vorname',
sortable: true, sortable: true,
headerClassName: 'hidden sm:table-cell',
cellClassName: 'hidden sm:table-cell',
}, },
// 🔹 NEUE SPALTE: Darf Geräte bearbeiten
{ {
key: 'canEditDevices', key: 'canEditDevices',
header: 'Geräte bearbeiten', header: 'Geräte bearbeiten',
sortable: false, sortable: false,
headerClassName: 'w-40', headerClassName: 'hidden md:table-cell w-40',
cellClassName: 'w-40', cellClassName: 'hidden md:table-cell w-40',
render: (row) => { render: (row) => {
const group = allGroups.find((g) => g.id === row.groupId); const group = allGroups.find((g) => g.id === row.groupId);
const canEdit = !!group?.canEditDevices; const canEdit = !!group?.canEditDevices;
@ -641,13 +656,30 @@ export default function UsersTablesClient({
key: 'groupId', key: 'groupId',
header: 'Gruppe', header: 'Gruppe',
sortable: false, sortable: false,
render: (row) => ( render: (row) => {
<AssignGroupForm const groupName =
user={row} allGroups.find((g) => g.id === row.groupId)?.name ?? 'Ohne Gruppe';
defaultGroupId={row.groupId ?? null}
allGroups={allGroups} return (
/> <>
), {/* Mobile: nur Text (kein gequetschtes Select) */}
<div className="sm:hidden">
<span className="inline-flex max-w-[220px] truncate rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{groupName}
</span>
</div>
{/* Desktop/Tablet: interaktives AssignGroupForm */}
<div className="hidden sm:block">
<AssignGroupForm
user={row}
defaultGroupId={row.groupId ?? null}
allGroups={allGroups}
/>
</div>
</>
);
},
}, },
], ],
[allGroups, currentUserId], [allGroups, currentUserId],
@ -681,7 +713,7 @@ export default function UsersTablesClient({
<div className="space-y-4"> <div className="space-y-4">
{/* 1. Tab-Reihe: Hauptgruppen (Cluster) + Cluster-Löschen-Button */} {/* 1. Tab-Reihe: Hauptgruppen (Cluster) + Cluster-Löschen-Button */}
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<Tabs <Tabs
tabs={mainTabs} tabs={mainTabs}
variant='pillsBrand' variant='pillsBrand'
@ -708,7 +740,7 @@ export default function UsersTablesClient({
{/* 2. Tab-Reihe: Untergruppen + Untergruppe-löschen-Button */} {/* 2. Tab-Reihe: Untergruppen + Untergruppe-löschen-Button */}
{safeActiveMainTab !== 'ungrouped' && subTabs.length > 0 && ( {safeActiveMainTab !== 'ungrouped' && subTabs.length > 0 && (
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<Tabs <Tabs
tabs={subTabs} tabs={subTabs}
variant='pillsBrand' variant='pillsBrand'
@ -736,31 +768,38 @@ export default function UsersTablesClient({
</div> </div>
<div className="relative"> <div className="relative">
<Table<UserWithAvatar> {/* Mobile: nutze volle Breite + horizontales Scrollen statt Quetschen */}
data={tableData} <div className="-mx-4 sm:mx-0">
columns={userColumns} <div className="overflow-x-auto px-4 sm:px-0">
getRowId={(row) => row.nwkennung} <div className="min-w-[860px] sm:min-w-0">
actionsHeader="Aktionen" <Table<UserWithAvatar>
selectable data={tableData}
onSelectionChange={handleSelectionChange} columns={userColumns}
renderActions={(row) => ( getRowId={(row) => row.nwkennung}
<UserRowActions actionsHeader="Aktionen"
user={row} selectable
currentUserId={currentUserId} onSelectionChange={handleSelectionChange}
onEdit={() => openEditForUser(row)} renderActions={(row) => (
onChangePassword={() => openPwForUser(row)} <UserRowActions
onDelete={() => handleDeleteUser(row)} user={row}
isDeleting={ currentUserId={currentUserId}
deleteUserPending && deletingUserId === row.nwkennung onEdit={() => openEditForUser(row)}
} onChangePassword={() => openPwForUser(row)}
isSavingPassword={ onDelete={() => handleDeleteUser(row)}
savingPw && pwUser?.nwkennung === row.nwkennung isDeleting={
} deleteUserPending && deletingUserId === row.nwkennung
/> }
)} isSavingPassword={
defaultSortKey="arbeitsname" savingPw && pwUser?.nwkennung === row.nwkennung
defaultSortDirection="asc" }
/> />
)}
defaultSortKey="arbeitsname"
defaultSortDirection="asc"
/>
</div>
</div>
</div>
{/* Floating Actions in Card, unten mittig über der Tabelle */} {/* Floating Actions in Card, unten mittig über der Tabelle */}
{selectedUserIds.length > 0 && ( {selectedUserIds.length > 0 && (

View File

@ -1,7 +1,7 @@
// /components/GlobalSearch.tsx // /components/GlobalSearch.tsx
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Combobox } from '@headlessui/react'; import { Combobox } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'; import { MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import clsx from 'clsx'; import clsx from 'clsx';
@ -24,6 +24,8 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const [menuTop, setMenuTop] = useState<number | null>(null);
// Geräte nur einmal laden, wenn das erste Mal gesucht wird // Geräte nur einmal laden, wenn das erste Mal gesucht wird
useEffect(() => { useEffect(() => {
@ -105,6 +107,39 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
const hasMenu = const hasMenu =
query.trim().length > 0 && (loading || loadError || filteredDevices.length > 0); query.trim().length > 0 && (loading || loadError || filteredDevices.length > 0);
useEffect(() => {
if (!hasMenu) {
setMenuTop(null);
return;
}
const update = () => {
const el = anchorRef.current;
if (!el) return;
// Nur auf Mobile fix positionieren
const isMobile = window.matchMedia('(max-width: 639px)').matches; // < sm
if (!isMobile) {
setMenuTop(null);
return;
}
const rect = el.getBoundingClientRect();
// +4px entspricht ungefähr "mt-1"
setMenuTop(rect.bottom + 4);
};
update();
window.addEventListener('resize', update);
// capture=true, damit es auch in scroll-containern zuverlässig feuert
window.addEventListener('scroll', update, true);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [hasMenu, query, loading, loadError, filteredDevices.length]);
const handleSelect = (item: DeviceSearchItem | null) => { const handleSelect = (item: DeviceSearchItem | null) => {
if (!item) return; if (!item) return;
onDeviceSelected?.(item.inventoryNumber); onDeviceSelected?.(item.inventoryNumber);
@ -114,7 +149,7 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
return ( return (
<Combobox value={null} onChange={handleSelect} nullable> <Combobox value={null} onChange={handleSelect} nullable>
<div className="relative w-full"> <div ref={anchorRef} className="relative w-full">
{/* Suchfeld */} {/* Suchfeld */}
<MagnifyingGlassIcon <MagnifyingGlassIcon
aria-hidden="true" aria-hidden="true"
@ -131,8 +166,14 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
{/* Dropdown-Menü unterhalb */} {/* Dropdown-Menü unterhalb */}
{hasMenu && ( {hasMenu && (
<Combobox.Options <Combobox.Options
style={menuTop != null ? { top: menuTop } : undefined}
className={clsx( className={clsx(
'absolute z-50 mt-1 max-h-80 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5', // ✅ Mobile: wirklich Viewport-edge-to-edge
'fixed z-50 max-h-80 inset-x-0 overflow-auto bg-white py-1 text-sm shadow-lg ring-1 ring-black/5',
// optional: edge-to-edge fühlt sich ohne rounding oft besser an
'rounded-none sm:rounded-md',
// ✅ Ab sm: wieder "normal" unter dem Input, so wie vorher
'sm:absolute sm:inset-x-0 sm:top-full sm:mt-1',
'dark:bg-gray-800 dark:ring-white/10', 'dark:bg-gray-800 dark:ring-white/10',
)} )}
> >
@ -162,7 +203,7 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
value={device} value={device}
className={({ active }) => className={({ active }) =>
clsx( clsx(
'cursor-pointer px-3 py-2', 'cursor-pointer w-full px-3 py-2', // ⬅️ hier: w-full ergänzt
'flex flex-col gap-0.5', 'flex flex-col gap-0.5',
active active
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-600/25 dark:text-white' ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-600/25 dark:text-white'
@ -184,9 +225,7 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
{device.name || 'Ohne Bezeichnung'} {device.name || 'Ohne Bezeichnung'}
</div> </div>
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400"> <div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
{device.manufacturer && ( {device.manufacturer && <span>{device.manufacturer}</span>}
<span>{device.manufacturer}</span>
)}
{device.group && ( {device.group && (
<span className="before:content-['·'] before:px-1"> <span className="before:content-['·'] before:px-1">
{device.group} {device.group}

View File

@ -126,7 +126,7 @@ export default function ScanModal({ open, onClose, onResult }: ScanModalProps) {
{!error && ( {!error && (
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
Richte deine Kamera auf den QR-Code. Sobald er erkannt wird, öffnet sich das Gerätedetail. Richte deine Kamera auf den QR-Code. Sobald er erkannt wird, öffnen sich die Gerätedetails.
</p> </p>
)} )}

View File

@ -137,7 +137,6 @@ function renderActionButton(
); );
} }
/* ───────── Modal-Komponente ───────── */ /* ───────── Modal-Komponente ───────── */
export function Modal({ export function Modal({
@ -173,163 +172,159 @@ export function Modal({
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50" className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
/> />
<div className="fixed inset-0 z-50 w-screen overflow-y-auto"> {/* Zentrierter Container keine eigene Scrollbar mehr, die Höhe begrenzt das Panel */}
<div className="flex min-h-full items-start justify-center p-4 text-center sm:p-8"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 text-center sm:p-8">
<DialogPanel <DialogPanel
transition transition
className={clsx( className={clsx(
'relative flex w-full max-h-[calc(100vh-8rem)] lg:max-h-[950px] flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' + 'relative flex w-full max-h-[calc(100vh-3rem)] flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
'data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out ' + 'data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out ' +
'data-leave:duration-200 data-leave:ease-in sm:my-8 data-closed:sm:translate-y-0 data-closed:sm:scale-95 ' + 'data-leave:duration-200 data-leave:ease-in ' +
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10', 'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
panelSizeClasses, panelSizeClasses,
)} )}
> >
{/* X-Button oben rechts (optional) */} {/* X-Button oben rechts (optional) */}
{showCloseButton && ( {showCloseButton && (
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block"> <div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white" className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
)}
{/* HEADER + MAIN (Body+Sidebar) */}
<div className="flex-1 flex flex-col min-h-0 bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800 overflow-hidden">
{/* Header */}
<div
className={clsx(
'flex',
isAlert
? 'items-start gap-3 text-left'
: 'flex-col items-center text-center',
)}
> >
{icon && ( <span className="sr-only">Close</span>
<div <XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
)}
{/* HEADER + MAIN (Body+Sidebar) dieser Block bekommt flex-1 und min-h-0 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white px-4 pt-5 pb-4 dark:bg-gray-800 sm:p-6 sm:pb-4">
{/* Header (nicht scrollend) */}
<div
className={clsx(
'flex',
isAlert
? 'items-start gap-3 text-left'
: 'flex-col items-center text-center',
)}
>
{icon && (
<div
className={clsx(
'flex size-12 shrink-0 items-center justify-center rounded-full sm:size-10',
toneStyle.iconBg,
!isAlert && 'mx-auto',
)}
>
<span
aria-hidden="true"
className={clsx( className={clsx(
'flex size-12 shrink-0 items-center justify-center rounded-full sm:size-10', 'flex items-center justify-center',
toneStyle.iconBg, toneStyle.iconColor,
!isAlert && 'mx-auto',
)} )}
> >
<span {icon}
aria-hidden="true" </span>
className={clsx( </div>
'flex items-center justify-center', )}
toneStyle.iconColor,
)} <div
> className={clsx(
{icon} isAlert ? 'mt-3 sm:mt-0 sm:text-left' : 'mt-3 sm:mt-4',
</span> !isAlert && 'w-full',
)}
>
{title && (
<DialogTitle
as="h3"
className={clsx(
'text-base font-semibold text-gray-900 dark:text-white',
!isAlert && 'text-center',
)}
>
{title}
</DialogTitle>
)}
{!children && description && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
{headerExtras && (
<div className="mt-4 w-full">{headerExtras}</div>
)}
</div>
</div>
{/* MAIN: Body + Sidebar bekommt min-h-0 und nur der Body scrollt */}
{(bodyContent || sidebar) && (
<div
className={clsx(
'mt-6 flex min-h-0 flex-1 flex-col',
sidebar
? 'sm:mt-8 sm:flex-row sm:items-stretch sm:gap-6'
: undefined,
)}
>
{/* Linker Inhalt (scrollbar) */}
{bodyContent && (
<div
className={clsx(
'min-h-0 flex-1 overflow-y-auto text-left',
!sidebar && 'mx-auto max-w-2xl',
)}
>
{bodyContent}
</div> </div>
)} )}
<div {/* Rechte Sidebar (nicht scrollbar, eigene interne Scroller möglich) */}
className={clsx( {sidebar && (
isAlert <aside className="sm:min-h-0 sm:w-60 sm:shrink-0 sm:overflow-hidden lg:w-80">
? 'mt-3 sm:mt-0 sm:text-left' {sidebar}
: 'mt-3 sm:mt-4', </aside>
!isAlert && 'w-full', )}
)}
>
{title && (
<DialogTitle
as="h3"
className={clsx(
'text-base font-semibold text-gray-900 dark:text-white',
!isAlert && 'text-center',
)}
>
{title}
</DialogTitle>
)}
{!children && description && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
{headerExtras && (
<div className="mt-4 w-full">{headerExtras}</div>
)}
</div>
</div> </div>
)}
</div>
{/* MAIN: Body + Sidebar nimmt den Rest der Höhe ein */} {/* Footer (immer sichtbar, außerhalb des scrollbaren Bereichs) */}
{(bodyContent || sidebar) && ( {footer ? (
<div footer
className={clsx( ) : hasActions ? (
'mt-6 flex flex-col min-h-0', <div
sidebar className={clsx(
? 'sm:mt-8 sm:flex-row sm:items-stretch sm:gap-6' useGrayFooter
: 'overflow-y-auto' // nur ohne Sidebar soll der Body global scrollen ? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25'
)} : 'px-4 py-3 sm:px-6',
>
{/* Linker Inhalt (Details / Formulare) */}
{bodyContent && (
<div
className={clsx(
'flex-1 min-h-0 overflow-y-auto text-left',
!sidebar && 'mx-auto max-w-2xl',
)}
>
{bodyContent}
</div>
)}
{/* Rechte Sidebar (QR + Verlauf) */}
{sidebar && (
<aside className="sm:min-h-0 sm:overflow-hidden sm:w-60 lg:w-80 sm:shrink-0">
{sidebar}
</aside>
)}
</div>
)} )}
</div> >
{/* Footer */}
{footer ? (
footer
) : hasActions ? (
<div <div
className={clsx( className={clsx(
useGrayFooter 'flex flex-col gap-3',
? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25' hasBothActions && 'sm:flex-row-reverse',
: 'px-4 py-3 sm:px-6',
)} )}
> >
<div {primaryAction &&
className={clsx( renderActionButton(
'flex flex-col gap-3', primaryAction,
hasBothActions && 'sm:flex-row-reverse', hasBothActions ? 'flex-1' : undefined,
)}
{secondaryAction &&
renderActionButton(
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
hasBothActions ? 'flex-1' : undefined,
)} )}
>
{primaryAction &&
renderActionButton(
primaryAction,
hasBothActions ? 'flex-1' : undefined,
)}
{secondaryAction &&
renderActionButton(
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
hasBothActions ? 'flex-1' : undefined,
)}
</div>
</div> </div>
) : null} </div>
</DialogPanel> ) : null}
</div> </DialogPanel>
</div> </div>
</Dialog> </Dialog>
); );
} }
export default Modal; export default Modal;

View File

@ -171,7 +171,7 @@ export default function Table<T>(props: TableProps<T>) {
return ( return (
<div className="relative overflow-visible rounded-lg border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/40"> <div className="relative overflow-visible rounded-lg border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/40">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm dark:divide-white/10"> <table className="w-full table-fixed divide-y divide-gray-200 text-left text-sm dark:divide-white/10">
<thead className="bg-gray-50 dark:bg-gray-800/60"> <thead className="bg-gray-50 dark:bg-gray-800/60">
<tr> <tr>
{selectable && ( {selectable && (

736
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@
"@prisma/client": "^7.1.0", "@prisma/client": "^7.1.0",
"@zxing/browser": "^0.1.5", "@zxing/browser": "^0.1.5",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"next": "16.0.3", "next": "^16.1.4",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"pg": "^8.16.3", "pg": "^8.16.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@ -38,11 +38,11 @@
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"baseline-browser-mapping": "^2.8.32", "baseline-browser-mapping": "^2.9.17",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.3", "eslint-config-next": "16.0.3",
"prisma": "^7.1.0", "prisma": "^6.19.2",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB