updated
This commit is contained in:
parent
5e6f7e872d
commit
e2a71e4fa5
@ -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}
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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
736
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
|||||||
BIN
public/avatars/nw083118-1765801010828.png
Normal file
BIN
public/avatars/nw083118-1765801010828.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Loading…
x
Reference in New Issue
Block a user