This commit is contained in:
Linrador 2025-11-17 15:26:43 +01:00
parent d1f13fd77e
commit 90231bff83
24 changed files with 2045 additions and 757 deletions

View 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>
);
}

View File

@ -1,14 +1,28 @@
// app/(app)/devices/DeviceHistorySidebar.tsx
'use client';
import { useEffect, useState } from 'react';
import Feed, { FeedItem } from '@/components/ui/Feed';
import { useEffect, useState, ElementType } from 'react';
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;
changeType: 'CREATED' | 'UPDATED' | 'DELETED';
changedAt: string;
changedBy?: string | null;
changedBy: string | null;
changes: {
field: string;
from: string | null;
to: string | null;
}[];
};
function formatDateTime(iso: string) {
@ -18,102 +32,163 @@ function formatDateTime(iso: string) {
}).format(new Date(iso));
}
function changeTypeLabel(type: DeviceHistoryEntry['changeType']) {
switch (type) {
case 'CREATED':
return 'Gerät angelegt';
case 'UPDATED':
return 'Gerät aktualisiert';
case 'DELETED':
return 'Gerät gelöscht';
function mapFieldLabel(field: string): string {
switch (field) {
case 'name':
return 'die Bezeichnung';
case 'manufacturer':
return 'den Hersteller';
case 'model':
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:
return type;
return field;
}
}
interface DeviceHistorySidebarProps {
inventoryNumber: string;
}
export default function DeviceHistorySidebar({
inventoryNumber,
}: DeviceHistorySidebarProps) {
const [entries, setEntries] = useState<DeviceHistoryEntry[]>([]);
asSidebar = false,
}: Props) {
const [items, setItems] = useState<FeedItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!inventoryNumber) return;
let cancelled = false;
const loadHistory = async () => {
async function loadHistory() {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
{ cache: 'no-store' },
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
},
);
if (!res.ok) {
setError('Historie konnte nicht geladen werden.');
return;
throw new Error(`HTTP ${res.status}`);
}
const data = (await res.json()) as DeviceHistoryEntry[];
setEntries(data);
} catch (err) {
console.error('Error loading device history', err);
setError('Netzwerkfehler beim Laden der Historie.');
} finally {
setLoading(false);
}
const data = (await res.json()) as ApiHistoryEntry[];
if (cancelled) return;
const mapped: FeedItem[] = data.map((entry) => {
const person = {
name: entry.changedBy ?? 'Unbekannter Benutzer',
};
const date = formatDateTime(entry.changedAt);
loadHistory();
}, [inventoryNumber]);
if (loading) {
return (
<p className="text-sm text-gray-500 dark:text-gray-400">
Historie wird geladen
</p>
);
}
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),
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) {
console.error('Error loading device history', err);
if (!cancelled) {
setError('Historie konnte nicht geladen werden.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadHistory();
return () => {
cancelled = true;
};
}, [inventoryNumber]);
// Root-Tag & Klassen abhängig vom Einsatz
const Root: ElementType = asSidebar ? 'div' : 'aside';
const rootClasses = asSidebar
? 'flex h-full flex-col text-sm'
: 'flex h-full flex-col border-l border-gray-200 px-4 py-4 text-sm dark:border-white/10';
return (
<div className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Historie
</h4>
<Feed items={feedItems} />
<Root className={rootClasses}>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Änderungsverlauf
</h2>
{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>
);
}

View File

@ -1,20 +1,21 @@
// app/(app)/devices/page.tsx
'use client';
import { useCallback, useEffect, useState, ChangeEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import Button from '@/components/ui/Button';
import Table, { TableColumn } from '@/components/ui/Table';
import { Dropdown } from '@/components/ui/Dropdown';
import Modal from '@/components/ui/Modal';
import {
BookOpenIcon,
PencilIcon,
TrashIcon,
} 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;
name: string;
@ -32,11 +33,12 @@ type DeviceRow = {
group?: string | null;
location?: string | null;
tags?: string[] | null;
updatedAt: string;
};
type DeviceDetail = DeviceRow & {
export type DeviceDetail = DeviceRow & {
createdAt?: string;
};
@ -106,6 +108,14 @@ const columns: TableColumn<DeviceRow>[] = [
canHide: true,
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',
header: 'Geändert am',
@ -121,12 +131,13 @@ export default function DevicesPage() {
const [listLoading, setListLoading] = useState(false);
const [listError, setListError] = useState<string | null>(null);
// Modal-State
const [editOpen, setEditOpen] = useState(false);
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editDevice, setEditDevice] = useState<DeviceDetail | null>(null);
const [saveLoading, setSaveLoading] = useState(false);
// welches Gerät ist gerade im Edit-Modal geöffnet?
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(
null,
);
// 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) ───────── */
@ -147,6 +158,18 @@ export default function DevicesPage() {
const data = (await res.json()) as DeviceRow[];
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) {
console.error('Error loading devices', err);
setListError('Netzwerkfehler beim Laden der Geräte.');
@ -160,128 +183,60 @@ export default function DevicesPage() {
loadDevices();
}, [loadDevices]);
// "Live"-Updates: alle 10 Sekunden neu laden
// ✅ Echte Live-Updates via Socket.IO
useEffect(() => {
const id = setInterval(() => {
loadDevices();
}, 10000);
return () => clearInterval(id);
}, [loadDevices]);
const socket = getSocket();
/* ───────── Edit-Modal ───────── */
const closeEditModal = useCallback(() => {
if (saveLoading) return; // während Speichern nicht schließen
setEditOpen(false);
setEditDevice(null);
setEditError(null);
}, [saveLoading]);
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',
},
const handleUpdated = (payload: DeviceRow) => {
setDevices((prev) => {
const exists = prev.some(
(d) => d.inventoryNumber === payload.inventoryNumber,
);
if (!res.ok) {
if (res.status === 404) {
setEditError('Gerät wurde nicht gefunden.');
} else {
setEditError(
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
if (!exists) {
// falls du Updates & Creates über das gleiche Event schickst
return [...prev, payload];
}
return prev.map((d) =>
d.inventoryNumber === payload.inventoryNumber ? payload : d,
);
}
return;
}
});
};
const data = (await res.json()) as DeviceDetail;
setEditDevice(data);
} catch (err) {
console.error('Error loading device', err);
setEditError('Netzwerkfehler beim Laden der Gerätedaten.');
} finally {
setEditLoading(false);
const handleCreated = (payload: DeviceRow) => {
setDevices((prev) => {
if (prev.some((d) => d.inventoryNumber === payload.inventoryNumber)) {
return prev;
}
}, []);
return [...prev, payload];
});
};
const handleFieldChange = (
field: keyof DeviceDetail,
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const value = e.target.value;
setEditDevice((prev) =>
prev ? ({ ...prev, [field]: value } as DeviceDetail) : prev,
const handleDeleted = (data: { inventoryNumber: string }) => {
setDevices((prev) =>
prev.filter((d) => d.inventoryNumber !== data.inventoryNumber),
);
};
const handleSave = useCallback(async () => {
if (!editDevice) return;
socket.on('device:updated', handleUpdated);
socket.on('device:created', handleCreated);
socket.on('device:deleted', handleDeleted);
setSaveLoading(true);
setEditError(null);
return () => {
socket.off('device:updated', handleUpdated);
socket.off('device:created', handleCreated);
socket.off('device:deleted', handleDeleted);
};
}, []);
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,
}),
},
);
/* ───────── Edit-Modal Trigger ───────── */
if (!res.ok) {
if (res.status === 404) {
setEditError('Gerät wurde nicht gefunden.');
} else {
setEditError('Speichern der Änderungen ist fehlgeschlagen.');
}
return;
}
const handleEdit = useCallback((inventoryNumber: string) => {
setEditInventoryNumber(inventoryNumber);
}, []);
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]);
const closeEditModal = useCallback(() => {
setEditInventoryNumber(null);
}, []);
/* ───────── Render ───────── */
@ -397,227 +352,22 @@ export default function DevicesPage() {
</div>
{/* Edit-/Details-Modal */}
<Modal
open={editOpen}
<DeviceEditModal
open={editInventoryNumber !== null}
inventoryNumber={editInventoryNumber}
onClose={closeEditModal}
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,
allTags={allTags}
setAllTags={setAllTags}
onSaved={(updated) => {
setDevices((prev) =>
prev.map((d) =>
d.inventoryNumber === updated.inventoryNumber
? { ...d, ...updated }
: d,
),
);
}}
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>
</>
);
}

View File

@ -1,82 +1,7 @@
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { PrismaClient } from '@prisma/client';
import { compare } from 'bcryptjs';
import { authOptions } from '@/lib/auth-options';
const prisma = new PrismaClient();
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;
},
},
});
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@ -2,37 +2,57 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
type RouteParams = { id: string };
// ⬇️ wie bei /api/devices/[id]
type RouteContext = { params: Promise<RouteParams> };
export async function GET(
_req: Request,
{ params }: { params: { id: string } },
ctx: RouteContext,
) {
// In der URL ist "id" = inventoryNumber
const inventoryNumber = decodeURIComponent(params.id);
// params-Promise auflösen
const { id } = await ctx.params;
const inventoryNumber = decodeURIComponent(id);
try {
const history = await prisma.deviceHistory.findMany({
where: { deviceId: inventoryNumber },
include: {
changedBy: true,
},
orderBy: {
changedAt: 'desc',
},
include: { changedBy: true },
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;
const rawChanges: any[] = Array.isArray(snapshot?.changes)
? snapshot.changes
: [];
const changes = rawChanges.map((c) => ({
field: String(c.field),
from:
c.before === null || c.before === undefined
? null
: String(c.before),
to:
c.after === null || c.after === undefined
? null
: String(c.after),
}));
return {
id: entry.id,
changeType: entry.changeType,
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);
} catch (err) {
console.error('[GET /api/devices/[id]/history]', err);

View File

@ -3,17 +3,18 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client';
import { getCurrentUserId } from '@/lib/auth';
import type { Server as IOServer } from 'socket.io';
type RouteParams = { id: string };
// ⬇️ Next liefert params als Promise
// in Next 15+ ist params ein Promise
type RouteContext = { params: Promise<RouteParams> };
export async function GET(
_req: Request,
ctx: RouteContext,
) {
// ⬇️ Promise auflösen
// params-Promise auflösen
const { id } = await ctx.params;
if (!id) {
@ -26,6 +27,7 @@ export async function GET(
include: {
group: true,
location: true,
tags: true, // 🔹 NEU
},
});
@ -45,23 +47,25 @@ export async function GET(
ipv6Address: device.ipv6Address,
macAddress: device.macAddress,
username: device.username,
// passwordHash gebe ich hier absichtlich nicht zurück
// passwordHash bewusst weggelassen
group: device.group?.name ?? null,
location: device.location?.name ?? null,
tags: device.tags.map((t) => t.name), // 🔹
createdAt: device.createdAt.toISOString(),
updatedAt: device.updatedAt.toISOString(),
});
} catch (err) {
console.error('[GET /api/devices/[id]]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}
export async function PATCH(
req: Request,
ctx: RouteContext,
) {
// ⬇️ Promise auflösen
const { id } = await ctx.params;
const body = await req.json();
@ -70,7 +74,7 @@ export async function PATCH(
}
try {
// User für updatedBy / History bestimmen
// ⬇️ hier jetzt die Request-Header durchreichen
const userId = await getCurrentUserId();
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
@ -79,6 +83,7 @@ export async function PATCH(
include: {
group: true,
location: true,
tags: true, // 🔹 NEU
},
});
@ -101,6 +106,41 @@ export async function PATCH(
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
if (userId) {
data.updatedBy = {
@ -132,6 +172,33 @@ export async function PATCH(
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)
const updated = await prisma.device.update({
where: { inventoryNumber: id },
@ -139,9 +206,11 @@ export async function PATCH(
include: {
group: true,
location: true,
tags: true,
},
});
// Felder, die wir tracken wollen
const trackedFields = [
'name',
'manufacturer',
@ -158,12 +227,14 @@ export async function PATCH(
type TrackedField = (typeof trackedFields)[number];
// explizit JSON-kompatible Types (string | null)
const changes: {
field: TrackedField | 'group' | 'location';
field: TrackedField | 'group' | 'location' | 'tags';
before: string | null;
after: string | null;
}[] = [];
// einfache Feld-Diffs
for (const field of trackedFields) {
const before = (existing 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 afterGroup = (updated.group?.name ?? null) as string | null;
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) {
const snapshot: Prisma.JsonObject = {
before: {
@ -209,6 +300,7 @@ export async function PATCH(
passwordHash: existing.passwordHash,
group: existing.group?.name ?? null,
location: existing.location?.name ?? null,
tags: existing.tags.map((t) => t.name),
createdAt: existing.createdAt.toISOString(),
updatedAt: existing.updatedAt.toISOString(),
},
@ -227,10 +319,15 @@ export async function PATCH(
passwordHash: updated.passwordHash,
group: updated.group?.name ?? null,
location: updated.location?.name ?? null,
tags: updated.tags.map((t) => t.name),
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
},
changes,
changes: changes.map((c) => ({
field: c.field,
before: c.before,
after: c.after,
})),
};
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({
inventoryNumber: updated.inventoryNumber,
name: updated.name,

View File

@ -8,30 +8,31 @@ export async function GET() {
include: {
group: true,
location: true,
},
orderBy: {
updatedAt: 'desc',
tags: true, // 🔹 NEU
},
});
const rows = devices.map((device) => ({
inventoryNumber: device.inventoryNumber,
name: device.name,
manufacturer: device.manufacturer,
model: device.model,
serialNumber: device.serialNumber,
productNumber: device.productNumber,
comment: device.comment,
ipv4Address: device.ipv4Address,
ipv6Address: device.ipv6Address,
macAddress: device.macAddress,
username: device.username,
group: device.group?.name ?? null,
location: device.location?.name ?? null,
updatedAt: device.updatedAt.toISOString(),
}));
return NextResponse.json(
devices.map((d) => ({
inventoryNumber: d.inventoryNumber,
name: d.name,
manufacturer: d.manufacturer,
model: d.model,
serialNumber: d.serialNumber,
productNumber: d.productNumber,
comment: d.comment,
ipv4Address: d.ipv4Address,
ipv6Address: d.ipv6Address,
macAddress: d.macAddress,
username: d.username,
passwordHash: d.passwordHash,
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) {
console.error('[GET /api/devices]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });

View File

@ -22,7 +22,5 @@
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

161
components/ui/Badge.tsx Normal file
View 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>
);
}

View File

@ -2,11 +2,9 @@
'use client';
import * as React from 'react';
import { Fragment } from 'react';
import {
ChatBubbleLeftEllipsisIcon,
TagIcon,
UserCircleIcon,
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
@ -15,6 +13,7 @@ import clsx from 'clsx';
export type FeedPerson = {
name: string;
href?: string;
imageUrl?: string; // optional: Avatar-Bild
};
export type FeedTag = {
@ -24,6 +23,14 @@ export type FeedTag = {
color?: string;
};
export type FeedChange = {
field: string;
/** Anzeigename, z.B. "Standort" statt "location" */
label?: string;
from: string | null;
to: string | null;
};
export type FeedItem =
| {
id: string | number;
@ -46,6 +53,13 @@ export type FeedItem =
person: FeedPerson;
tags: FeedTag[];
date: string;
}
| {
id: string | number;
type: 'change';
person: FeedPerson;
changes: FeedChange[];
date: string;
};
export interface FeedProps {
@ -59,166 +73,236 @@ function classNames(...classes: Array<string | false | null | undefined>) {
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 ───────── */
export default function Feed({ items, className }: FeedProps) {
if (!items.length) {
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.
</p>
);
}
return (
<div className={clsx('flow-root', className)}>
<ul role="list" className="-mb-8">
{items.map((activityItem, idx) => (
<li key={activityItem.id}>
<div className="relative pb-8">
<div className={clsx('h-full overflow-y-auto pr-2', className)}>
<ul role="list" className="pb-4">
{items.map((item, idx) => {
// Icon + Hintergrund ähnlich wie im Beispiel
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
ChatBubbleLeftEllipsisIcon;
let iconBg = 'bg-gray-400 dark:bg-gray-600';
if (item.type === 'tags') {
Icon = TagIcon;
iconBg = 'bg-amber-500';
} else if (item.type === 'change') {
const isTagsOnly =
item.changes.length === 1 && item.changes[0].field === 'tags';
Icon = isTagsOnly ? TagIcon : ChatBubbleLeftEllipsisIcon;
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-emerald-500';
} else if (item.type === 'comment') {
iconBg = colorFromName(item.person.name);
} else if (item.type === 'assignment') {
iconBg = 'bg-indigo-500';
}
// Textinhalt ähnlich wie "content + target"
let content: React.ReactNode = null;
if (item.type === 'comment') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
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>
))}
{item.changes.length > 2 && (
<span className="ml-1 text-gray-500 dark:text-gray-600">· </span>
)}
</p>
)}
</div>
);
}
return (
<li key={item.id}>
<div className="relative pb-6">
{idx !== items.length - 1 ? (
<span
aria-hidden="true"
className="absolute left-5 top-5 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
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 items-start space-x-3">
{activityItem.type === 'comment' ? (
<>
<div className="relative">
{activityItem.imageUrl ? (
<img
alt=""
src={activityItem.imageUrl}
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"
/>
) : (
<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">
<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">
<ChatBubbleLeftEllipsisIcon
aria-hidden="true"
className="size-5 text-gray-400"
/>
</span>
</div>
<div className="min-w-0 flex-1">
<div className="relative flex space-x-3">
{/* Icon-Kreis wie im Beispiel */}
<div>
<div className="text-sm">
<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"
<span
className={classNames(
tag.color ?? 'fill-gray-400',
'size-1.5',
iconBg,
'flex size-8 items-center justify-center rounded-full',
)}
>
<circle r={3} cx={3} cy={3} />
</svg>
{tag.name}
</a>{' '}
</Fragment>
))}
</span>
<span className="whitespace-nowrap">
{activityItem.date}
<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>
</>
) : null}
</div>
</div>
</li>
))}
);
})}
</ul>
</div>
);

View File

@ -13,7 +13,7 @@ import clsx from 'clsx';
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
export type ModalVariant = 'centered' | 'alert';
export type ModalSize = 'sm' | 'md' | 'lg';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
export interface ModalAction {
label: string;
@ -95,6 +95,7 @@ const sizeClasses: Record<ModalSize, string> = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-lg',
lg: 'sm:max-w-3xl', // ein bisschen breiter für Sidebar
xl: 'sm:max-w-5xl', // ein bisschen breiter für Sidebar
};
const baseButtonClasses =
@ -194,7 +195,7 @@ export function Modal({
)}
{/* 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) */}
<div
className={clsx(
@ -253,12 +254,13 @@ export function Modal({
</div>
</div>
{/* Body + Sidebar */}
{/* Body + Sidebar */ }
{(bodyContent || sidebar) && (
<div
className={clsx(
'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 && (
@ -273,7 +275,7 @@ export function Modal({
)}
{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}
</aside>
)}

View 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
View 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;
},
},
};

View File

@ -1,69 +1,44 @@
// lib/auth.ts
'use server';
import { headers } from 'next/headers';
import { getServerSession } from 'next-auth';
import { authOptions } from './auth-options';
import { prisma } from './prisma';
/**
* Liefert die aktuelle User-ID oder null,
* falls kein User ermittelt werden kann.
*
* Reihenfolge:
* 1. HTTP-Header: x-user-id
* 2. HTTP-Header: x-user-email
* 3. Fallback: DEFAULT_USER_EMAIL env oder "user@domain.local"
*/
type SessionUser = {
id?: string;
email?: string | null;
name?: 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');
const headerUserEmail = h.get('x-user-email');
// 1) ID direkt aus dem Token
if (user?.id) {
return user.id;
}
// 1) Direkt über ID (Header)
if (headerUserId) {
const userById = await prisma.user.findUnique({
where: { id: headerUserId },
// 2) Fallback über E-Mail aus der Session
if (user?.email) {
const dbUser = await prisma.user.findUnique({
where: { email: user.email },
select: { id: true },
});
if (userById) {
return userById.id;
}
if (dbUser) return dbUser.id;
}
// 2) Über Email (Header)
if (headerUserEmail) {
const userByEmail = await prisma.user.findUnique({
where: { email: headerUserEmail },
select: { id: true },
});
if (userByEmail) {
return userByEmail.id;
}
}
// 3) Fallback: Standard-User (z.B. dein Test-User)
const fallbackEmail =
process.env.DEFAULT_USER_EMAIL ?? 'user@domain.local';
const fallbackUser = await prisma.user.findUnique({
where: { email: fallbackEmail },
select: { id: true },
});
return fallbackUser?.id ?? null;
// 3) keine Session -> kein User
return null;
}
/**
* Optional: kompletten User holen (falls du später mehr brauchst)
*/
export async function getCurrentUser() {
const userId = await getCurrentUserId();
if (!userId) return null;
const id = await getCurrentUserId();
if (!id) return null;
return prisma.user.findUnique({
where: { id: userId },
where: { id },
include: {
roles: {
include: {

16
lib/socketClient.ts Normal file
View 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
View 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
View File

@ -17,6 +17,8 @@
"postcss": "^8.5.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"ts-node": "^10.9.2"
},
"devDependencies": {
@ -1544,6 +1546,12 @@
"dev": true,
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -1893,6 +1901,15 @@
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2516,6 +2533,19 @@
"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": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -2827,6 +2857,15 @@
"dev": true,
"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": {
"version": "2.8.28",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz",
@ -3132,6 +3171,19 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@ -3401,6 +3453,82 @@
"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": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -5544,6 +5672,27 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -5571,7 +5720,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -5615,6 +5763,15 @@
"dev": true,
"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": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
@ -5771,7 +5928,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -6717,6 +6873,130 @@
"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": {
"version": "1.2.1",
"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==",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -7488,6 +7777,35 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -19,6 +19,8 @@
"postcss": "^8.5.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"ts-node": "^10.9.2"
},
"devDependencies": {

46
pages/api/socketio.ts Normal file
View 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();
}

View File

@ -4,10 +4,13 @@ import { hash } from 'bcryptjs';
const prisma = new PrismaClient();
// Location.name ist @unique → upsert per name
async function ensureLocation(name: string) {
const existing = await prisma.location.findFirst({ where: { name } });
if (existing) return existing;
return prisma.location.create({ data: { name } });
return prisma.location.upsert({
where: { name },
update: {},
create: { name },
});
}
async function main() {
@ -27,7 +30,7 @@ async function main() {
create: {
email,
username,
name: 'Test User',
name: 'Admin',
passwordHash,
},
});
@ -79,14 +82,17 @@ async function main() {
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 lagerKeller = await ensureLocation('Lager Keller');
// Geräte anlegen / aktualisieren (inventoryNumber ist @id)
const device1 = await prisma.device.upsert({
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: {
inventoryNumber: '1',
name: 'Dienstrechner Sachbearbeitung 1',
@ -103,6 +109,24 @@ async function main() {
locationId: raum112.id,
createdById: 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,
createdById: 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' },
},
],
},
},
});

Binary file not shown.

View 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");

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "DeviceHistory_changedById_idx" ON "DeviceHistory"("changedById");

View File

@ -98,6 +98,8 @@ model Device {
location Location? @relation(fields: [locationId], references: [id])
locationId String?
tags Tag[]
// Audit-Felder
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -116,6 +118,12 @@ model Device {
@@index([locationId])
}
model Tag {
id String @id @default(cuid())
name String @unique
devices Device[] // implizite Join-Tabelle
}
/* ──────────────────────────────────────────
History / Änderungsverlauf
────────────────────────────────────────── */
@ -137,8 +145,14 @@ model DeviceHistory {
snapshot Json
changedAt DateTime @default(now())
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
// 🔹 FK-Spalte
changedById String?
// 🔹 Relation zu User
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
@@index([deviceId, changedAt])
@@index([changedById])
}