updated
This commit is contained in:
parent
90231bff83
commit
7f683d5828
2
.env
2
.env
@ -11,5 +11,5 @@
|
|||||||
|
|
||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=https://10.0.1.25
|
||||||
NEXTAUTH_SECRET=tegvideo7010!
|
NEXTAUTH_SECRET=tegvideo7010!
|
||||||
405
app/(app)/devices/DeviceCreateModal.tsx
Normal file
405
app/(app)/devices/DeviceCreateModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
365
app/(app)/devices/DeviceDetailModal.tsx
Normal file
365
app/(app)/devices/DeviceDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,19 @@
|
|||||||
// app/(app)/devices/DeviceEditModal.tsx
|
|
||||||
'use client';
|
'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 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 DeviceHistorySidebar from './DeviceHistorySidebar';
|
||||||
import TagMultiCombobox, { TagOption } from '@/components/ui/TagMultiCombobox';
|
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 = {
|
type DeviceEditModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -29,25 +36,23 @@ export default function DeviceEditModal({
|
|||||||
const [editLoading, setEditLoading] = useState(false);
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
|
|
||||||
// Gerät laden, wenn Modal geöffnet wird
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !inventoryNumber) {
|
if (!open || !inventoryNumber) return;
|
||||||
setEditDevice(null);
|
|
||||||
setEditError(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const inv = inventoryNumber;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function loadDevice() {
|
setEditLoading(true);
|
||||||
setEditLoading(true);
|
setEditError(null);
|
||||||
setEditError(null);
|
setJustSaved(false);
|
||||||
setEditDevice(null);
|
setEditDevice(null);
|
||||||
|
|
||||||
|
async function loadDevice() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/devices/${encodeURIComponent(inventoryNumber)}`,
|
`/api/devices/${encodeURIComponent(inv)}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -59,7 +64,9 @@ export default function DeviceEditModal({
|
|||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
throw new Error('Gerät wurde nicht gefunden.');
|
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;
|
const data = (await res.json()) as DeviceDetail;
|
||||||
@ -70,7 +77,9 @@ export default function DeviceEditModal({
|
|||||||
console.error('Error loading device', err);
|
console.error('Error loading device', err);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setEditError(
|
setEditError(
|
||||||
err instanceof Error ? err.message : 'Netzwerkfehler beim Laden der Gerätedaten.',
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Netzwerkfehler beim Laden der Gerätedaten.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -137,16 +146,23 @@ export default function DeviceEditModal({
|
|||||||
|
|
||||||
const updated = (await res.json()) as DeviceDetail;
|
const updated = (await res.json()) as DeviceDetail;
|
||||||
setEditDevice(updated);
|
setEditDevice(updated);
|
||||||
onSaved(updated); // Tabelle im Parent aktualisieren
|
onSaved(updated);
|
||||||
|
|
||||||
|
setJustSaved(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error saving device', err);
|
console.error('Error saving device', err);
|
||||||
setEditError(
|
setEditError(
|
||||||
err instanceof Error ? err.message : 'Netzwerkfehler beim Speichern der Gerätedaten.',
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Netzwerkfehler beim Speichern der Gerätedaten.',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSaveLoading(false);
|
setSaveLoading(false);
|
||||||
}
|
}
|
||||||
}, [editDevice, onSaved]);
|
}, [editDevice, onSaved, onClose]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (saveLoading) return;
|
if (saveLoading) return;
|
||||||
@ -163,22 +179,54 @@ export default function DeviceEditModal({
|
|||||||
: 'Gerätedaten werden geladen …'
|
: 'Gerätedaten werden geladen …'
|
||||||
}
|
}
|
||||||
icon={<PencilIcon className="size-6" />}
|
icon={<PencilIcon className="size-6" />}
|
||||||
tone="info"
|
tone={justSaved ? 'success' : 'info'}
|
||||||
variant="centered"
|
variant="centered"
|
||||||
size="lg"
|
size="xl"
|
||||||
primaryAction={{
|
footer={
|
||||||
label: saveLoading ? 'Speichern …' : 'Speichern',
|
<div className="px-4 py-3 sm:px-6">
|
||||||
onClick: handleSave,
|
<div className="flex flex-col gap-3 sm:flex-row-reverse">
|
||||||
autoFocus: true,
|
<Button
|
||||||
}}
|
type="button"
|
||||||
secondaryAction={{
|
onClick={handleSave}
|
||||||
label: 'Abbrechen',
|
size="lg"
|
||||||
variant: 'secondary',
|
variant="primary"
|
||||||
onClick: handleClose,
|
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={
|
sidebar={
|
||||||
editDevice ? (
|
editDevice ? (
|
||||||
<DeviceHistorySidebar
|
<DeviceHistorySidebar
|
||||||
|
key={editDevice.updatedAt}
|
||||||
inventoryNumber={editDevice.inventoryNumber}
|
inventoryNumber={editDevice.inventoryNumber}
|
||||||
asSidebar
|
asSidebar
|
||||||
/>
|
/>
|
||||||
@ -192,11 +240,13 @@ export default function DeviceEditModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{editError && (
|
{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 && (
|
{!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 */}
|
{/* Inventarnummer */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||||
@ -307,12 +357,10 @@ export default function DeviceEditModal({
|
|||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
const names = next.map((t) => t.name);
|
const names = next.map((t) => t.name);
|
||||||
|
|
||||||
// in editDevice speichern
|
|
||||||
setEditDevice((prev) =>
|
setEditDevice((prev) =>
|
||||||
prev ? ({ ...prev, tags: names } as DeviceDetail) : prev,
|
prev ? ({ ...prev, tags: names } as DeviceDetail) : prev,
|
||||||
);
|
);
|
||||||
|
|
||||||
// allTags im Parent erweitern
|
|
||||||
setAllTags((prev) => {
|
setAllTags((prev) => {
|
||||||
const map = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
|
const map = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
|
||||||
for (const t of next) {
|
for (const t of next) {
|
||||||
@ -365,6 +413,7 @@ export default function DeviceEditModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zugangsdaten */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||||
Benutzername
|
Benutzername
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import Feed, {
|
|||||||
FeedItem,
|
FeedItem,
|
||||||
FeedChange,
|
FeedChange,
|
||||||
} from '@/components/ui/Feed';
|
} from '@/components/ui/Feed';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
inventoryNumber: string;
|
inventoryNumber: string;
|
||||||
@ -35,33 +37,33 @@ function formatDateTime(iso: string) {
|
|||||||
function mapFieldLabel(field: string): string {
|
function mapFieldLabel(field: string): string {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'name':
|
case 'name':
|
||||||
return 'die Bezeichnung';
|
return 'Bezeichnung';
|
||||||
case 'manufacturer':
|
case 'manufacturer':
|
||||||
return 'den Hersteller';
|
return 'Hersteller';
|
||||||
case 'model':
|
case 'model':
|
||||||
return 'das Modell';
|
return 'Modell';
|
||||||
case 'serialNumber':
|
case 'serialNumber':
|
||||||
return 'die Seriennummer';
|
return 'Seriennummer';
|
||||||
case 'productNumber':
|
case 'productNumber':
|
||||||
return 'die Produktnummer';
|
return 'Produktnummer';
|
||||||
case 'comment':
|
case 'comment':
|
||||||
return 'den Kommentar';
|
return 'Kommentar';
|
||||||
case 'ipv4Address':
|
case 'ipv4Address':
|
||||||
return 'die IPv4-Adresse';
|
return 'IPv4-Adresse';
|
||||||
case 'ipv6Address':
|
case 'ipv6Address':
|
||||||
return 'die IPv6-Adresse';
|
return 'IPv6-Adresse';
|
||||||
case 'macAddress':
|
case 'macAddress':
|
||||||
return 'die MAC-Adresse';
|
return 'MAC-Adresse';
|
||||||
case 'username':
|
case 'username':
|
||||||
return 'den Benutzernamen';
|
return 'Benutzernamen';
|
||||||
case 'passwordHash':
|
case 'passwordHash':
|
||||||
return 'das Passwort';
|
return 'Passwort';
|
||||||
case 'group':
|
case 'group':
|
||||||
return 'die Gruppe';
|
return 'Gruppe';
|
||||||
case 'location':
|
case 'location':
|
||||||
return 'den Standort';
|
return 'Standort';
|
||||||
case 'tags':
|
case 'tags':
|
||||||
return 'die Tags';
|
return 'Tags';
|
||||||
default:
|
default:
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
@ -81,7 +83,6 @@ export default function DeviceHistorySidebar({
|
|||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
|
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
|
||||||
@ -123,12 +124,17 @@ export default function DeviceHistorySidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let comment = '';
|
let comment = '';
|
||||||
|
let commentKind: 'created' | 'deleted' | 'generic' = 'generic';
|
||||||
|
|
||||||
if (entry.changeType === 'CREATED') {
|
if (entry.changeType === 'CREATED') {
|
||||||
comment = 'Gerät angelegt.';
|
comment = 'hat das Gerät angelegt.';
|
||||||
|
commentKind = 'created';
|
||||||
} else if (entry.changeType === 'DELETED') {
|
} else if (entry.changeType === 'DELETED') {
|
||||||
comment = 'Gerät gelöscht.';
|
comment = 'hat das Gerät gelöscht.';
|
||||||
|
commentKind = 'deleted';
|
||||||
} else {
|
} else {
|
||||||
comment = 'Gerät geändert.';
|
comment = 'hat das Gerät geändert.';
|
||||||
|
commentKind = 'generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -137,6 +143,7 @@ export default function DeviceHistorySidebar({
|
|||||||
person,
|
person,
|
||||||
date,
|
date,
|
||||||
comment,
|
comment,
|
||||||
|
commentKind,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -161,13 +168,14 @@ export default function DeviceHistorySidebar({
|
|||||||
}, [inventoryNumber]);
|
}, [inventoryNumber]);
|
||||||
|
|
||||||
// Root-Tag & Klassen abhängig vom Einsatz
|
// Root-Tag & Klassen abhängig vom Einsatz
|
||||||
|
|
||||||
const Root: ElementType = asSidebar ? 'div' : 'aside';
|
const Root: ElementType = asSidebar ? 'div' : 'aside';
|
||||||
const rootClasses = asSidebar
|
const rootClassName = asSidebar
|
||||||
? 'flex h-full flex-col text-sm'
|
? 'flex h-full min-h-0 flex-col'
|
||||||
: 'flex h-full flex-col border-l border-gray-200 px-4 py-4 text-sm dark:border-white/10';
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root className={rootClasses}>
|
<Root className={rootClassName}>
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Änderungsverlauf
|
Änderungsverlauf
|
||||||
</h2>
|
</h2>
|
||||||
@ -185,10 +193,15 @@ export default function DeviceHistorySidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<div className="mt-3 flex-1 min-h-0">
|
<div
|
||||||
<Feed items={items} className="h-full" />
|
className={clsx(
|
||||||
|
'mt-3',
|
||||||
|
asSidebar && 'min-h-0 flex-1 overflow-y-auto pr-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Feed items={items} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Root>
|
</Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -10,31 +10,31 @@ import {
|
|||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
PlusIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { getSocket } from '@/lib/socketClient';
|
import { getSocket } from '@/lib/socketClient';
|
||||||
import type { TagOption } from '@/components/ui/TagMultiCombobox';
|
import type { TagOption } from '@/components/ui/TagMultiCombobox';
|
||||||
import DeviceEditModal from './DeviceEditModal';
|
import DeviceEditModal from './DeviceEditModal';
|
||||||
|
import DeviceDetailModal from './DeviceDetailModal';
|
||||||
|
import DeviceCreateModal from './DeviceCreateModal';
|
||||||
|
|
||||||
export type DeviceRow = {
|
export type DeviceRow = {
|
||||||
inventoryNumber: string;
|
inventoryNumber: string;
|
||||||
|
|
||||||
name: string;
|
name: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
model: string;
|
model: string;
|
||||||
serialNumber?: string | null;
|
serialNumber: string | null;
|
||||||
productNumber?: string | null;
|
productNumber: string | null;
|
||||||
comment?: string | null;
|
comment: string | null;
|
||||||
|
group: string | null;
|
||||||
ipv4Address?: string | null;
|
location: string | null;
|
||||||
ipv6Address?: string | null;
|
ipv4Address: string | null;
|
||||||
macAddress?: string | null;
|
ipv6Address: string | null;
|
||||||
username?: string | null;
|
macAddress: string | null;
|
||||||
passwordHash?: string | null;
|
username: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
group?: string | null;
|
tags: string[];
|
||||||
location?: string | null;
|
createdAt: string;
|
||||||
tags?: string[] | null;
|
|
||||||
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,17 +52,15 @@ function formatDate(iso: string) {
|
|||||||
const columns: TableColumn<DeviceRow>[] = [
|
const columns: TableColumn<DeviceRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'inventoryNumber',
|
key: 'inventoryNumber',
|
||||||
header: 'Inventar-Nr.',
|
header: 'Nr.',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
canHide: false,
|
canHide: false,
|
||||||
headerClassName: 'min-w-32',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Bezeichnung',
|
header: 'Bezeichnung',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
canHide: true,
|
canHide: true,
|
||||||
headerClassName: 'min-w-48',
|
|
||||||
cellClassName: 'font-medium text-gray-900 dark:text-white',
|
cellClassName: 'font-medium text-gray-900 dark:text-white',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -132,9 +130,13 @@ export default function DevicesPage() {
|
|||||||
const [listError, setListError] = useState<string | null>(null);
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
|
|
||||||
// welches Gerät ist gerade im Edit-Modal geöffnet?
|
// welches Gerät ist gerade im Edit-Modal geöffnet?
|
||||||
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(
|
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(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)
|
// Alle bekannten Tags (kannst du später auch aus eigener /api/tags laden)
|
||||||
const [allTags, setAllTags] = useState<TagOption[]>([]);
|
const [allTags, setAllTags] = useState<TagOption[]>([]);
|
||||||
@ -238,6 +240,23 @@ export default function DevicesPage() {
|
|||||||
setEditInventoryNumber(null);
|
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 ───────── */
|
/* ───────── Render ───────── */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -253,12 +272,17 @@ export default function DevicesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="soft"
|
||||||
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"
|
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
|
Neues Gerät anlegen
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{listLoading && (
|
{listLoading && (
|
||||||
@ -291,7 +315,7 @@ export default function DevicesPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
icon={<BookOpenIcon className="size-5" />}
|
icon={<BookOpenIcon className="size-5" />}
|
||||||
aria-label={`Details zu ${row.inventoryNumber}`}
|
aria-label={`Details zu ${row.inventoryNumber}`}
|
||||||
onClick={() => console.log('Details', row.inventoryNumber)}
|
onClick={() => handleDetails(row.inventoryNumber)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -326,8 +350,7 @@ export default function DevicesPage() {
|
|||||||
{
|
{
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
icon: <BookOpenIcon className="size-4" />,
|
icon: <BookOpenIcon className="size-4" />,
|
||||||
onClick: () =>
|
onClick: () => handleDetails(row.inventoryNumber),
|
||||||
console.log('Details', row.inventoryNumber),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Bearbeiten',
|
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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,9 +28,11 @@ import {
|
|||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
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 { useSession, signOut } from 'next-auth/react';
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import ScanModal from '@/components/ScanModal';
|
||||||
|
import DeviceDetailModal from './devices/DeviceDetailModal';
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
||||||
@ -143,7 +145,11 @@ function UserMenu({ displayName, avatarInitial, avatarColorClass }: UserMenuProp
|
|||||||
|
|
||||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
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 pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
const rawName =
|
const rawName =
|
||||||
@ -154,6 +160,43 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
const displayName = rawName;
|
const displayName = rawName;
|
||||||
const avatarInitial = getInitial(rawName);
|
const avatarInitial = getInitial(rawName);
|
||||||
const avatarColorClass = getAvatarColor(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 (
|
return (
|
||||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
<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">
|
<div className="flex flex-1 items-center gap-x-4 self-stretch lg:gap-x-6">
|
||||||
{/* Suche */}
|
{/* Suche */}
|
||||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
<form
|
||||||
<input
|
action="#"
|
||||||
name="search"
|
method="GET"
|
||||||
placeholder="Suchen..."
|
className="grid flex-1 grid-cols-1"
|
||||||
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"
|
<div className="relative">
|
||||||
/>
|
|
||||||
<MagnifyingGlassIcon
|
<MagnifyingGlassIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
|
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>
|
</form>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
<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 */}
|
{/* Kamera-Button – nur mobil */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white lg:hidden"
|
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>
|
<span className="sr-only">Gerät mit Kamera erfassen</span>
|
||||||
<CameraIcon aria-hidden="true" className="size-5" />
|
<CameraIcon aria-hidden="true" className="size-5" />
|
||||||
@ -420,6 +471,18 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
<main className="py-10">
|
<main className="py-10">
|
||||||
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<ScanModal
|
||||||
|
open={scanOpen}
|
||||||
|
onClose={() => setScanOpen(false)}
|
||||||
|
onResult={handleScanResult}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeviceDetailModal
|
||||||
|
open={detailOpen}
|
||||||
|
inventoryNumber={detailInventoryNumber}
|
||||||
|
onClose={() => setDetailOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,59 +3,63 @@ import { NextResponse } from 'next/server';
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
type RouteParams = { id: string };
|
||||||
// ⬇️ wie bei /api/devices/[id]
|
|
||||||
type RouteContext = { params: Promise<RouteParams> };
|
type RouteContext = { params: Promise<RouteParams> };
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(_req: Request, ctx: RouteContext) {
|
||||||
_req: Request,
|
|
||||||
ctx: RouteContext,
|
|
||||||
) {
|
|
||||||
// params-Promise auflösen
|
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const inventoryNumber = decodeURIComponent(id);
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = await prisma.deviceHistory.findMany({
|
const history = await prisma.deviceHistory.findMany({
|
||||||
where: { deviceId: inventoryNumber },
|
where: { deviceId: id },
|
||||||
include: { changedBy: true },
|
|
||||||
orderBy: { changedAt: 'desc' },
|
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 snapshot = entry.snapshot as any;
|
||||||
|
if (snapshot && Array.isArray(snapshot.changes)) {
|
||||||
const rawChanges: any[] = Array.isArray(snapshot?.changes)
|
changes = snapshot.changes.map((c: any) => ({
|
||||||
? snapshot.changes
|
field: String(c.field),
|
||||||
: [];
|
from: c.before ?? null,
|
||||||
|
to: c.after ?? null,
|
||||||
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),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
changeType: entry.changeType, // 'CREATED' | 'UPDATED' | 'DELETED'
|
changeType: entry.changeType,
|
||||||
changedAt: entry.changedAt.toISOString(),
|
changedAt: entry.changedAt.toISOString(),
|
||||||
changedBy:
|
changedBy: changedByName, // 👈 passt zu deinem ApiHistoryEntry
|
||||||
entry.changedBy?.name ??
|
|
||||||
entry.changedBy?.username ??
|
|
||||||
entry.changedBy?.email ??
|
|
||||||
null,
|
|
||||||
changes,
|
changes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[GET /api/devices/[id]/history]', 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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
export async function PATCH(
|
||||||
req: Request,
|
req: Request,
|
||||||
ctx: RouteContext,
|
ctx: RouteContext,
|
||||||
) {
|
) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const body = await req.json();
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
|
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ⬇️ hier jetzt die Request-Header durchreichen
|
const body = await req.json();
|
||||||
|
|
||||||
|
// aktuell eingeloggten User ermitteln
|
||||||
const userId = await getCurrentUserId();
|
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)
|
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
|
||||||
const existing = await prisma.device.findUnique({
|
const existing = await prisma.device.findUnique({
|
||||||
where: { inventoryNumber: id },
|
where: { inventoryNumber: id },
|
||||||
include: {
|
include: {
|
||||||
group: true,
|
group: true,
|
||||||
location: true,
|
location: true,
|
||||||
tags: true, // 🔹 NEU
|
tags: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -96,53 +332,18 @@ export async function PATCH(
|
|||||||
name: body.name,
|
name: body.name,
|
||||||
manufacturer: body.manufacturer,
|
manufacturer: body.manufacturer,
|
||||||
model: body.model,
|
model: body.model,
|
||||||
serialNumber: body.serialNumber,
|
serialNumber: body.serialNumber ?? null,
|
||||||
productNumber: body.productNumber,
|
productNumber: body.productNumber ?? null,
|
||||||
comment: body.comment,
|
comment: body.comment ?? null,
|
||||||
ipv4Address: body.ipv4Address,
|
ipv4Address: body.ipv4Address ?? null,
|
||||||
ipv6Address: body.ipv6Address,
|
ipv6Address: body.ipv6Address ?? null,
|
||||||
macAddress: body.macAddress,
|
macAddress: body.macAddress ?? null,
|
||||||
username: body.username,
|
username: body.username ?? null,
|
||||||
passwordHash: body.passwordHash,
|
passwordHash: body.passwordHash ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tags aus dem Body bereinigen
|
// updatedBy nur setzen, wenn User existiert
|
||||||
const incomingTags: string[] = Array.isArray(body.tags)
|
if (canConnectUpdatedBy && userId) {
|
||||||
? 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) {
|
|
||||||
data.updatedBy = {
|
data.updatedBy = {
|
||||||
connect: { id: userId },
|
connect: { id: userId },
|
||||||
};
|
};
|
||||||
@ -172,15 +373,15 @@ export async function PATCH(
|
|||||||
data.location = { disconnect: true };
|
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)) {
|
if (Array.isArray(body.tags)) {
|
||||||
const tagNames = (body.tags as string[])
|
const tagNames = (body.tags as string[])
|
||||||
.map((t) => String(t).trim())
|
.map((t) => String(t).trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const beforeNames = existing.tags.map((t) => t.name.toLowerCase());
|
const beforeNames = existing.tags.map((t) => t.name.toLowerCase());
|
||||||
|
|
||||||
const normalized = tagNames.map((n) => n.toLowerCase());
|
const normalized = tagNames.map((n) => n.toLowerCase());
|
||||||
|
|
||||||
const toConnect = tagNames.filter(
|
const toConnect = tagNames.filter(
|
||||||
(n) => !beforeNames.includes(n.toLowerCase()),
|
(n) => !beforeNames.includes(n.toLowerCase()),
|
||||||
);
|
);
|
||||||
@ -189,12 +390,10 @@ export async function PATCH(
|
|||||||
.filter((n) => !normalized.includes(n.toLowerCase()));
|
.filter((n) => !normalized.includes(n.toLowerCase()));
|
||||||
|
|
||||||
data.tags = {
|
data.tags = {
|
||||||
// neue / fehlende Tags verknüpfen (und ggf. anlegen)
|
|
||||||
connectOrCreate: toConnect.map((name) => ({
|
connectOrCreate: toConnect.map((name) => ({
|
||||||
where: { name },
|
where: { name },
|
||||||
create: { name },
|
create: { name },
|
||||||
})),
|
})),
|
||||||
// nicht mehr vorhandene Tags trennen
|
|
||||||
disconnect: toDisconnect.map((name) => ({ name })),
|
disconnect: toDisconnect.map((name) => ({ name })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -227,7 +426,6 @@ export async function PATCH(
|
|||||||
|
|
||||||
type TrackedField = (typeof trackedFields)[number];
|
type TrackedField = (typeof trackedFields)[number];
|
||||||
|
|
||||||
// explizit JSON-kompatible Types (string | null)
|
|
||||||
const changes: {
|
const changes: {
|
||||||
field: TrackedField | 'group' | 'location' | 'tags';
|
field: TrackedField | 'group' | 'location' | 'tags';
|
||||||
before: string | null;
|
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) {
|
if (changes.length > 0) {
|
||||||
const snapshot: Prisma.JsonObject = {
|
const snapshot: Prisma.JsonObject = {
|
||||||
before: {
|
before: {
|
||||||
@ -330,17 +527,18 @@ export async function PATCH(
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔴 WICHTIG: changedById nur setzen, wenn der User wirklich existiert
|
||||||
await prisma.deviceHistory.create({
|
await prisma.deviceHistory.create({
|
||||||
data: {
|
data: {
|
||||||
deviceId: updated.inventoryNumber,
|
deviceId: updated.inventoryNumber,
|
||||||
changeType: 'UPDATED',
|
changeType: 'UPDATED',
|
||||||
snapshot,
|
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;
|
const io = (global as any).devicesIo as IOServer | undefined;
|
||||||
if (io) {
|
if (io) {
|
||||||
io.emit('device:updated', {
|
io.emit('device:updated', {
|
||||||
@ -361,7 +559,7 @@ export async function PATCH(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antwort an den Client (flattened)
|
// Antwort an den Client
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
inventoryNumber: updated.inventoryNumber,
|
inventoryNumber: updated.inventoryNumber,
|
||||||
name: updated.name,
|
name: updated.name,
|
||||||
@ -376,6 +574,7 @@ export async function PATCH(
|
|||||||
username: updated.username,
|
username: updated.username,
|
||||||
group: updated.group?.name ?? null,
|
group: updated.group?.name ?? null,
|
||||||
location: updated.location?.name ?? null,
|
location: updated.location?.name ?? null,
|
||||||
|
tags: updated.tags.map((t) => t.name),
|
||||||
createdAt: updated.createdAt.toISOString(),
|
createdAt: updated.createdAt.toISOString(),
|
||||||
updatedAt: updated.updatedAt.toISOString(),
|
updatedAt: updated.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
@ -383,4 +582,4 @@ export async function PATCH(
|
|||||||
console.error('[PATCH /api/devices/[id]]', err);
|
console.error('[PATCH /api/devices/[id]]', err);
|
||||||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,9 @@
|
|||||||
// app/api/devices/route.ts
|
// app/api/devices/route.ts
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@ -8,7 +11,7 @@ export async function GET() {
|
|||||||
include: {
|
include: {
|
||||||
group: true,
|
group: true,
|
||||||
location: true,
|
location: true,
|
||||||
tags: true, // 🔹 NEU
|
tags: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -32,9 +35,232 @@ export async function GET() {
|
|||||||
updatedAt: d.updatedAt.toISOString(),
|
updatedAt: d.updatedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[GET /api/devices]', err);
|
console.error('[GET /api/devices]', err);
|
||||||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
components/DeviceQrCode.tsx
Normal file
33
components/DeviceQrCode.tsx
Normal 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
145
components/ScanModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import clsx from 'clsx';
|
|||||||
export type ButtonVariant = 'primary' | 'secondary' | 'soft';
|
export type ButtonVariant = 'primary' | 'secondary' | 'soft';
|
||||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
export type ButtonShape = 'default' | 'pill' | 'circle';
|
export type ButtonShape = 'default' | 'pill' | 'circle';
|
||||||
export type ButtonTone = 'indigo' | 'gray' | 'rose';
|
export type ButtonTone = 'indigo' | 'gray' | 'rose' | 'emerald';
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
@ -38,6 +38,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
|||||||
'bg-rose-600 text-white hover:bg-rose-500 ' +
|
'bg-rose-600 text-white hover:bg-rose-500 ' +
|
||||||
'focus-visible:outline-rose-600 ' +
|
'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',
|
'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: {
|
secondary: {
|
||||||
indigo:
|
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 ' +
|
'bg-white text-rose-600 inset-ring inset-ring-rose-200 hover:bg-rose-50 ' +
|
||||||
'focus-visible:outline-rose-600 ' +
|
'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',
|
'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: {
|
soft: {
|
||||||
indigo:
|
indigo:
|
||||||
@ -66,6 +74,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
|||||||
'bg-rose-50 text-rose-600 hover:bg-rose-100 ' +
|
'bg-rose-50 text-rose-600 hover:bg-rose-100 ' +
|
||||||
'focus-visible:outline-rose-600 ' +
|
'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',
|
'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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
ChatBubbleLeftEllipsisIcon,
|
|
||||||
TagIcon,
|
TagIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
PencilIcon
|
||||||
} from '@heroicons/react/20/solid';
|
} from '@heroicons/react/20/solid';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
@ -39,6 +41,8 @@ export type FeedItem =
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
/** Art des Kommentars – steuert Icon/Farbe */
|
||||||
|
commentKind?: 'created' | 'deleted' | 'generic';
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@ -94,10 +98,13 @@ function colorFromName(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sprechende Zusammenfassung für "change"
|
// 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;
|
const { changes } = item;
|
||||||
if (!changes.length) return 'hat Änderungen vorgenommen';
|
if (!changes.length) return 'hat Änderungen vorgenommen';
|
||||||
|
|
||||||
|
// Sonderfall: nur Tags
|
||||||
if (changes.length === 1 && changes[0].field === 'tags') {
|
if (changes.length === 1 && changes[0].field === 'tags') {
|
||||||
const c = changes[0];
|
const c = changes[0];
|
||||||
const beforeList = (c.from ?? '')
|
const beforeList = (c.from ?? '')
|
||||||
@ -120,15 +127,15 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (added.length) {
|
if (added.length) parts.push(`hinzugefügt: ${added.join(', ')}`);
|
||||||
parts.push(`hinzugefügt: ${added.join(', ')}`);
|
if (removed.length) parts.push(`entfernt: ${removed.join(', ')}`);
|
||||||
}
|
|
||||||
if (removed.length) {
|
|
||||||
parts.push(`entfernt: ${removed.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
return `hat Tags ${parts.join(' · ')}`;
|
return (
|
||||||
|
<>
|
||||||
|
hat Tags <em>{parts.join(' · ')}</em>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return 'hat Tags angepasst';
|
return 'hat Tags angepasst';
|
||||||
}
|
}
|
||||||
@ -136,7 +143,11 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
|||||||
if (changes.length === 1) {
|
if (changes.length === 1) {
|
||||||
const c = changes[0];
|
const c = changes[0];
|
||||||
const label = c.label ?? c.field;
|
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);
|
const labels = changes.map((c) => c.label ?? c.field);
|
||||||
@ -144,11 +155,34 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
|||||||
const maxShow = 3;
|
const maxShow = 3;
|
||||||
|
|
||||||
if (uniqueLabels.length <= maxShow) {
|
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(', ');
|
const first = uniqueLabels.slice(0, maxShow);
|
||||||
return `hat ${first} und weitere geändert`;
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
hat{' '}
|
||||||
|
{first.map((label, index) => (
|
||||||
|
<React.Fragment key={label}>
|
||||||
|
<em>{label}</em>
|
||||||
|
{index < first.length - 1 ? ', ' : ' '}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
und weitere geändert
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Component ───────── */
|
/* ───────── Component ───────── */
|
||||||
@ -168,142 +202,151 @@ export default function Feed({ items, className }: FeedProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('h-full overflow-y-auto pr-2', className)}>
|
<ul
|
||||||
<ul role="list" className="pb-4">
|
role="list"
|
||||||
{items.map((item, idx) => {
|
className={clsx('pb-4', className)}
|
||||||
// Icon + Hintergrund ähnlich wie im Beispiel
|
>
|
||||||
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
|
{items.map((item, idx) => {
|
||||||
ChatBubbleLeftEllipsisIcon;
|
// Icon + Hintergrund
|
||||||
let iconBg = 'bg-gray-400 dark:bg-gray-600';
|
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
|
||||||
|
PencilIcon;
|
||||||
|
let iconBg = 'bg-gray-400 dark:bg-gray-600';
|
||||||
|
|
||||||
if (item.type === 'tags') {
|
if (item.type === 'tags') {
|
||||||
Icon = TagIcon;
|
Icon = TagIcon;
|
||||||
iconBg = 'bg-amber-500';
|
iconBg = 'bg-amber-500';
|
||||||
} else if (item.type === 'change') {
|
} else if (item.type === 'change') {
|
||||||
const isTagsOnly =
|
const isTagsOnly =
|
||||||
item.changes.length === 1 && item.changes[0].field === 'tags';
|
item.changes.length === 1 && item.changes[0].field === 'tags';
|
||||||
Icon = isTagsOnly ? TagIcon : ChatBubbleLeftEllipsisIcon;
|
Icon = isTagsOnly ? TagIcon : PencilIcon;
|
||||||
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-emerald-500';
|
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-cyan-500';
|
||||||
} else if (item.type === 'comment') {
|
} 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);
|
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"
|
// Textinhalt (content) – dein bisheriger Code unverändert:
|
||||||
let content: React.ReactNode = null;
|
let content: React.ReactNode = null;
|
||||||
|
if (item.type === 'comment') {
|
||||||
if (item.type === 'comment') {
|
content = (
|
||||||
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">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
{item.person.name}
|
{item.person.name}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
hat kommentiert:{' '}
|
{summary}
|
||||||
<span className="text-gray-300 dark:text-gray-200">
|
|
||||||
{item.comment}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
);
|
{item.changes.length > 0 && (
|
||||||
} else if (item.type === 'assignment') {
|
<p className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
content = (
|
{item.changes.slice(0, 2).map((c, i) => (
|
||||||
<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>
|
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
key={`${c.field}-${i}`}
|
||||||
iconBg,
|
className="flex flex-wrap items-baseline gap-x-1"
|
||||||
'flex size-8 items-center justify-center rounded-full',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<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>
|
</span>
|
||||||
</div>
|
))}
|
||||||
|
{item.changes.length > 2 && (
|
||||||
|
<span className="ml-1 text-gray-500 dark:text-gray-600">
|
||||||
|
· …
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Text + Datum rechts */}
|
return (
|
||||||
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
<li key={item.id}>
|
||||||
<div>{content}</div>
|
<div className="relative pb-6">
|
||||||
<div className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400">
|
{idx !== items.length - 1 ? (
|
||||||
{item.date}
|
<span
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
);
|
</li>
|
||||||
})}
|
);
|
||||||
</ul>
|
})}
|
||||||
</div>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
||||||
export type ModalVariant = 'centered' | 'alert';
|
export type ModalVariant = 'centered' | 'alert';
|
||||||
@ -52,15 +53,19 @@ export interface ModalProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wenn gesetzt, wird dieser Footer anstelle der auto-generierten Buttons gerendert.
|
* Wenn gesetzt, wird dieser Footer anstelle der auto-generierten Buttons gerendert.
|
||||||
* Damit kannst du komplett eigene Layouts bauen.
|
|
||||||
*/
|
*/
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optionaler Inhalt für eine rechte Sidebar (z.B. Geräthistorie).
|
* 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;
|
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 ───────── */
|
/* ───────── Layout-Helfer ───────── */
|
||||||
@ -94,27 +99,8 @@ const toneStyles: Record<
|
|||||||
const sizeClasses: Record<ModalSize, string> = {
|
const sizeClasses: Record<ModalSize, string> = {
|
||||||
sm: 'sm:max-w-sm',
|
sm: 'sm:max-w-sm',
|
||||||
md: 'sm:max-w-lg',
|
md: 'sm:max-w-lg',
|
||||||
lg: 'sm:max-w-3xl', // ein bisschen breiter für Sidebar
|
lg: 'sm:max-w-3xl',
|
||||||
xl: 'sm:max-w-5xl', // ein bisschen breiter für Sidebar
|
xl: 'sm:max-w-5xl',
|
||||||
};
|
|
||||||
|
|
||||||
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',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderActionButton(
|
function renderActionButton(
|
||||||
@ -123,15 +109,29 @@ function renderActionButton(
|
|||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
const variant = action.variant ?? 'primary';
|
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 (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
data-autofocus={action.autoFocus ? true : undefined}
|
autoFocus={action.autoFocus}
|
||||||
className={clsx(baseButtonClasses, buttonVariantClasses[variant], extraClasses)}
|
variant={buttonVariant}
|
||||||
|
tone={tone}
|
||||||
|
size="lg"
|
||||||
|
className={clsx('w-full', extraClasses)}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,10 +153,12 @@ export function Modal({
|
|||||||
useGrayFooter = false,
|
useGrayFooter = false,
|
||||||
footer,
|
footer,
|
||||||
sidebar,
|
sidebar,
|
||||||
|
headerExtras,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const toneStyle = toneStyles[tone];
|
const toneStyle = toneStyles[tone];
|
||||||
const panelSizeClasses = sizeClasses[size];
|
const panelSizeClasses = sizeClasses[size];
|
||||||
const hasActions = !!primaryAction || !!secondaryAction;
|
const hasActions = !!primaryAction || !!secondaryAction;
|
||||||
|
const hasBothActions = !!primaryAction && !!secondaryAction;
|
||||||
|
|
||||||
const isAlert = variant === 'alert';
|
const isAlert = variant === 'alert';
|
||||||
const bodyContent = children ?? description;
|
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="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
|
<DialogPanel
|
||||||
transition
|
transition
|
||||||
className={clsx(
|
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-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 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',
|
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||||||
@ -194,9 +196,9 @@ export function Modal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header + Body + Sidebar */}
|
{/* HEADER + MAIN (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">
|
<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 (Icon + Titel + optionale Beschreibung) */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex',
|
'flex',
|
||||||
@ -245,28 +247,33 @@ export function Modal({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Beschreibung nur anzeigen, wenn keine eigenen Children übergeben wurden */}
|
|
||||||
{!children && description && (
|
{!children && description && (
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{headerExtras && (
|
||||||
|
<div className="mt-4 w-full">{headerExtras}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body + Sidebar */ }
|
{/* MAIN: Body + Sidebar – nimmt den Rest der Höhe ein */}
|
||||||
{(bodyContent || sidebar) && (
|
{(bodyContent || sidebar) && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'mt-6',
|
'mt-6 flex flex-col min-h-0',
|
||||||
// vorher: sidebar && 'sm:mt-8 sm:flex sm:items-start sm:gap-6',
|
sidebar
|
||||||
sidebar && 'sm:mt-8 sm:flex sm:items-stretch sm:gap-6',
|
? '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 && (
|
{bodyContent && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex-1 text-left',
|
'flex-1 min-h-0 overflow-y-auto text-left',
|
||||||
!sidebar && 'mx-auto max-w-2xl',
|
!sidebar && 'mx-auto max-w-2xl',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -274,8 +281,9 @@ export function Modal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rechte Sidebar (QR + Verlauf) */}
|
||||||
{sidebar && (
|
{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}
|
{sidebar}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
@ -292,32 +300,25 @@ export function Modal({
|
|||||||
useGrayFooter
|
useGrayFooter
|
||||||
? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25'
|
? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25'
|
||||||
: 'px-4 py-3 sm:px-6',
|
: '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 &&
|
<div
|
||||||
renderActionButton(
|
className={clsx(
|
||||||
primaryAction,
|
'flex flex-col gap-3',
|
||||||
isAlert
|
hasBothActions && 'sm:flex-row-reverse',
|
||||||
? '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',
|
|
||||||
),
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{primaryAction &&
|
||||||
|
renderActionButton(
|
||||||
|
primaryAction,
|
||||||
|
hasBothActions ? 'flex-1' : undefined,
|
||||||
|
)}
|
||||||
|
{secondaryAction &&
|
||||||
|
renderActionButton(
|
||||||
|
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
|
||||||
|
hasBothActions ? 'flex-1' : undefined,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
@ -328,3 +329,4 @@ export function Modal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|
||||||
|
|||||||
@ -81,11 +81,21 @@ export default function Table<T>(props: TableProps<T>) {
|
|||||||
if (va == null) return sort.direction === 'asc' ? -1 : 1;
|
if (va == null) return sort.direction === 'asc' ? -1 : 1;
|
||||||
if (vb == 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') {
|
if (typeof va === 'number' && typeof vb === 'number') {
|
||||||
return sort.direction === 'asc' ? va - vb : vb - va;
|
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
|
// Date / ISO-String
|
||||||
const sa = va instanceof Date ? va.getTime() : String(va);
|
const sa = va instanceof Date ? va.getTime() : String(va);
|
||||||
const sb = vb instanceof Date ? vb.getTime() : String(vb);
|
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)}
|
key={String(col.key)}
|
||||||
scope="col"
|
scope="col"
|
||||||
className={classNames(
|
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,
|
col.headerClassName,
|
||||||
isHideable
|
isHideable
|
||||||
? 'hidden lg:table-cell'
|
? 'hidden lg:table-cell'
|
||||||
@ -238,7 +248,7 @@ export default function Table<T>(props: TableProps<T>) {
|
|||||||
{renderActions && (
|
{renderActions && (
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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}
|
{actionsHeader}
|
||||||
</th>
|
</th>
|
||||||
@ -256,7 +266,7 @@ export default function Table<T>(props: TableProps<T>) {
|
|||||||
className={classNames(isSelected && 'bg-gray-50 dark:bg-gray-800/60')}
|
className={classNames(isSelected && 'bg-gray-50 dark:bg-gray-800/60')}
|
||||||
>
|
>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<td className="px-4">
|
<td className="px-2">
|
||||||
<div className="group grid size-4 grid-cols-1">
|
<div className="group grid size-4 grid-cols-1">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -292,7 +302,7 @@ export default function Table<T>(props: TableProps<T>) {
|
|||||||
<td
|
<td
|
||||||
key={String(col.key)}
|
key={String(col.key)}
|
||||||
className={classNames(
|
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.cellClassName,
|
||||||
col.canHide && 'hidden lg:table-cell', // <-- HIER
|
col.canHide && 'hidden lg:table-cell', // <-- HIER
|
||||||
)}
|
)}
|
||||||
@ -302,7 +312,7 @@ export default function Table<T>(props: TableProps<T>) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{renderActions && (
|
{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)}
|
{renderActions(row)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
@ -314,7 +324,7 @@ export default function Table<T>(props: TableProps<T>) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0)}
|
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.
|
Keine Daten vorhanden.
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -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 = {
|
export type TabItem = {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type TabsProps = {
|
type TabsProps = {
|
||||||
tabs: TabItem[]
|
tabs: TabItem[];
|
||||||
defaultTabId?: string
|
/** aktuell ausgewählter Tab (id) */
|
||||||
onChange?(id: string): void
|
value: string;
|
||||||
className?: string
|
/** Callback bei Wechsel */
|
||||||
}
|
onChange: (id: string) => void;
|
||||||
|
className?: string;
|
||||||
|
/** Optional eigenes aria-label */
|
||||||
|
ariaLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function Tabs({ tabs, defaultTabId, onChange, className }: TabsProps) {
|
export default function Tabs({
|
||||||
const [activeId, setActiveId] = React.useState(
|
tabs,
|
||||||
defaultTabId ?? (tabs[0]?.id ?? ''),
|
value,
|
||||||
)
|
onChange,
|
||||||
|
className,
|
||||||
const handleClick = (id: string) => {
|
ariaLabel = 'Ansicht auswählen',
|
||||||
setActiveId(id)
|
}: TabsProps) {
|
||||||
onChange?.(id)
|
const current = tabs.find((t) => t.id === value) ?? tabs[0];
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={className}>
|
||||||
className={[
|
{/* Mobile: Select + Chevron */}
|
||||||
'border-b border-gray-200 dark:border-white/10',
|
<div className="grid grid-cols-1 sm:hidden">
|
||||||
className,
|
<select
|
||||||
]
|
value={current.id}
|
||||||
.filter(Boolean)
|
onChange={(e) => onChange(e.target.value)}
|
||||||
.join(' ')}
|
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"
|
||||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => (
|
||||||
const isActive = tab.id === activeId
|
<option key={tab.id} value={tab.id}>
|
||||||
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(' ')}
|
|
||||||
>
|
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</option>
|
||||||
)
|
))}
|
||||||
})}
|
</select>
|
||||||
</nav>
|
<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>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
56
package-lock.json
generated
56
package-lock.json
generated
@ -11,10 +11,13 @@
|
|||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"@zxing/library": "^0.21.3",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
@ -2533,6 +2536,41 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@ -6400,6 +6438,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@ -7324,6 +7371,15 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/ts-node": {
|
||||||
"version": "10.9.2",
|
"version": "10.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
|
|||||||
@ -13,10 +13,13 @@
|
|||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"@zxing/library": "^0.21.3",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
|||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@ -0,0 +1,5 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "DeviceHistory_changedById_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "DeviceHistory_deviceId_changedAt_idx";
|
||||||
@ -136,23 +136,14 @@ enum DeviceChangeType {
|
|||||||
|
|
||||||
model DeviceHistory {
|
model DeviceHistory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
deviceId String
|
deviceId String
|
||||||
device Device @relation(fields: [deviceId], references: [inventoryNumber])
|
device Device @relation(fields: [deviceId], references: [inventoryNumber])
|
||||||
|
|
||||||
changeType DeviceChangeType
|
changeType DeviceChangeType
|
||||||
|
|
||||||
snapshot Json
|
snapshot Json
|
||||||
changedAt DateTime @default(now())
|
changedAt DateTime @default(now())
|
||||||
|
|
||||||
// 🔹 FK-Spalte
|
|
||||||
changedById String?
|
changedById String?
|
||||||
|
|
||||||
// 🔹 Relation zu User
|
|
||||||
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
|
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
|
||||||
|
|
||||||
@@index([deviceId, changedAt])
|
|
||||||
@@index([changedById])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user