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">
{/* 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 */}
<div className="flex w-auto shrink-0">
<div className="min-w-0">
<span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
>
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`}
/>
<span>{statusLabel}</span>
<span className="truncate">{statusLabel}</span>
</span>
</div>
{/* rechte Seite: Buttons */}
<div className="flex flex-row gap-2">
<div className="shrink-0 flex flex-row gap-2">
<Button
size="md"
variant="primary"
@ -528,7 +528,7 @@ export default function DeviceDetailModal({
{ id: 'details', label: 'Details' },
{ id: 'history', label: 'Änderungsverlauf' },
]}
variant='pillsBrand'
variant="pillsBrand"
value={activeTab}
onChange={(id) =>
setActiveTab(id as 'details' | 'history')
@ -553,11 +553,11 @@ export default function DeviceDetailModal({
}
sidebar={
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 */}
<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="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} />
<p className="text-[13px] font-mono tracking-wide text-gray-100">
{device.inventoryNumber}
@ -566,10 +566,10 @@ export default function DeviceDetailModal({
</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 */}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
<DeviceHistorySidebar
key={device.updatedAt}
inventoryNumber={device.inventoryNumber}
@ -581,6 +581,8 @@ export default function DeviceDetailModal({
) : undefined
}
>
{/* 👇 Nur der Body ist scrollbar, Header & Footer bleiben fix */}
<div className="max-h-[70vh] sm:max-h-[75vh] overflow-y-auto pr-2">
{loading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Gerätedaten werden geladen
@ -602,7 +604,11 @@ export default function DeviceDetailModal({
device={device}
onStartLoan={handleStartLoan}
canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
onEdit={
onEdit
? () => onEdit(device.inventoryNumber)
: undefined
}
/>
) : (
<DeviceHistorySidebar
@ -619,13 +625,17 @@ export default function DeviceDetailModal({
device={device}
onStartLoan={handleStartLoan}
canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
onEdit={
onEdit ? () => onEdit(device.inventoryNumber) : undefined
}
/>
</div>
</>
)}
</div>
</Modal>
{device && (
<LoanDeviceModal
open={loanModalOpen}

View File

@ -577,18 +577,31 @@ export default function UsersTablesClient({
header: 'Arbeitsname',
sortable: true,
render: (row) => {
const isCurrent =
!!currentUserId && row.nwkennung === currentUserId;
const isCurrent = !!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 (
<div className="flex items-center gap-2">
<span>{row.arbeitsname}</span>
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{row.arbeitsname}</span>
{isCurrent && (
<Badge size="sm" tone="green" variant="flat">
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>
);
},
},
@ -596,20 +609,22 @@ export default function UsersTablesClient({
key: 'lastName',
header: 'Nachname',
sortable: true,
headerClassName: 'hidden sm:table-cell',
cellClassName: 'hidden sm:table-cell',
},
{
key: 'firstName',
header: 'Vorname',
sortable: true,
headerClassName: 'hidden sm:table-cell',
cellClassName: 'hidden sm:table-cell',
},
// 🔹 NEUE SPALTE: Darf Geräte bearbeiten
{
key: 'canEditDevices',
header: 'Geräte bearbeiten',
sortable: false,
headerClassName: 'w-40',
cellClassName: 'w-40',
headerClassName: 'hidden md:table-cell w-40',
cellClassName: 'hidden md:table-cell w-40',
render: (row) => {
const group = allGroups.find((g) => g.id === row.groupId);
const canEdit = !!group?.canEditDevices;
@ -641,13 +656,30 @@ export default function UsersTablesClient({
key: 'groupId',
header: 'Gruppe',
sortable: false,
render: (row) => (
render: (row) => {
const groupName =
allGroups.find((g) => g.id === row.groupId)?.name ?? 'Ohne Gruppe';
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],
@ -681,7 +713,7 @@ export default function UsersTablesClient({
<div className="space-y-4">
{/* 1. Tab-Reihe: Hauptgruppen (Cluster) + Cluster-Löschen-Button */}
<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={mainTabs}
variant='pillsBrand'
@ -708,7 +740,7 @@ export default function UsersTablesClient({
{/* 2. Tab-Reihe: Untergruppen + Untergruppe-löschen-Button */}
{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={subTabs}
variant='pillsBrand'
@ -736,6 +768,10 @@ export default function UsersTablesClient({
</div>
<div className="relative">
{/* Mobile: nutze volle Breite + horizontales Scrollen statt Quetschen */}
<div className="-mx-4 sm:mx-0">
<div className="overflow-x-auto px-4 sm:px-0">
<div className="min-w-[860px] sm:min-w-0">
<Table<UserWithAvatar>
data={tableData}
columns={userColumns}
@ -761,6 +797,9 @@ export default function UsersTablesClient({
defaultSortKey="arbeitsname"
defaultSortDirection="asc"
/>
</div>
</div>
</div>
{/* Floating Actions in Card, unten mittig über der Tabelle */}
{selectedUserIds.length > 0 && (

View File

@ -1,7 +1,7 @@
// /components/GlobalSearch.tsx
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Combobox } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import clsx from 'clsx';
@ -24,6 +24,8 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
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
useEffect(() => {
@ -105,6 +107,39 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
const hasMenu =
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) => {
if (!item) return;
onDeviceSelected?.(item.inventoryNumber);
@ -114,7 +149,7 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
return (
<Combobox value={null} onChange={handleSelect} nullable>
<div className="relative w-full">
<div ref={anchorRef} className="relative w-full">
{/* Suchfeld */}
<MagnifyingGlassIcon
aria-hidden="true"
@ -131,8 +166,14 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
{/* Dropdown-Menü unterhalb */}
{hasMenu && (
<Combobox.Options
style={menuTop != null ? { top: menuTop } : undefined}
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',
)}
>
@ -162,7 +203,7 @@ export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
value={device}
className={({ active }) =>
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',
active
? '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'}
</div>
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
{device.manufacturer && (
<span>{device.manufacturer}</span>
)}
{device.manufacturer && <span>{device.manufacturer}</span>}
{device.group && (
<span className="before:content-['·'] before:px-1">
{device.group}

View File

@ -126,7 +126,7 @@ export default function ScanModal({ open, onClose, onResult }: ScanModalProps) {
{!error && (
<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>
)}

View File

@ -137,7 +137,6 @@ function renderActionButton(
);
}
/* ───────── Modal-Komponente ───────── */
export function Modal({
@ -173,14 +172,14 @@ 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"
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:p-8">
{/* Zentrierter Container keine eigene Scrollbar mehr, die Höhe begrenzt das Panel */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 text-center sm:p-8">
<DialogPanel
transition
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-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',
panelSizeClasses,
)}
@ -199,9 +198,9 @@ export function Modal({
</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 */}
{/* 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',
@ -232,9 +231,7 @@ export function Modal({
<div
className={clsx(
isAlert
? 'mt-3 sm:mt-0 sm:text-left'
: 'mt-3 sm:mt-4',
isAlert ? 'mt-3 sm:mt-0 sm:text-left' : 'mt-3 sm:mt-4',
!isAlert && 'w-full',
)}
>
@ -262,21 +259,21 @@ export function Modal({
</div>
</div>
{/* MAIN: Body + Sidebar nimmt den Rest der Höhe ein */}
{/* MAIN: Body + Sidebar bekommt min-h-0 und nur der Body scrollt */}
{(bodyContent || sidebar) && (
<div
className={clsx(
'mt-6 flex flex-col min-h-0',
'mt-6 flex min-h-0 flex-1 flex-col',
sidebar
? 'sm:mt-8 sm:flex-row sm:items-stretch sm:gap-6'
: 'overflow-y-auto' // nur ohne Sidebar soll der Body global scrollen
: undefined,
)}
>
{/* Linker Inhalt (Details / Formulare) */}
{/* Linker Inhalt (scrollbar) */}
{bodyContent && (
<div
className={clsx(
'flex-1 min-h-0 overflow-y-auto text-left',
'min-h-0 flex-1 overflow-y-auto text-left',
!sidebar && 'mx-auto max-w-2xl',
)}
>
@ -284,9 +281,9 @@ export function Modal({
</div>
)}
{/* Rechte Sidebar (QR + Verlauf) */}
{/* Rechte Sidebar (nicht scrollbar, eigene interne Scroller möglich) */}
{sidebar && (
<aside className="sm:min-h-0 sm:overflow-hidden sm:w-60 lg:w-80 sm:shrink-0">
<aside className="sm:min-h-0 sm:w-60 sm:shrink-0 sm:overflow-hidden lg:w-80">
{sidebar}
</aside>
)}
@ -294,7 +291,7 @@ export function Modal({
)}
</div>
{/* Footer */}
{/* Footer (immer sichtbar, außerhalb des scrollbaren Bereichs) */}
{footer ? (
footer
) : hasActions ? (
@ -326,10 +323,8 @@ export function Modal({
) : null}
</DialogPanel>
</div>
</div>
</Dialog>
);
}
export default Modal;

View File

@ -171,7 +171,7 @@ export default function Table<T>(props: TableProps<T>) {
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="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">
<tr>
{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",
"@zxing/browser": "^0.1.5",
"bcryptjs": "^3.0.3",
"next": "16.0.3",
"next": "^16.1.4",
"next-auth": "^4.24.13",
"pg": "^8.16.3",
"postcss": "^8.5.6",
@ -38,11 +38,11 @@
"@types/pg": "^8.15.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"baseline-browser-mapping": "^2.8.32",
"baseline-browser-mapping": "^2.9.17",
"dotenv": "^17.2.3",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"prisma": "^7.1.0",
"prisma": "^6.19.2",
"tailwindcss": "^4.1.17",
"tsx": "^4.20.6",
"typescript": "^5.9.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB