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
|
||||
'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);
|
||||
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);
|
||||
|
||||
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);
|
||||
setError('Netzwerkfehler beim Laden der Historie.');
|
||||
if (!cancelled) {
|
||||
setError('Historie konnte nicht geladen werden.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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),
|
||||
}));
|
||||
// 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} />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
setEditError('Gerät wurde nicht gefunden.');
|
||||
} else {
|
||||
setEditError(
|
||||
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
|
||||
);
|
||||
const handleUpdated = (payload: DeviceRow) => {
|
||||
setDevices((prev) => {
|
||||
const exists = prev.some(
|
||||
(d) => d.inventoryNumber === payload.inventoryNumber,
|
||||
);
|
||||
if (!exists) {
|
||||
// falls du Updates & Creates über das gleiche Event schickst
|
||||
return [...prev, payload];
|
||||
}
|
||||
return;
|
||||
}
|
||||
return prev.map((d) =>
|
||||
d.inventoryNumber === payload.inventoryNumber ? payload : d,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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 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 = (
|
||||
field: keyof DeviceDetail,
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const value = e.target.value;
|
||||
setEditDevice((prev) =>
|
||||
prev ? ({ ...prev, [field]: value } as DeviceDetail) : prev,
|
||||
);
|
||||
};
|
||||
/* ───────── Edit-Modal Trigger ───────── */
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editDevice) return;
|
||||
const handleEdit = useCallback((inventoryNumber: string) => {
|
||||
setEditInventoryNumber(inventoryNumber);
|
||||
}, []);
|
||||
|
||||
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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
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]);
|
||||
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>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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) => ({
|
||||
id: entry.id,
|
||||
changeType: entry.changeType,
|
||||
changedAt: entry.changedAt.toISOString(),
|
||||
changedBy:
|
||||
entry.changedBy?.name ??
|
||||
entry.changedBy?.username ??
|
||||
entry.changedBy?.email ??
|
||||
null,
|
||||
}));
|
||||
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, // '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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -22,7 +22,5 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
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';
|
||||
|
||||
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">
|
||||
{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"
|
||||
/>
|
||||
) : null}
|
||||
<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';
|
||||
|
||||
<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>
|
||||
)}
|
||||
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';
|
||||
}
|
||||
|
||||
<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"
|
||||
/>
|
||||
// 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>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<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"
|
||||
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}
|
||||
))}
|
||||
{item.changes.length > 2 && (
|
||||
<span className="ml-1 text-gray-500 dark:text-gray-600">· …</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
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
|
||||
'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) Direkt über ID (Header)
|
||||
if (headerUserId) {
|
||||
const userById = await prisma.user.findUnique({
|
||||
where: { id: headerUserId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (userById) {
|
||||
return userById.id;
|
||||
}
|
||||
// 1) ID direkt aus dem Token
|
||||
if (user?.id) {
|
||||
return user.id;
|
||||
}
|
||||
|
||||
// 2) Über Email (Header)
|
||||
if (headerUserEmail) {
|
||||
const userByEmail = await prisma.user.findUnique({
|
||||
where: { email: headerUserEmail },
|
||||
// 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 (userByEmail) {
|
||||
return userByEmail.id;
|
||||
}
|
||||
if (dbUser) return dbUser.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
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",
|
||||
"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",
|
||||
|
||||
@ -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
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();
|
||||
|
||||
// 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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
name String?
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
name String?
|
||||
passwordHash String
|
||||
|
||||
roles UserRole[]
|
||||
roles UserRole[]
|
||||
|
||||
// Audit-Relations
|
||||
devicesCreated Device[] @relation("DeviceCreatedBy")
|
||||
devicesUpdated Device[] @relation("DeviceUpdatedBy")
|
||||
historyEntries DeviceHistory[] @relation("DeviceHistoryChangedBy")
|
||||
devicesCreated Device[] @relation("DeviceCreatedBy")
|
||||
devicesUpdated Device[] @relation("DeviceUpdatedBy")
|
||||
historyEntries DeviceHistory[] @relation("DeviceHistoryChangedBy")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@ -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])
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user