updated
This commit is contained in:
parent
d1f13fd77e
commit
90231bff83
408
app/(app)/devices/DeviceEditModal.tsx
Normal file
408
app/(app)/devices/DeviceEditModal.tsx
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
// app/(app)/devices/DeviceEditModal.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, ChangeEvent, Dispatch, SetStateAction } from 'react';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||||
|
import DeviceHistorySidebar from './DeviceHistorySidebar';
|
||||||
|
import TagMultiCombobox, { TagOption } from '@/components/ui/TagMultiCombobox';
|
||||||
|
import type { DeviceDetail } from './page'; // Typ aus page.tsx (siehe unten)
|
||||||
|
|
||||||
|
type DeviceEditModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
inventoryNumber: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (device: DeviceDetail) => void;
|
||||||
|
allTags: TagOption[];
|
||||||
|
setAllTags: Dispatch<SetStateAction<TagOption[]>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeviceEditModal({
|
||||||
|
open,
|
||||||
|
inventoryNumber,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
allTags,
|
||||||
|
setAllTags,
|
||||||
|
}: DeviceEditModalProps) {
|
||||||
|
const [editDevice, setEditDevice] = useState<DeviceDetail | null>(null);
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
|
||||||
|
// Gerät laden, wenn Modal geöffnet wird
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inventoryNumber) {
|
||||||
|
setEditDevice(null);
|
||||||
|
setEditError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadDevice() {
|
||||||
|
setEditLoading(true);
|
||||||
|
setEditError(null);
|
||||||
|
setEditDevice(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/devices/${encodeURIComponent(inventoryNumber)}`,
|
||||||
|
{
|
||||||
|
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) {
|
||||||
|
setEditDevice(data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error loading device', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setEditError(
|
||||||
|
err instanceof Error ? err.message : 'Netzwerkfehler beim Laden der Gerätedaten.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDevice();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, inventoryNumber]);
|
||||||
|
|
||||||
|
const handleFieldChange = (
|
||||||
|
field: keyof DeviceDetail,
|
||||||
|
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setEditDevice((prev) =>
|
||||||
|
prev ? ({ ...prev, [field]: value } as DeviceDetail) : prev,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!editDevice) return;
|
||||||
|
|
||||||
|
setSaveLoading(true);
|
||||||
|
setEditError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/devices/${encodeURIComponent(editDevice.inventoryNumber)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: editDevice.name,
|
||||||
|
manufacturer: editDevice.manufacturer,
|
||||||
|
model: editDevice.model,
|
||||||
|
serialNumber: editDevice.serialNumber || null,
|
||||||
|
productNumber: editDevice.productNumber || null,
|
||||||
|
comment: editDevice.comment || null,
|
||||||
|
group: editDevice.group || null,
|
||||||
|
location: editDevice.location || null,
|
||||||
|
ipv4Address: editDevice.ipv4Address || null,
|
||||||
|
ipv6Address: editDevice.ipv6Address || null,
|
||||||
|
macAddress: editDevice.macAddress || null,
|
||||||
|
username: editDevice.username || null,
|
||||||
|
passwordHash: editDevice.passwordHash || null,
|
||||||
|
tags: editDevice.tags ?? [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new Error('Gerät wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
throw new Error('Speichern der Änderungen ist fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = (await res.json()) as DeviceDetail;
|
||||||
|
setEditDevice(updated);
|
||||||
|
onSaved(updated); // Tabelle im Parent aktualisieren
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error saving device', err);
|
||||||
|
setEditError(
|
||||||
|
err instanceof Error ? err.message : 'Netzwerkfehler beim Speichern der Gerätedaten.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
}, [editDevice, onSaved]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (saveLoading) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={
|
||||||
|
editDevice
|
||||||
|
? `Gerät bearbeiten: ${editDevice.name}`
|
||||||
|
: 'Gerätedaten werden geladen …'
|
||||||
|
}
|
||||||
|
icon={<PencilIcon className="size-6" />}
|
||||||
|
tone="info"
|
||||||
|
variant="centered"
|
||||||
|
size="lg"
|
||||||
|
primaryAction={{
|
||||||
|
label: saveLoading ? 'Speichern …' : 'Speichern',
|
||||||
|
onClick: handleSave,
|
||||||
|
autoFocus: true,
|
||||||
|
}}
|
||||||
|
secondaryAction={{
|
||||||
|
label: 'Abbrechen',
|
||||||
|
variant: 'secondary',
|
||||||
|
onClick: handleClose,
|
||||||
|
}}
|
||||||
|
sidebar={
|
||||||
|
editDevice ? (
|
||||||
|
<DeviceHistorySidebar
|
||||||
|
inventoryNumber={editDevice.inventoryNumber}
|
||||||
|
asSidebar
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editLoading && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Gerätedaten werden geladen …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editError && (
|
||||||
|
<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">
|
||||||
|
{/* 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-400 shadow-xs ring-1 ring-inset ring-gray-800"
|
||||||
|
value={editDevice.inventoryNumber}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</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={editDevice.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={editDevice.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={editDevice.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={editDevice.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={editDevice.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={editDevice.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={editDevice.group ?? ''}
|
||||||
|
onChange={(e) => handleFieldChange('group', e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<TagMultiCombobox
|
||||||
|
label="Tags"
|
||||||
|
availableTags={allTags}
|
||||||
|
value={(editDevice.tags ?? []).map((name) => ({ name }))}
|
||||||
|
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) {
|
||||||
|
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={editDevice.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={editDevice.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={editDevice.macAddress ?? ''}
|
||||||
|
onChange={(e) => handleFieldChange('macAddress', e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={editDevice.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={editDevice.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={editDevice.comment ?? ''}
|
||||||
|
onChange={(e) => handleFieldChange('comment', e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +1,28 @@
|
|||||||
// app/(app)/devices/DeviceHistorySidebar.tsx
|
// app/(app)/devices/DeviceHistorySidebar.tsx
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, ElementType } from 'react';
|
||||||
import Feed, { FeedItem } from '@/components/ui/Feed';
|
import Feed, {
|
||||||
|
FeedItem,
|
||||||
|
FeedChange,
|
||||||
|
} from '@/components/ui/Feed';
|
||||||
|
|
||||||
type DeviceHistoryEntry = {
|
type Props = {
|
||||||
|
inventoryNumber: string;
|
||||||
|
/** Wenn true: wird als Inhalt für Modal.sidebar gerendert (ohne eigenes <aside>/Border) */
|
||||||
|
asSidebar?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiHistoryEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
changeType: 'CREATED' | 'UPDATED' | 'DELETED';
|
changeType: 'CREATED' | 'UPDATED' | 'DELETED';
|
||||||
changedAt: string;
|
changedAt: string;
|
||||||
changedBy?: string | null;
|
changedBy: string | null;
|
||||||
|
changes: {
|
||||||
|
field: string;
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDateTime(iso: string) {
|
function formatDateTime(iso: string) {
|
||||||
@ -18,102 +32,163 @@ function formatDateTime(iso: string) {
|
|||||||
}).format(new Date(iso));
|
}).format(new Date(iso));
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeTypeLabel(type: DeviceHistoryEntry['changeType']) {
|
function mapFieldLabel(field: string): string {
|
||||||
switch (type) {
|
switch (field) {
|
||||||
case 'CREATED':
|
case 'name':
|
||||||
return 'Gerät angelegt';
|
return 'die Bezeichnung';
|
||||||
case 'UPDATED':
|
case 'manufacturer':
|
||||||
return 'Gerät aktualisiert';
|
return 'den Hersteller';
|
||||||
case 'DELETED':
|
case 'model':
|
||||||
return 'Gerät gelöscht';
|
return 'das Modell';
|
||||||
|
case 'serialNumber':
|
||||||
|
return 'die Seriennummer';
|
||||||
|
case 'productNumber':
|
||||||
|
return 'die Produktnummer';
|
||||||
|
case 'comment':
|
||||||
|
return 'den Kommentar';
|
||||||
|
case 'ipv4Address':
|
||||||
|
return 'die IPv4-Adresse';
|
||||||
|
case 'ipv6Address':
|
||||||
|
return 'die IPv6-Adresse';
|
||||||
|
case 'macAddress':
|
||||||
|
return 'die MAC-Adresse';
|
||||||
|
case 'username':
|
||||||
|
return 'den Benutzernamen';
|
||||||
|
case 'passwordHash':
|
||||||
|
return 'das Passwort';
|
||||||
|
case 'group':
|
||||||
|
return 'die Gruppe';
|
||||||
|
case 'location':
|
||||||
|
return 'den Standort';
|
||||||
|
case 'tags':
|
||||||
|
return 'die Tags';
|
||||||
default:
|
default:
|
||||||
return type;
|
return field;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeviceHistorySidebarProps {
|
|
||||||
inventoryNumber: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeviceHistorySidebar({
|
export default function DeviceHistorySidebar({
|
||||||
inventoryNumber,
|
inventoryNumber,
|
||||||
}: DeviceHistorySidebarProps) {
|
asSidebar = false,
|
||||||
const [entries, setEntries] = useState<DeviceHistoryEntry[]>([]);
|
}: Props) {
|
||||||
|
const [items, setItems] = useState<FeedItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inventoryNumber) return;
|
let cancelled = false;
|
||||||
|
|
||||||
const loadHistory = async () => {
|
async function loadHistory() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
|
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
|
||||||
{ cache: 'no-store' },
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError('Historie konnte nicht geladen werden.');
|
throw new Error(`HTTP ${res.status}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as DeviceHistoryEntry[];
|
const data = (await res.json()) as ApiHistoryEntry[];
|
||||||
setEntries(data);
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const mapped: FeedItem[] = data.map((entry) => {
|
||||||
|
const person = {
|
||||||
|
name: entry.changedBy ?? 'Unbekannter Benutzer',
|
||||||
|
};
|
||||||
|
const date = formatDateTime(entry.changedAt);
|
||||||
|
|
||||||
|
if (entry.changeType === 'UPDATED' && entry.changes.length > 0) {
|
||||||
|
const changes: FeedChange[] = entry.changes.map((c) => ({
|
||||||
|
field: c.field,
|
||||||
|
label: mapFieldLabel(c.field),
|
||||||
|
from: c.from,
|
||||||
|
to: c.to,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
type: 'change',
|
||||||
|
person,
|
||||||
|
date,
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let comment = '';
|
||||||
|
if (entry.changeType === 'CREATED') {
|
||||||
|
comment = 'Gerät angelegt.';
|
||||||
|
} else if (entry.changeType === 'DELETED') {
|
||||||
|
comment = 'Gerät gelöscht.';
|
||||||
|
} else {
|
||||||
|
comment = 'Gerät geändert.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
type: 'comment',
|
||||||
|
person,
|
||||||
|
date,
|
||||||
|
comment,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems(mapped);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading device history', err);
|
console.error('Error loading device history', err);
|
||||||
setError('Netzwerkfehler beim Laden der Historie.');
|
if (!cancelled) {
|
||||||
|
setError('Historie konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
loadHistory();
|
loadHistory();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [inventoryNumber]);
|
}, [inventoryNumber]);
|
||||||
|
|
||||||
if (loading) {
|
// Root-Tag & Klassen abhängig vom Einsatz
|
||||||
return (
|
const Root: ElementType = asSidebar ? 'div' : 'aside';
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
const rootClasses = asSidebar
|
||||||
Historie wird geladen …
|
? 'flex h-full flex-col text-sm'
|
||||||
</p>
|
: 'flex h-full flex-col border-l border-gray-200 px-4 py-4 text-sm dark:border-white/10';
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entries.length) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Noch keine Historie vorhanden.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const feedItems: FeedItem[] = entries.map((entry) => ({
|
|
||||||
id: entry.id,
|
|
||||||
type: 'comment',
|
|
||||||
person: {
|
|
||||||
name: entry.changedBy ?? 'System',
|
|
||||||
href: '#',
|
|
||||||
},
|
|
||||||
comment: changeTypeLabel(entry.changeType),
|
|
||||||
date: formatDateTime(entry.changedAt),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<Root className={rootClasses}>
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Historie
|
Änderungsverlauf
|
||||||
</h4>
|
</h2>
|
||||||
<Feed items={feedItems} />
|
|
||||||
</div>
|
{loading && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Historie wird geladen …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="mt-3 flex-1 min-h-0">
|
||||||
|
<Feed items={items} className="h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,21 @@
|
|||||||
// app/(app)/devices/page.tsx
|
// app/(app)/devices/page.tsx
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, ChangeEvent } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Table, { TableColumn } from '@/components/ui/Table';
|
import Table, { TableColumn } from '@/components/ui/Table';
|
||||||
import { Dropdown } from '@/components/ui/Dropdown';
|
import { Dropdown } from '@/components/ui/Dropdown';
|
||||||
import Modal from '@/components/ui/Modal';
|
|
||||||
import {
|
import {
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import DeviceHistorySidebar from './DeviceHistorySidebar';
|
import { getSocket } from '@/lib/socketClient';
|
||||||
|
import type { TagOption } from '@/components/ui/TagMultiCombobox';
|
||||||
|
import DeviceEditModal from './DeviceEditModal';
|
||||||
|
|
||||||
type DeviceRow = {
|
export type DeviceRow = {
|
||||||
inventoryNumber: string;
|
inventoryNumber: string;
|
||||||
|
|
||||||
name: string;
|
name: string;
|
||||||
@ -32,11 +33,12 @@ type DeviceRow = {
|
|||||||
|
|
||||||
group?: string | null;
|
group?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeviceDetail = DeviceRow & {
|
export type DeviceDetail = DeviceRow & {
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -106,6 +108,14 @@ const columns: TableColumn<DeviceRow>[] = [
|
|||||||
canHide: true,
|
canHide: true,
|
||||||
cellClassName: 'whitespace-normal max-w-xs',
|
cellClassName: 'whitespace-normal max-w-xs',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
header: 'Tags',
|
||||||
|
sortable: false,
|
||||||
|
canHide: true,
|
||||||
|
render: (row) =>
|
||||||
|
row.tags && row.tags.length > 0 ? row.tags.join(', ') : '',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'updatedAt',
|
key: 'updatedAt',
|
||||||
header: 'Geändert am',
|
header: 'Geändert am',
|
||||||
@ -121,12 +131,13 @@ export default function DevicesPage() {
|
|||||||
const [listLoading, setListLoading] = useState(false);
|
const [listLoading, setListLoading] = useState(false);
|
||||||
const [listError, setListError] = useState<string | null>(null);
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Modal-State
|
// welches Gerät ist gerade im Edit-Modal geöffnet?
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(
|
||||||
const [editLoading, setEditLoading] = useState(false);
|
null,
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
);
|
||||||
const [editDevice, setEditDevice] = useState<DeviceDetail | null>(null);
|
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
// Alle bekannten Tags (kannst du später auch aus eigener /api/tags laden)
|
||||||
|
const [allTags, setAllTags] = useState<TagOption[]>([]);
|
||||||
|
|
||||||
/* ───────── Geräte-Liste laden (auch für "live"-Updates) ───────── */
|
/* ───────── Geräte-Liste laden (auch für "live"-Updates) ───────── */
|
||||||
|
|
||||||
@ -147,6 +158,18 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const data = (await res.json()) as DeviceRow[];
|
const data = (await res.json()) as DeviceRow[];
|
||||||
setDevices(data);
|
setDevices(data);
|
||||||
|
|
||||||
|
// 🔹 alle Tags aus der Liste ableiten
|
||||||
|
const tagSet = new Map<string, TagOption>();
|
||||||
|
for (const d of data) {
|
||||||
|
(d.tags ?? []).forEach((name) => {
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (!tagSet.has(key)) {
|
||||||
|
tagSet.set(key, { name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAllTags(Array.from(tagSet.values()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading devices', err);
|
console.error('Error loading devices', err);
|
||||||
setListError('Netzwerkfehler beim Laden der Geräte.');
|
setListError('Netzwerkfehler beim Laden der Geräte.');
|
||||||
@ -160,128 +183,60 @@ export default function DevicesPage() {
|
|||||||
loadDevices();
|
loadDevices();
|
||||||
}, [loadDevices]);
|
}, [loadDevices]);
|
||||||
|
|
||||||
// "Live"-Updates: alle 10 Sekunden neu laden
|
// ✅ Echte Live-Updates via Socket.IO
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const socket = getSocket();
|
||||||
loadDevices();
|
|
||||||
}, 10000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [loadDevices]);
|
|
||||||
|
|
||||||
/* ───────── Edit-Modal ───────── */
|
const handleUpdated = (payload: DeviceRow) => {
|
||||||
|
setDevices((prev) => {
|
||||||
const closeEditModal = useCallback(() => {
|
const exists = prev.some(
|
||||||
if (saveLoading) return; // während Speichern nicht schließen
|
(d) => d.inventoryNumber === payload.inventoryNumber,
|
||||||
setEditOpen(false);
|
);
|
||||||
setEditDevice(null);
|
if (!exists) {
|
||||||
setEditError(null);
|
// falls du Updates & Creates über das gleiche Event schickst
|
||||||
}, [saveLoading]);
|
return [...prev, payload];
|
||||||
|
|
||||||
const handleEdit = useCallback(async (inventoryNumber: string) => {
|
|
||||||
// Modal direkt öffnen & Loader anzeigen
|
|
||||||
setEditOpen(true);
|
|
||||||
setEditLoading(true);
|
|
||||||
setEditError(null);
|
|
||||||
setEditDevice(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/devices/${encodeURIComponent(inventoryNumber)}`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 404) {
|
|
||||||
setEditError('Gerät wurde nicht gefunden.');
|
|
||||||
} else {
|
|
||||||
setEditError(
|
|
||||||
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return prev.map((d) =>
|
||||||
}
|
d.inventoryNumber === payload.inventoryNumber ? payload : d,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const data = (await res.json()) as DeviceDetail;
|
const handleCreated = (payload: DeviceRow) => {
|
||||||
setEditDevice(data);
|
setDevices((prev) => {
|
||||||
} catch (err) {
|
if (prev.some((d) => d.inventoryNumber === payload.inventoryNumber)) {
|
||||||
console.error('Error loading device', err);
|
return prev;
|
||||||
setEditError('Netzwerkfehler beim Laden der Gerätedaten.');
|
}
|
||||||
} finally {
|
return [...prev, payload];
|
||||||
setEditLoading(false);
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleDeleted = (data: { inventoryNumber: string }) => {
|
||||||
|
setDevices((prev) =>
|
||||||
|
prev.filter((d) => d.inventoryNumber !== data.inventoryNumber),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('device:updated', handleUpdated);
|
||||||
|
socket.on('device:created', handleCreated);
|
||||||
|
socket.on('device:deleted', handleDeleted);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('device:updated', handleUpdated);
|
||||||
|
socket.off('device:created', handleCreated);
|
||||||
|
socket.off('device:deleted', handleDeleted);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFieldChange = (
|
/* ───────── Edit-Modal Trigger ───────── */
|
||||||
field: keyof DeviceDetail,
|
|
||||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
||||||
) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setEditDevice((prev) =>
|
|
||||||
prev ? ({ ...prev, [field]: value } as DeviceDetail) : prev,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleEdit = useCallback((inventoryNumber: string) => {
|
||||||
if (!editDevice) return;
|
setEditInventoryNumber(inventoryNumber);
|
||||||
|
}, []);
|
||||||
|
|
||||||
setSaveLoading(true);
|
const closeEditModal = useCallback(() => {
|
||||||
setEditError(null);
|
setEditInventoryNumber(null);
|
||||||
|
}, []);
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/devices/${encodeURIComponent(editDevice.inventoryNumber)}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: editDevice.name,
|
|
||||||
manufacturer: editDevice.manufacturer,
|
|
||||||
model: editDevice.model,
|
|
||||||
serialNumber: editDevice.serialNumber || null,
|
|
||||||
productNumber: editDevice.productNumber || null,
|
|
||||||
comment: editDevice.comment || null,
|
|
||||||
group: editDevice.group || null,
|
|
||||||
location: editDevice.location || null,
|
|
||||||
ipv4Address: editDevice.ipv4Address || null,
|
|
||||||
ipv6Address: editDevice.ipv6Address || null,
|
|
||||||
macAddress: editDevice.macAddress || null,
|
|
||||||
username: editDevice.username || null,
|
|
||||||
passwordHash: editDevice.passwordHash || null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 404) {
|
|
||||||
setEditError('Gerät wurde nicht gefunden.');
|
|
||||||
} else {
|
|
||||||
setEditError('Speichern der Änderungen ist fehlgeschlagen.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = (await res.json()) as DeviceDetail;
|
|
||||||
setEditDevice(updated);
|
|
||||||
|
|
||||||
// Tabelle aktualisieren (damit andere Felder sofort stimmen)
|
|
||||||
setDevices((prev) =>
|
|
||||||
prev.map((d) =>
|
|
||||||
d.inventoryNumber === updated.inventoryNumber
|
|
||||||
? { ...d, ...updated }
|
|
||||||
: d,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error saving device', err);
|
|
||||||
setEditError('Netzwerkfehler beim Speichern der Gerätedaten.');
|
|
||||||
} finally {
|
|
||||||
setSaveLoading(false);
|
|
||||||
}
|
|
||||||
}, [editDevice]);
|
|
||||||
|
|
||||||
/* ───────── Render ───────── */
|
/* ───────── Render ───────── */
|
||||||
|
|
||||||
@ -397,227 +352,22 @@ export default function DevicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit-/Details-Modal */}
|
{/* Edit-/Details-Modal */}
|
||||||
<Modal
|
<DeviceEditModal
|
||||||
open={editOpen}
|
open={editInventoryNumber !== null}
|
||||||
|
inventoryNumber={editInventoryNumber}
|
||||||
onClose={closeEditModal}
|
onClose={closeEditModal}
|
||||||
title={
|
allTags={allTags}
|
||||||
editDevice
|
setAllTags={setAllTags}
|
||||||
? `Gerät bearbeiten: ${editDevice.name}`
|
onSaved={(updated) => {
|
||||||
: 'Gerätedaten werden geladen …'
|
setDevices((prev) =>
|
||||||
}
|
prev.map((d) =>
|
||||||
icon={<PencilIcon className="size-6" />}
|
d.inventoryNumber === updated.inventoryNumber
|
||||||
tone="info"
|
? { ...d, ...updated }
|
||||||
variant="centered"
|
: d,
|
||||||
size="lg"
|
),
|
||||||
primaryAction={{
|
);
|
||||||
label: saveLoading ? 'Speichern …' : 'Speichern',
|
|
||||||
onClick: handleSave,
|
|
||||||
autoFocus: true,
|
|
||||||
}}
|
}}
|
||||||
secondaryAction={{
|
/>
|
||||||
label: 'Abbrechen',
|
|
||||||
variant: 'secondary',
|
|
||||||
onClick: closeEditModal,
|
|
||||||
}}
|
|
||||||
sidebar={
|
|
||||||
editDevice ? (
|
|
||||||
<DeviceHistorySidebar
|
|
||||||
inventoryNumber={editDevice.inventoryNumber}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{editLoading && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Gerätedaten werden geladen …
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editError && (
|
|
||||||
<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">
|
|
||||||
|
|
||||||
{/* 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-400 shadow-xs ring-1 ring-inset ring-gray-800"
|
|
||||||
value={editDevice.inventoryNumber}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</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 dark:bg-gray-900"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.group ?? ''}
|
|
||||||
onChange={(e) => handleFieldChange('group', e)}
|
|
||||||
/>
|
|
||||||
</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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.ipv6Address ?? ''}
|
|
||||||
onChange={(e) => handleFieldChange('ipv6Address', e)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='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"
|
|
||||||
value={editDevice.macAddress ?? ''}
|
|
||||||
onChange={(e) => handleFieldChange('macAddress', e)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.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"
|
|
||||||
value={editDevice.comment ?? ''}
|
|
||||||
onChange={(e) => handleFieldChange('comment', e)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,82 +1,7 @@
|
|||||||
// app/api/auth/[...nextauth]/route.ts
|
// app/api/auth/[...nextauth]/route.ts
|
||||||
import NextAuth from 'next-auth';
|
import NextAuth from 'next-auth';
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
import { authOptions } from '@/lib/auth-options';
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { compare } from 'bcryptjs';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
const handler = NextAuth({
|
|
||||||
providers: [
|
|
||||||
CredentialsProvider({
|
|
||||||
name: 'Credentials',
|
|
||||||
credentials: {
|
|
||||||
email: {
|
|
||||||
label: 'Benutzername oder E-Mail',
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
label: 'Passwort',
|
|
||||||
type: 'password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async authorize(credentials) {
|
|
||||||
if (!credentials?.email || !credentials.password) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const identifier = credentials.email.trim();
|
|
||||||
|
|
||||||
// User per E-Mail ODER Benutzername suchen
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ email: identifier },
|
|
||||||
{ username: identifier },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.passwordHash) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await compare(credentials.password, user.passwordHash);
|
|
||||||
if (!isValid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal-Userobjekt für NextAuth zurückgeben
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
name: user.name ?? user.username ?? user.email,
|
|
||||||
email: user.email,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
pages: {
|
|
||||||
signIn: '/login',
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
strategy: 'jwt',
|
|
||||||
},
|
|
||||||
callbacks: {
|
|
||||||
async jwt({ token, user }) {
|
|
||||||
// beim Login User-ID in den Token schreiben
|
|
||||||
if (user) {
|
|
||||||
token.id = user.id;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
async session({ session, token }) {
|
|
||||||
// ID aus dem Token wieder in die Session kopieren
|
|
||||||
if (session.user && token.id) {
|
|
||||||
(session.user as any).id = token.id;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export { handler as GET, handler as POST };
|
export { handler as GET, handler as POST };
|
||||||
|
|||||||
@ -2,37 +2,57 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
// ⬇️ wie bei /api/devices/[id]
|
||||||
|
type RouteContext = { params: Promise<RouteParams> };
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: { id: string } },
|
ctx: RouteContext,
|
||||||
) {
|
) {
|
||||||
// In der URL ist "id" = inventoryNumber
|
// params-Promise auflösen
|
||||||
const inventoryNumber = decodeURIComponent(params.id);
|
const { id } = await ctx.params;
|
||||||
|
const inventoryNumber = decodeURIComponent(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = await prisma.deviceHistory.findMany({
|
const history = await prisma.deviceHistory.findMany({
|
||||||
where: { deviceId: inventoryNumber },
|
where: { deviceId: inventoryNumber },
|
||||||
include: {
|
include: { changedBy: true },
|
||||||
changedBy: true,
|
orderBy: { changedAt: 'desc' },
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
changedAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auf das Format für DeviceHistorySidebar mappen
|
const payload = history.map((entry) => {
|
||||||
const payload = history.map((entry) => ({
|
const snapshot = entry.snapshot as any;
|
||||||
id: entry.id,
|
|
||||||
changeType: entry.changeType,
|
const rawChanges: any[] = Array.isArray(snapshot?.changes)
|
||||||
changedAt: entry.changedAt.toISOString(),
|
? snapshot.changes
|
||||||
changedBy:
|
: [];
|
||||||
entry.changedBy?.name ??
|
|
||||||
entry.changedBy?.username ??
|
const changes = rawChanges.map((c) => ({
|
||||||
entry.changedBy?.email ??
|
field: String(c.field),
|
||||||
null,
|
from:
|
||||||
}));
|
c.before === null || c.before === undefined
|
||||||
|
? null
|
||||||
|
: String(c.before),
|
||||||
|
to:
|
||||||
|
c.after === null || c.after === undefined
|
||||||
|
? null
|
||||||
|
: String(c.after),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
changeType: entry.changeType, // 'CREATED' | 'UPDATED' | 'DELETED'
|
||||||
|
changedAt: entry.changedAt.toISOString(),
|
||||||
|
changedBy:
|
||||||
|
entry.changedBy?.name ??
|
||||||
|
entry.changedBy?.username ??
|
||||||
|
entry.changedBy?.email ??
|
||||||
|
null,
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Auch bei leerer Liste 200 + [] zurückgeben
|
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[GET /api/devices/[id]/history]', err);
|
console.error('[GET /api/devices/[id]/history]', err);
|
||||||
|
|||||||
@ -3,17 +3,18 @@ import { NextResponse } from 'next/server';
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { getCurrentUserId } from '@/lib/auth';
|
import { getCurrentUserId } from '@/lib/auth';
|
||||||
|
import type { Server as IOServer } from 'socket.io';
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
// ⬇️ Next liefert params als Promise
|
// in Next 15+ ist params ein Promise
|
||||||
type RouteContext = { params: Promise<RouteParams> };
|
type RouteContext = { params: Promise<RouteParams> };
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
ctx: RouteContext,
|
ctx: RouteContext,
|
||||||
) {
|
) {
|
||||||
// ⬇️ Promise auflösen
|
// params-Promise auflösen
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@ -26,6 +27,7 @@ export async function GET(
|
|||||||
include: {
|
include: {
|
||||||
group: true,
|
group: true,
|
||||||
location: true,
|
location: true,
|
||||||
|
tags: true, // 🔹 NEU
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -45,23 +47,25 @@ export async function GET(
|
|||||||
ipv6Address: device.ipv6Address,
|
ipv6Address: device.ipv6Address,
|
||||||
macAddress: device.macAddress,
|
macAddress: device.macAddress,
|
||||||
username: device.username,
|
username: device.username,
|
||||||
// passwordHash gebe ich hier absichtlich nicht zurück
|
// passwordHash bewusst weggelassen
|
||||||
group: device.group?.name ?? null,
|
group: device.group?.name ?? null,
|
||||||
location: device.location?.name ?? null,
|
location: device.location?.name ?? null,
|
||||||
|
tags: device.tags.map((t) => t.name), // 🔹
|
||||||
createdAt: device.createdAt.toISOString(),
|
createdAt: device.createdAt.toISOString(),
|
||||||
updatedAt: device.updatedAt.toISOString(),
|
updatedAt: device.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[GET /api/devices/[id]]', err);
|
console.error('[GET /api/devices/[id]]', err);
|
||||||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
req: Request,
|
req: Request,
|
||||||
ctx: RouteContext,
|
ctx: RouteContext,
|
||||||
) {
|
) {
|
||||||
// ⬇️ Promise auflösen
|
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
@ -70,7 +74,7 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// User für updatedBy / History bestimmen
|
// ⬇️ hier jetzt die Request-Header durchreichen
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
|
|
||||||
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
|
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
|
||||||
@ -79,6 +83,7 @@ export async function PATCH(
|
|||||||
include: {
|
include: {
|
||||||
group: true,
|
group: true,
|
||||||
location: true,
|
location: true,
|
||||||
|
tags: true, // 🔹 NEU
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -101,6 +106,41 @@ export async function PATCH(
|
|||||||
passwordHash: body.passwordHash,
|
passwordHash: body.passwordHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
// updatedBy setzen, wenn User da
|
||||||
if (userId) {
|
if (userId) {
|
||||||
data.updatedBy = {
|
data.updatedBy = {
|
||||||
@ -132,6 +172,33 @@ export async function PATCH(
|
|||||||
data.location = { disconnect: true };
|
data.location = { disconnect: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()),
|
||||||
|
);
|
||||||
|
const toDisconnect = existing.tags
|
||||||
|
.map((t) => t.name)
|
||||||
|
.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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Update durchführen (für "after"-Snapshot)
|
// Update durchführen (für "after"-Snapshot)
|
||||||
const updated = await prisma.device.update({
|
const updated = await prisma.device.update({
|
||||||
where: { inventoryNumber: id },
|
where: { inventoryNumber: id },
|
||||||
@ -139,9 +206,11 @@ export async function PATCH(
|
|||||||
include: {
|
include: {
|
||||||
group: true,
|
group: true,
|
||||||
location: true,
|
location: true,
|
||||||
|
tags: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Felder, die wir tracken wollen
|
||||||
const trackedFields = [
|
const trackedFields = [
|
||||||
'name',
|
'name',
|
||||||
'manufacturer',
|
'manufacturer',
|
||||||
@ -158,12 +227,14 @@ export async function PATCH(
|
|||||||
|
|
||||||
type TrackedField = (typeof trackedFields)[number];
|
type TrackedField = (typeof trackedFields)[number];
|
||||||
|
|
||||||
|
// explizit JSON-kompatible Types (string | null)
|
||||||
const changes: {
|
const changes: {
|
||||||
field: TrackedField | 'group' | 'location';
|
field: TrackedField | 'group' | 'location' | 'tags';
|
||||||
before: string | null;
|
before: string | null;
|
||||||
after: string | null;
|
after: string | null;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
|
// einfache Feld-Diffs
|
||||||
for (const field of trackedFields) {
|
for (const field of trackedFields) {
|
||||||
const before = (existing as any)[field] as string | null;
|
const before = (existing as any)[field] as string | null;
|
||||||
const after = (updated as any)[field] as string | null;
|
const after = (updated as any)[field] as string | null;
|
||||||
@ -172,6 +243,7 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// group / location per Name vergleichen
|
||||||
const beforeGroup = (existing.group?.name ?? null) as string | null;
|
const beforeGroup = (existing.group?.name ?? null) as string | null;
|
||||||
const afterGroup = (updated.group?.name ?? null) as string | null;
|
const afterGroup = (updated.group?.name ?? null) as string | null;
|
||||||
if (beforeGroup !== afterGroup) {
|
if (beforeGroup !== afterGroup) {
|
||||||
@ -192,6 +264,25 @@ export async function PATCH(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tags vergleichen (als kommagetrennte Liste)
|
||||||
|
const beforeTagsList = existing.tags.map((t) => t.name).sort();
|
||||||
|
const afterTagsList = updated.tags.map((t) => t.name).sort();
|
||||||
|
|
||||||
|
const beforeTags =
|
||||||
|
beforeTagsList.length > 0 ? beforeTagsList.join(', ') : null;
|
||||||
|
const afterTags =
|
||||||
|
afterTagsList.length > 0 ? afterTagsList.join(', ') : null;
|
||||||
|
|
||||||
|
if (beforeTags !== afterTags) {
|
||||||
|
changes.push({
|
||||||
|
field: 'tags',
|
||||||
|
before: beforeTags,
|
||||||
|
after: afterTags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Falls sich *gar nichts* geändert hat, kein History-Eintrag
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
const snapshot: Prisma.JsonObject = {
|
const snapshot: Prisma.JsonObject = {
|
||||||
before: {
|
before: {
|
||||||
@ -209,6 +300,7 @@ export async function PATCH(
|
|||||||
passwordHash: existing.passwordHash,
|
passwordHash: existing.passwordHash,
|
||||||
group: existing.group?.name ?? null,
|
group: existing.group?.name ?? null,
|
||||||
location: existing.location?.name ?? null,
|
location: existing.location?.name ?? null,
|
||||||
|
tags: existing.tags.map((t) => t.name),
|
||||||
createdAt: existing.createdAt.toISOString(),
|
createdAt: existing.createdAt.toISOString(),
|
||||||
updatedAt: existing.updatedAt.toISOString(),
|
updatedAt: existing.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
@ -227,10 +319,15 @@ export async function PATCH(
|
|||||||
passwordHash: updated.passwordHash,
|
passwordHash: updated.passwordHash,
|
||||||
group: updated.group?.name ?? null,
|
group: updated.group?.name ?? null,
|
||||||
location: updated.location?.name ?? null,
|
location: updated.location?.name ?? null,
|
||||||
|
tags: updated.tags.map((t) => t.name),
|
||||||
createdAt: updated.createdAt.toISOString(),
|
createdAt: updated.createdAt.toISOString(),
|
||||||
updatedAt: updated.updatedAt.toISOString(),
|
updatedAt: updated.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
changes,
|
changes: changes.map((c) => ({
|
||||||
|
field: c.field,
|
||||||
|
before: c.before,
|
||||||
|
after: c.after,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
await prisma.deviceHistory.create({
|
await prisma.deviceHistory.create({
|
||||||
@ -243,6 +340,28 @@ export async function PATCH(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔊 Socket.IO-Broadcast für echte Live-Updates
|
||||||
|
const io = (global as any).devicesIo as IOServer | undefined;
|
||||||
|
if (io) {
|
||||||
|
io.emit('device:updated', {
|
||||||
|
inventoryNumber: updated.inventoryNumber,
|
||||||
|
name: updated.name,
|
||||||
|
manufacturer: updated.manufacturer,
|
||||||
|
model: updated.model,
|
||||||
|
serialNumber: updated.serialNumber,
|
||||||
|
productNumber: updated.productNumber,
|
||||||
|
comment: updated.comment,
|
||||||
|
ipv4Address: updated.ipv4Address,
|
||||||
|
ipv6Address: updated.ipv6Address,
|
||||||
|
macAddress: updated.macAddress,
|
||||||
|
username: updated.username,
|
||||||
|
group: updated.group?.name ?? null,
|
||||||
|
location: updated.location?.name ?? null,
|
||||||
|
updatedAt: updated.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antwort an den Client (flattened)
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
inventoryNumber: updated.inventoryNumber,
|
inventoryNumber: updated.inventoryNumber,
|
||||||
name: updated.name,
|
name: updated.name,
|
||||||
|
|||||||
@ -8,30 +8,31 @@ export async function GET() {
|
|||||||
include: {
|
include: {
|
||||||
group: true,
|
group: true,
|
||||||
location: true,
|
location: true,
|
||||||
},
|
tags: true, // 🔹 NEU
|
||||||
orderBy: {
|
|
||||||
updatedAt: 'desc',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = devices.map((device) => ({
|
return NextResponse.json(
|
||||||
inventoryNumber: device.inventoryNumber,
|
devices.map((d) => ({
|
||||||
name: device.name,
|
inventoryNumber: d.inventoryNumber,
|
||||||
manufacturer: device.manufacturer,
|
name: d.name,
|
||||||
model: device.model,
|
manufacturer: d.manufacturer,
|
||||||
serialNumber: device.serialNumber,
|
model: d.model,
|
||||||
productNumber: device.productNumber,
|
serialNumber: d.serialNumber,
|
||||||
comment: device.comment,
|
productNumber: d.productNumber,
|
||||||
ipv4Address: device.ipv4Address,
|
comment: d.comment,
|
||||||
ipv6Address: device.ipv6Address,
|
ipv4Address: d.ipv4Address,
|
||||||
macAddress: device.macAddress,
|
ipv6Address: d.ipv6Address,
|
||||||
username: device.username,
|
macAddress: d.macAddress,
|
||||||
group: device.group?.name ?? null,
|
username: d.username,
|
||||||
location: device.location?.name ?? null,
|
passwordHash: d.passwordHash,
|
||||||
updatedAt: device.updatedAt.toISOString(),
|
group: d.group?.name ?? null,
|
||||||
}));
|
location: d.location?.name ?? null,
|
||||||
|
tags: d.tags.map((t) => t.name),
|
||||||
|
updatedAt: d.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json(rows);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[GET /api/devices]', err);
|
console.error('[GET /api/devices]', err);
|
||||||
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
|
||||||
|
|||||||
@ -22,7 +22,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
161
components/ui/Badge.tsx
Normal file
161
components/ui/Badge.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
// components/ui/Badge.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
type BadgeTone =
|
||||||
|
| 'gray'
|
||||||
|
| 'red'
|
||||||
|
| 'yellow'
|
||||||
|
| 'green'
|
||||||
|
| 'blue'
|
||||||
|
| 'indigo'
|
||||||
|
| 'purple'
|
||||||
|
| 'pink';
|
||||||
|
|
||||||
|
type BadgeVariant = 'border' | 'flat';
|
||||||
|
type BadgeShape = 'rounded' | 'pill';
|
||||||
|
type BadgeSize = 'sm' | 'md';
|
||||||
|
|
||||||
|
export type BadgeProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
tone?: BadgeTone;
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
shape?: BadgeShape;
|
||||||
|
size?: BadgeSize;
|
||||||
|
/** Zeigt vorne einen farbigen Punkt an */
|
||||||
|
dot?: boolean;
|
||||||
|
/** Wenn gesetzt, wird rechts ein Remove-Button angezeigt */
|
||||||
|
onRemove?: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ───────── Style-Mappings ───────── */
|
||||||
|
|
||||||
|
const baseClasses =
|
||||||
|
'inline-flex items-center text-xs font-medium';
|
||||||
|
|
||||||
|
const sizeClasses: Record<BadgeSize, string> = {
|
||||||
|
md: 'px-2 py-1',
|
||||||
|
sm: 'px-1.5 py-0.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shapeClasses: Record<BadgeShape, string> = {
|
||||||
|
rounded: 'rounded-md',
|
||||||
|
pill: 'rounded-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
const borderToneClasses: Record<BadgeTone, string> = {
|
||||||
|
gray:
|
||||||
|
'bg-gray-50 text-gray-600 inset-ring inset-ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:inset-ring-gray-400/20',
|
||||||
|
red:
|
||||||
|
'bg-red-50 text-red-700 inset-ring inset-ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:inset-ring-red-400/20',
|
||||||
|
yellow:
|
||||||
|
'bg-yellow-50 text-yellow-800 inset-ring inset-ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:inset-ring-yellow-400/20',
|
||||||
|
green:
|
||||||
|
'bg-green-50 text-green-700 inset-ring inset-ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:inset-ring-green-500/20',
|
||||||
|
blue:
|
||||||
|
'bg-blue-50 text-blue-700 inset-ring inset-ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:inset-ring-blue-400/30',
|
||||||
|
indigo:
|
||||||
|
'bg-indigo-50 text-indigo-700 inset-ring inset-ring-indigo-700/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:inset-ring-indigo-400/30',
|
||||||
|
purple:
|
||||||
|
'bg-purple-50 text-purple-700 inset-ring inset-ring-purple-700/10 dark:bg-purple-400/10 dark:text-purple-400 dark:inset-ring-purple-400/30',
|
||||||
|
pink:
|
||||||
|
'bg-pink-50 text-pink-700 inset-ring inset-ring-pink-700/10 dark:bg-pink-400/10 dark:text-pink-400 dark:inset-ring-pink-400/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const flatToneClasses: Record<BadgeTone, string> = {
|
||||||
|
gray:
|
||||||
|
'bg-gray-100 text-gray-600 dark:bg-gray-400/10 dark:text-gray-400',
|
||||||
|
red:
|
||||||
|
'bg-red-100 text-red-700 dark:bg-red-400/10 dark:text-red-400',
|
||||||
|
yellow:
|
||||||
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-500',
|
||||||
|
green:
|
||||||
|
'bg-green-100 text-green-700 dark:bg-green-400/10 dark:text-green-400',
|
||||||
|
blue:
|
||||||
|
'bg-blue-100 text-blue-700 dark:bg-blue-400/10 dark:text-blue-400',
|
||||||
|
indigo:
|
||||||
|
'bg-indigo-100 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-400',
|
||||||
|
purple:
|
||||||
|
'bg-purple-100 text-purple-700 dark:bg-purple-400/10 dark:text-purple-400',
|
||||||
|
pink:
|
||||||
|
'bg-pink-100 text-pink-700 dark:bg-pink-400/10 dark:text-pink-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotToneClasses: Record<BadgeTone, string> = {
|
||||||
|
gray: 'fill-gray-400',
|
||||||
|
red: 'fill-red-500 dark:fill-red-400',
|
||||||
|
yellow: 'fill-yellow-500 dark:fill-yellow-400',
|
||||||
|
green: 'fill-green-500 dark:fill-green-400',
|
||||||
|
blue: 'fill-blue-500 dark:fill-blue-400',
|
||||||
|
indigo: 'fill-indigo-500 dark:fill-indigo-400',
|
||||||
|
purple: 'fill-purple-500 dark:fill-purple-400',
|
||||||
|
pink: 'fill-pink-500 dark:fill-pink-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ───────── Component ───────── */
|
||||||
|
|
||||||
|
export default function Badge({
|
||||||
|
children,
|
||||||
|
tone = 'gray',
|
||||||
|
variant = 'border',
|
||||||
|
shape = 'rounded',
|
||||||
|
size = 'md',
|
||||||
|
dot = false,
|
||||||
|
onRemove,
|
||||||
|
className,
|
||||||
|
}: BadgeProps) {
|
||||||
|
const toneClasses =
|
||||||
|
variant === 'border'
|
||||||
|
? borderToneClasses[tone]
|
||||||
|
: flatToneClasses[tone];
|
||||||
|
|
||||||
|
const hasRemove = typeof onRemove === 'function';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
baseClasses,
|
||||||
|
sizeClasses[size],
|
||||||
|
shapeClasses[shape],
|
||||||
|
toneClasses,
|
||||||
|
dot && 'gap-x-1.5',
|
||||||
|
hasRemove && 'gap-x-0.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Dot (optional) */}
|
||||||
|
{dot && (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 6 6"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx('size-1.5', dotToneClasses[tone])}
|
||||||
|
>
|
||||||
|
<circle r={3} cx={3} cy={3} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span>{children}</span>
|
||||||
|
|
||||||
|
{/* Remove-Button (optional) */}
|
||||||
|
{hasRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="group relative -mr-1 size-3.5 rounded-xs hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Entfernen</span>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
className="size-3.5 stroke-current/60 group-hover:stroke-current/90"
|
||||||
|
>
|
||||||
|
<path d="M4 4l6 6m0-6l-6 6" />
|
||||||
|
</svg>
|
||||||
|
<span className="absolute -inset-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,11 +2,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Fragment } from 'react';
|
|
||||||
import {
|
import {
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
UserCircleIcon,
|
|
||||||
} from '@heroicons/react/20/solid';
|
} from '@heroicons/react/20/solid';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
@ -15,6 +13,7 @@ import clsx from 'clsx';
|
|||||||
export type FeedPerson = {
|
export type FeedPerson = {
|
||||||
name: string;
|
name: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
imageUrl?: string; // optional: Avatar-Bild
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeedTag = {
|
export type FeedTag = {
|
||||||
@ -24,6 +23,14 @@ export type FeedTag = {
|
|||||||
color?: string;
|
color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FeedChange = {
|
||||||
|
field: string;
|
||||||
|
/** Anzeigename, z.B. "Standort" statt "location" */
|
||||||
|
label?: string;
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type FeedItem =
|
export type FeedItem =
|
||||||
| {
|
| {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@ -46,6 +53,13 @@ export type FeedItem =
|
|||||||
person: FeedPerson;
|
person: FeedPerson;
|
||||||
tags: FeedTag[];
|
tags: FeedTag[];
|
||||||
date: string;
|
date: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string | number;
|
||||||
|
type: 'change';
|
||||||
|
person: FeedPerson;
|
||||||
|
changes: FeedChange[];
|
||||||
|
date: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FeedProps {
|
export interface FeedProps {
|
||||||
@ -59,166 +73,236 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
|||||||
return classes.filter(Boolean).join(' ');
|
return classes.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deterministische Farbe aus dem Namen
|
||||||
|
function colorFromName(name: string): string {
|
||||||
|
const palette = [
|
||||||
|
'bg-sky-500',
|
||||||
|
'bg-emerald-500',
|
||||||
|
'bg-violet-500',
|
||||||
|
'bg-amber-500',
|
||||||
|
'bg-rose-500',
|
||||||
|
'bg-indigo-500',
|
||||||
|
'bg-teal-500',
|
||||||
|
'bg-fuchsia-500',
|
||||||
|
];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
const idx = Math.abs(hash) % palette.length;
|
||||||
|
return palette[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// sprechende Zusammenfassung für "change"
|
||||||
|
function getChangeSummary(item: Extract<FeedItem, { type: 'change' }>): string {
|
||||||
|
const { changes } = item;
|
||||||
|
if (!changes.length) return 'hat Änderungen vorgenommen';
|
||||||
|
|
||||||
|
if (changes.length === 1 && changes[0].field === 'tags') {
|
||||||
|
const c = changes[0];
|
||||||
|
const beforeList = (c.from ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const afterList = (c.to ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const beforeLower = beforeList.map((x) => x.toLowerCase());
|
||||||
|
const afterLower = afterList.map((x) => x.toLowerCase());
|
||||||
|
|
||||||
|
const added = afterList.filter(
|
||||||
|
(t) => !beforeLower.includes(t.toLowerCase()),
|
||||||
|
);
|
||||||
|
const removed = beforeList.filter(
|
||||||
|
(t) => !afterLower.includes(t.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
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 angepasst';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length === 1) {
|
||||||
|
const c = changes[0];
|
||||||
|
const label = c.label ?? c.field;
|
||||||
|
return `hat ${label} geändert`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = changes.map((c) => c.label ?? c.field);
|
||||||
|
const uniqueLabels = Array.from(new Set(labels));
|
||||||
|
const maxShow = 3;
|
||||||
|
|
||||||
|
if (uniqueLabels.length <= maxShow) {
|
||||||
|
return `hat ${uniqueLabels.join(', ')} geändert`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = uniqueLabels.slice(0, maxShow).join(', ');
|
||||||
|
return `hat ${first} und weitere geändert`;
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────── Component ───────── */
|
/* ───────── Component ───────── */
|
||||||
|
|
||||||
export default function Feed({ items, className }: FeedProps) {
|
export default function Feed({ items, className }: FeedProps) {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return (
|
return (
|
||||||
<p className={clsx('text-sm text-gray-500 dark:text-gray-400', className)}>
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'text-sm text-gray-500 dark:text-gray-400',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
Keine Aktivitäten vorhanden.
|
Keine Aktivitäten vorhanden.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flow-root', className)}>
|
<div className={clsx('h-full overflow-y-auto pr-2', className)}>
|
||||||
<ul role="list" className="-mb-8">
|
<ul role="list" className="pb-4">
|
||||||
{items.map((activityItem, idx) => (
|
{items.map((item, idx) => {
|
||||||
<li key={activityItem.id}>
|
// Icon + Hintergrund ähnlich wie im Beispiel
|
||||||
<div className="relative pb-8">
|
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
|
||||||
{idx !== items.length - 1 ? (
|
ChatBubbleLeftEllipsisIcon;
|
||||||
<span
|
let iconBg = 'bg-gray-400 dark:bg-gray-600';
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute left-5 top-5 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="relative flex items-start space-x-3">
|
if (item.type === 'tags') {
|
||||||
{activityItem.type === 'comment' ? (
|
Icon = TagIcon;
|
||||||
<>
|
iconBg = 'bg-amber-500';
|
||||||
<div className="relative">
|
} else if (item.type === 'change') {
|
||||||
{activityItem.imageUrl ? (
|
const isTagsOnly =
|
||||||
<img
|
item.changes.length === 1 && item.changes[0].field === 'tags';
|
||||||
alt=""
|
Icon = isTagsOnly ? TagIcon : ChatBubbleLeftEllipsisIcon;
|
||||||
src={activityItem.imageUrl}
|
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-emerald-500';
|
||||||
className="flex size-10 items-center justify-center rounded-full bg-gray-400 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:ring-gray-900 dark:outline-white/10"
|
} else if (item.type === 'comment') {
|
||||||
/>
|
iconBg = colorFromName(item.person.name);
|
||||||
) : (
|
} else if (item.type === 'assignment') {
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-gray-200 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
|
iconBg = 'bg-indigo-500';
|
||||||
<ChatBubbleLeftEllipsisIcon
|
}
|
||||||
aria-hidden="true"
|
|
||||||
className="size-5 text-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="absolute -right-1 -bottom-0.5 rounded-tl bg-white px-0.5 py-px dark:bg-gray-900">
|
// Textinhalt ähnlich wie "content + target"
|
||||||
<ChatBubbleLeftEllipsisIcon
|
let content: React.ReactNode = null;
|
||||||
aria-hidden="true"
|
|
||||||
className="size-5 text-gray-400"
|
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>{' '}
|
||||||
|
hat kommentiert:{' '}
|
||||||
|
<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>{' '}
|
||||||
|
{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>
|
</span>
|
||||||
</div>
|
))}
|
||||||
|
{item.changes.length > 2 && (
|
||||||
<div className="min-w-0 flex-1">
|
<span className="ml-1 text-gray-500 dark:text-gray-600">· …</span>
|
||||||
<div>
|
)}
|
||||||
<div className="text-sm">
|
</p>
|
||||||
<a
|
)}
|
||||||
href={activityItem.person.href ?? '#'}
|
|
||||||
className="font-medium text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{activityItem.person.name}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Kommentiert {activityItem.date}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
<p>{activityItem.comment}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : activityItem.type === 'assignment' ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<div className="relative px-1">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
|
|
||||||
<UserCircleIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-5 text-gray-500 dark:text-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 py-1.5">
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<a
|
|
||||||
href={activityItem.person.href ?? '#'}
|
|
||||||
className="font-medium text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{activityItem.person.name}
|
|
||||||
</a>{' '}
|
|
||||||
hat{' '}
|
|
||||||
<a
|
|
||||||
href={activityItem.assigned.href ?? '#'}
|
|
||||||
className="font-medium text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{activityItem.assigned.name}
|
|
||||||
</a>{' '}
|
|
||||||
zugewiesen{' '}
|
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
{activityItem.date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : activityItem.type === 'tags' ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<div className="relative px-1">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
|
|
||||||
<TagIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-5 text-gray-500 dark:text-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 py-0">
|
|
||||||
<div className="text-sm/8 text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="mr-0.5">
|
|
||||||
<a
|
|
||||||
href={activityItem.person.href ?? '#'}
|
|
||||||
className="font-medium text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{activityItem.person.name}
|
|
||||||
</a>{' '}
|
|
||||||
hat Tags hinzugefügt
|
|
||||||
</span>{' '}
|
|
||||||
<span className="mr-0.5">
|
|
||||||
{activityItem.tags.map((tag) => (
|
|
||||||
<Fragment key={tag.name}>
|
|
||||||
<a
|
|
||||||
href={tag.href ?? '#'}
|
|
||||||
className="inline-flex items-center gap-x-1.5 rounded-full px-2 py-1 text-xs font-medium text-gray-900 inset-ring inset-ring-gray-200 dark:bg-white/5 dark:text-gray-100 dark:inset-ring-white/10"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 6 6"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={classNames(
|
|
||||||
tag.color ?? 'fill-gray-400',
|
|
||||||
'size-1.5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<circle r={3} cx={3} cy={3} />
|
|
||||||
</svg>
|
|
||||||
{tag.name}
|
|
||||||
</a>{' '}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
{activityItem.date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</li>
|
}
|
||||||
))}
|
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<div className="relative pb-6">
|
||||||
|
{idx !== items.length - 1 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative flex space-x-3">
|
||||||
|
{/* Icon-Kreis wie im Beispiel */}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
iconBg,
|
||||||
|
'flex size-8 items-center justify-center rounded-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" className="size-4 text-white" />
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import clsx from 'clsx';
|
|||||||
|
|
||||||
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
||||||
export type ModalVariant = 'centered' | 'alert';
|
export type ModalVariant = 'centered' | 'alert';
|
||||||
export type ModalSize = 'sm' | 'md' | 'lg';
|
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
export interface ModalAction {
|
export interface ModalAction {
|
||||||
label: string;
|
label: string;
|
||||||
@ -95,6 +95,7 @@ const sizeClasses: Record<ModalSize, string> = {
|
|||||||
sm: 'sm:max-w-sm',
|
sm: 'sm:max-w-sm',
|
||||||
md: 'sm:max-w-lg',
|
md: 'sm:max-w-lg',
|
||||||
lg: 'sm:max-w-3xl', // ein bisschen breiter für Sidebar
|
lg: 'sm:max-w-3xl', // ein bisschen breiter für Sidebar
|
||||||
|
xl: 'sm:max-w-5xl', // ein bisschen breiter für Sidebar
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseButtonClasses =
|
const baseButtonClasses =
|
||||||
@ -194,7 +195,7 @@ export function Modal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header + Body + Sidebar */}
|
{/* Header + Body + Sidebar */}
|
||||||
<div className="flex-1 overflow-y-auto bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800">
|
<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 (Icon + Titel + optionale Beschreibung) */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -253,12 +254,13 @@ export function Modal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body + Sidebar */}
|
{/* Body + Sidebar */ }
|
||||||
{(bodyContent || sidebar) && (
|
{(bodyContent || sidebar) && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'mt-6',
|
'mt-6',
|
||||||
sidebar && 'sm:mt-8 sm:flex sm:items-start sm:gap-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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{bodyContent && (
|
{bodyContent && (
|
||||||
@ -273,7 +275,7 @@ export function Modal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sidebar && (
|
{sidebar && (
|
||||||
<aside className="mt-6 border-t border-gray-200 pt-6 text-left text-sm sm:mt-0 sm:w-80 sm:shrink-0 sm:border-l sm:border-t-0 sm:pl-6 dark:border-white/10">
|
<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" >
|
||||||
{sidebar}
|
{sidebar}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|||||||
233
components/ui/TagMultiCombobox.tsx
Normal file
233
components/ui/TagMultiCombobox.tsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
// components/ui/TagMultiCombobox.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxButton,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxOption,
|
||||||
|
ComboboxOptions,
|
||||||
|
Label,
|
||||||
|
} from '@headlessui/react';
|
||||||
|
import { ChevronDownIcon, CheckIcon } from '@heroicons/react/20/solid';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import Badge from '@/components/ui/Badge';
|
||||||
|
|
||||||
|
export type TagOption = {
|
||||||
|
id?: string | null;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TagMultiComboboxProps = {
|
||||||
|
label?: string;
|
||||||
|
availableTags: TagOption[];
|
||||||
|
value: TagOption[]; // ausgewählte Tags
|
||||||
|
onChange: (next: TagOption[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TagMultiCombobox({
|
||||||
|
label = 'Tags',
|
||||||
|
availableTags,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Tags auswählen oder neu eingeben …',
|
||||||
|
className,
|
||||||
|
}: TagMultiComboboxProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [allTags, setAllTags] = useState<TagOption[]>(availableTags);
|
||||||
|
|
||||||
|
const selectedNames = useMemo(
|
||||||
|
() => new Set(value.map((t) => t.name.toLowerCase())),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAllTags((prev) => {
|
||||||
|
const byName = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
|
||||||
|
for (const t of availableTags) {
|
||||||
|
const key = t.name.toLowerCase();
|
||||||
|
if (!byName.has(key)) {
|
||||||
|
byName.set(key, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byName.values());
|
||||||
|
});
|
||||||
|
}, [availableTags]);
|
||||||
|
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
if (!query.trim()) return allTags;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return allTags.filter((tag) => tag.name.toLowerCase().includes(q));
|
||||||
|
}, [allTags, query]);
|
||||||
|
|
||||||
|
function handleRemoveTag(tag: TagOption) {
|
||||||
|
onChange(
|
||||||
|
value.filter((t) => t.name.toLowerCase() !== tag.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleTag(tag: TagOption) {
|
||||||
|
const key = tag.name.toLowerCase();
|
||||||
|
if (selectedNames.has(key)) {
|
||||||
|
onChange(value.filter((t) => t.name.toLowerCase() !== key));
|
||||||
|
} else {
|
||||||
|
onChange([...value, tag]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateTagFromQuery() {
|
||||||
|
const name = query.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const existing = allTags.find(
|
||||||
|
(t) => t.name.toLowerCase() === name.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (!selectedNames.has(existing.name.toLowerCase())) {
|
||||||
|
onChange([...value, existing]);
|
||||||
|
}
|
||||||
|
setQuery('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTag: TagOption = {
|
||||||
|
id: `__new-${Date.now()}`,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
|
||||||
|
setAllTags((prev) => [...prev, newTag]);
|
||||||
|
onChange([...value, newTag]);
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
as="div"
|
||||||
|
multiple
|
||||||
|
value={value}
|
||||||
|
// wir steuern das selbst über handleToggleTag / handleRemoveTag
|
||||||
|
onChange={() => {}}
|
||||||
|
className={clsx('w-full', className)}
|
||||||
|
>
|
||||||
|
<Label className="block text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="mt-2 relative">
|
||||||
|
{/* Chips + Input in einem „Feld“ */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
// neu: relative + extra padding rechts für den Pfeil
|
||||||
|
'relative flex flex-wrap items-center gap-1 rounded-md bg-white px-2 py-1.5 text-sm text-gray-900 ' +
|
||||||
|
'outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 ' +
|
||||||
|
'focus-within:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-gray-700 ' +
|
||||||
|
'dark:focus-within:outline-indigo-500 pr-8', // <– Platz für Chevron
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Ausgewählte Tags als Badges */}
|
||||||
|
{value.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id ?? tag.name}
|
||||||
|
tone="blue"
|
||||||
|
variant="flat"
|
||||||
|
shape="pill"
|
||||||
|
size="md"
|
||||||
|
onRemove={() => handleRemoveTag(tag)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Eingabefeld */}
|
||||||
|
<ComboboxInput
|
||||||
|
className="flex-1 min-w-[6rem] bg-transparent border-0 px-1 py-0.5 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
displayValue={() => ''}
|
||||||
|
placeholder={value.length === 0 ? placeholder : undefined}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleCreateTagFromQuery();
|
||||||
|
} else if (
|
||||||
|
event.key === 'Backspace' &&
|
||||||
|
!query &&
|
||||||
|
value.length > 0
|
||||||
|
) {
|
||||||
|
const last = value[value.length - 1];
|
||||||
|
handleRemoveTag(last);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Chevron immer rechts im Feld */}
|
||||||
|
<ComboboxButton className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none">
|
||||||
|
<ChevronDownIcon className="size-5" aria-hidden="true" />
|
||||||
|
</ComboboxButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
<ComboboxOptions
|
||||||
|
transition
|
||||||
|
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||||
|
>
|
||||||
|
{query.trim().length > 0 &&
|
||||||
|
!allTags.some(
|
||||||
|
(t) => t.name.toLowerCase() === query.trim().toLowerCase(),
|
||||||
|
) && (
|
||||||
|
<ComboboxOption
|
||||||
|
value={{ id: `__new-${query}`, name: query.trim() }}
|
||||||
|
className="cursor-default select-none px-3 py-2 text-gray-900 data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-gray-200 dark:data-focus:bg-indigo-500"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateTagFromQuery();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
„{query.trim()}“ erstellen
|
||||||
|
</span>
|
||||||
|
</ComboboxOption>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredTags.length === 0 && query.trim().length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-xs">
|
||||||
|
Keine Tags vorhanden.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredTags.map((tag) => {
|
||||||
|
const isSelected = selectedNames.has(tag.name.toLowerCase());
|
||||||
|
return (
|
||||||
|
<ComboboxOption
|
||||||
|
key={tag.id ?? tag.name}
|
||||||
|
value={tag}
|
||||||
|
className={({ active }) =>
|
||||||
|
clsx(
|
||||||
|
'flex cursor-default select-none items-center justify-between px-3 py-2 text-gray-900 dark:text-gray-100',
|
||||||
|
active && 'bg-indigo-600 text-white dark:bg-indigo-500',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleTag(tag);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckIcon
|
||||||
|
className="size-4 text-indigo-600 dark:text-indigo-300 data-focus:text-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComboboxOption>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ComboboxOptions>
|
||||||
|
</div>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
lib/auth-options.ts
Normal file
72
lib/auth-options.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
// lib/auth-options.ts
|
||||||
|
import type { NextAuthOptions } from 'next-auth';
|
||||||
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
import { compare } from 'bcryptjs';
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: 'Credentials',
|
||||||
|
credentials: {
|
||||||
|
email: {
|
||||||
|
label: 'Benutzername oder E-Mail',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: 'Passwort',
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials.password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = credentials.email.trim();
|
||||||
|
|
||||||
|
// User per E-Mail ODER Benutzername suchen
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ email: identifier }, { username: identifier }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await compare(credentials.password, user.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name ?? user.username ?? user.email,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pages: {
|
||||||
|
signIn: '/login',
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
strategy: 'jwt',
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user && token.id) {
|
||||||
|
(session.user as any).id = token.id;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
71
lib/auth.ts
71
lib/auth.ts
@ -1,69 +1,44 @@
|
|||||||
// lib/auth.ts
|
// lib/auth.ts
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { headers } from 'next/headers';
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from './auth-options';
|
||||||
import { prisma } from './prisma';
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
/**
|
type SessionUser = {
|
||||||
* Liefert die aktuelle User-ID oder null,
|
id?: string;
|
||||||
* falls kein User ermittelt werden kann.
|
email?: string | null;
|
||||||
*
|
name?: string | null;
|
||||||
* Reihenfolge:
|
};
|
||||||
* 1. HTTP-Header: x-user-id
|
|
||||||
* 2. HTTP-Header: x-user-email
|
|
||||||
* 3. Fallback: DEFAULT_USER_EMAIL env oder "user@domain.local"
|
|
||||||
*/
|
|
||||||
export async function getCurrentUserId(): Promise<string | null> {
|
export async function getCurrentUserId(): Promise<string | null> {
|
||||||
const h = await headers();
|
const session = await getServerSession(authOptions);
|
||||||
|
const user = session?.user as SessionUser | undefined;
|
||||||
|
|
||||||
const headerUserId = h.get('x-user-id');
|
// 1) ID direkt aus dem Token
|
||||||
const headerUserEmail = h.get('x-user-email');
|
if (user?.id) {
|
||||||
|
return user.id;
|
||||||
// 1) Direkt über ID (Header)
|
|
||||||
if (headerUserId) {
|
|
||||||
const userById = await prisma.user.findUnique({
|
|
||||||
where: { id: headerUserId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userById) {
|
|
||||||
return userById.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Über Email (Header)
|
// 2) Fallback über E-Mail aus der Session
|
||||||
if (headerUserEmail) {
|
if (user?.email) {
|
||||||
const userByEmail = await prisma.user.findUnique({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: { email: headerUserEmail },
|
where: { email: user.email },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (dbUser) return dbUser.id;
|
||||||
if (userByEmail) {
|
|
||||||
return userByEmail.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Fallback: Standard-User (z.B. dein Test-User)
|
// 3) keine Session -> kein User
|
||||||
const fallbackEmail =
|
return null;
|
||||||
process.env.DEFAULT_USER_EMAIL ?? 'user@domain.local';
|
|
||||||
|
|
||||||
const fallbackUser = await prisma.user.findUnique({
|
|
||||||
where: { email: fallbackEmail },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return fallbackUser?.id ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional: kompletten User holen (falls du später mehr brauchst)
|
|
||||||
*/
|
|
||||||
export async function getCurrentUser() {
|
export async function getCurrentUser() {
|
||||||
const userId = await getCurrentUserId();
|
const id = await getCurrentUserId();
|
||||||
if (!userId) return null;
|
if (!id) return null;
|
||||||
|
|
||||||
return prisma.user.findUnique({
|
return prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
roles: {
|
roles: {
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
16
lib/socketClient.ts
Normal file
16
lib/socketClient.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// lib/socketClient.ts
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
export function getSocket(): Socket {
|
||||||
|
if (!socket) {
|
||||||
|
// Standard: gleiche Origin, /socket.io
|
||||||
|
socket = io({
|
||||||
|
path: '/api/socketio', // entspricht unserem Server-Endpunkt unten
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
7
lib/socketServer.ts
Normal file
7
lib/socketServer.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// lib/socketServer.ts
|
||||||
|
import type { Server as IOServer } from 'socket.io';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function getDevicesIo(): IOServer | null {
|
||||||
|
return (global as any).devicesIo ?? null;
|
||||||
|
}
|
||||||
322
package-lock.json
generated
322
package-lock.json
generated
@ -17,6 +17,8 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -1544,6 +1546,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
@ -1893,6 +1901,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cors": {
|
||||||
|
"version": "2.8.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
|
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -2516,6 +2533,19 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@ -2827,6 +2857,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64id": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^4.5.0 || >= 5.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.28",
|
"version": "2.8.28",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz",
|
||||||
@ -3132,6 +3171,19 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
|
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/create-require": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
@ -3401,6 +3453,82 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cors": "^2.8.12",
|
||||||
|
"@types/node": ">=10.0.0",
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "2.0.0",
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.3.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.17.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
||||||
|
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.3.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.17.1",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-client/node_modules/debug": {
|
||||||
|
"version": "4.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/debug": {
|
||||||
|
"version": "4.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.3",
|
"version": "5.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||||
@ -5544,6 +5672,27 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@ -5571,7 +5720,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -5615,6 +5763,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.0.3",
|
"version": "16.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
||||||
@ -5771,7 +5928,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@ -6717,6 +6873,130 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io": {
|
||||||
|
"version": "4.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||||
|
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "~2.0.0",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.3.2",
|
||||||
|
"engine.io": "~6.6.0",
|
||||||
|
"socket.io-adapter": "~2.5.2",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-adapter": {
|
||||||
|
"version": "2.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
|
||||||
|
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "~4.3.4",
|
||||||
|
"ws": "~8.17.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-adapter/node_modules/debug": {
|
||||||
|
"version": "4.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||||
|
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.3.2",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-client/node_modules/debug": {
|
||||||
|
"version": "4.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser/node_modules/debug": {
|
||||||
|
"version": "4.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/debug": {
|
||||||
|
"version": "4.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -7373,6 +7653,15 @@
|
|||||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@ -7488,6 +7777,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -19,6 +19,8 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
46
pages/api/socketio.ts
Normal file
46
pages/api/socketio.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// server/socketio.ts
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Server as IOServer } from 'socket.io';
|
||||||
|
import type { Server as HTTPServer } from 'http';
|
||||||
|
|
||||||
|
// TS-Hilfstypen
|
||||||
|
type NextApiResponseWithSocket = NextApiResponse & {
|
||||||
|
socket: {
|
||||||
|
server: HTTPServer & {
|
||||||
|
io?: IOServer;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function handler(
|
||||||
|
_req: NextApiRequest,
|
||||||
|
res: NextApiResponseWithSocket,
|
||||||
|
) {
|
||||||
|
if (!res.socket.server.io) {
|
||||||
|
const io = new IOServer(res.socket.server, {
|
||||||
|
path: '/api/socketio',
|
||||||
|
cors: {
|
||||||
|
origin: '*', // für lokal egal, in Produktion einschränken
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// global speichern, damit wir aus anderen Routen darauf zugreifen können
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(global as any).devicesIo = io;
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log('[Socket.IO] client connected', socket.id);
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('[Socket.IO] client disconnected', socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.socket.server.io = io;
|
||||||
|
console.log('[Socket.IO] server initialized');
|
||||||
|
} else {
|
||||||
|
console.log('[Socket.IO] server already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
@ -4,10 +4,13 @@ import { hash } from 'bcryptjs';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Location.name ist @unique → upsert per name
|
||||||
async function ensureLocation(name: string) {
|
async function ensureLocation(name: string) {
|
||||||
const existing = await prisma.location.findFirst({ where: { name } });
|
return prisma.location.upsert({
|
||||||
if (existing) return existing;
|
where: { name },
|
||||||
return prisma.location.create({ data: { name } });
|
update: {},
|
||||||
|
create: { name },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@ -27,7 +30,7 @@ async function main() {
|
|||||||
create: {
|
create: {
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
name: 'Test User',
|
name: 'Admin',
|
||||||
passwordHash,
|
passwordHash,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -79,14 +82,17 @@ async function main() {
|
|||||||
create: { name: 'Monitore' },
|
create: { name: 'Monitore' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Standorte anlegen (Location.name ist NICHT unique, daher findFirst + create)
|
// Standorte anlegen (Location.name ist @unique → ensureLocation nutzt upsert)
|
||||||
const raum112 = await ensureLocation('Raum 1.12');
|
const raum112 = await ensureLocation('Raum 1.12');
|
||||||
const lagerKeller = await ensureLocation('Lager Keller');
|
const lagerKeller = await ensureLocation('Lager Keller');
|
||||||
|
|
||||||
// Geräte anlegen / aktualisieren (inventoryNumber ist @id)
|
// Geräte anlegen / aktualisieren (inventoryNumber ist @id)
|
||||||
const device1 = await prisma.device.upsert({
|
const device1 = await prisma.device.upsert({
|
||||||
where: { inventoryNumber: '1' },
|
where: { inventoryNumber: '1' },
|
||||||
update: {},
|
update: {
|
||||||
|
// falls du bei erneutem Aufruf auch z.B. Hersteller / Model aktualisieren willst,
|
||||||
|
// kannst du die Felder hier noch einmal setzen
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
inventoryNumber: '1',
|
inventoryNumber: '1',
|
||||||
name: 'Dienstrechner Sachbearbeitung 1',
|
name: 'Dienstrechner Sachbearbeitung 1',
|
||||||
@ -103,6 +109,24 @@ async function main() {
|
|||||||
locationId: raum112.id,
|
locationId: raum112.id,
|
||||||
createdById: user.id,
|
createdById: user.id,
|
||||||
updatedById: user.id,
|
updatedById: user.id,
|
||||||
|
|
||||||
|
// 🔹 Beispiel-Tags für Gerät 1
|
||||||
|
tags: {
|
||||||
|
connectOrCreate: [
|
||||||
|
{
|
||||||
|
where: { name: 'Dienstrechner' },
|
||||||
|
create: { name: 'Dienstrechner' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: { name: 'Büro' },
|
||||||
|
create: { name: 'Büro' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: { name: 'Windows' },
|
||||||
|
create: { name: 'Windows' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -125,6 +149,20 @@ async function main() {
|
|||||||
locationId: lagerKeller.id,
|
locationId: lagerKeller.id,
|
||||||
createdById: user.id,
|
createdById: user.id,
|
||||||
updatedById: user.id,
|
updatedById: user.id,
|
||||||
|
|
||||||
|
// 🔹 Beispiel-Tags für Gerät 2
|
||||||
|
tags: {
|
||||||
|
connectOrCreate: [
|
||||||
|
{
|
||||||
|
where: { name: 'Monitor' },
|
||||||
|
create: { name: 'Monitor' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: { name: 'Lager' },
|
||||||
|
create: { name: 'Lager' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
22
prisma/migrations/20251117092638/migration.sql
Normal file
22
prisma/migrations/20251117092638/migration.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tag" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_DeviceToTag" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "_DeviceToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Device" ("inventoryNumber") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "_DeviceToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "_DeviceToTag_AB_unique" ON "_DeviceToTag"("A", "B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_DeviceToTag_B_index" ON "_DeviceToTag"("B");
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DeviceHistory_changedById_idx" ON "DeviceHistory"("changedById");
|
||||||
@ -18,18 +18,18 @@ datasource db {
|
|||||||
────────────────────────────────────────── */
|
────────────────────────────────────────── */
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
username String @unique
|
username String @unique
|
||||||
name String?
|
name String?
|
||||||
passwordHash String
|
passwordHash String
|
||||||
|
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
|
|
||||||
// Audit-Relations
|
// Audit-Relations
|
||||||
devicesCreated Device[] @relation("DeviceCreatedBy")
|
devicesCreated Device[] @relation("DeviceCreatedBy")
|
||||||
devicesUpdated Device[] @relation("DeviceUpdatedBy")
|
devicesUpdated Device[] @relation("DeviceUpdatedBy")
|
||||||
historyEntries DeviceHistory[] @relation("DeviceHistoryChangedBy")
|
historyEntries DeviceHistory[] @relation("DeviceHistoryChangedBy")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -98,6 +98,8 @@ model Device {
|
|||||||
location Location? @relation(fields: [locationId], references: [id])
|
location Location? @relation(fields: [locationId], references: [id])
|
||||||
locationId String?
|
locationId String?
|
||||||
|
|
||||||
|
tags Tag[]
|
||||||
|
|
||||||
// Audit-Felder
|
// Audit-Felder
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -116,6 +118,12 @@ model Device {
|
|||||||
@@index([locationId])
|
@@index([locationId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
devices Device[] // implizite Join-Tabelle
|
||||||
|
}
|
||||||
|
|
||||||
/* ──────────────────────────────────────────
|
/* ──────────────────────────────────────────
|
||||||
History / Änderungsverlauf
|
History / Änderungsverlauf
|
||||||
────────────────────────────────────────── */
|
────────────────────────────────────────── */
|
||||||
@ -137,8 +145,14 @@ model DeviceHistory {
|
|||||||
snapshot Json
|
snapshot Json
|
||||||
changedAt DateTime @default(now())
|
changedAt DateTime @default(now())
|
||||||
|
|
||||||
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
|
// 🔹 FK-Spalte
|
||||||
changedById String?
|
changedById String?
|
||||||
|
|
||||||
|
// 🔹 Relation zu User
|
||||||
|
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
|
||||||
|
|
||||||
@@index([deviceId, changedAt])
|
@@index([deviceId, changedAt])
|
||||||
|
@@index([changedById])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user