This commit is contained in:
Linrador 2025-11-18 14:44:36 +01:00
parent 90231bff83
commit 7f683d5828
22 changed files with 2154 additions and 464 deletions

2
.env
View File

@ -11,5 +11,5 @@
DATABASE_URL="file:./dev.db"
NEXTAUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=https://10.0.1.25
NEXTAUTH_SECRET=tegvideo7010!

View File

@ -0,0 +1,405 @@
'use client';
import {
ChangeEvent,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import Modal from '@/components/ui/Modal';
import { PlusIcon } from '@heroicons/react/24/outline';
import TagMultiCombobox, { TagOption } from '@/components/ui/TagMultiCombobox';
import Button from '@/components/ui/Button';
import type { DeviceDetail } from './page';
type DeviceCreateModalProps = {
open: boolean;
onClose: () => void;
onCreated: (device: DeviceDetail) => void;
allTags: TagOption[];
setAllTags: Dispatch<SetStateAction<TagOption[]>>;
};
type NewDevice = {
inventoryNumber: string;
name: string;
manufacturer: string;
model: string;
serialNumber: string | null;
productNumber: string | null;
comment: string | null;
group: string | null;
location: string | null;
ipv4Address: string | null;
ipv6Address: string | null;
macAddress: string | null;
username: string | null;
passwordHash: string | null;
tags: string[];
};
const emptyDevice: NewDevice = {
inventoryNumber: '',
name: '',
manufacturer: '',
model: '',
serialNumber: null,
productNumber: null,
comment: null,
group: null,
location: null,
ipv4Address: null,
ipv6Address: null,
macAddress: null,
username: null,
passwordHash: null,
tags: [],
};
export default function DeviceCreateModal({
open,
onClose,
onCreated,
allTags,
setAllTags,
}: DeviceCreateModalProps) {
const [form, setForm] = useState<NewDevice>(emptyDevice);
const [saveLoading, setSaveLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Formular resetten, wenn Modal neu geöffnet wird
useEffect(() => {
if (open) {
setForm(emptyDevice);
setError(null);
setSaveLoading(false);
}
}, [open]);
const handleFieldChange = (
field: keyof NewDevice,
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const value = e.target.value;
setForm((prev) => ({
...prev,
[field]: value === '' && prev[field] === null ? null : value,
}));
};
const handleSave = useCallback(async () => {
if (!form.inventoryNumber.trim() || !form.name.trim()) {
setError('Bitte mindestens Inventar-Nr. und Bezeichnung ausfüllen.');
return;
}
setSaveLoading(true);
setError(null);
try {
const res = await fetch('/api/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inventoryNumber: form.inventoryNumber.trim(),
name: form.name.trim(),
manufacturer: form.manufacturer || '',
model: form.model || '',
serialNumber: form.serialNumber || null,
productNumber: form.productNumber || null,
comment: form.comment || null,
group: form.group || null,
location: form.location || null,
ipv4Address: form.ipv4Address || null,
ipv6Address: form.ipv6Address || null,
macAddress: form.macAddress || null,
username: form.username || null,
passwordHash: form.passwordHash || null,
tags: form.tags ?? [],
}),
});
if (!res.ok) {
if (res.status === 409) {
throw new Error(
'Es existiert bereits ein Gerät mit dieser Inventar-Nr.',
);
}
throw new Error('Anlegen des Geräts ist fehlgeschlagen.');
}
const created = (await res.json()) as DeviceDetail;
onCreated(created);
onClose();
} catch (err: any) {
console.error('Error creating device', err);
setError(
err instanceof Error
? err.message
: 'Netzwerkfehler beim Anlegen des Geräts.',
);
} finally {
setSaveLoading(false);
}
}, [form, onCreated, onClose]);
const handleClose = () => {
if (saveLoading) return;
onClose();
};
return (
<Modal
open={open}
onClose={handleClose}
title="Neues Gerät anlegen"
icon={<PlusIcon className="size-6" />}
tone="info"
variant="centered"
size="sm"
footer={
<div className="px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse sm:gap-3">
<Button
type="button"
onClick={handleSave}
size="md"
fullWidth
variant="primary"
tone="indigo"
disabled={saveLoading}
className="sm:w-auto"
>
{saveLoading ? 'Anlegen …' : 'Anlegen'}
</Button>
<Button
type="button"
onClick={handleClose}
size="md"
variant="secondary"
tone="gray"
className="mt-3 sm:mt-0 sm:w-auto"
>
Abbrechen
</Button>
</div>
}
>
{error && (
<p className="mb-3 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
{/* Body wie bei DeviceDetailModal: einfach Inhalt, Scroll kommt vom Modal */}
<div className="pr-2 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* Inventarnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Inventar-Nr. *
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.inventoryNumber}
onChange={(e) => handleFieldChange('inventoryNumber', e)}
/>
</div>
{/* Bezeichnung */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Bezeichnung *
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.name}
onChange={(e) => handleFieldChange('name', e)}
/>
</div>
{/* Hersteller / Modell */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Hersteller
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.manufacturer}
onChange={(e) => handleFieldChange('manufacturer', e)}
/>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Modell
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.model}
onChange={(e) => handleFieldChange('model', e)}
/>
</div>
{/* Seriennummer / Produktnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Seriennummer
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.serialNumber ?? ''}
onChange={(e) => handleFieldChange('serialNumber', e)}
/>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Produktnummer
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.productNumber ?? ''}
onChange={(e) => handleFieldChange('productNumber', e)}
/>
</div>
{/* Standort / Gruppe */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Standort / Raum
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.location ?? ''}
onChange={(e) => handleFieldChange('location', e)}
/>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Gruppe
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.group ?? ''}
onChange={(e) => handleFieldChange('group', e)}
/>
</div>
{/* Tags */}
<div className="sm:col-span-2">
<TagMultiCombobox
label="Tags"
availableTags={allTags}
value={(form.tags ?? []).map((name) => ({ name }))}
onChange={(next) => {
const names = next.map((t) => t.name);
setForm((prev) => ({
...prev,
tags: names,
}));
setAllTags((prev) => {
const map = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
for (const t of next) {
const key = t.name.toLowerCase();
if (!map.has(key)) {
map.set(key, t);
}
}
return Array.from(map.values());
});
}}
placeholder="z.B. Drucker, Serverraum, kritisch"
/>
</div>
{/* Netzwerkdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
IPv4-Adresse
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.ipv4Address ?? ''}
onChange={(e) => handleFieldChange('ipv4Address', e)}
/>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
IPv6-Adresse
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.ipv6Address ?? ''}
onChange={(e) => handleFieldChange('ipv6Address', e)}
/>
</div>
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
MAC-Adresse
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.macAddress ?? ''}
onChange={(e) => handleFieldChange('macAddress', e)}
/>
</div>
{/* Zugangsdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Benutzername
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.username ?? ''}
onChange={(e) => handleFieldChange('username', e)}
/>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Passwort
</p>
<input
type="password"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.passwordHash ?? ''}
onChange={(e) => handleFieldChange('passwordHash', e)}
/>
</div>
{/* Kommentar */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Kommentar
</p>
<textarea
rows={3}
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={form.comment ?? ''}
onChange={(e) => handleFieldChange('comment', e)}
/>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,365 @@
// app/(app)/devices/DeviceDetailModal.tsx
'use client';
import { useEffect, useState } from 'react';
import Modal from '@/components/ui/Modal';
import { BookOpenIcon } from '@heroicons/react/24/outline';
import DeviceHistorySidebar from './DeviceHistorySidebar';
import Button from '@/components/ui/Button';
import type { DeviceDetail } from './page';
import { DeviceQrCode } from '@/components/DeviceQrCode';
import Tabs from '@/components/ui/Tabs';
type DeviceDetailModalProps = {
open: boolean;
inventoryNumber: string | null;
onClose: () => void;
};
const dtf = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {
return (
<div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* Inventarnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Inventar-Nr.
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.inventoryNumber}
</p>
</div>
{/* Bezeichnung */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Bezeichnung
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.name || ''}
</p>
</div>
{/* Hersteller / Modell */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Hersteller
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.manufacturer || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Modell
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.model || ''}
</p>
</div>
{/* Seriennummer / Produktnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Seriennummer
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.serialNumber || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Produktnummer
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.productNumber || ''}
</p>
</div>
{/* Standort / Gruppe */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Standort / Raum
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.location || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Gruppe
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.group || ''}
</p>
</div>
{/* Tags */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Tags
</p>
{device.tags && device.tags.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{device.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-gray-800/60 px-2.5 py-0.5 text-xs font-medium text-gray-100 dark:bg-gray-700/70"
>
{tag}
</span>
))}
</div>
) : (
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400"></p>
)}
</div>
{/* Netzwerkdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
IPv4-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.ipv4Address || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
IPv6-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.ipv6Address || ''}
</p>
</div>
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
MAC-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.macAddress || ''}
</p>
</div>
{/* Zugangsdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Benutzername
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.username || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Passwort (Hash)
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400 break-all">
{device.passwordHash || ''}
</p>
</div>
{/* Kommentar */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Kommentar
</p>
<div className="mt-1 rounded-md border border-gray-700 bg-gray-400/20 px-2.5 py-2 text-sm text-gray-800 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-700">
{device.comment && device.comment.trim().length > 0
? device.comment
: ''}
</div>
</div>
{/* Metadaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Angelegt am
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.createdAt
? dtf.format(new Date(device.createdAt))
: ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Zuletzt geändert am
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.updatedAt
? dtf.format(new Date(device.updatedAt))
: ''}
</p>
</div>
</div>
);
}
export default function DeviceDetailModal({
open,
inventoryNumber,
onClose,
}: DeviceDetailModalProps) {
const [device, setDevice] = useState<DeviceDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details');
useEffect(() => {
if (!open || !inventoryNumber) return;
const inv = inventoryNumber;
let cancelled = false;
setLoading(true);
setError(null);
setDevice(null);
async function loadDevice() {
try {
const res = await fetch(`/api/devices/${encodeURIComponent(inv)}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
});
if (!res.ok) {
if (res.status === 404) {
throw new Error('Gerät wurde nicht gefunden.');
}
throw new Error('Beim Laden der Gerätedaten ist ein Fehler aufgetreten.');
}
const data = (await res.json()) as DeviceDetail;
if (!cancelled) setDevice(data);
} catch (err: any) {
console.error('Error loading device details', err);
if (!cancelled) {
setError(
err instanceof Error
? err.message
: 'Netzwerkfehler beim Laden der Gerätedaten.',
);
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadDevice();
return () => {
cancelled = true;
};
}, [open, inventoryNumber]);
const handleClose = () => onClose();
return (
<Modal
open={open}
onClose={handleClose}
title={
device
? `Gerätedetails: ${device.name}`
: 'Gerätedaten werden geladen …'
}
icon={<BookOpenIcon className="size-6" />}
tone="info"
variant="centered"
size="xl"
primaryAction={{
label: 'Schließen',
onClick: handleClose,
variant: 'primary',
}}
headerExtras={
device && (
<div className="sm:hidden">
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'history', label: 'Änderungsverlauf' },
]}
value={activeTab}
onChange={(id) => setActiveTab(id as 'details' | 'history')}
ariaLabel="Ansicht wählen"
/>
</div>
)
}
sidebar={
device ? (
<div className="hidden w-full h-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 p-2">
<DeviceQrCode inventoryNumber={device.inventoryNumber} />
</div>
</div>
<p className="mt-2 text-center text-[12px] text-gray-500">
#{device.inventoryNumber}
</p>
</div>
<div className="border-t border-gray-800 dark:border-white/10 mx-1" />
{/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */}
<div className="flex-1 min-h-0 overflow-hidden">
<DeviceHistorySidebar
key={device.updatedAt}
inventoryNumber={device.inventoryNumber}
asSidebar
/>
</div>
</div>
) : undefined
}
>
{loading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Gerätedaten werden geladen
</p>
)}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
{!loading && !error && device && (
<>
{/* Mobile-Inhalt (Tabs steuern Ansicht) */}
<div className="sm:hidden pr-2">
{activeTab === 'details' ? (
<DeviceDetailsGrid device={device} />
) : (
<DeviceHistorySidebar
key={device.updatedAt + '-mobile'}
inventoryNumber={device.inventoryNumber}
/>
)}
</div>
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */}
<div className="hidden sm:block pr-2">
<DeviceDetailsGrid device={device} />
</div>
</>
)}
</Modal>
);
}

View File

@ -1,12 +1,19 @@
// app/(app)/devices/DeviceEditModal.tsx
'use client';
import { useCallback, useEffect, useState, ChangeEvent, Dispatch, SetStateAction } from 'react';
import {
useCallback,
useEffect,
useState,
ChangeEvent,
Dispatch,
SetStateAction,
} from 'react';
import Modal from '@/components/ui/Modal';
import { PencilIcon } from '@heroicons/react/24/outline';
import { PencilIcon, CheckCircleIcon } from '@heroicons/react/24/solid';
import DeviceHistorySidebar from './DeviceHistorySidebar';
import TagMultiCombobox, { TagOption } from '@/components/ui/TagMultiCombobox';
import type { DeviceDetail } from './page'; // Typ aus page.tsx (siehe unten)
import Button from '@/components/ui/Button';
import type { DeviceDetail } from './page';
type DeviceEditModalProps = {
open: boolean;
@ -29,25 +36,23 @@ export default function DeviceEditModal({
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [saveLoading, setSaveLoading] = useState(false);
const [justSaved, setJustSaved] = useState(false);
// Gerät laden, wenn Modal geöffnet wird
useEffect(() => {
if (!open || !inventoryNumber) {
setEditDevice(null);
setEditError(null);
return;
}
if (!open || !inventoryNumber) return;
const inv = inventoryNumber;
let cancelled = false;
async function loadDevice() {
setEditLoading(true);
setEditError(null);
setEditDevice(null);
setEditLoading(true);
setEditError(null);
setJustSaved(false);
setEditDevice(null);
async function loadDevice() {
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(inventoryNumber)}`,
`/api/devices/${encodeURIComponent(inv)}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
@ -59,7 +64,9 @@ export default function DeviceEditModal({
if (res.status === 404) {
throw new Error('Gerät wurde nicht gefunden.');
}
throw new Error('Beim Laden der Gerätedaten ist ein Fehler aufgetreten.');
throw new Error(
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
);
}
const data = (await res.json()) as DeviceDetail;
@ -70,7 +77,9 @@ export default function DeviceEditModal({
console.error('Error loading device', err);
if (!cancelled) {
setEditError(
err instanceof Error ? err.message : 'Netzwerkfehler beim Laden der Gerätedaten.',
err instanceof Error
? err.message
: 'Netzwerkfehler beim Laden der Gerätedaten.',
);
}
} finally {
@ -137,16 +146,23 @@ export default function DeviceEditModal({
const updated = (await res.json()) as DeviceDetail;
setEditDevice(updated);
onSaved(updated); // Tabelle im Parent aktualisieren
onSaved(updated);
setJustSaved(true);
setTimeout(() => {
onClose();
}, 1000);
} catch (err: any) {
console.error('Error saving device', err);
setEditError(
err instanceof Error ? err.message : 'Netzwerkfehler beim Speichern der Gerätedaten.',
err instanceof Error
? err.message
: 'Netzwerkfehler beim Speichern der Gerätedaten.',
);
} finally {
setSaveLoading(false);
}
}, [editDevice, onSaved]);
}, [editDevice, onSaved, onClose]);
const handleClose = () => {
if (saveLoading) return;
@ -163,22 +179,54 @@ export default function DeviceEditModal({
: 'Gerätedaten werden geladen …'
}
icon={<PencilIcon className="size-6" />}
tone="info"
tone={justSaved ? 'success' : 'info'}
variant="centered"
size="lg"
primaryAction={{
label: saveLoading ? 'Speichern …' : 'Speichern',
onClick: handleSave,
autoFocus: true,
}}
secondaryAction={{
label: 'Abbrechen',
variant: 'secondary',
onClick: handleClose,
}}
size="xl"
footer={
<div className="px-4 py-3 sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<Button
type="button"
onClick={handleSave}
size="lg"
variant="primary"
tone={justSaved ? 'emerald' : 'indigo'}
disabled={saveLoading}
className="w-full sm:flex-1"
icon={
justSaved ? (
<CheckCircleIcon
aria-hidden="true"
className="-ml-0.5 size-5"
/>
) : undefined
}
iconPosition="leading"
>
{saveLoading
? 'Speichern …'
: justSaved
? 'Gespeichert'
: 'Speichern'}
</Button>
<Button
type="button"
onClick={handleClose}
size="lg"
variant="secondary"
tone="gray"
className="w-full sm:flex-1"
>
Abbrechen
</Button>
</div>
</div>
}
sidebar={
editDevice ? (
<DeviceHistorySidebar
key={editDevice.updatedAt}
inventoryNumber={editDevice.inventoryNumber}
asSidebar
/>
@ -192,11 +240,13 @@ export default function DeviceEditModal({
)}
{editError && (
<p className="text-sm text-red-600 dark:text-red-400">{editError}</p>
<p className="text-sm text-red-600 dark:text-red-400">
{editError}
</p>
)}
{!editLoading && !editError && editDevice && (
<div className="mt-3 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
<div className="pr-2 mt-3 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* Inventarnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
@ -307,12 +357,10 @@ export default function DeviceEditModal({
onChange={(next) => {
const names = next.map((t) => t.name);
// in editDevice speichern
setEditDevice((prev) =>
prev ? ({ ...prev, tags: names } as DeviceDetail) : prev,
);
// allTags im Parent erweitern
setAllTags((prev) => {
const map = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
for (const t of next) {
@ -365,6 +413,7 @@ export default function DeviceEditModal({
/>
</div>
{/* Zugangsdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Benutzername

View File

@ -6,6 +6,8 @@ import Feed, {
FeedItem,
FeedChange,
} from '@/components/ui/Feed';
import clsx from 'clsx';
type Props = {
inventoryNumber: string;
@ -35,33 +37,33 @@ function formatDateTime(iso: string) {
function mapFieldLabel(field: string): string {
switch (field) {
case 'name':
return 'die Bezeichnung';
return 'Bezeichnung';
case 'manufacturer':
return 'den Hersteller';
return 'Hersteller';
case 'model':
return 'das Modell';
return 'Modell';
case 'serialNumber':
return 'die Seriennummer';
return 'Seriennummer';
case 'productNumber':
return 'die Produktnummer';
return 'Produktnummer';
case 'comment':
return 'den Kommentar';
return 'Kommentar';
case 'ipv4Address':
return 'die IPv4-Adresse';
return 'IPv4-Adresse';
case 'ipv6Address':
return 'die IPv6-Adresse';
return 'IPv6-Adresse';
case 'macAddress':
return 'die MAC-Adresse';
return 'MAC-Adresse';
case 'username':
return 'den Benutzernamen';
return 'Benutzernamen';
case 'passwordHash':
return 'das Passwort';
return 'Passwort';
case 'group':
return 'die Gruppe';
return 'Gruppe';
case 'location':
return 'den Standort';
return 'Standort';
case 'tags':
return 'die Tags';
return 'Tags';
default:
return field;
}
@ -81,7 +83,6 @@ export default function DeviceHistorySidebar({
async function loadHistory() {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
@ -123,12 +124,17 @@ export default function DeviceHistorySidebar({
}
let comment = '';
let commentKind: 'created' | 'deleted' | 'generic' = 'generic';
if (entry.changeType === 'CREATED') {
comment = 'Gerät angelegt.';
comment = 'hat das Gerät angelegt.';
commentKind = 'created';
} else if (entry.changeType === 'DELETED') {
comment = 'Gerät gelöscht.';
comment = 'hat das Gerät gelöscht.';
commentKind = 'deleted';
} else {
comment = 'Gerät geändert.';
comment = 'hat das Gerät geändert.';
commentKind = 'generic';
}
return {
@ -137,6 +143,7 @@ export default function DeviceHistorySidebar({
person,
date,
comment,
commentKind,
};
});
@ -161,13 +168,14 @@ export default function DeviceHistorySidebar({
}, [inventoryNumber]);
// Root-Tag & Klassen abhängig vom Einsatz
const Root: ElementType = asSidebar ? 'div' : 'aside';
const rootClasses = asSidebar
? 'flex h-full flex-col text-sm'
: 'flex h-full flex-col border-l border-gray-200 px-4 py-4 text-sm dark:border-white/10';
const rootClassName = asSidebar
? 'flex h-full min-h-0 flex-col'
: undefined;
return (
<Root className={rootClasses}>
<Root className={rootClassName}>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Änderungsverlauf
</h2>
@ -185,10 +193,15 @@ export default function DeviceHistorySidebar({
)}
{!loading && !error && (
<div className="mt-3 flex-1 min-h-0">
<Feed items={items} className="h-full" />
<div
className={clsx(
'mt-3',
asSidebar && 'min-h-0 flex-1 overflow-y-auto pr-1'
)}
>
<Feed items={items} />
</div>
)}
</Root>
);
}
}

View File

@ -10,31 +10,31 @@ import {
BookOpenIcon,
PencilIcon,
TrashIcon,
PlusIcon
} from '@heroicons/react/24/outline';
import { getSocket } from '@/lib/socketClient';
import type { TagOption } from '@/components/ui/TagMultiCombobox';
import DeviceEditModal from './DeviceEditModal';
import DeviceDetailModal from './DeviceDetailModal';
import DeviceCreateModal from './DeviceCreateModal';
export type DeviceRow = {
inventoryNumber: string;
name: string;
manufacturer: string;
model: string;
serialNumber?: string | null;
productNumber?: string | null;
comment?: string | null;
ipv4Address?: string | null;
ipv6Address?: string | null;
macAddress?: string | null;
username?: string | null;
passwordHash?: string | null;
group?: string | null;
location?: string | null;
tags?: string[] | null;
serialNumber: string | null;
productNumber: string | null;
comment: string | null;
group: string | null;
location: string | null;
ipv4Address: string | null;
ipv6Address: string | null;
macAddress: string | null;
username: string | null;
passwordHash: string | null;
tags: string[];
createdAt: string;
updatedAt: string;
};
@ -52,17 +52,15 @@ function formatDate(iso: string) {
const columns: TableColumn<DeviceRow>[] = [
{
key: 'inventoryNumber',
header: 'Inventar-Nr.',
header: 'Nr.',
sortable: true,
canHide: false,
headerClassName: 'min-w-32',
},
{
key: 'name',
header: 'Bezeichnung',
sortable: true,
canHide: true,
headerClassName: 'min-w-48',
cellClassName: 'font-medium text-gray-900 dark:text-white',
},
{
@ -132,9 +130,13 @@ export default function DevicesPage() {
const [listError, setListError] = useState<string | null>(null);
// welches Gerät ist gerade im Edit-Modal geöffnet?
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(
null,
);
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(null);
// welches Gerät ist im Detail-Modal geöffnet?
const [detailInventoryNumber, setDetailInventoryNumber] = useState<string | null>(null);
// Create-Modal
const [createOpen, setCreateOpen] = useState(false);
// Alle bekannten Tags (kannst du später auch aus eigener /api/tags laden)
const [allTags, setAllTags] = useState<TagOption[]>([]);
@ -238,6 +240,23 @@ export default function DevicesPage() {
setEditInventoryNumber(null);
}, []);
const handleDetails = useCallback((inventoryNumber: string) => {
setDetailInventoryNumber(inventoryNumber);
}, []);
const closeDetailModal = useCallback(() => {
setDetailInventoryNumber(null);
}, []);
const openCreateModal = useCallback(() => {
setCreateOpen(true);
}, []);
const closeCreateModal = useCallback(() => {
setCreateOpen(false);
}, []);
/* ───────── Render ───────── */
return (
@ -253,12 +272,17 @@ export default function DevicesPage() {
</p>
</div>
<button
type="button"
className="inline-flex items-center rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
<Button
variant="soft"
tone="indigo"
size="md"
icon={<PlusIcon className="size-5" />}
aria-label="Neues Gerät anlegen"
onClick={openCreateModal}
title='Neues Gerät anlegen'
>
Neues Gerät anlegen
</button>
</Button>
</div>
{listLoading && (
@ -291,7 +315,7 @@ export default function DevicesPage() {
size="md"
icon={<BookOpenIcon className="size-5" />}
aria-label={`Details zu ${row.inventoryNumber}`}
onClick={() => console.log('Details', row.inventoryNumber)}
onClick={() => handleDetails(row.inventoryNumber)}
/>
<Button
@ -326,8 +350,7 @@ export default function DevicesPage() {
{
label: 'Details',
icon: <BookOpenIcon className="size-4" />,
onClick: () =>
console.log('Details', row.inventoryNumber),
onClick: () => handleDetails(row.inventoryNumber),
},
{
label: 'Bearbeiten',
@ -368,6 +391,28 @@ export default function DevicesPage() {
);
}}
/>
<DeviceCreateModal
open={createOpen}
onClose={closeCreateModal}
allTags={allTags}
setAllTags={setAllTags}
onCreated={(created) => {
setDevices((prev) => {
// falls Live-Update denselben Eintrag schon gebracht hat
if (prev.some((d) => d.inventoryNumber === created.inventoryNumber)) {
return prev;
}
return [...prev, created];
});
}}
/>
<DeviceDetailModal
open={detailInventoryNumber !== null}
inventoryNumber={detailInventoryNumber}
onClose={closeDetailModal}
/>
</>
);
}

View File

@ -28,9 +28,11 @@ import {
XMarkIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import { usePathname } from 'next/navigation';
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';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
@ -143,7 +145,11 @@ function UserMenu({ displayName, avatarInitial, avatarColorClass }: UserMenuProp
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 =
@ -154,6 +160,43 @@ export default function AppLayout({ children }: { children: ReactNode }) {
const displayName = rawName;
const avatarInitial = getInitial(rawName);
const avatarColorClass = getAvatarColor(rawName);
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);
};
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
@ -347,17 +390,24 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div className="flex flex-1 items-center gap-x-4 self-stretch lg:gap-x-6">
{/* Suche */}
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
<input
name="search"
placeholder="Suchen..."
aria-label="Suchen..."
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
/>
<form
action="#"
method="GET"
className="grid flex-1 grid-cols-1"
>
<div className="relative">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
type="search"
name="search"
placeholder="Suchen…"
aria-label="Suchen"
className="block w-full rounded-xl border-0 bg-gray-50 py-1.5 pl-10 pr-3 text-sm text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:placeholder:text-gray-500"
/>
</div>
</form>
<div className="flex items-center gap-x-4 lg:gap-x-6">
@ -369,8 +419,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{/* 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"
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" />
@ -420,6 +471,18 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<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>
);

View File

@ -3,59 +3,63 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
type RouteParams = { id: string };
// ⬇️ wie bei /api/devices/[id]
type RouteContext = { params: Promise<RouteParams> };
export async function GET(
_req: Request,
ctx: RouteContext,
) {
// params-Promise auflösen
export async function GET(_req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
const inventoryNumber = decodeURIComponent(id);
if (!id) {
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
}
try {
const history = await prisma.deviceHistory.findMany({
where: { deviceId: inventoryNumber },
include: { changedBy: true },
where: { deviceId: id },
orderBy: { changedAt: 'desc' },
include: {
changedBy: true, // 👈 User-Relation laden
},
});
const payload = history.map((entry) => {
const result = history.map((entry) => {
// 👇 hier baust du den Anzeigenamen
const changedByName =
entry.changedBy?.name ??
entry.changedBy?.username ??
entry.changedBy?.email ??
null;
// snapshot.changes aus dem JSON holen (bei CREATED leer)
let changes: {
field: string;
from: string | null;
to: string | null;
}[] = [];
const snapshot = entry.snapshot as any;
const rawChanges: any[] = Array.isArray(snapshot?.changes)
? snapshot.changes
: [];
const changes = rawChanges.map((c) => ({
field: String(c.field),
from:
c.before === null || c.before === undefined
? null
: String(c.before),
to:
c.after === null || c.after === undefined
? null
: String(c.after),
}));
if (snapshot && Array.isArray(snapshot.changes)) {
changes = snapshot.changes.map((c: any) => ({
field: String(c.field),
from: c.before ?? null,
to: c.after ?? null,
}));
}
return {
id: entry.id,
changeType: entry.changeType, // 'CREATED' | 'UPDATED' | 'DELETED'
changeType: entry.changeType,
changedAt: entry.changedAt.toISOString(),
changedBy:
entry.changedBy?.name ??
entry.changedBy?.username ??
entry.changedBy?.email ??
null,
changedBy: changedByName, // 👈 passt zu deinem ApiHistoryEntry
changes,
};
});
return NextResponse.json(payload);
return NextResponse.json(result);
} catch (err) {
console.error('[GET /api/devices/[id]/history]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
return NextResponse.json(
{ error: 'INTERNAL_ERROR' },
{ status: 500 },
);
}
}

View File

@ -61,29 +61,265 @@ export async function GET(
}
}
export async function POST(req: Request) {
try {
const body = await req.json();
const {
inventoryNumber,
name,
manufacturer,
model,
serialNumber,
productNumber,
comment,
group, // Name der Gruppe (string)
location, // Name des Standorts (string)
ipv4Address,
ipv6Address,
macAddress,
username,
passwordHash,
tags, // string[]
} = body;
if (!inventoryNumber || !name) {
return NextResponse.json(
{ error: 'inventoryNumber und name sind Pflichtfelder.' },
{ status: 400 },
);
}
// prüfen, ob Inventar-Nr. schon existiert
const existing = await prisma.device.findUnique({
where: { inventoryNumber },
});
if (existing) {
return NextResponse.json(
{ error: 'Gerät mit dieser Inventar-Nr. existiert bereits.' },
{ status: 409 },
);
}
// 🔹 aktuell eingeloggten User ermitteln
const userId = await getCurrentUserId();
// 🔹 nur verknüpfen, wenn der User wirklich in der DB existiert
let canConnectUser = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (user) {
canConnectUser = true;
} else {
console.warn(
`[POST /api/devices] User mit id=${userId} nicht gefunden createdBy/changedBy werden nicht gesetzt.`,
);
}
}
// ✅ Gruppe nach Name auflösen / anlegen
let groupId: string | null = null;
if (group && typeof group === 'string' && group.trim() !== '') {
const grp = await prisma.deviceGroup.upsert({
where: { name: group.trim() },
update: {},
create: { name: group.trim() },
});
groupId = grp.id;
}
// ✅ Location nach Name auflösen / anlegen
let locationId: string | null = null;
if (location && typeof location === 'string' && location.trim() !== '') {
const loc = await prisma.location.upsert({
where: { name: location.trim() },
update: {},
create: { name: location.trim() },
});
locationId = loc.id;
}
// ✅ Tag-Namen vorbereiten
const tagNames: string[] = Array.isArray(tags)
? tags
.map((t: unknown) => String(t).trim())
.filter((t) => t.length > 0)
: [];
// ✅ Device anlegen (mit createdBy & Tags)
const created = await prisma.device.create({
data: {
inventoryNumber,
name,
manufacturer,
model,
serialNumber: serialNumber ?? null,
productNumber: productNumber ?? null,
comment: comment ?? null,
ipv4Address: ipv4Address ?? null,
ipv6Address: ipv6Address ?? null,
macAddress: macAddress ?? null,
username: username ?? null,
passwordHash: passwordHash ?? null,
groupId,
locationId,
// User, der das Gerät angelegt hat
...(canConnectUser && userId
? {
createdBy: {
connect: { id: userId },
},
}
: {}),
// Tags Many-to-Many
...(tagNames.length
? {
tags: {
connectOrCreate: tagNames.map((name) => ({
where: { name },
create: { name },
})),
},
}
: {}),
},
include: {
group: true,
location: true,
tags: true,
createdBy: true,
},
});
// 🔹 History-Eintrag "CREATED" mit changedById
const snapshot: Prisma.JsonObject = {
before: null,
after: {
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
changes: [],
};
await prisma.deviceHistory.create({
data: {
deviceId: created.inventoryNumber,
changeType: 'CREATED',
snapshot,
changedById: canConnectUser && userId ? userId : null,
},
});
// 🔊 optional: Socket.IO-Broadcast für Live-Listen
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:created', {
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
updatedAt: created.updatedAt.toISOString(),
});
}
// Antwort wie bei GET /api/devices
return NextResponse.json(
{
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },
);
} catch (err) {
console.error('Error in POST /api/devices', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Anlegen des Geräts.' },
{ status: 500 },
);
}
}
export async function PATCH(
req: Request,
ctx: RouteContext,
) {
const { id } = await ctx.params;
const body = await req.json();
if (!id) {
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
}
try {
// ⬇️ hier jetzt die Request-Header durchreichen
const body = await req.json();
// aktuell eingeloggten User ermitteln
const userId = await getCurrentUserId();
// nur verbinden, wenn der User in der DB existiert
let canConnectUpdatedBy = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (user) {
canConnectUpdatedBy = true;
} else {
console.warn(
`[PATCH /api/devices/${id}] User mit id=${userId} nicht gefunden updatedBy wird nicht gesetzt.`,
);
}
}
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
const existing = await prisma.device.findUnique({
where: { inventoryNumber: id },
include: {
group: true,
location: true,
tags: true, // 🔹 NEU
tags: true,
},
});
@ -96,53 +332,18 @@ export async function PATCH(
name: body.name,
manufacturer: body.manufacturer,
model: body.model,
serialNumber: body.serialNumber,
productNumber: body.productNumber,
comment: body.comment,
ipv4Address: body.ipv4Address,
ipv6Address: body.ipv6Address,
macAddress: body.macAddress,
username: body.username,
passwordHash: body.passwordHash,
serialNumber: body.serialNumber ?? null,
productNumber: body.productNumber ?? null,
comment: body.comment ?? null,
ipv4Address: body.ipv4Address ?? null,
ipv6Address: body.ipv6Address ?? null,
macAddress: body.macAddress ?? null,
username: body.username ?? null,
passwordHash: body.passwordHash ?? null,
};
// Tags aus dem Body bereinigen
const incomingTags: string[] = Array.isArray(body.tags)
? body.tags.map((t: unknown) => String(t).trim()).filter(Boolean)
: [];
// existierende Tag-Namen
const existingTagNames = existing.tags.map((t) => t.name);
// welche sollen entfernt werden?
const tagsToRemove = existingTagNames.filter(
(name) => !incomingTags.includes(name),
);
// welche sind neu hinzuzufügen?
const tagsToAdd = incomingTags.filter(
(name) => !existingTagNames.includes(name),
);
if (!data.tags) {
data.tags = {};
}
// Tags, die nicht mehr vorkommen → disconnect über name (weil @unique)
if (tagsToRemove.length > 0) {
(data.tags as any).disconnect = tagsToRemove.map((name) => ({ name }));
}
// neue Tags → connectOrCreate (Tag wird bei Bedarf angelegt)
if (tagsToAdd.length > 0) {
(data.tags as any).connectOrCreate = tagsToAdd.map((name) => ({
where: { name },
create: { name },
}));
}
// updatedBy setzen, wenn User da
if (userId) {
// updatedBy nur setzen, wenn User existiert
if (canConnectUpdatedBy && userId) {
data.updatedBy = {
connect: { id: userId },
};
@ -172,15 +373,15 @@ export async function PATCH(
data.location = { disconnect: true };
}
// Tags (Many-to-Many via Tag.name @unique)
// Tags (Many-to-Many via Tag.name @unique)
if (Array.isArray(body.tags)) {
const tagNames = (body.tags as string[])
.map((t) => String(t).trim())
.filter(Boolean);
const beforeNames = existing.tags.map((t) => t.name.toLowerCase());
const normalized = tagNames.map((n) => n.toLowerCase());
const toConnect = tagNames.filter(
(n) => !beforeNames.includes(n.toLowerCase()),
);
@ -189,12 +390,10 @@ export async function PATCH(
.filter((n) => !normalized.includes(n.toLowerCase()));
data.tags = {
// neue / fehlende Tags verknüpfen (und ggf. anlegen)
connectOrCreate: toConnect.map((name) => ({
where: { name },
create: { name },
})),
// nicht mehr vorhandene Tags trennen
disconnect: toDisconnect.map((name) => ({ name })),
};
}
@ -227,7 +426,6 @@ export async function PATCH(
type TrackedField = (typeof trackedFields)[number];
// explizit JSON-kompatible Types (string | null)
const changes: {
field: TrackedField | 'group' | 'location' | 'tags';
before: string | null;
@ -281,8 +479,7 @@ export async function PATCH(
});
}
// Falls sich *gar nichts* geändert hat, kein History-Eintrag
// Falls sich gar nichts geändert hat, kein History-Eintrag
if (changes.length > 0) {
const snapshot: Prisma.JsonObject = {
before: {
@ -330,17 +527,18 @@ export async function PATCH(
})),
};
// 🔴 WICHTIG: changedById nur setzen, wenn der User wirklich existiert
await prisma.deviceHistory.create({
data: {
deviceId: updated.inventoryNumber,
changeType: 'UPDATED',
snapshot,
changedById: userId ?? null,
changedById: canConnectUpdatedBy && userId ? userId : null,
},
});
}
// 🔊 Socket.IO-Broadcast für echte Live-Updates
// 🔊 Socket.IO-Broadcast für Live-Updates
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:updated', {
@ -361,7 +559,7 @@ export async function PATCH(
});
}
// Antwort an den Client (flattened)
// Antwort an den Client
return NextResponse.json({
inventoryNumber: updated.inventoryNumber,
name: updated.name,
@ -376,6 +574,7 @@ export async function PATCH(
username: updated.username,
group: updated.group?.name ?? null,
location: updated.location?.name ?? null,
tags: updated.tags.map((t) => t.name),
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
});
@ -383,4 +582,4 @@ export async function PATCH(
console.error('[PATCH /api/devices/[id]]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}
}

View File

@ -1,6 +1,9 @@
// app/api/devices/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client';
import { getCurrentUserId } from '@/lib/auth';
import type { Server as IOServer } from 'socket.io';
export async function GET() {
try {
@ -8,7 +11,7 @@ export async function GET() {
include: {
group: true,
location: true,
tags: true, // 🔹 NEU
tags: true,
},
});
@ -32,9 +35,232 @@ export async function GET() {
updatedAt: d.updatedAt.toISOString(),
})),
);
} catch (err) {
console.error('[GET /api/devices]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
const body = await req.json();
const {
inventoryNumber,
name,
manufacturer,
model,
serialNumber,
productNumber,
comment,
group,
location,
ipv4Address,
ipv6Address,
macAddress,
username,
passwordHash,
tags,
} = body;
if (!inventoryNumber || !name) {
return NextResponse.json(
{ error: 'inventoryNumber und name sind Pflichtfelder.' },
{ status: 400 },
);
}
const existing = await prisma.device.findUnique({
where: { inventoryNumber },
});
if (existing) {
return NextResponse.json(
{ error: 'Gerät mit dieser Inventar-Nr. existiert bereits.' },
{ status: 409 },
);
}
// aktuell eingeloggten User ermitteln
const userId = await getCurrentUserId();
let canConnectUser = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (user) {
canConnectUser = true;
} else {
console.warn(
`[POST /api/devices] User mit id=${userId} nicht gefunden createdBy/changedBy werden nicht gesetzt.`,
);
}
}
// Gruppe / Standort (per Name) anlegen/finden
let groupId: string | null = null;
if (group && typeof group === 'string' && group.trim() !== '') {
const grp = await prisma.deviceGroup.upsert({
where: { name: group.trim() },
update: {},
create: { name: group.trim() },
});
groupId = grp.id;
}
let locationId: string | null = null;
if (location && typeof location === 'string' && location.trim() !== '') {
const loc = await prisma.location.upsert({
where: { name: location.trim() },
update: {},
create: { name: location.trim() },
});
locationId = loc.id;
}
const tagNames: string[] = Array.isArray(tags)
? tags.map((t: unknown) => String(t).trim()).filter(Boolean)
: [];
const created = await prisma.device.create({
data: {
inventoryNumber,
name,
manufacturer,
model,
serialNumber: serialNumber ?? null,
productNumber: productNumber ?? null,
comment: comment ?? null,
ipv4Address: ipv4Address ?? null,
ipv6Address: ipv6Address ?? null,
macAddress: macAddress ?? null,
username: username ?? null,
passwordHash: passwordHash ?? null,
// ⬇️ Relation über group/location, nicht groupId/locationId
...(groupId
? {
group: {
connect: { id: groupId },
},
}
: {}),
...(locationId
? {
location: {
connect: { id: locationId },
},
}
: {}),
// User, der das Gerät angelegt hat
...(canConnectUser && userId
? { createdBy: { connect: { id: userId } } }
: {}),
// Tags Many-to-Many
...(tagNames.length
? {
tags: {
connectOrCreate: tagNames.map((name) => ({
where: { name },
create: { name },
})),
},
}
: {}),
},
include: {
group: true,
location: true,
tags: true,
},
});
// History-Eintrag "CREATED" mit changedById
const snapshot: Prisma.JsonObject = {
before: null,
after: {
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
changes: [],
};
await prisma.deviceHistory.create({
data: {
deviceId: created.inventoryNumber,
changeType: 'CREATED',
snapshot,
changedById: canConnectUser && userId ? userId : null,
},
});
// Socket-Event
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:created', {
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
updatedAt: created.updatedAt.toISOString(),
});
}
return NextResponse.json(
{
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },
);
} catch (err) {
console.error('[POST /api/devices]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Anlegen des Geräts.' },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,33 @@
// components/DeviceQrCode.tsx
'use client';
import { QRCodeSVG } from 'qrcode.react';
type DeviceQrCodeProps = {
inventoryNumber: string;
size?: number;
};
export function DeviceQrCode({ inventoryNumber, size = 180 }: DeviceQrCodeProps) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
// Immer vollständige URL für externe Scanner erzeugen
const qrValue = baseUrl
? `${baseUrl.replace(/\/$/, '')}/devices/${encodeURIComponent(inventoryNumber)}`
: inventoryNumber;
return (
<div className="inline-flex flex-col items-center gap-2">
<QRCodeSVG
value={qrValue}
size={size}
level="M" // Fehlertoleranz
includeMargin // wichtiger weißer Rand (= Quiet Zone)
bgColor="#FFFFFF"
fgColor="#000000"
/>
{/* Optional: zum Debuggen den Wert anzeigen */}
{/* <p className="text-[10px] text-gray-500 break-all text-center">{qrValue}</p> */}
</div>
);
}

145
components/ScanModal.tsx Normal file
View File

@ -0,0 +1,145 @@
// components/ScanModal.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import Modal from '@/components/ui/Modal';
import { CameraIcon } from '@heroicons/react/24/outline';
import { BrowserMultiFormatReader, IScannerControls } from '@zxing/browser';
type ScanModalProps = {
open: boolean;
onClose: () => void;
onResult: (code: string) => void;
};
export default function ScanModal({ open, onClose, onResult }: ScanModalProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setError(null);
// Browser / Secure-Context Check
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices ||
typeof navigator.mediaDevices.getUserMedia !== 'function'
) {
setError(
'Kamera wird in diesem Kontext nicht unterstützt. Bitte die Seite über HTTPS oder localhost aufrufen.',
);
return;
}
const codeReader = new BrowserMultiFormatReader();
let controls: IScannerControls | null = null;
let stopped = false;
let rafId: number | null = null;
const startScanner = () => {
// warten, bis das <video> da ist
if (!videoRef.current) {
rafId = requestAnimationFrame(startScanner);
return;
}
codeReader
.decodeFromConstraints(
{
audio: false,
video: {
facingMode: { ideal: 'environment' }, // Rückkamera bevorzugen
},
},
videoRef.current,
(result, err, _controls) => {
if (_controls && !controls) {
controls = _controls;
}
if (result && !stopped) {
stopped = true;
if (controls) controls.stop();
onResult(result.getText());
onClose();
}
if (err && err.name !== 'NotFoundException') {
console.warn('Scanner-Fehler:', err);
}
},
)
.catch((e) => {
console.error('Error starting camera', e);
setError(
'Kamera konnte nicht geöffnet werden. Bitte Berechtigungen prüfen oder einen anderen Browser verwenden.',
);
});
};
startScanner();
return () => {
stopped = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
if (controls) {
controls.stop();
}
// Stream hart stoppen, falls noch aktiv
if (videoRef.current && videoRef.current.srcObject instanceof MediaStream) {
videoRef.current.srcObject.getTracks().forEach((t) => t.stop());
videoRef.current.srcObject = null;
}
// ⚠️ KEIN codeReader.reset() mehr macht bei dir Runtime-Fehler
};
}, [open, onClose, onResult]);
const handleClose = () => onClose();
return (
<Modal
open={open}
onClose={handleClose}
title="QR-Code scannen"
icon={<CameraIcon className="size-6" />}
tone="info"
variant="centered"
size="md"
primaryAction={{
label: 'Abbrechen',
onClick: handleClose,
variant: 'secondary',
}}
>
<div className="mt-4 space-y-3">
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{!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.
</p>
)}
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200 bg-black dark:border-white/10">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="block w-full h-80 object-contain"
/>
</div>
</div>
</Modal>
);
}

View File

@ -5,7 +5,7 @@ import clsx from 'clsx';
export type ButtonVariant = 'primary' | 'secondary' | 'soft';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type ButtonShape = 'default' | 'pill' | 'circle';
export type ButtonTone = 'indigo' | 'gray' | 'rose';
export type ButtonTone = 'indigo' | 'gray' | 'rose' | 'emerald';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@ -38,6 +38,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
'bg-rose-600 text-white hover:bg-rose-500 ' +
'focus-visible:outline-rose-600 ' +
'dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400 dark:shadow-none dark:focus-visible:outline-rose-500',
emerald:
'bg-emerald-600 text-white hover:bg-emerald-500 ' +
'focus-visible:outline-emerald-600 ' +
'dark:bg-emerald-500 dark:text-white dark:hover:bg-emerald-400 dark:shadow-none dark:focus-visible:outline-emerald-500',
},
secondary: {
indigo:
@ -52,6 +56,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
'bg-white text-rose-600 inset-ring inset-ring-rose-200 hover:bg-rose-50 ' +
'focus-visible:outline-rose-600 ' +
'dark:bg-white/10 dark:text-rose-300 dark:shadow-none dark:inset-ring-rose-500/40 dark:hover:bg-rose-500/10 dark:focus-visible:outline-rose-500',
emerald:
'bg-white text-emerald-600 inset-ring inset-ring-emerald-200 hover:bg-emerald-50 ' +
'focus-visible:outline-emerald-600 ' +
'dark:bg-white/10 dark:text-emerald-300 dark:shadow-none dark:inset-ring-emerald-500/40 dark:hover:bg-emerald-500/10 dark:focus-visible:outline-emerald-500',
},
soft: {
indigo:
@ -66,6 +74,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
'bg-rose-50 text-rose-600 hover:bg-rose-100 ' +
'focus-visible:outline-rose-600 ' +
'dark:bg-rose-500/20 dark:text-rose-300 dark:shadow-none dark:hover:bg-rose-500/30 dark:focus-visible:outline-rose-500',
emerald:
'bg-emerald-50 text-emerald-600 hover:bg-emerald-100 ' +
'focus-visible:outline-emerald-600 ' +
'dark:bg-emerald-500/20 dark:text-emerald-300 dark:shadow-none dark:hover:bg-emerald-500/30 dark:focus-visible:outline-emerald-500',
},
};

View File

@ -3,8 +3,10 @@
import * as React from 'react';
import {
ChatBubbleLeftEllipsisIcon,
TagIcon,
PlusIcon,
TrashIcon,
PencilIcon
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
@ -39,6 +41,8 @@ export type FeedItem =
imageUrl?: string;
comment: string;
date: string;
/** Art des Kommentars steuert Icon/Farbe */
commentKind?: 'created' | 'deleted' | 'generic';
}
| {
id: string | number;
@ -94,10 +98,13 @@ function colorFromName(name: string): string {
}
// sprechende Zusammenfassung für "change"
function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
function getChangeSummary(
item: Extract<FeedItem, { type: 'change' }>,
): React.ReactNode {
const { changes } = item;
if (!changes.length) return 'hat Änderungen vorgenommen';
// Sonderfall: nur Tags
if (changes.length === 1 && changes[0].field === 'tags') {
const c = changes[0];
const beforeList = (c.from ?? '')
@ -120,15 +127,15 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
);
const parts: string[] = [];
if (added.length) {
parts.push(`hinzugefügt: ${added.join(', ')}`);
}
if (removed.length) {
parts.push(`entfernt: ${removed.join(', ')}`);
}
if (added.length) parts.push(`hinzugefügt: ${added.join(', ')}`);
if (removed.length) parts.push(`entfernt: ${removed.join(', ')}`);
if (parts.length > 0) {
return `hat Tags ${parts.join(' · ')}`;
return (
<>
hat Tags <em>{parts.join(' · ')}</em>
</>
);
}
return 'hat Tags angepasst';
}
@ -136,7 +143,11 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
if (changes.length === 1) {
const c = changes[0];
const label = c.label ?? c.field;
return `hat ${label} geändert`;
return (
<>
hat <em>{label}</em> geändert
</>
);
}
const labels = changes.map((c) => c.label ?? c.field);
@ -144,11 +155,34 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
const maxShow = 3;
if (uniqueLabels.length <= maxShow) {
return `hat ${uniqueLabels.join(', ')} geändert`;
return (
<>
hat{' '}
{uniqueLabels.map((label, index) => (
<React.Fragment key={label}>
<em>{label}</em>
{index < uniqueLabels.length - 1 ? ', ' : ' '}
</React.Fragment>
))}
geändert
</>
);
}
const first = uniqueLabels.slice(0, maxShow).join(', ');
return `hat ${first} und weitere geändert`;
const first = uniqueLabels.slice(0, maxShow);
return (
<>
hat{' '}
{first.map((label, index) => (
<React.Fragment key={label}>
<em>{label}</em>
{index < first.length - 1 ? ', ' : ' '}
</React.Fragment>
))}
und weitere geändert
</>
);
}
/* ───────── Component ───────── */
@ -168,142 +202,151 @@ export default function Feed({ items, className }: FeedProps) {
}
return (
<div className={clsx('h-full overflow-y-auto pr-2', className)}>
<ul role="list" className="pb-4">
{items.map((item, idx) => {
// Icon + Hintergrund ähnlich wie im Beispiel
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
ChatBubbleLeftEllipsisIcon;
let iconBg = 'bg-gray-400 dark:bg-gray-600';
<ul
role="list"
className={clsx('pb-4', className)}
>
{items.map((item, idx) => {
// Icon + Hintergrund
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
PencilIcon;
let iconBg = 'bg-gray-400 dark:bg-gray-600';
if (item.type === 'tags') {
Icon = TagIcon;
iconBg = 'bg-amber-500';
} else if (item.type === 'change') {
const isTagsOnly =
item.changes.length === 1 && item.changes[0].field === 'tags';
Icon = isTagsOnly ? TagIcon : ChatBubbleLeftEllipsisIcon;
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-emerald-500';
} else if (item.type === 'comment') {
if (item.type === 'tags') {
Icon = TagIcon;
iconBg = 'bg-amber-500';
} else if (item.type === 'change') {
const isTagsOnly =
item.changes.length === 1 && item.changes[0].field === 'tags';
Icon = isTagsOnly ? TagIcon : PencilIcon;
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-cyan-500';
} else if (item.type === 'comment') {
if (item.commentKind === 'created') {
Icon = PlusIcon;
iconBg = 'bg-emerald-500';
} else if (item.commentKind === 'deleted') {
Icon = TrashIcon;
iconBg = 'bg-rose-500';
} else {
iconBg = colorFromName(item.person.name);
} else if (item.type === 'assignment') {
iconBg = 'bg-indigo-500';
}
} else if (item.type === 'assignment') {
iconBg = 'bg-indigo-500';
}
// Textinhalt ähnlich wie "content + target"
let content: React.ReactNode = null;
if (item.type === 'comment') {
content = (
// Textinhalt (content) dein bisheriger Code unverändert:
let content: React.ReactNode = null;
if (item.type === 'comment') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
<span className="text-gray-300 dark:text-gray-200">
{item.comment}
</span>
</p>
);
} else if (item.type === 'assignment') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
hat{' '}
<span className="font-medium text-gray-900 dark:text-white">
{item.assigned.name}
</span>{' '}
zugewiesen.
</p>
);
} else if (item.type === 'tags') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
hat Tags hinzugefügt:{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">
{item.tags.map((t) => t.name).join(', ')}
</span>
</p>
);
} else if (item.type === 'change') {
const summary = getChangeSummary(item);
content = (
<div className="space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
hat kommentiert:{' '}
<span className="text-gray-300 dark:text-gray-200">
{item.comment}
</span>
{summary}
</p>
);
} else if (item.type === 'assignment') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
hat{' '}
<span className="font-medium text-gray-900 dark:text-white">
{item.assigned.name}
</span>{' '}
zugewiesen.
</p>
);
} else if (item.type === 'tags') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
hat Tags hinzugefügt:{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">
{item.tags.map((t) => t.name).join(', ')}
</span>
</p>
);
} else if (item.type === 'change') {
const summary = getChangeSummary(item);
content = (
<div className="space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
{summary}
</p>
{item.changes.length > 0 && (
<p className="text-[11px] text-gray-400 dark:text-gray-500">
{item.changes.slice(0, 2).map((c, i) => (
<span
key={`${c.field}-${i}`}
className="flex flex-wrap items-baseline gap-x-1"
>
<span className="line-through text-red-500/80 dark:text-red-400/90">
{c.from ?? '—'}
</span>
<span className="text-gray-400"></span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">
{c.to ?? '—'}
</span>
{i < Math.min(2, item.changes.length) - 1 && (
<span className="mx-1 text-gray-500 dark:text-gray-600">·</span>
)}
</span>
))}
{item.changes.length > 2 && (
<span className="ml-1 text-gray-500 dark:text-gray-600">· </span>
)}
</p>
)}
</div>
);
}
return (
<li key={item.id}>
<div className="relative pb-6">
{idx !== items.length - 1 ? (
<span
aria-hidden="true"
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
/>
) : null}
<div className="relative flex space-x-3">
{/* Icon-Kreis wie im Beispiel */}
<div>
{item.changes.length > 0 && (
<p className="text-[11px] text-gray-400 dark:text-gray-500">
{item.changes.slice(0, 2).map((c, i) => (
<span
className={classNames(
iconBg,
'flex size-8 items-center justify-center rounded-full',
)}
key={`${c.field}-${i}`}
className="flex flex-wrap items-baseline gap-x-1"
>
<Icon aria-hidden="true" className="size-4 text-white" />
<span className="line-through text-red-500/80 dark:text-red-400/90">
{c.from ?? '—'}
</span>
<span className="text-gray-400"></span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">
{c.to ?? '—'}
</span>
{i < Math.min(2, item.changes.length) - 1 && (
<span className="mx-1 text-gray-500 dark:text-gray-600">
·
</span>
)}
</span>
</div>
))}
{item.changes.length > 2 && (
<span className="ml-1 text-gray-500 dark:text-gray-600">
·
</span>
)}
</p>
)}
</div>
);
}
{/* Text + Datum rechts */}
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>{content}</div>
<div className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400">
{item.date}
</div>
return (
<li key={item.id}>
<div className="relative pb-6">
{idx !== items.length - 1 ? (
<span
aria-hidden="true"
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
/>
) : null}
<div className="relative flex space-x-3">
<div>
<span
className={classNames(
iconBg,
'flex size-8 items-center justify-center rounded-full',
)}
>
<Icon aria-hidden="true" className="size-4 text-white" />
</span>
</div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>{content}</div>
<div className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400">
{item.date}
</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</li>
);
})}
</ul>
);
}
}

View File

@ -10,6 +10,7 @@ import {
} from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Button from './Button';
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
export type ModalVariant = 'centered' | 'alert';
@ -52,15 +53,19 @@ export interface ModalProps {
/**
* Wenn gesetzt, wird dieser Footer anstelle der auto-generierten Buttons gerendert.
* Damit kannst du komplett eigene Layouts bauen.
*/
footer?: React.ReactNode;
/**
* Optionaler Inhalt für eine rechte Sidebar (z.B. Geräthistorie).
* Auf kleinen Screens unten angehängt, ab sm rechts als Spalte.
*/
sidebar?: React.ReactNode;
/**
* Zusätzlicher Inhalt direkt UNTER dem Titel im Header (z.B. Tabs).
* Liegt außerhalb des scrollbaren Body-Bereichs.
*/
headerExtras?: React.ReactNode;
}
/* ───────── Layout-Helfer ───────── */
@ -94,27 +99,8 @@ const toneStyles: Record<
const sizeClasses: Record<ModalSize, string> = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-lg',
lg: 'sm:max-w-3xl', // ein bisschen breiter für Sidebar
xl: 'sm:max-w-5xl', // ein bisschen breiter für Sidebar
};
const baseButtonClasses =
'inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold shadow-xs ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 ';
const buttonVariantClasses: Record<
NonNullable<ModalAction['variant']>,
string
> = {
primary:
'bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:outline-indigo-600 ' +
'dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500 dark:shadow-none',
secondary:
'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
danger:
'bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600 ' +
'dark:bg-red-500 dark:hover:bg-red-400 dark:shadow-none',
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
};
function renderActionButton(
@ -123,15 +109,29 @@ function renderActionButton(
): React.ReactNode {
const variant = action.variant ?? 'primary';
let buttonVariant: 'primary' | 'secondary' | 'soft' = 'primary';
let tone: 'indigo' | 'gray' | 'rose' = 'indigo';
if (variant === 'secondary') {
buttonVariant = 'secondary';
tone = 'gray';
} else if (variant === 'danger') {
buttonVariant = 'primary';
tone = 'rose';
}
return (
<button
<Button
type="button"
onClick={action.onClick}
data-autofocus={action.autoFocus ? true : undefined}
className={clsx(baseButtonClasses, buttonVariantClasses[variant], extraClasses)}
autoFocus={action.autoFocus}
variant={buttonVariant}
tone={tone}
size="lg"
className={clsx('w-full', extraClasses)}
>
{action.label}
</button>
</Button>
);
}
@ -153,10 +153,12 @@ export function Modal({
useGrayFooter = false,
footer,
sidebar,
headerExtras,
}: ModalProps) {
const toneStyle = toneStyles[tone];
const panelSizeClasses = sizeClasses[size];
const hasActions = !!primaryAction || !!secondaryAction;
const hasBothActions = !!primaryAction && !!secondaryAction;
const isAlert = variant === 'alert';
const bodyContent = children ?? description;
@ -169,11 +171,11 @@ export function Modal({
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:p-8">
<DialogPanel
transition
className={clsx(
'relative flex max-h-[90vh] w-full flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
'relative flex w-full max-h-[calc(100vh-8rem)] lg:max-h-[800px] 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 ' +
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
@ -194,9 +196,9 @@ export function Modal({
</div>
)}
{/* Header + Body + Sidebar */}
<div className="flex-1 overflow-hidden bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800">
{/* Header (Icon + Titel + optionale Beschreibung) */}
{/* 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',
@ -245,28 +247,33 @@ export function Modal({
</DialogTitle>
)}
{/* Beschreibung nur anzeigen, wenn keine eigenen Children übergeben wurden */}
{!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>
{/* Body + Sidebar */ }
{/* MAIN: Body + Sidebar nimmt den Rest der Höhe ein */}
{(bodyContent || sidebar) && (
<div
className={clsx(
'mt-6',
// vorher: sidebar && 'sm:mt-8 sm:flex sm:items-start sm:gap-6',
sidebar && 'sm:mt-8 sm:flex sm:items-stretch sm:gap-6',
'mt-6 flex flex-col min-h-0',
sidebar
? 'sm:mt-8 sm:flex-row sm:items-stretch sm:gap-6'
: 'overflow-y-auto' // nur ohne Sidebar soll der Body global scrollen
)}
>
{/* Linker Inhalt (Details / Formulare) */}
{bodyContent && (
<div
className={clsx(
'flex-1 text-left',
'flex-1 min-h-0 overflow-y-auto text-left',
!sidebar && 'mx-auto max-w-2xl',
)}
>
@ -274,8 +281,9 @@ export function Modal({
</div>
)}
{/* Rechte Sidebar (QR + Verlauf) */}
{sidebar && (
<aside className="border-t border-gray-200 text-left text-sm sm:flex sm:h-full sm:w-80 sm:shrink-0 sm:border-l sm:border-t-0 sm:pl-4 dark:border-white/10" >
<aside className="sm:min-h-0 sm:overflow-hidden">
{sidebar}
</aside>
)}
@ -292,32 +300,25 @@ export function Modal({
useGrayFooter
? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25'
: 'px-4 py-3 sm:px-6',
isAlert
? 'sm:flex sm:flex-row-reverse sm:gap-3'
: secondaryAction
? 'sm:mt-2 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3'
: 'sm:mt-2',
)}
>
{primaryAction &&
renderActionButton(
primaryAction,
isAlert
? 'sm:w-auto'
: secondaryAction
? 'sm:col-start-2'
: '',
)}
{secondaryAction &&
renderActionButton(
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
clsx(
'mt-3 sm:mt-0',
isAlert && 'sm:w-auto sm:mr-3',
!useGrayFooter && 'bg-white',
),
<div
className={clsx(
'flex flex-col gap-3',
hasBothActions && 'sm:flex-row-reverse',
)}
>
{primaryAction &&
renderActionButton(
primaryAction,
hasBothActions ? 'flex-1' : undefined,
)}
{secondaryAction &&
renderActionButton(
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
hasBothActions ? 'flex-1' : undefined,
)}
</div>
</div>
) : null}
</DialogPanel>
@ -328,3 +329,4 @@ export function Modal({
}
export default Modal;

View File

@ -81,11 +81,21 @@ export default function Table<T>(props: TableProps<T>) {
if (va == null) return sort.direction === 'asc' ? -1 : 1;
if (vb == null) return sort.direction === 'asc' ? 1 : -1;
// Numbers
// Reine Numbers
if (typeof va === 'number' && typeof vb === 'number') {
return sort.direction === 'asc' ? va - vb : vb - va;
}
// Numerische Strings wie "1", "123", "42"
if (typeof va === 'string' && typeof vb === 'string') {
const na = Number(va);
const nb = Number(vb);
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
return sort.direction === 'asc' ? na - nb : nb - na;
}
}
// Date / ISO-String
const sa = va instanceof Date ? va.getTime() : String(va);
const sb = vb instanceof Date ? vb.getTime() : String(vb);
@ -197,7 +207,7 @@ export default function Table<T>(props: TableProps<T>) {
key={String(col.key)}
scope="col"
className={classNames(
'py-3.5 px-4 text-left text-sm font-semibold text-gray-900 dark:text-white',
'py-3.5 px-2 text-left text-sm font-semibold text-gray-900 dark:text-white',
col.headerClassName,
isHideable
? 'hidden lg:table-cell'
@ -238,7 +248,7 @@ export default function Table<T>(props: TableProps<T>) {
{renderActions && (
<th
scope="col"
className="py-3.5 px-4 text-sm font-semibold text-gray-900 dark:text-white"
className="py-3.5 px-2 text-sm font-semibold text-gray-900 dark:text-white"
>
{actionsHeader}
</th>
@ -256,7 +266,7 @@ export default function Table<T>(props: TableProps<T>) {
className={classNames(isSelected && 'bg-gray-50 dark:bg-gray-800/60')}
>
{selectable && (
<td className="px-4">
<td className="px-2">
<div className="group grid size-4 grid-cols-1">
<input
type="checkbox"
@ -292,7 +302,7 @@ export default function Table<T>(props: TableProps<T>) {
<td
key={String(col.key)}
className={classNames(
'px-4 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
'px-2 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
col.cellClassName,
col.canHide && 'hidden lg:table-cell', // <-- HIER
)}
@ -302,7 +312,7 @@ export default function Table<T>(props: TableProps<T>) {
))}
{renderActions && (
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
<td className="px-2 py-3 text-sm whitespace-nowrap text-right">
{renderActions(row)}
</td>
)}
@ -314,7 +324,7 @@ export default function Table<T>(props: TableProps<T>) {
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0)}
className="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
className="px-2 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
Keine Daten vorhanden.
</td>

View File

@ -1,62 +1,83 @@
// /components/ui/Tabs.tsx
// components/ui/Tabs.tsx
'use client'
'use client';
import * as React from 'react'
import { ChevronDownIcon } from '@heroicons/react/16/solid';
import clsx from 'clsx';
import * as React from 'react';
export type TabItem = {
id: string
label: string
}
id: string;
label: string;
};
export type TabsProps = {
tabs: TabItem[]
defaultTabId?: string
onChange?(id: string): void
className?: string
}
type TabsProps = {
tabs: TabItem[];
/** aktuell ausgewählter Tab (id) */
value: string;
/** Callback bei Wechsel */
onChange: (id: string) => void;
className?: string;
/** Optional eigenes aria-label */
ariaLabel?: string;
};
export function Tabs({ tabs, defaultTabId, onChange, className }: TabsProps) {
const [activeId, setActiveId] = React.useState(
defaultTabId ?? (tabs[0]?.id ?? ''),
)
const handleClick = (id: string) => {
setActiveId(id)
onChange?.(id)
}
export default function Tabs({
tabs,
value,
onChange,
className,
ariaLabel = 'Ansicht auswählen',
}: TabsProps) {
const current = tabs.find((t) => t.id === value) ?? tabs[0];
return (
<div
className={[
'border-b border-gray-200 dark:border-white/10',
className,
]
.filter(Boolean)
.join(' ')}
>
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => {
const isActive = tab.id === activeId
return (
<button
key={tab.id}
type="button"
onClick={() => handleClick(tab.id)}
className={[
'whitespace-nowrap border-b-2 px-1 pb-3 pt-2 text-sm/6 font-semibold transition-colors',
isActive
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
]
.filter(Boolean)
.join(' ')}
>
<div className={className}>
{/* Mobile: Select + Chevron */}
<div className="grid grid-cols-1 sm:hidden">
<select
value={current.id}
onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel}
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
{tab.label}
</button>
)
})}
</nav>
</option>
))}
</select>
<ChevronDownIcon
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500 dark:fill-gray-400"
/>
</div>
{/* Desktop: Underline-Tabs */}
<div className="hidden sm:block">
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const isCurrent = tab.id === current.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={clsx(
isCurrent
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
'border-b-2 px-1 py-3 text-sm font-medium whitespace-nowrap',
)}
>
{tab.label}
</button>
);
})}
</nav>
</div>
</div>
</div>
)
);
}

56
package-lock.json generated
View File

@ -11,10 +11,13 @@
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
"@zxing/browser": "^0.1.5",
"@zxing/library": "^0.21.3",
"bcryptjs": "^3.0.3",
"next": "16.0.3",
"next-auth": "^4.24.13",
"postcss": "^8.5.6",
"qrcode.react": "^4.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"socket.io": "^4.8.1",
@ -2533,6 +2536,41 @@
"win32"
]
},
"node_modules/@zxing/browser": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz",
"integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.21.0"
}
},
"node_modules/@zxing/library": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz",
"integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==",
"license": "MIT",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.2.1"
},
"engines": {
"node": ">= 10.4.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -6400,6 +6438,15 @@
],
"license": "MIT"
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -7324,6 +7371,15 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",

View File

@ -13,10 +13,13 @@
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
"@zxing/browser": "^0.1.5",
"@zxing/library": "^0.21.3",
"bcryptjs": "^3.0.3",
"next": "16.0.3",
"next-auth": "^4.24.13",
"postcss": "^8.5.6",
"qrcode.react": "^4.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"socket.io": "^4.8.1",

Binary file not shown.

View File

@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "DeviceHistory_changedById_idx";
-- DropIndex
DROP INDEX "DeviceHistory_deviceId_changedAt_idx";

View File

@ -136,23 +136,14 @@ enum DeviceChangeType {
model DeviceHistory {
id String @id @default(cuid())
deviceId String
device Device @relation(fields: [deviceId], references: [inventoryNumber])
changeType DeviceChangeType
snapshot Json
changedAt DateTime @default(now())
// 🔹 FK-Spalte
changedById String?
// 🔹 Relation zu User
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
@@index([deviceId, changedAt])
@@index([changedById])
}