updated
This commit is contained in:
parent
90231bff83
commit
7f683d5828
2
.env
2
.env
@ -11,5 +11,5 @@
|
||||
|
||||
DATABASE_URL="file:./dev.db"
|
||||
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL=https://10.0.1.25
|
||||
NEXTAUTH_SECRET=tegvideo7010!
|
||||
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';
|
||||
|
||||
import { useCallback, useEffect, useState, ChangeEvent, Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
ChangeEvent,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { PencilIcon, CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||
import DeviceHistorySidebar from './DeviceHistorySidebar';
|
||||
import TagMultiCombobox, { TagOption } from '@/components/ui/TagMultiCombobox';
|
||||
import type { DeviceDetail } from './page'; // Typ aus page.tsx (siehe unten)
|
||||
import Button from '@/components/ui/Button';
|
||||
import type { DeviceDetail } from './page';
|
||||
|
||||
type DeviceEditModalProps = {
|
||||
open: boolean;
|
||||
@ -29,25 +36,23 @@ export default function DeviceEditModal({
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
|
||||
// Gerät laden, wenn Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (!open || !inventoryNumber) {
|
||||
setEditDevice(null);
|
||||
setEditError(null);
|
||||
return;
|
||||
}
|
||||
if (!open || !inventoryNumber) return;
|
||||
|
||||
const inv = inventoryNumber;
|
||||
let cancelled = false;
|
||||
|
||||
async function loadDevice() {
|
||||
setEditLoading(true);
|
||||
setEditError(null);
|
||||
setEditDevice(null);
|
||||
setEditLoading(true);
|
||||
setEditError(null);
|
||||
setJustSaved(false);
|
||||
setEditDevice(null);
|
||||
|
||||
async function loadDevice() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/devices/${encodeURIComponent(inventoryNumber)}`,
|
||||
`/api/devices/${encodeURIComponent(inv)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -59,7 +64,9 @@ export default function DeviceEditModal({
|
||||
if (res.status === 404) {
|
||||
throw new Error('Gerät wurde nicht gefunden.');
|
||||
}
|
||||
throw new Error('Beim Laden der Gerätedaten ist ein Fehler aufgetreten.');
|
||||
throw new Error(
|
||||
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as DeviceDetail;
|
||||
@ -70,7 +77,9 @@ export default function DeviceEditModal({
|
||||
console.error('Error loading device', err);
|
||||
if (!cancelled) {
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : 'Netzwerkfehler beim Laden der Gerätedaten.',
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Netzwerkfehler beim Laden der Gerätedaten.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@ -137,16 +146,23 @@ export default function DeviceEditModal({
|
||||
|
||||
const updated = (await res.json()) as DeviceDetail;
|
||||
setEditDevice(updated);
|
||||
onSaved(updated); // Tabelle im Parent aktualisieren
|
||||
onSaved(updated);
|
||||
|
||||
setJustSaved(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
console.error('Error saving device', err);
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : 'Netzwerkfehler beim Speichern der Gerätedaten.',
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Netzwerkfehler beim Speichern der Gerätedaten.',
|
||||
);
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}, [editDevice, onSaved]);
|
||||
}, [editDevice, onSaved, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (saveLoading) return;
|
||||
@ -163,22 +179,54 @@ export default function DeviceEditModal({
|
||||
: 'Gerätedaten werden geladen …'
|
||||
}
|
||||
icon={<PencilIcon className="size-6" />}
|
||||
tone="info"
|
||||
tone={justSaved ? 'success' : 'info'}
|
||||
variant="centered"
|
||||
size="lg"
|
||||
primaryAction={{
|
||||
label: saveLoading ? 'Speichern …' : 'Speichern',
|
||||
onClick: handleSave,
|
||||
autoFocus: true,
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: 'Abbrechen',
|
||||
variant: 'secondary',
|
||||
onClick: handleClose,
|
||||
}}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
size="lg"
|
||||
variant="primary"
|
||||
tone={justSaved ? 'emerald' : 'indigo'}
|
||||
disabled={saveLoading}
|
||||
className="w-full sm:flex-1"
|
||||
icon={
|
||||
justSaved ? (
|
||||
<CheckCircleIcon
|
||||
aria-hidden="true"
|
||||
className="-ml-0.5 size-5"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
iconPosition="leading"
|
||||
>
|
||||
{saveLoading
|
||||
? 'Speichern …'
|
||||
: justSaved
|
||||
? 'Gespeichert'
|
||||
: 'Speichern'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
tone="gray"
|
||||
className="w-full sm:flex-1"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
sidebar={
|
||||
editDevice ? (
|
||||
<DeviceHistorySidebar
|
||||
key={editDevice.updatedAt}
|
||||
inventoryNumber={editDevice.inventoryNumber}
|
||||
asSidebar
|
||||
/>
|
||||
@ -192,11 +240,13 @@ export default function DeviceEditModal({
|
||||
)}
|
||||
|
||||
{editError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{editError}</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!editLoading && !editError && editDevice && (
|
||||
<div className="mt-3 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||
<div className="pr-2 mt-3 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||
{/* Inventarnummer */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
@ -307,12 +357,10 @@ export default function DeviceEditModal({
|
||||
onChange={(next) => {
|
||||
const names = next.map((t) => t.name);
|
||||
|
||||
// in editDevice speichern
|
||||
setEditDevice((prev) =>
|
||||
prev ? ({ ...prev, tags: names } as DeviceDetail) : prev,
|
||||
);
|
||||
|
||||
// allTags im Parent erweitern
|
||||
setAllTags((prev) => {
|
||||
const map = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
|
||||
for (const t of next) {
|
||||
@ -365,6 +413,7 @@ export default function DeviceEditModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zugangsdaten */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Benutzername
|
||||
|
||||
@ -6,6 +6,8 @@ import Feed, {
|
||||
FeedItem,
|
||||
FeedChange,
|
||||
} from '@/components/ui/Feed';
|
||||
import clsx from 'clsx';
|
||||
|
||||
|
||||
type Props = {
|
||||
inventoryNumber: string;
|
||||
@ -35,33 +37,33 @@ function formatDateTime(iso: string) {
|
||||
function mapFieldLabel(field: string): string {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return 'die Bezeichnung';
|
||||
return 'Bezeichnung';
|
||||
case 'manufacturer':
|
||||
return 'den Hersteller';
|
||||
return 'Hersteller';
|
||||
case 'model':
|
||||
return 'das Modell';
|
||||
return 'Modell';
|
||||
case 'serialNumber':
|
||||
return 'die Seriennummer';
|
||||
return 'Seriennummer';
|
||||
case 'productNumber':
|
||||
return 'die Produktnummer';
|
||||
return 'Produktnummer';
|
||||
case 'comment':
|
||||
return 'den Kommentar';
|
||||
return 'Kommentar';
|
||||
case 'ipv4Address':
|
||||
return 'die IPv4-Adresse';
|
||||
return 'IPv4-Adresse';
|
||||
case 'ipv6Address':
|
||||
return 'die IPv6-Adresse';
|
||||
return 'IPv6-Adresse';
|
||||
case 'macAddress':
|
||||
return 'die MAC-Adresse';
|
||||
return 'MAC-Adresse';
|
||||
case 'username':
|
||||
return 'den Benutzernamen';
|
||||
return 'Benutzernamen';
|
||||
case 'passwordHash':
|
||||
return 'das Passwort';
|
||||
return 'Passwort';
|
||||
case 'group':
|
||||
return 'die Gruppe';
|
||||
return 'Gruppe';
|
||||
case 'location':
|
||||
return 'den Standort';
|
||||
return 'Standort';
|
||||
case 'tags':
|
||||
return 'die Tags';
|
||||
return 'Tags';
|
||||
default:
|
||||
return field;
|
||||
}
|
||||
@ -81,7 +83,6 @@ export default function DeviceHistorySidebar({
|
||||
async function loadHistory() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
|
||||
@ -123,12 +124,17 @@ export default function DeviceHistorySidebar({
|
||||
}
|
||||
|
||||
let comment = '';
|
||||
let commentKind: 'created' | 'deleted' | 'generic' = 'generic';
|
||||
|
||||
if (entry.changeType === 'CREATED') {
|
||||
comment = 'Gerät angelegt.';
|
||||
comment = 'hat das Gerät angelegt.';
|
||||
commentKind = 'created';
|
||||
} else if (entry.changeType === 'DELETED') {
|
||||
comment = 'Gerät gelöscht.';
|
||||
comment = 'hat das Gerät gelöscht.';
|
||||
commentKind = 'deleted';
|
||||
} else {
|
||||
comment = 'Gerät geändert.';
|
||||
comment = 'hat das Gerät geändert.';
|
||||
commentKind = 'generic';
|
||||
}
|
||||
|
||||
return {
|
||||
@ -137,6 +143,7 @@ export default function DeviceHistorySidebar({
|
||||
person,
|
||||
date,
|
||||
comment,
|
||||
commentKind,
|
||||
};
|
||||
});
|
||||
|
||||
@ -161,13 +168,14 @@ export default function DeviceHistorySidebar({
|
||||
}, [inventoryNumber]);
|
||||
|
||||
// Root-Tag & Klassen abhängig vom Einsatz
|
||||
|
||||
const Root: ElementType = asSidebar ? 'div' : 'aside';
|
||||
const rootClasses = asSidebar
|
||||
? 'flex h-full flex-col text-sm'
|
||||
: 'flex h-full flex-col border-l border-gray-200 px-4 py-4 text-sm dark:border-white/10';
|
||||
|
||||
const rootClassName = asSidebar
|
||||
? 'flex h-full min-h-0 flex-col'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Root className={rootClasses}>
|
||||
<Root className={rootClassName}>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Änderungsverlauf
|
||||
</h2>
|
||||
@ -185,10 +193,15 @@ export default function DeviceHistorySidebar({
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="mt-3 flex-1 min-h-0">
|
||||
<Feed items={items} className="h-full" />
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-3',
|
||||
asSidebar && 'min-h-0 flex-1 overflow-y-auto pr-1'
|
||||
)}
|
||||
>
|
||||
<Feed items={items} />
|
||||
</div>
|
||||
)}
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,31 +10,31 @@ import {
|
||||
BookOpenIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { getSocket } from '@/lib/socketClient';
|
||||
import type { TagOption } from '@/components/ui/TagMultiCombobox';
|
||||
import DeviceEditModal from './DeviceEditModal';
|
||||
import DeviceDetailModal from './DeviceDetailModal';
|
||||
import DeviceCreateModal from './DeviceCreateModal';
|
||||
|
||||
export type DeviceRow = {
|
||||
inventoryNumber: string;
|
||||
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
serialNumber?: string | null;
|
||||
productNumber?: string | null;
|
||||
comment?: string | null;
|
||||
|
||||
ipv4Address?: string | null;
|
||||
ipv6Address?: string | null;
|
||||
macAddress?: string | null;
|
||||
username?: string | null;
|
||||
passwordHash?: string | null;
|
||||
|
||||
group?: string | null;
|
||||
location?: string | null;
|
||||
tags?: string[] | null;
|
||||
|
||||
serialNumber: string | null;
|
||||
productNumber: string | null;
|
||||
comment: string | null;
|
||||
group: string | null;
|
||||
location: string | null;
|
||||
ipv4Address: string | null;
|
||||
ipv6Address: string | null;
|
||||
macAddress: string | null;
|
||||
username: string | null;
|
||||
passwordHash: string | null;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@ -52,17 +52,15 @@ function formatDate(iso: string) {
|
||||
const columns: TableColumn<DeviceRow>[] = [
|
||||
{
|
||||
key: 'inventoryNumber',
|
||||
header: 'Inventar-Nr.',
|
||||
header: 'Nr.',
|
||||
sortable: true,
|
||||
canHide: false,
|
||||
headerClassName: 'min-w-32',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Bezeichnung',
|
||||
sortable: true,
|
||||
canHide: true,
|
||||
headerClassName: 'min-w-48',
|
||||
cellClassName: 'font-medium text-gray-900 dark:text-white',
|
||||
},
|
||||
{
|
||||
@ -132,9 +130,13 @@ export default function DevicesPage() {
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
|
||||
// welches Gerät ist gerade im Edit-Modal geöffnet?
|
||||
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(null);
|
||||
|
||||
// welches Gerät ist im Detail-Modal geöffnet?
|
||||
const [detailInventoryNumber, setDetailInventoryNumber] = useState<string | null>(null);
|
||||
|
||||
// Create-Modal
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
// Alle bekannten Tags (kannst du später auch aus eigener /api/tags laden)
|
||||
const [allTags, setAllTags] = useState<TagOption[]>([]);
|
||||
@ -238,6 +240,23 @@ export default function DevicesPage() {
|
||||
setEditInventoryNumber(null);
|
||||
}, []);
|
||||
|
||||
const handleDetails = useCallback((inventoryNumber: string) => {
|
||||
setDetailInventoryNumber(inventoryNumber);
|
||||
}, []);
|
||||
|
||||
const closeDetailModal = useCallback(() => {
|
||||
setDetailInventoryNumber(null);
|
||||
}, []);
|
||||
|
||||
const openCreateModal = useCallback(() => {
|
||||
setCreateOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeCreateModal = useCallback(() => {
|
||||
setCreateOpen(false);
|
||||
}, []);
|
||||
|
||||
|
||||
/* ───────── Render ───────── */
|
||||
|
||||
return (
|
||||
@ -253,12 +272,17 @@ export default function DevicesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
||||
<Button
|
||||
variant="soft"
|
||||
tone="indigo"
|
||||
size="md"
|
||||
icon={<PlusIcon className="size-5" />}
|
||||
aria-label="Neues Gerät anlegen"
|
||||
onClick={openCreateModal}
|
||||
title='Neues Gerät anlegen'
|
||||
>
|
||||
Neues Gerät anlegen
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listLoading && (
|
||||
@ -291,7 +315,7 @@ export default function DevicesPage() {
|
||||
size="md"
|
||||
icon={<BookOpenIcon className="size-5" />}
|
||||
aria-label={`Details zu ${row.inventoryNumber}`}
|
||||
onClick={() => console.log('Details', row.inventoryNumber)}
|
||||
onClick={() => handleDetails(row.inventoryNumber)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@ -326,8 +350,7 @@ export default function DevicesPage() {
|
||||
{
|
||||
label: 'Details',
|
||||
icon: <BookOpenIcon className="size-4" />,
|
||||
onClick: () =>
|
||||
console.log('Details', row.inventoryNumber),
|
||||
onClick: () => handleDetails(row.inventoryNumber),
|
||||
},
|
||||
{
|
||||
label: 'Bearbeiten',
|
||||
@ -368,6 +391,28 @@ export default function DevicesPage() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeviceCreateModal
|
||||
open={createOpen}
|
||||
onClose={closeCreateModal}
|
||||
allTags={allTags}
|
||||
setAllTags={setAllTags}
|
||||
onCreated={(created) => {
|
||||
setDevices((prev) => {
|
||||
// falls Live-Update denselben Eintrag schon gebracht hat
|
||||
if (prev.some((d) => d.inventoryNumber === created.inventoryNumber)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, created];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeviceDetailModal
|
||||
open={detailInventoryNumber !== null}
|
||||
inventoryNumber={detailInventoryNumber}
|
||||
onClose={closeDetailModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,9 +28,11 @@ import {
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import ScanModal from '@/components/ScanModal';
|
||||
import DeviceDetailModal from './devices/DeviceDetailModal';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
||||
@ -143,7 +145,11 @@ function UserMenu({ displayName, avatarInitial, avatarColorClass }: UserMenuProp
|
||||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailInventoryNumber, setDetailInventoryNumber] = useState<string | null>(null);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const rawName =
|
||||
@ -154,6 +160,43 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
const displayName = rawName;
|
||||
const avatarInitial = getInitial(rawName);
|
||||
const avatarColorClass = getAvatarColor(rawName);
|
||||
|
||||
const handleScanResult = (code: string) => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// 1) Versuch: QR-Code ist eine URL
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
|
||||
const isSameOrigin =
|
||||
typeof window !== 'undefined' &&
|
||||
url.origin === window.location.origin;
|
||||
|
||||
if (isSameOrigin) {
|
||||
// Beispiel: /devices/123456
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const idx = parts.indexOf('devices');
|
||||
|
||||
if (idx >= 0 && parts[idx + 1]) {
|
||||
const inv = decodeURIComponent(parts[idx + 1]);
|
||||
setDetailInventoryNumber(inv);
|
||||
setDetailOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Andere Domain → im Browser extern öffnen
|
||||
window.open(trimmed, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
} catch {
|
||||
// Kein gültiger URL → weiter unten behandeln
|
||||
}
|
||||
|
||||
// 2) Kein URL → pure Inventarnummer in der Webapp
|
||||
setDetailInventoryNumber(trimmed);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
@ -347,17 +390,24 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4 self-stretch lg:gap-x-6">
|
||||
{/* Suche */}
|
||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Suchen..."
|
||||
aria-label="Suchen..."
|
||||
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<form
|
||||
action="#"
|
||||
method="GET"
|
||||
className="grid flex-1 grid-cols-1"
|
||||
>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Suchen…"
|
||||
aria-label="Suchen"
|
||||
className="block w-full rounded-xl border-0 bg-gray-50 py-1.5 pl-10 pr-3 text-sm text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||
@ -369,8 +419,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
{/* Kamera-Button – nur mobil */}
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white lg:hidden"
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white lg:hidden"
|
||||
onClick={() => setScanOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Gerät mit Kamera erfassen</span>
|
||||
<CameraIcon aria-hidden="true" className="size-5" />
|
||||
@ -420,6 +471,18 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
<main className="py-10">
|
||||
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
||||
</main>
|
||||
|
||||
<ScanModal
|
||||
open={scanOpen}
|
||||
onClose={() => setScanOpen(false)}
|
||||
onResult={handleScanResult}
|
||||
/>
|
||||
|
||||
<DeviceDetailModal
|
||||
open={detailOpen}
|
||||
inventoryNumber={detailInventoryNumber}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,59 +3,63 @@ import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
// ⬇️ wie bei /api/devices/[id]
|
||||
type RouteContext = { params: Promise<RouteParams> };
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
ctx: RouteContext,
|
||||
) {
|
||||
// params-Promise auflösen
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
const { id } = await ctx.params;
|
||||
const inventoryNumber = decodeURIComponent(id);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await prisma.deviceHistory.findMany({
|
||||
where: { deviceId: inventoryNumber },
|
||||
include: { changedBy: true },
|
||||
where: { deviceId: id },
|
||||
orderBy: { changedAt: 'desc' },
|
||||
include: {
|
||||
changedBy: true, // 👈 User-Relation laden
|
||||
},
|
||||
});
|
||||
|
||||
const payload = history.map((entry) => {
|
||||
const result = history.map((entry) => {
|
||||
// 👇 hier baust du den Anzeigenamen
|
||||
const changedByName =
|
||||
entry.changedBy?.name ??
|
||||
entry.changedBy?.username ??
|
||||
entry.changedBy?.email ??
|
||||
null;
|
||||
|
||||
// snapshot.changes aus dem JSON holen (bei CREATED leer)
|
||||
let changes: {
|
||||
field: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}[] = [];
|
||||
|
||||
const snapshot = entry.snapshot as any;
|
||||
|
||||
const rawChanges: any[] = Array.isArray(snapshot?.changes)
|
||||
? snapshot.changes
|
||||
: [];
|
||||
|
||||
const changes = rawChanges.map((c) => ({
|
||||
field: String(c.field),
|
||||
from:
|
||||
c.before === null || c.before === undefined
|
||||
? null
|
||||
: String(c.before),
|
||||
to:
|
||||
c.after === null || c.after === undefined
|
||||
? null
|
||||
: String(c.after),
|
||||
}));
|
||||
if (snapshot && Array.isArray(snapshot.changes)) {
|
||||
changes = snapshot.changes.map((c: any) => ({
|
||||
field: String(c.field),
|
||||
from: c.before ?? null,
|
||||
to: c.after ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
changeType: entry.changeType, // 'CREATED' | 'UPDATED' | 'DELETED'
|
||||
changeType: entry.changeType,
|
||||
changedAt: entry.changedAt.toISOString(),
|
||||
changedBy:
|
||||
entry.changedBy?.name ??
|
||||
entry.changedBy?.username ??
|
||||
entry.changedBy?.email ??
|
||||
null,
|
||||
changedBy: changedByName, // 👈 passt zu deinem ApiHistoryEntry
|
||||
changes,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(payload);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
console.error('[GET /api/devices/[id]/history]', err);
|
||||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ error: 'INTERNAL_ERROR' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,29 +61,265 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const {
|
||||
inventoryNumber,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
serialNumber,
|
||||
productNumber,
|
||||
comment,
|
||||
group, // Name der Gruppe (string)
|
||||
location, // Name des Standorts (string)
|
||||
ipv4Address,
|
||||
ipv6Address,
|
||||
macAddress,
|
||||
username,
|
||||
passwordHash,
|
||||
tags, // string[]
|
||||
} = body;
|
||||
|
||||
if (!inventoryNumber || !name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'inventoryNumber und name sind Pflichtfelder.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// prüfen, ob Inventar-Nr. schon existiert
|
||||
const existing = await prisma.device.findUnique({
|
||||
where: { inventoryNumber },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Gerät mit dieser Inventar-Nr. existiert bereits.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 aktuell eingeloggten User ermitteln
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
// 🔹 nur verknüpfen, wenn der User wirklich in der DB existiert
|
||||
let canConnectUser = false;
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (user) {
|
||||
canConnectUser = true;
|
||||
} else {
|
||||
console.warn(
|
||||
`[POST /api/devices] User mit id=${userId} nicht gefunden – createdBy/changedBy werden nicht gesetzt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Gruppe nach Name auflösen / anlegen
|
||||
let groupId: string | null = null;
|
||||
if (group && typeof group === 'string' && group.trim() !== '') {
|
||||
const grp = await prisma.deviceGroup.upsert({
|
||||
where: { name: group.trim() },
|
||||
update: {},
|
||||
create: { name: group.trim() },
|
||||
});
|
||||
groupId = grp.id;
|
||||
}
|
||||
|
||||
// ✅ Location nach Name auflösen / anlegen
|
||||
let locationId: string | null = null;
|
||||
if (location && typeof location === 'string' && location.trim() !== '') {
|
||||
const loc = await prisma.location.upsert({
|
||||
where: { name: location.trim() },
|
||||
update: {},
|
||||
create: { name: location.trim() },
|
||||
});
|
||||
locationId = loc.id;
|
||||
}
|
||||
|
||||
// ✅ Tag-Namen vorbereiten
|
||||
const tagNames: string[] = Array.isArray(tags)
|
||||
? tags
|
||||
.map((t: unknown) => String(t).trim())
|
||||
.filter((t) => t.length > 0)
|
||||
: [];
|
||||
|
||||
// ✅ Device anlegen (mit createdBy & Tags)
|
||||
const created = await prisma.device.create({
|
||||
data: {
|
||||
inventoryNumber,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
serialNumber: serialNumber ?? null,
|
||||
productNumber: productNumber ?? null,
|
||||
comment: comment ?? null,
|
||||
ipv4Address: ipv4Address ?? null,
|
||||
ipv6Address: ipv6Address ?? null,
|
||||
macAddress: macAddress ?? null,
|
||||
username: username ?? null,
|
||||
passwordHash: passwordHash ?? null,
|
||||
groupId,
|
||||
locationId,
|
||||
// User, der das Gerät angelegt hat
|
||||
...(canConnectUser && userId
|
||||
? {
|
||||
createdBy: {
|
||||
connect: { id: userId },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Tags Many-to-Many
|
||||
...(tagNames.length
|
||||
? {
|
||||
tags: {
|
||||
connectOrCreate: tagNames.map((name) => ({
|
||||
where: { name },
|
||||
create: { name },
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
group: true,
|
||||
location: true,
|
||||
tags: true,
|
||||
createdBy: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔹 History-Eintrag "CREATED" mit changedById
|
||||
const snapshot: Prisma.JsonObject = {
|
||||
before: null,
|
||||
after: {
|
||||
inventoryNumber: created.inventoryNumber,
|
||||
name: created.name,
|
||||
manufacturer: created.manufacturer,
|
||||
model: created.model,
|
||||
serialNumber: created.serialNumber,
|
||||
productNumber: created.productNumber,
|
||||
comment: created.comment,
|
||||
ipv4Address: created.ipv4Address,
|
||||
ipv6Address: created.ipv6Address,
|
||||
macAddress: created.macAddress,
|
||||
username: created.username,
|
||||
passwordHash: created.passwordHash,
|
||||
group: created.group?.name ?? null,
|
||||
location: created.location?.name ?? null,
|
||||
tags: created.tags.map((t) => t.name),
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
},
|
||||
changes: [],
|
||||
};
|
||||
|
||||
await prisma.deviceHistory.create({
|
||||
data: {
|
||||
deviceId: created.inventoryNumber,
|
||||
changeType: 'CREATED',
|
||||
snapshot,
|
||||
changedById: canConnectUser && userId ? userId : null,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔊 optional: Socket.IO-Broadcast für Live-Listen
|
||||
const io = (global as any).devicesIo as IOServer | undefined;
|
||||
if (io) {
|
||||
io.emit('device:created', {
|
||||
inventoryNumber: created.inventoryNumber,
|
||||
name: created.name,
|
||||
manufacturer: created.manufacturer,
|
||||
model: created.model,
|
||||
serialNumber: created.serialNumber,
|
||||
productNumber: created.productNumber,
|
||||
comment: created.comment,
|
||||
ipv4Address: created.ipv4Address,
|
||||
ipv6Address: created.ipv6Address,
|
||||
macAddress: created.macAddress,
|
||||
username: created.username,
|
||||
group: created.group?.name ?? null,
|
||||
location: created.location?.name ?? null,
|
||||
tags: created.tags.map((t) => t.name),
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Antwort wie bei GET /api/devices
|
||||
return NextResponse.json(
|
||||
{
|
||||
inventoryNumber: created.inventoryNumber,
|
||||
name: created.name,
|
||||
manufacturer: created.manufacturer,
|
||||
model: created.model,
|
||||
serialNumber: created.serialNumber,
|
||||
productNumber: created.productNumber,
|
||||
comment: created.comment,
|
||||
ipv4Address: created.ipv4Address,
|
||||
ipv6Address: created.ipv6Address,
|
||||
macAddress: created.macAddress,
|
||||
username: created.username,
|
||||
passwordHash: created.passwordHash,
|
||||
group: created.group?.name ?? null,
|
||||
location: created.location?.name ?? null,
|
||||
tags: created.tags.map((t) => t.name),
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error in POST /api/devices', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Interner Serverfehler beim Anlegen des Geräts.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
ctx: RouteContext,
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await req.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// ⬇️ hier jetzt die Request-Header durchreichen
|
||||
const body = await req.json();
|
||||
|
||||
// aktuell eingeloggten User ermitteln
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
// nur verbinden, wenn der User in der DB existiert
|
||||
let canConnectUpdatedBy = false;
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (user) {
|
||||
canConnectUpdatedBy = true;
|
||||
} else {
|
||||
console.warn(
|
||||
`[PATCH /api/devices/${id}] User mit id=${userId} nicht gefunden – updatedBy wird nicht gesetzt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
|
||||
const existing = await prisma.device.findUnique({
|
||||
where: { inventoryNumber: id },
|
||||
include: {
|
||||
group: true,
|
||||
location: true,
|
||||
tags: true, // 🔹 NEU
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -96,53 +332,18 @@ export async function PATCH(
|
||||
name: body.name,
|
||||
manufacturer: body.manufacturer,
|
||||
model: body.model,
|
||||
serialNumber: body.serialNumber,
|
||||
productNumber: body.productNumber,
|
||||
comment: body.comment,
|
||||
ipv4Address: body.ipv4Address,
|
||||
ipv6Address: body.ipv6Address,
|
||||
macAddress: body.macAddress,
|
||||
username: body.username,
|
||||
passwordHash: body.passwordHash,
|
||||
serialNumber: body.serialNumber ?? null,
|
||||
productNumber: body.productNumber ?? null,
|
||||
comment: body.comment ?? null,
|
||||
ipv4Address: body.ipv4Address ?? null,
|
||||
ipv6Address: body.ipv6Address ?? null,
|
||||
macAddress: body.macAddress ?? null,
|
||||
username: body.username ?? null,
|
||||
passwordHash: body.passwordHash ?? null,
|
||||
};
|
||||
|
||||
// Tags aus dem Body bereinigen
|
||||
const incomingTags: string[] = Array.isArray(body.tags)
|
||||
? body.tags.map((t: unknown) => String(t).trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
// existierende Tag-Namen
|
||||
const existingTagNames = existing.tags.map((t) => t.name);
|
||||
|
||||
// welche sollen entfernt werden?
|
||||
const tagsToRemove = existingTagNames.filter(
|
||||
(name) => !incomingTags.includes(name),
|
||||
);
|
||||
|
||||
// welche sind neu hinzuzufügen?
|
||||
const tagsToAdd = incomingTags.filter(
|
||||
(name) => !existingTagNames.includes(name),
|
||||
);
|
||||
|
||||
if (!data.tags) {
|
||||
data.tags = {};
|
||||
}
|
||||
|
||||
// Tags, die nicht mehr vorkommen → disconnect über name (weil @unique)
|
||||
if (tagsToRemove.length > 0) {
|
||||
(data.tags as any).disconnect = tagsToRemove.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
// neue Tags → connectOrCreate (Tag wird bei Bedarf angelegt)
|
||||
if (tagsToAdd.length > 0) {
|
||||
(data.tags as any).connectOrCreate = tagsToAdd.map((name) => ({
|
||||
where: { name },
|
||||
create: { name },
|
||||
}));
|
||||
}
|
||||
|
||||
// updatedBy setzen, wenn User da
|
||||
if (userId) {
|
||||
// updatedBy nur setzen, wenn User existiert
|
||||
if (canConnectUpdatedBy && userId) {
|
||||
data.updatedBy = {
|
||||
connect: { id: userId },
|
||||
};
|
||||
@ -172,15 +373,15 @@ export async function PATCH(
|
||||
data.location = { disconnect: true };
|
||||
}
|
||||
|
||||
// Tags (Many-to-Many via Tag.name @unique)
|
||||
// Tags (Many-to-Many via Tag.name @unique)
|
||||
if (Array.isArray(body.tags)) {
|
||||
const tagNames = (body.tags as string[])
|
||||
.map((t) => String(t).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const beforeNames = existing.tags.map((t) => t.name.toLowerCase());
|
||||
|
||||
const normalized = tagNames.map((n) => n.toLowerCase());
|
||||
|
||||
const toConnect = tagNames.filter(
|
||||
(n) => !beforeNames.includes(n.toLowerCase()),
|
||||
);
|
||||
@ -189,12 +390,10 @@ export async function PATCH(
|
||||
.filter((n) => !normalized.includes(n.toLowerCase()));
|
||||
|
||||
data.tags = {
|
||||
// neue / fehlende Tags verknüpfen (und ggf. anlegen)
|
||||
connectOrCreate: toConnect.map((name) => ({
|
||||
where: { name },
|
||||
create: { name },
|
||||
})),
|
||||
// nicht mehr vorhandene Tags trennen
|
||||
disconnect: toDisconnect.map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
@ -227,7 +426,6 @@ export async function PATCH(
|
||||
|
||||
type TrackedField = (typeof trackedFields)[number];
|
||||
|
||||
// explizit JSON-kompatible Types (string | null)
|
||||
const changes: {
|
||||
field: TrackedField | 'group' | 'location' | 'tags';
|
||||
before: string | null;
|
||||
@ -281,8 +479,7 @@ export async function PATCH(
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Falls sich *gar nichts* geändert hat, kein History-Eintrag
|
||||
// Falls sich gar nichts geändert hat, kein History-Eintrag
|
||||
if (changes.length > 0) {
|
||||
const snapshot: Prisma.JsonObject = {
|
||||
before: {
|
||||
@ -330,17 +527,18 @@ export async function PATCH(
|
||||
})),
|
||||
};
|
||||
|
||||
// 🔴 WICHTIG: changedById nur setzen, wenn der User wirklich existiert
|
||||
await prisma.deviceHistory.create({
|
||||
data: {
|
||||
deviceId: updated.inventoryNumber,
|
||||
changeType: 'UPDATED',
|
||||
snapshot,
|
||||
changedById: userId ?? null,
|
||||
changedById: canConnectUpdatedBy && userId ? userId : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 🔊 Socket.IO-Broadcast für echte Live-Updates
|
||||
// 🔊 Socket.IO-Broadcast für Live-Updates
|
||||
const io = (global as any).devicesIo as IOServer | undefined;
|
||||
if (io) {
|
||||
io.emit('device:updated', {
|
||||
@ -361,7 +559,7 @@ export async function PATCH(
|
||||
});
|
||||
}
|
||||
|
||||
// Antwort an den Client (flattened)
|
||||
// Antwort an den Client
|
||||
return NextResponse.json({
|
||||
inventoryNumber: updated.inventoryNumber,
|
||||
name: updated.name,
|
||||
@ -376,6 +574,7 @@ export async function PATCH(
|
||||
username: updated.username,
|
||||
group: updated.group?.name ?? null,
|
||||
location: updated.location?.name ?? null,
|
||||
tags: updated.tags.map((t) => t.name),
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
});
|
||||
@ -383,4 +582,4 @@ export async function PATCH(
|
||||
console.error('[PATCH /api/devices/[id]]', err);
|
||||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
// app/api/devices/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { getCurrentUserId } from '@/lib/auth';
|
||||
import type { Server as IOServer } from 'socket.io';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@ -8,7 +11,7 @@ export async function GET() {
|
||||
include: {
|
||||
group: true,
|
||||
location: true,
|
||||
tags: true, // 🔹 NEU
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -32,9 +35,232 @@ export async function GET() {
|
||||
updatedAt: d.updatedAt.toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[GET /api/devices]', err);
|
||||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const {
|
||||
inventoryNumber,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
serialNumber,
|
||||
productNumber,
|
||||
comment,
|
||||
group,
|
||||
location,
|
||||
ipv4Address,
|
||||
ipv6Address,
|
||||
macAddress,
|
||||
username,
|
||||
passwordHash,
|
||||
tags,
|
||||
} = body;
|
||||
|
||||
if (!inventoryNumber || !name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'inventoryNumber und name sind Pflichtfelder.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.device.findUnique({
|
||||
where: { inventoryNumber },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Gerät mit dieser Inventar-Nr. existiert bereits.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// aktuell eingeloggten User ermitteln
|
||||
const userId = await getCurrentUserId();
|
||||
let canConnectUser = false;
|
||||
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (user) {
|
||||
canConnectUser = true;
|
||||
} else {
|
||||
console.warn(
|
||||
`[POST /api/devices] User mit id=${userId} nicht gefunden – createdBy/changedBy werden nicht gesetzt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gruppe / Standort (per Name) anlegen/finden
|
||||
let groupId: string | null = null;
|
||||
if (group && typeof group === 'string' && group.trim() !== '') {
|
||||
const grp = await prisma.deviceGroup.upsert({
|
||||
where: { name: group.trim() },
|
||||
update: {},
|
||||
create: { name: group.trim() },
|
||||
});
|
||||
groupId = grp.id;
|
||||
}
|
||||
|
||||
let locationId: string | null = null;
|
||||
if (location && typeof location === 'string' && location.trim() !== '') {
|
||||
const loc = await prisma.location.upsert({
|
||||
where: { name: location.trim() },
|
||||
update: {},
|
||||
create: { name: location.trim() },
|
||||
});
|
||||
locationId = loc.id;
|
||||
}
|
||||
|
||||
const tagNames: string[] = Array.isArray(tags)
|
||||
? tags.map((t: unknown) => String(t).trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const created = await prisma.device.create({
|
||||
data: {
|
||||
inventoryNumber,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
serialNumber: serialNumber ?? null,
|
||||
productNumber: productNumber ?? null,
|
||||
comment: comment ?? null,
|
||||
ipv4Address: ipv4Address ?? null,
|
||||
ipv6Address: ipv6Address ?? null,
|
||||
macAddress: macAddress ?? null,
|
||||
username: username ?? null,
|
||||
passwordHash: passwordHash ?? null,
|
||||
|
||||
// ⬇️ Relation über group/location, nicht groupId/locationId
|
||||
...(groupId
|
||||
? {
|
||||
group: {
|
||||
connect: { id: groupId },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
...(locationId
|
||||
? {
|
||||
location: {
|
||||
connect: { id: locationId },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
// User, der das Gerät angelegt hat
|
||||
...(canConnectUser && userId
|
||||
? { createdBy: { connect: { id: userId } } }
|
||||
: {}),
|
||||
|
||||
// Tags Many-to-Many
|
||||
...(tagNames.length
|
||||
? {
|
||||
tags: {
|
||||
connectOrCreate: tagNames.map((name) => ({
|
||||
where: { name },
|
||||
create: { name },
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
group: true,
|
||||
location: true,
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
// History-Eintrag "CREATED" mit changedById
|
||||
const snapshot: Prisma.JsonObject = {
|
||||
before: null,
|
||||
after: {
|
||||
inventoryNumber: created.inventoryNumber,
|
||||
name: created.name,
|
||||
manufacturer: created.manufacturer,
|
||||
model: created.model,
|
||||
serialNumber: created.serialNumber,
|
||||
productNumber: created.productNumber,
|
||||
comment: created.comment,
|
||||
ipv4Address: created.ipv4Address,
|
||||
ipv6Address: created.ipv6Address,
|
||||
macAddress: created.macAddress,
|
||||
username: created.username,
|
||||
passwordHash: created.passwordHash,
|
||||
group: created.group?.name ?? null,
|
||||
location: created.location?.name ?? null,
|
||||
tags: created.tags.map((t) => t.name),
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
},
|
||||
changes: [],
|
||||
};
|
||||
|
||||
await prisma.deviceHistory.create({
|
||||
data: {
|
||||
deviceId: created.inventoryNumber,
|
||||
changeType: 'CREATED',
|
||||
snapshot,
|
||||
changedById: canConnectUser && userId ? userId : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Socket-Event
|
||||
const io = (global as any).devicesIo as IOServer | undefined;
|
||||
if (io) {
|
||||
io.emit('device:created', {
|
||||
inventoryNumber: created.inventoryNumber,
|
||||
name: created.name,
|
||||
manufacturer: created.manufacturer,
|
||||
model: created.model,
|
||||
serialNumber: created.serialNumber,
|
||||
productNumber: created.productNumber,
|
||||
comment: created.comment,
|
||||
ipv4Address: created.ipv4Address,
|
||||
ipv6Address: created.ipv6Address,
|
||||
macAddress: created.macAddress,
|
||||
username: created.username,
|
||||
group: created.group?.name ?? null,
|
||||
location: created.location?.name ?? null,
|
||||
tags: created.tags.map((t) => t.name),
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
inventoryNumber: created.inventoryNumber,
|
||||
name: created.name,
|
||||
manufacturer: created.manufacturer,
|
||||
model: created.model,
|
||||
serialNumber: created.serialNumber,
|
||||
productNumber: created.productNumber,
|
||||
comment: created.comment,
|
||||
ipv4Address: created.ipv4Address,
|
||||
ipv6Address: created.ipv6Address,
|
||||
macAddress: created.macAddress,
|
||||
username: created.username,
|
||||
passwordHash: created.passwordHash,
|
||||
group: created.group?.name ?? null,
|
||||
location: created.location?.name ?? null,
|
||||
tags: created.tags.map((t) => t.name),
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[POST /api/devices]', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Interner Serverfehler beim Anlegen des Geräts.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
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 ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type ButtonShape = 'default' | 'pill' | 'circle';
|
||||
export type ButtonTone = 'indigo' | 'gray' | 'rose';
|
||||
export type ButtonTone = 'indigo' | 'gray' | 'rose' | 'emerald';
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
@ -38,6 +38,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
||||
'bg-rose-600 text-white hover:bg-rose-500 ' +
|
||||
'focus-visible:outline-rose-600 ' +
|
||||
'dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400 dark:shadow-none dark:focus-visible:outline-rose-500',
|
||||
emerald:
|
||||
'bg-emerald-600 text-white hover:bg-emerald-500 ' +
|
||||
'focus-visible:outline-emerald-600 ' +
|
||||
'dark:bg-emerald-500 dark:text-white dark:hover:bg-emerald-400 dark:shadow-none dark:focus-visible:outline-emerald-500',
|
||||
},
|
||||
secondary: {
|
||||
indigo:
|
||||
@ -52,6 +56,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
||||
'bg-white text-rose-600 inset-ring inset-ring-rose-200 hover:bg-rose-50 ' +
|
||||
'focus-visible:outline-rose-600 ' +
|
||||
'dark:bg-white/10 dark:text-rose-300 dark:shadow-none dark:inset-ring-rose-500/40 dark:hover:bg-rose-500/10 dark:focus-visible:outline-rose-500',
|
||||
emerald:
|
||||
'bg-white text-emerald-600 inset-ring inset-ring-emerald-200 hover:bg-emerald-50 ' +
|
||||
'focus-visible:outline-emerald-600 ' +
|
||||
'dark:bg-white/10 dark:text-emerald-300 dark:shadow-none dark:inset-ring-emerald-500/40 dark:hover:bg-emerald-500/10 dark:focus-visible:outline-emerald-500',
|
||||
},
|
||||
soft: {
|
||||
indigo:
|
||||
@ -66,6 +74,10 @@ const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
||||
'bg-rose-50 text-rose-600 hover:bg-rose-100 ' +
|
||||
'focus-visible:outline-rose-600 ' +
|
||||
'dark:bg-rose-500/20 dark:text-rose-300 dark:shadow-none dark:hover:bg-rose-500/30 dark:focus-visible:outline-rose-500',
|
||||
emerald:
|
||||
'bg-emerald-50 text-emerald-600 hover:bg-emerald-100 ' +
|
||||
'focus-visible:outline-emerald-600 ' +
|
||||
'dark:bg-emerald-500/20 dark:text-emerald-300 dark:shadow-none dark:hover:bg-emerald-500/30 dark:focus-visible:outline-emerald-500',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
TagIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
PencilIcon
|
||||
} from '@heroicons/react/20/solid';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@ -39,6 +41,8 @@ export type FeedItem =
|
||||
imageUrl?: string;
|
||||
comment: string;
|
||||
date: string;
|
||||
/** Art des Kommentars – steuert Icon/Farbe */
|
||||
commentKind?: 'created' | 'deleted' | 'generic';
|
||||
}
|
||||
| {
|
||||
id: string | number;
|
||||
@ -94,10 +98,13 @@ function colorFromName(name: string): string {
|
||||
}
|
||||
|
||||
// sprechende Zusammenfassung für "change"
|
||||
function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
||||
function getChangeSummary(
|
||||
item: Extract<FeedItem, { type: 'change' }>,
|
||||
): React.ReactNode {
|
||||
const { changes } = item;
|
||||
if (!changes.length) return 'hat Änderungen vorgenommen';
|
||||
|
||||
// Sonderfall: nur Tags
|
||||
if (changes.length === 1 && changes[0].field === 'tags') {
|
||||
const c = changes[0];
|
||||
const beforeList = (c.from ?? '')
|
||||
@ -120,15 +127,15 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
||||
);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (added.length) {
|
||||
parts.push(`hinzugefügt: ${added.join(', ')}`);
|
||||
}
|
||||
if (removed.length) {
|
||||
parts.push(`entfernt: ${removed.join(', ')}`);
|
||||
}
|
||||
if (added.length) parts.push(`hinzugefügt: ${added.join(', ')}`);
|
||||
if (removed.length) parts.push(`entfernt: ${removed.join(', ')}`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
return `hat Tags ${parts.join(' · ')}`;
|
||||
return (
|
||||
<>
|
||||
hat Tags <em>{parts.join(' · ')}</em>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return 'hat Tags angepasst';
|
||||
}
|
||||
@ -136,7 +143,11 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
||||
if (changes.length === 1) {
|
||||
const c = changes[0];
|
||||
const label = c.label ?? c.field;
|
||||
return `hat ${label} geändert`;
|
||||
return (
|
||||
<>
|
||||
hat <em>{label}</em> geändert
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const labels = changes.map((c) => c.label ?? c.field);
|
||||
@ -144,11 +155,34 @@ function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
||||
const maxShow = 3;
|
||||
|
||||
if (uniqueLabels.length <= maxShow) {
|
||||
return `hat ${uniqueLabels.join(', ')} geändert`;
|
||||
return (
|
||||
<>
|
||||
hat{' '}
|
||||
{uniqueLabels.map((label, index) => (
|
||||
<React.Fragment key={label}>
|
||||
<em>{label}</em>
|
||||
{index < uniqueLabels.length - 1 ? ', ' : ' '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
geändert
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const first = uniqueLabels.slice(0, maxShow).join(', ');
|
||||
return `hat ${first} und weitere geändert`;
|
||||
const first = uniqueLabels.slice(0, maxShow);
|
||||
|
||||
return (
|
||||
<>
|
||||
hat{' '}
|
||||
{first.map((label, index) => (
|
||||
<React.Fragment key={label}>
|
||||
<em>{label}</em>
|
||||
{index < first.length - 1 ? ', ' : ' '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
und weitere geändert
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Component ───────── */
|
||||
@ -168,142 +202,151 @@ export default function Feed({ items, className }: FeedProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('h-full overflow-y-auto pr-2', className)}>
|
||||
<ul role="list" className="pb-4">
|
||||
{items.map((item, idx) => {
|
||||
// Icon + Hintergrund ähnlich wie im Beispiel
|
||||
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
|
||||
ChatBubbleLeftEllipsisIcon;
|
||||
let iconBg = 'bg-gray-400 dark:bg-gray-600';
|
||||
<ul
|
||||
role="list"
|
||||
className={clsx('pb-4', className)}
|
||||
>
|
||||
{items.map((item, idx) => {
|
||||
// Icon + Hintergrund
|
||||
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
|
||||
PencilIcon;
|
||||
let iconBg = 'bg-gray-400 dark:bg-gray-600';
|
||||
|
||||
if (item.type === 'tags') {
|
||||
Icon = TagIcon;
|
||||
iconBg = 'bg-amber-500';
|
||||
} else if (item.type === 'change') {
|
||||
const isTagsOnly =
|
||||
item.changes.length === 1 && item.changes[0].field === 'tags';
|
||||
Icon = isTagsOnly ? TagIcon : ChatBubbleLeftEllipsisIcon;
|
||||
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
} else if (item.type === 'comment') {
|
||||
if (item.type === 'tags') {
|
||||
Icon = TagIcon;
|
||||
iconBg = 'bg-amber-500';
|
||||
} else if (item.type === 'change') {
|
||||
const isTagsOnly =
|
||||
item.changes.length === 1 && item.changes[0].field === 'tags';
|
||||
Icon = isTagsOnly ? TagIcon : PencilIcon;
|
||||
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-cyan-500';
|
||||
} else if (item.type === 'comment') {
|
||||
if (item.commentKind === 'created') {
|
||||
Icon = PlusIcon;
|
||||
iconBg = 'bg-emerald-500';
|
||||
} else if (item.commentKind === 'deleted') {
|
||||
Icon = TrashIcon;
|
||||
iconBg = 'bg-rose-500';
|
||||
} else {
|
||||
iconBg = colorFromName(item.person.name);
|
||||
} else if (item.type === 'assignment') {
|
||||
iconBg = 'bg-indigo-500';
|
||||
}
|
||||
} else if (item.type === 'assignment') {
|
||||
iconBg = 'bg-indigo-500';
|
||||
}
|
||||
|
||||
// Textinhalt ähnlich wie "content + target"
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
if (item.type === 'comment') {
|
||||
content = (
|
||||
// Textinhalt (content) – dein bisheriger Code unverändert:
|
||||
let content: React.ReactNode = null;
|
||||
if (item.type === 'comment') {
|
||||
content = (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.person.name}
|
||||
</span>{' '}
|
||||
<span className="text-gray-300 dark:text-gray-200">
|
||||
{item.comment}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
} else if (item.type === 'assignment') {
|
||||
content = (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.person.name}
|
||||
</span>{' '}
|
||||
hat{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.assigned.name}
|
||||
</span>{' '}
|
||||
zugewiesen.
|
||||
</p>
|
||||
);
|
||||
} else if (item.type === 'tags') {
|
||||
content = (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.person.name}
|
||||
</span>{' '}
|
||||
hat Tags hinzugefügt:{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.tags.map((t) => t.name).join(', ')}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
} else if (item.type === 'change') {
|
||||
const summary = getChangeSummary(item);
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.person.name}
|
||||
</span>{' '}
|
||||
hat kommentiert:{' '}
|
||||
<span className="text-gray-300 dark:text-gray-200">
|
||||
{item.comment}
|
||||
</span>
|
||||
{summary}
|
||||
</p>
|
||||
);
|
||||
} else if (item.type === 'assignment') {
|
||||
content = (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.person.name}
|
||||
</span>{' '}
|
||||
hat{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.assigned.name}
|
||||
</span>{' '}
|
||||
zugewiesen.
|
||||
</p>
|
||||
);
|
||||
} else if (item.type === 'tags') {
|
||||
content = (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.person.name}
|
||||
</span>{' '}
|
||||
hat Tags hinzugefügt:{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.tags.map((t) => t.name).join(', ')}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
} else if (item.type === 'change') {
|
||||
const summary = getChangeSummary(item);
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{item.person.name}
|
||||
</span>{' '}
|
||||
{summary}
|
||||
</p>
|
||||
{item.changes.length > 0 && (
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{item.changes.slice(0, 2).map((c, i) => (
|
||||
<span
|
||||
key={`${c.field}-${i}`}
|
||||
className="flex flex-wrap items-baseline gap-x-1"
|
||||
>
|
||||
<span className="line-through text-red-500/80 dark:text-red-400/90">
|
||||
{c.from ?? '—'}
|
||||
</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{c.to ?? '—'}
|
||||
</span>
|
||||
{i < Math.min(2, item.changes.length) - 1 && (
|
||||
<span className="mx-1 text-gray-500 dark:text-gray-600">·</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{item.changes.length > 2 && (
|
||||
<span className="ml-1 text-gray-500 dark:text-gray-600">· …</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<div className="relative pb-6">
|
||||
{idx !== items.length - 1 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex space-x-3">
|
||||
{/* Icon-Kreis wie im Beispiel */}
|
||||
<div>
|
||||
{item.changes.length > 0 && (
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{item.changes.slice(0, 2).map((c, i) => (
|
||||
<span
|
||||
className={classNames(
|
||||
iconBg,
|
||||
'flex size-8 items-center justify-center rounded-full',
|
||||
)}
|
||||
key={`${c.field}-${i}`}
|
||||
className="flex flex-wrap items-baseline gap-x-1"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4 text-white" />
|
||||
<span className="line-through text-red-500/80 dark:text-red-400/90">
|
||||
{c.from ?? '—'}
|
||||
</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{c.to ?? '—'}
|
||||
</span>
|
||||
{i < Math.min(2, item.changes.length) - 1 && (
|
||||
<span className="mx-1 text-gray-500 dark:text-gray-600">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{item.changes.length > 2 && (
|
||||
<span className="ml-1 text-gray-500 dark:text-gray-600">
|
||||
· …
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Text + Datum rechts */}
|
||||
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>{content}</div>
|
||||
<div className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{item.date}
|
||||
</div>
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<div className="relative pb-6">
|
||||
{idx !== items.length - 1 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span
|
||||
className={classNames(
|
||||
iconBg,
|
||||
'flex size-8 items-center justify-center rounded-full',
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>{content}</div>
|
||||
<div className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{item.date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from '@headlessui/react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import Button from './Button';
|
||||
|
||||
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
||||
export type ModalVariant = 'centered' | 'alert';
|
||||
@ -52,15 +53,19 @@ export interface ModalProps {
|
||||
|
||||
/**
|
||||
* Wenn gesetzt, wird dieser Footer anstelle der auto-generierten Buttons gerendert.
|
||||
* Damit kannst du komplett eigene Layouts bauen.
|
||||
*/
|
||||
footer?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Optionaler Inhalt für eine rechte Sidebar (z.B. Geräthistorie).
|
||||
* Auf kleinen Screens unten angehängt, ab sm rechts als Spalte.
|
||||
*/
|
||||
sidebar?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Zusätzlicher Inhalt direkt UNTER dem Titel im Header (z.B. Tabs).
|
||||
* Liegt außerhalb des scrollbaren Body-Bereichs.
|
||||
*/
|
||||
headerExtras?: React.ReactNode;
|
||||
}
|
||||
|
||||
/* ───────── Layout-Helfer ───────── */
|
||||
@ -94,27 +99,8 @@ const toneStyles: Record<
|
||||
const sizeClasses: Record<ModalSize, string> = {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-lg',
|
||||
lg: 'sm:max-w-3xl', // ein bisschen breiter für Sidebar
|
||||
xl: 'sm:max-w-5xl', // ein bisschen breiter für Sidebar
|
||||
};
|
||||
|
||||
const baseButtonClasses =
|
||||
'inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold shadow-xs ' +
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 ';
|
||||
|
||||
const buttonVariantClasses: Record<
|
||||
NonNullable<ModalAction['variant']>,
|
||||
string
|
||||
> = {
|
||||
primary:
|
||||
'bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:outline-indigo-600 ' +
|
||||
'dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500 dark:shadow-none',
|
||||
secondary:
|
||||
'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
|
||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
||||
danger:
|
||||
'bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600 ' +
|
||||
'dark:bg-red-500 dark:hover:bg-red-400 dark:shadow-none',
|
||||
lg: 'sm:max-w-3xl',
|
||||
xl: 'sm:max-w-5xl',
|
||||
};
|
||||
|
||||
function renderActionButton(
|
||||
@ -123,15 +109,29 @@ function renderActionButton(
|
||||
): React.ReactNode {
|
||||
const variant = action.variant ?? 'primary';
|
||||
|
||||
let buttonVariant: 'primary' | 'secondary' | 'soft' = 'primary';
|
||||
let tone: 'indigo' | 'gray' | 'rose' = 'indigo';
|
||||
|
||||
if (variant === 'secondary') {
|
||||
buttonVariant = 'secondary';
|
||||
tone = 'gray';
|
||||
} else if (variant === 'danger') {
|
||||
buttonVariant = 'primary';
|
||||
tone = 'rose';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
data-autofocus={action.autoFocus ? true : undefined}
|
||||
className={clsx(baseButtonClasses, buttonVariantClasses[variant], extraClasses)}
|
||||
autoFocus={action.autoFocus}
|
||||
variant={buttonVariant}
|
||||
tone={tone}
|
||||
size="lg"
|
||||
className={clsx('w-full', extraClasses)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -153,10 +153,12 @@ export function Modal({
|
||||
useGrayFooter = false,
|
||||
footer,
|
||||
sidebar,
|
||||
headerExtras,
|
||||
}: ModalProps) {
|
||||
const toneStyle = toneStyles[tone];
|
||||
const panelSizeClasses = sizeClasses[size];
|
||||
const hasActions = !!primaryAction || !!secondaryAction;
|
||||
const hasBothActions = !!primaryAction && !!secondaryAction;
|
||||
|
||||
const isAlert = variant === 'alert';
|
||||
const bodyContent = children ?? description;
|
||||
@ -169,11 +171,11 @@ export function Modal({
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:p-8">
|
||||
<DialogPanel
|
||||
transition
|
||||
className={clsx(
|
||||
'relative flex max-h-[90vh] w-full flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
|
||||
'relative flex w-full max-h-[calc(100vh-8rem)] lg:max-h-[800px] flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
|
||||
'data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out ' +
|
||||
'data-leave:duration-200 data-leave:ease-in sm:my-8 data-closed:sm:translate-y-0 data-closed:sm:scale-95 ' +
|
||||
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||||
@ -194,9 +196,9 @@ export function Modal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header + Body + Sidebar */}
|
||||
<div className="flex-1 overflow-hidden bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800">
|
||||
{/* Header (Icon + Titel + optionale Beschreibung) */}
|
||||
{/* HEADER + MAIN (Body+Sidebar) */}
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex',
|
||||
@ -245,28 +247,33 @@ export function Modal({
|
||||
</DialogTitle>
|
||||
)}
|
||||
|
||||
{/* Beschreibung nur anzeigen, wenn keine eigenen Children übergeben wurden */}
|
||||
{!children && description && (
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{headerExtras && (
|
||||
<div className="mt-4 w-full">{headerExtras}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body + Sidebar */ }
|
||||
{/* MAIN: Body + Sidebar – nimmt den Rest der Höhe ein */}
|
||||
{(bodyContent || sidebar) && (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-6',
|
||||
// vorher: sidebar && 'sm:mt-8 sm:flex sm:items-start sm:gap-6',
|
||||
sidebar && 'sm:mt-8 sm:flex sm:items-stretch sm:gap-6',
|
||||
'mt-6 flex flex-col min-h-0',
|
||||
sidebar
|
||||
? 'sm:mt-8 sm:flex-row sm:items-stretch sm:gap-6'
|
||||
: 'overflow-y-auto' // nur ohne Sidebar soll der Body global scrollen
|
||||
)}
|
||||
>
|
||||
{/* Linker Inhalt (Details / Formulare) */}
|
||||
{bodyContent && (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 text-left',
|
||||
'flex-1 min-h-0 overflow-y-auto text-left',
|
||||
!sidebar && 'mx-auto max-w-2xl',
|
||||
)}
|
||||
>
|
||||
@ -274,8 +281,9 @@ export function Modal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rechte Sidebar (QR + Verlauf) */}
|
||||
{sidebar && (
|
||||
<aside className="border-t border-gray-200 text-left text-sm sm:flex sm:h-full sm:w-80 sm:shrink-0 sm:border-l sm:border-t-0 sm:pl-4 dark:border-white/10" >
|
||||
<aside className="sm:min-h-0 sm:overflow-hidden">
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
@ -292,32 +300,25 @@ export function Modal({
|
||||
useGrayFooter
|
||||
? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25'
|
||||
: 'px-4 py-3 sm:px-6',
|
||||
isAlert
|
||||
? 'sm:flex sm:flex-row-reverse sm:gap-3'
|
||||
: secondaryAction
|
||||
? 'sm:mt-2 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3'
|
||||
: 'sm:mt-2',
|
||||
)}
|
||||
>
|
||||
{primaryAction &&
|
||||
renderActionButton(
|
||||
primaryAction,
|
||||
isAlert
|
||||
? 'sm:w-auto'
|
||||
: secondaryAction
|
||||
? 'sm:col-start-2'
|
||||
: '',
|
||||
)}
|
||||
|
||||
{secondaryAction &&
|
||||
renderActionButton(
|
||||
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
|
||||
clsx(
|
||||
'mt-3 sm:mt-0',
|
||||
isAlert && 'sm:w-auto sm:mr-3',
|
||||
!useGrayFooter && 'bg-white',
|
||||
),
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col gap-3',
|
||||
hasBothActions && 'sm:flex-row-reverse',
|
||||
)}
|
||||
>
|
||||
{primaryAction &&
|
||||
renderActionButton(
|
||||
primaryAction,
|
||||
hasBothActions ? 'flex-1' : undefined,
|
||||
)}
|
||||
{secondaryAction &&
|
||||
renderActionButton(
|
||||
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
|
||||
hasBothActions ? 'flex-1' : undefined,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogPanel>
|
||||
@ -328,3 +329,4 @@ export function Modal({
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
|
||||
|
||||
@ -81,11 +81,21 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
if (va == null) return sort.direction === 'asc' ? -1 : 1;
|
||||
if (vb == null) return sort.direction === 'asc' ? 1 : -1;
|
||||
|
||||
// Numbers
|
||||
// Reine Numbers
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sort.direction === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
|
||||
// Numerische Strings wie "1", "123", "42"
|
||||
if (typeof va === 'string' && typeof vb === 'string') {
|
||||
const na = Number(va);
|
||||
const nb = Number(vb);
|
||||
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
|
||||
return sort.direction === 'asc' ? na - nb : nb - na;
|
||||
}
|
||||
}
|
||||
|
||||
// Date / ISO-String
|
||||
const sa = va instanceof Date ? va.getTime() : String(va);
|
||||
const sb = vb instanceof Date ? vb.getTime() : String(vb);
|
||||
@ -197,7 +207,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
key={String(col.key)}
|
||||
scope="col"
|
||||
className={classNames(
|
||||
'py-3.5 px-4 text-left text-sm font-semibold text-gray-900 dark:text-white',
|
||||
'py-3.5 px-2 text-left text-sm font-semibold text-gray-900 dark:text-white',
|
||||
col.headerClassName,
|
||||
isHideable
|
||||
? 'hidden lg:table-cell'
|
||||
@ -238,7 +248,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
{renderActions && (
|
||||
<th
|
||||
scope="col"
|
||||
className="py-3.5 px-4 text-sm font-semibold text-gray-900 dark:text-white"
|
||||
className="py-3.5 px-2 text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{actionsHeader}
|
||||
</th>
|
||||
@ -256,7 +266,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
className={classNames(isSelected && 'bg-gray-50 dark:bg-gray-800/60')}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-4">
|
||||
<td className="px-2">
|
||||
<div className="group grid size-4 grid-cols-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -292,7 +302,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className={classNames(
|
||||
'px-4 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
|
||||
'px-2 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
|
||||
col.cellClassName,
|
||||
col.canHide && 'hidden lg:table-cell', // <-- HIER
|
||||
)}
|
||||
@ -302,7 +312,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
))}
|
||||
|
||||
{renderActions && (
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
|
||||
<td className="px-2 py-3 text-sm whitespace-nowrap text-right">
|
||||
{renderActions(row)}
|
||||
</td>
|
||||
)}
|
||||
@ -314,7 +324,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0)}
|
||||
className="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
className="px-2 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Keine Daten vorhanden.
|
||||
</td>
|
||||
|
||||
@ -1,62 +1,83 @@
|
||||
// /components/ui/Tabs.tsx
|
||||
// components/ui/Tabs.tsx
|
||||
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import * as React from 'react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/16/solid';
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
|
||||
export type TabItem = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: TabItem[]
|
||||
defaultTabId?: string
|
||||
onChange?(id: string): void
|
||||
className?: string
|
||||
}
|
||||
type TabsProps = {
|
||||
tabs: TabItem[];
|
||||
/** aktuell ausgewählter Tab (id) */
|
||||
value: string;
|
||||
/** Callback bei Wechsel */
|
||||
onChange: (id: string) => void;
|
||||
className?: string;
|
||||
/** Optional eigenes aria-label */
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, defaultTabId, onChange, className }: TabsProps) {
|
||||
const [activeId, setActiveId] = React.useState(
|
||||
defaultTabId ?? (tabs[0]?.id ?? ''),
|
||||
)
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
setActiveId(id)
|
||||
onChange?.(id)
|
||||
}
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
ariaLabel = 'Ansicht auswählen',
|
||||
}: TabsProps) {
|
||||
const current = tabs.find((t) => t.id === value) ?? tabs[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'border-b border-gray-200 dark:border-white/10',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeId
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => handleClick(tab.id)}
|
||||
className={[
|
||||
'whitespace-nowrap border-b-2 px-1 pb-3 pt-2 text-sm/6 font-semibold transition-colors',
|
||||
isActive
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={className}>
|
||||
{/* Mobile: Select + Chevron */}
|
||||
<div className="grid grid-cols-1 sm:hidden">
|
||||
<select
|
||||
value={current.id}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500 dark:fill-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Underline-Tabs */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const isCurrent = tab.id === current.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={clsx(
|
||||
isCurrent
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||
'border-b-2 px-1 py-3 text-sm font-medium whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
56
package-lock.json
generated
56
package-lock.json
generated
@ -11,10 +11,13 @@
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"@zxing/library": "^0.21.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.0.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"postcss": "^8.5.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"socket.io": "^4.8.1",
|
||||
@ -2533,6 +2536,41 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@zxing/browser": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz",
|
||||
"integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "^0.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@zxing/library": "^0.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/library": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz",
|
||||
"integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ts-custom-error": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "~0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/text-encoding": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
||||
"license": "(Unlicense OR Apache-2.0)",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@ -6400,6 +6438,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@ -7324,6 +7371,15 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-custom-error": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
|
||||
@ -13,10 +13,13 @@
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"@zxing/library": "^0.21.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.0.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"postcss": "^8.5.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"socket.io": "^4.8.1",
|
||||
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
|
||||
deviceId String
|
||||
device Device @relation(fields: [deviceId], references: [inventoryNumber])
|
||||
|
||||
changeType DeviceChangeType
|
||||
|
||||
snapshot Json
|
||||
changedAt DateTime @default(now())
|
||||
|
||||
// 🔹 FK-Spalte
|
||||
changedById String?
|
||||
|
||||
// 🔹 Relation zu User
|
||||
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
|
||||
|
||||
@@index([deviceId, changedAt])
|
||||
@@index([changedById])
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user