This commit is contained in:
Linrador 2025-11-24 08:59:14 +01:00
parent 7f683d5828
commit 2f42c71fe9
74 changed files with 22674 additions and 1284 deletions

2
.env
View File

@ -9,7 +9,7 @@
# server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the
# one found in a remote Prisma Postgres URL, does not contain any sensitive information.
DATABASE_URL="file:./dev.db"
DATABASE_URL="postgresql://postgres:tegvideo7010!@localhost:5433/postgres"
NEXT_PUBLIC_APP_URL=https://10.0.1.25
NEXTAUTH_SECRET=tegvideo7010!

4
.gitignore vendored
View File

@ -41,3 +41,7 @@ yarn-error.log*
next-env.d.ts
/app/generated/prisma
/lib/generated/prisma
/lib/generated/prisma

View File

@ -9,6 +9,7 @@ import Button from '@/components/ui/Button';
import type { DeviceDetail } from './page';
import { DeviceQrCode } from '@/components/DeviceQrCode';
import Tabs from '@/components/ui/Tabs';
import LoanDeviceModal from './LoanDeviceModal';
type DeviceDetailModalProps = {
open: boolean;
@ -21,10 +22,40 @@ const dtf = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
});
function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {
type DeviceDetailsGridProps = {
device: DeviceDetail;
onStartLoan?: () => void;
};
function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
const isLoaned = Boolean(device.loanedTo);
const now = new Date();
const isOverdue =
isLoaned &&
device.loanedUntil != null &&
new Date(device.loanedUntil) < now;
const statusLabel = !isLoaned
? 'Verfügbar'
: isOverdue
? 'Verliehen (überfällig)'
: 'Verliehen';
const statusClasses = !isLoaned
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-100'
: isOverdue
? 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-100'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-100';
const dotClasses = !isLoaned
? 'bg-emerald-500'
: isOverdue
? 'bg-rose-500'
: 'bg-amber-500';
return (
<div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* Inventarnummer */}
{/* Inventarnummer (oben links) */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Inventar-Nr.
@ -34,8 +65,67 @@ function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {
</p>
</div>
{/* Bezeichnung */}
{/* Status */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Status
</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{/* linke „Spalte“: nur inhaltsbreit */}
<div className="flex w-auto shrink-0 flex-col gap-1">
{/* Pill nur content-breit */}
<span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
>
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`}
/>
<span>{statusLabel}</span>
</span>
{/* Infotext darunter */}
{device.loanedTo && (
<span className="text-xs text-gray-700 dark:text-gray-200">
an <span className="font-semibold">{device.loanedTo}</span>
{device.loanedFrom && (
<>
{' '}seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
)}
{device.loanedUntil && (
<>
{' '}bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '}- Hinweis: {device.loanComment}
</>
)}
</span>
)}
</div>
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
</Button>
</div>
</div>
{/* 🔹 Trenner nach Verleihstatus */}
<div className="sm:col-span-2">
<div className="my-1 border-t border-gray-200 dark:border-gray-700" />
</div>
{/* Bezeichnung jetzt UNTER dem Trenner */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Bezeichnung
</p>
@ -101,27 +191,6 @@ function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {
</p>
</div>
{/* Tags */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Tags
</p>
{device.tags && device.tags.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{device.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-gray-800/60 px-2.5 py-0.5 text-xs font-medium text-gray-100 dark:bg-gray-700/70"
>
{tag}
</span>
))}
</div>
) : (
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400"></p>
)}
</div>
{/* Netzwerkdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
@ -169,6 +238,27 @@ function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {
</p>
</div>
{/* Tags */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Tags
</p>
{device.tags && device.tags.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{device.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-gray-800/60 px-2.5 py-0.5 text-xs font-medium text-gray-100 dark:bg-gray-700/70"
>
{tag}
</span>
))}
</div>
) : (
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400"></p>
)}
</div>
{/* Kommentar */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
@ -216,6 +306,8 @@ export default function DeviceDetailModal({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details');
const [loanModalOpen, setLoanModalOpen] = useState(false);
const [historyRefresh, setHistoryRefresh] = useState(0);
useEffect(() => {
if (!open || !inventoryNumber) return;
@ -239,7 +331,9 @@ export default function DeviceDetailModal({
if (res.status === 404) {
throw new Error('Gerät wurde nicht gefunden.');
}
throw new Error('Beim Laden der Gerätedaten ist ein Fehler aufgetreten.');
throw new Error(
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
);
}
const data = (await res.json()) as DeviceDetail;
@ -266,100 +360,138 @@ export default function DeviceDetailModal({
const handleClose = () => onClose();
const handleStartLoan = () => {
if (!device) return;
setLoanModalOpen(true);
};
return (
<Modal
open={open}
onClose={handleClose}
title={
device
? `Gerätedetails: ${device.name}`
: 'Gerätedaten werden geladen …'
}
icon={<BookOpenIcon className="size-6" />}
tone="info"
variant="centered"
size="xl"
primaryAction={{
label: 'Schließen',
onClick: handleClose,
variant: 'primary',
}}
headerExtras={
device && (
<div className="sm:hidden">
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'history', label: 'Änderungsverlauf' },
]}
value={activeTab}
onChange={(id) => setActiveTab(id as 'details' | 'history')}
ariaLabel="Ansicht wählen"
/>
</div>
)
}
sidebar={
device ? (
<div className="hidden w-full h-full sm:flex sm:flex-col sm:gap-4">
{/* QR-Code oben, nicht scrollend */}
<div className="rounded-lg border border-gray-800 bg-gray-900/70 px-4 py-3 shadow-sm">
<div className="mt-2 flex justify-center">
<div className="rounded-md bg-black/80 p-2">
<DeviceQrCode inventoryNumber={device.inventoryNumber} />
<>
<Modal
open={open}
onClose={handleClose}
title={
device
? `Gerätedetails: ${device.name}`
: 'Gerätedaten werden geladen …'
}
icon={<BookOpenIcon className="size-6" />}
tone="info"
variant="centered"
size="xl"
primaryAction={{
label: 'Schließen',
onClick: handleClose,
variant: 'primary',
}}
headerExtras={
device && (
<div className="sm:hidden">
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'history', label: 'Änderungsverlauf' },
]}
value={activeTab}
onChange={(id) => setActiveTab(id as 'details' | 'history')}
ariaLabel="Ansicht wählen"
/>
</div>
)
}
sidebar={
device ? (
<div className="hidden w-full h-full sm:flex sm:flex-col sm:gap-4">
{/* QR-Code oben, nicht scrollend */}
<div className="rounded-lg border border-gray-800 bg-gray-900/70 px-4 py-3 shadow-sm">
<div className="mt-2 flex justify-center">
<div className="rounded-md bg-black/80 p-2">
<DeviceQrCode inventoryNumber={device.inventoryNumber} />
</div>
</div>
<p className="mt-2 text-center text-[14px] text-gray-500">
{device.inventoryNumber}
</p>
</div>
<p className="mt-2 text-center text-[12px] text-gray-500">
#{device.inventoryNumber}
</p>
<div className="border-t border-gray-800 dark:border-white/10 mx-1" />
{/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */}
<div className="flex-1 min-h-0 overflow-hidden">
<DeviceHistorySidebar
key={device.updatedAt}
inventoryNumber={device.inventoryNumber}
asSidebar
refreshToken={historyRefresh}
/>
</div>
</div>
) : undefined
}
>
{loading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Gerätedaten werden geladen
</p>
)}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
{!loading && !error && device && (
<>
{/* Mobile-Inhalt (Tabs steuern Ansicht) */}
<div className="sm:hidden pr-2">
{activeTab === 'details' ? (
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
/>
) : (
<DeviceHistorySidebar
key={device.updatedAt + '-mobile'}
inventoryNumber={device.inventoryNumber}
refreshToken={historyRefresh}
/>
)}
</div>
<div className="border-t border-gray-800 dark:border-white/10 mx-1" />
{/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */}
<div className="flex-1 min-h-0 overflow-hidden">
<DeviceHistorySidebar
key={device.updatedAt}
inventoryNumber={device.inventoryNumber}
asSidebar
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */}
<div className="hidden sm:block pr-2">
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
/>
</div>
</div>
) : undefined
}
>
{loading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Gerätedaten werden geladen
</p>
)}
</>
)}
</Modal>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
{device && (
<LoanDeviceModal
open={loanModalOpen}
onClose={() => setLoanModalOpen(false)}
device={device}
onUpdated={(patch) => {
// lokalen State aktualisieren, damit Details sofort aktualisiert sind
setDevice((prev) =>
prev
? {
...prev,
loanedTo: patch.loanedTo,
loanedFrom: patch.loanedFrom,
loanedUntil: patch.loanedUntil,
loanComment: patch.loanComment,
}
: prev,
);
setHistoryRefresh((prev) => prev + 1);
}}
/>
)}
{!loading && !error && device && (
<>
{/* Mobile-Inhalt (Tabs steuern Ansicht) */}
<div className="sm:hidden pr-2">
{activeTab === 'details' ? (
<DeviceDetailsGrid device={device} />
) : (
<DeviceHistorySidebar
key={device.updatedAt + '-mobile'}
inventoryNumber={device.inventoryNumber}
/>
)}
</div>
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */}
<div className="hidden sm:block pr-2">
<DeviceDetailsGrid device={device} />
</div>
</>
)}
</Modal>
</>
);
}

View File

@ -37,6 +37,7 @@ export default function DeviceEditModal({
const [editError, setEditError] = useState<string | null>(null);
const [saveLoading, setSaveLoading] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [historyRefresh, setHistoryRefresh] = useState(0);
useEffect(() => {
if (!open || !inventoryNumber) return;
@ -96,6 +97,16 @@ export default function DeviceEditModal({
};
}, [open, inventoryNumber]);
useEffect(() => {
if (!justSaved) return;
const id = setTimeout(() => {
setJustSaved(false);
}, 1500); // Dauer nach Geschmack anpassen
return () => clearTimeout(id);
}, [justSaved]);
const handleFieldChange = (
field: keyof DeviceDetail,
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
@ -148,10 +159,8 @@ export default function DeviceEditModal({
setEditDevice(updated);
onSaved(updated);
// Nur Status setzen NICHT schließen
setJustSaved(true);
setTimeout(() => {
onClose();
}, 1000);
} catch (err: any) {
console.error('Error saving device', err);
setEditError(
@ -162,7 +171,7 @@ export default function DeviceEditModal({
} finally {
setSaveLoading(false);
}
}, [editDevice, onSaved, onClose]);
}, [editDevice, onSaved]);
const handleClose = () => {
if (saveLoading) return;
@ -229,6 +238,7 @@ export default function DeviceEditModal({
key={editDevice.updatedAt}
inventoryNumber={editDevice.inventoryNumber}
asSidebar
refreshToken={historyRefresh}
/>
) : undefined
}
@ -348,34 +358,6 @@ export default function DeviceEditModal({
/>
</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);
setEditDevice((prev) =>
prev ? ({ ...prev, tags: names } as DeviceDetail) : prev,
);
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">
@ -438,6 +420,34 @@ export default function DeviceEditModal({
/>
</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);
setEditDevice((prev) =>
prev ? ({ ...prev, tags: names } as DeviceDetail) : prev,
);
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>
{/* Kommentar */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">

View File

@ -8,11 +8,12 @@ import Feed, {
} from '@/components/ui/Feed';
import clsx from 'clsx';
type Props = {
inventoryNumber: string;
/** Wenn true: wird als Inhalt für Modal.sidebar gerendert (ohne eigenes <aside>/Border) */
asSidebar?: boolean;
/** Wenn sich dieser Wert ändert, wird die Historie neu geladen */
refreshToken?: number | string;
};
type ApiHistoryEntry = {
@ -27,13 +28,6 @@ type ApiHistoryEntry = {
}[];
};
function formatDateTime(iso: string) {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
}
function mapFieldLabel(field: string): string {
switch (field) {
case 'name':
@ -64,6 +58,15 @@ function mapFieldLabel(field: string): string {
return 'Standort';
case 'tags':
return 'Tags';
case 'loanedTo':
return 'Verliehen an';
case 'loanedFrom':
return 'Verliehen seit';
case 'loanedUntil':
return 'Verliehen bis';
case 'loanComment':
return 'Verleih-Hinweis';
default:
return field;
}
@ -72,6 +75,7 @@ function mapFieldLabel(field: string): string {
export default function DeviceHistorySidebar({
inventoryNumber,
asSidebar = false,
refreshToken,
}: Props) {
const [items, setItems] = useState<FeedItem[]>([]);
const [loading, setLoading] = useState(false);
@ -104,9 +108,44 @@ export default function DeviceHistorySidebar({
const person = {
name: entry.changedBy ?? 'Unbekannter Benutzer',
};
const date = formatDateTime(entry.changedAt);
const date = entry.changedAt;
// 🔽 Verleih-Aktion erkennen: loanedTo von null -> Wert
if (entry.changeType === 'UPDATED' && entry.changes.length > 0) {
const loanedToChange = entry.changes.find(
(c) => c.field === 'loanedTo',
);
if (loanedToChange) {
const wasLoanedBefore = !!loanedToChange.from;
const isLoanedNow = !!loanedToChange.to;
// Neu verliehen: vorher null/leer, jetzt Ziel drin
if (!wasLoanedBefore && isLoanedNow) {
return {
id: entry.id,
type: 'comment',
person,
date,
comment: `hat das Gerät verliehen an ${loanedToChange.to}.`,
commentKind: 'loaned',
};
}
// Optional: zurückgegeben (von X -> null)
if (wasLoanedBefore && !isLoanedNow) {
return {
id: entry.id,
type: 'comment',
person,
date,
comment: `hat das Gerät zurückgenommen von ${loanedToChange.from}.`,
commentKind: 'returned',
};
}
}
// Kein spezieller Verleih-Fall → normales "change"-Event
const changes: FeedChange[] = entry.changes.map((c) => ({
field: c.field,
label: mapFieldLabel(c.field),
@ -123,6 +162,7 @@ export default function DeviceHistorySidebar({
};
}
// CREATED / DELETED → Kommentare
let comment = '';
let commentKind: 'created' | 'deleted' | 'generic' = 'generic';
@ -165,10 +205,9 @@ export default function DeviceHistorySidebar({
return () => {
cancelled = true;
};
}, [inventoryNumber]);
}, [inventoryNumber, refreshToken]);
// Root-Tag & Klassen abhängig vom Einsatz
const Root: ElementType = asSidebar ? 'div' : 'aside';
const rootClassName = asSidebar
? 'flex h-full min-h-0 flex-col'
@ -196,7 +235,7 @@ export default function DeviceHistorySidebar({
<div
className={clsx(
'mt-3',
asSidebar && 'min-h-0 flex-1 overflow-y-auto pr-1'
asSidebar && 'min-h-0 flex-1 overflow-y-auto pr-6',
)}
>
<Feed items={items} />

View File

@ -0,0 +1,547 @@
// app/(app)/devices/LoanDeviceModal.tsx
'use client';
import { useEffect, useMemo, useState } from 'react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import Dropdown, { type DropdownSection } from '@/components/ui/Dropdown';
import type { DeviceDetail } from './page';
type LoanDeviceModalProps = {
open: boolean;
onClose: () => void;
device: DeviceDetail;
/**
* Wird nach erfolgreichem Speichern/Beenden aufgerufen,
* damit der Parent den lokalen State aktualisieren kann.
*/
onUpdated?: (patch: {
loanedTo: string | null;
loanedFrom: string | null;
loanedUntil: string | null;
loanComment: string | null;
}) => void;
};
// gleiche Logik wie bei den User-Gruppen:
function getBaseGroupName(name: string): string {
const trimmed = name.trim();
if (!trimmed) return '';
const beforeDash = trimmed.split('-')[0].trim() || trimmed;
const withoutDigits = beforeDash.replace(/\d+$/, '').trim();
return withoutDigits || beforeDash;
}
type LoanUserOption = {
value: string; // wird in loanedTo gespeichert (z.B. arbeitsname)
label: string; // Anzeige-Text im Dropdown
group: string; // Hauptgruppe (BaseKey)
};
type UsersApiGroup = {
id: string;
name: string;
users: {
nwkennung: string;
arbeitsname: string;
firstName: string | null;
lastName: string | null;
}[];
};
function toDateInputValue(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
// lokale Datumskomponenten -> passend für <input type="date">
return `${year}-${month}-${day}`;
}
function fromDateInputValue(v: string): string | null {
if (!v) return null;
// Wir nehmen 00:00 Uhr lokale Zeit; toISOString() speichert sauber in DB
const d = new Date(v + 'T00:00:00');
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
const dtf = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
});
// "heute" im <input type="date">-Format
function todayInputDate(): string {
const d = new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`; // z.B. 2025-02-19
}
export default function LoanDeviceModal({
open,
onClose,
device,
onUpdated,
}: LoanDeviceModalProps) {
const [loanedTo, setLoanedTo] = useState('');
const [loanedFrom, setLoanedFrom] = useState('');
const [loanedUntil, setLoanedUntil] = useState('');
const [loanComment, setLoanComment] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userOptions, setUserOptions] = useState<LoanUserOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [optionsError, setOptionsError] = useState<string | null>(null);
// Beim Öffnen / Gerätewechsel Felder setzen
useEffect(() => {
if (!open) return;
const existingFrom = toDateInputValue(device.loanedFrom);
const nextLoanedFrom = existingFrom || todayInputDate();
setLoanedTo(device.loanedTo ?? '');
setLoanedFrom(nextLoanedFrom);
setLoanedUntil(toDateInputValue(device.loanedUntil));
setLoanComment(device.loanComment ?? '');
setError(null);
}, [
open,
device.inventoryNumber,
device.loanedFrom,
device.loanedUntil,
device.loanComment,
device.loanedTo,
]);
// Beim Öffnen User für Dropdown laden (nur User aus Gruppen, gruppiert nach Hauptgruppen)
useEffect(() => {
if (!open) return;
async function loadUsers() {
setOptionsLoading(true);
setOptionsError(null);
try {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`Fehler beim Laden der Benutzer (HTTP ${res.status})`);
}
const data = await res.json();
const groups = (data.groups ?? []) as UsersApiGroup[];
const opts: LoanUserOption[] = [];
for (const g of groups) {
const mainGroup = getBaseGroupName(g.name) || g.name;
for (const u of g.users ?? []) {
if (!u.arbeitsname) continue;
const nameParts: string[] = [u.arbeitsname];
const extra: string[] = [];
if (u.firstName) extra.push(u.firstName);
if (u.lastName) extra.push(u.lastName);
if (extra.length) {
nameParts.push(' ' + extra.join(' '));
}
opts.push({
value: u.arbeitsname, // in loanedTo speichern wir weiterhin den Arbeitsnamen
label: nameParts.join(' '),
group: mainGroup,
});
}
}
// bestehenden Wert, der kein User ist, als "Andere"-Option anhängen
const currentLoanedTo = device.loanedTo ?? '';
if (
currentLoanedTo &&
!opts.some((o) => o.value === currentLoanedTo)
) {
opts.push({
value: currentLoanedTo,
label: `${currentLoanedTo} (bisheriger Eintrag)`,
group: 'Andere',
});
}
// sortieren: erst nach Gruppe, dann nach Label
opts.sort((a, b) => {
const gComp = a.group.localeCompare(b.group, 'de');
if (gComp !== 0) return gComp;
return a.label.localeCompare(b.label, 'de');
});
setUserOptions(opts);
} catch (err) {
console.error('Fehler beim Laden der Benutzerliste für Verleih', err);
setOptionsError('Empfängerliste konnte nicht geladen werden.');
} finally {
setOptionsLoading(false);
}
}
loadUsers();
}, [open, device.loanedTo]);
// Optionen nach Hauptgruppe gruppieren
const groupedOptions = useMemo(() => {
const map = new Map<string, LoanUserOption[]>();
for (const opt of userOptions) {
const key = opt.group || 'Andere';
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)!.push(opt);
}
return Array.from(map.entries()).sort(([a], [b]) =>
a.localeCompare(b, 'de'),
);
}, [userOptions]);
// Dropdown-Sektionen für deine Dropdown-Komponente
const dropdownSections = useMemo<DropdownSection[]>(() => {
const sections: DropdownSection[] = [];
// Erste Sektion: Zurücksetzen / keine Auswahl
sections.push({
id: 'base',
items: [
{
id: 'none',
label: '— Bitte auswählen —',
onClick: () => setLoanedTo(''),
},
],
});
// Danach je Hauptgruppe eine eigene Sektion mit Label als Trenner
for (const [groupName, opts] of groupedOptions) {
sections.push({
id: groupName,
label: groupName, // <-- wird als Trenner angezeigt
items: opts.map((opt) => ({
id: `${groupName}-${opt.value}`,
label: opt.label, // <-- nur der Name, ohne Gruppen-Präfix
onClick: () => setLoanedTo(opt.value),
})),
});
}
return sections;
}, [groupedOptions, setLoanedTo]);
// Aktuell ausgewähltes Label für den Trigger-Button
const currentSelected = useMemo(
() => userOptions.find((o) => o.value === loanedTo) ?? null,
[userOptions, loanedTo],
);
const dropdownLabel =
currentSelected?.label || (loanedTo || 'Bitte auswählen …');
const isLoaned = !!device.loanedTo;
async function saveLoan() {
if (!device.inventoryNumber) return;
if (!loanedTo.trim()) {
setError('Bitte einen Empfänger (an wen) auswählen.');
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(device.inventoryNumber)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// ⚠️ PATCH-Route erwartet alle Felder, nicht nur Verleihfelder!
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,
passwordHash: (device as any).passwordHash ?? null,
group: device.group,
location: device.location,
tags: device.tags ?? [],
// Verleihfelder
loanedTo: loanedTo.trim(),
loanedFrom: fromDateInputValue(loanedFrom),
loanedUntil: fromDateInputValue(loanedUntil),
loanComment: loanComment.trim() || null,
}),
},
);
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(
data?.error ?? `Fehler beim Speichern (HTTP ${res.status})`,
);
}
const patch = {
loanedTo: loanedTo.trim(),
loanedFrom: fromDateInputValue(loanedFrom),
loanedUntil: fromDateInputValue(loanedUntil),
loanComment: loanComment.trim() || null,
};
onUpdated?.(patch);
onClose();
} catch (err: any) {
console.error('Error saving loan', err);
setError(
err instanceof Error
? err.message
: 'Fehler beim Speichern des Verleihstatus.',
);
} finally {
setSaving(false);
}
}
async function endLoan() {
if (!device.inventoryNumber) return;
setSaving(true);
setError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(device.inventoryNumber)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Alle bisherigen Felder durchreichen
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,
passwordHash: (device as any).passwordHash ?? null,
group: device.group,
location: device.location,
tags: device.tags ?? [],
// Verleih zurücksetzen
loanedTo: null,
loanedFrom: null,
loanedUntil: null,
loanComment: null,
}),
},
);
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(
data?.error ?? `Fehler beim Beenden des Verleihs (HTTP ${res.status})`,
);
}
onUpdated?.({
loanedTo: null,
loanedFrom: null,
loanedUntil: null,
loanComment: null,
});
onClose();
} catch (err: any) {
console.error('Error ending loan', err);
setError(
err instanceof Error
? err.message
: 'Fehler beim Beenden des Verleihs.',
);
} finally {
setSaving(false);
}
}
const title = isLoaned
? `Verleih bearbeiten ${device.name}`
: `Gerät verleihen ${device.name}`;
return (
<Modal
open={open}
onClose={onClose}
title={title}
tone={isLoaned ? 'warning' : 'info'}
variant="centered"
size="md"
primaryAction={{
label: saving
? 'Speichere …'
: isLoaned
? 'Verleih aktualisieren'
: 'Gerät verleihen',
onClick: saveLoan,
variant: 'primary',
}}
secondaryAction={{
label: 'Abbrechen',
onClick: onClose,
variant: 'secondary',
}}
useGrayFooter
>
<div className="space-y-4 text-sm">
{/* Aktueller Verleih-Hinweis mit deutlich sichtbarem „Verleih beenden“ */}
{isLoaned && (
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-xs text-amber-900 dark:border-amber-700/70 dark:bg-amber-950/40 dark:text-amber-50">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide">
Verliehen an
</p>
<p className="mt-0.5">
<span className="font-medium">{device.loanedTo}</span>
{device.loanedFrom && (
<>
{' '}seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
)}
{device.loanedUntil && (
<>
{' '}bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '}- Hinweis: {device.loanComment}
</>
)}
</p>
</div>
<Button
size="sm"
variant="secondary"
tone="gray"
onClick={endLoan}
disabled={saving}
>
Verleih beenden
</Button>
</div>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
{isLoaned
? 'Passe die Verleihdaten an oder beende den Verleih, wenn das Gerät zurückgegeben wurde.'
: 'Lege fest, an wen das Gerät verliehen wird und in welchem Zeitraum.'}
</p>
{/* Formularfelder */}
<div className="space-y-3 text-sm">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Verliehen an
</label>
<Dropdown
label={dropdownLabel}
ariaLabel="Empfänger auswählen"
align="left"
triggerVariant="button"
disabled={optionsLoading || userOptions.length === 0}
sections={dropdownSections}
triggerClassName="mt-1 w-full justify-between"
/>
{optionsLoading && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Lade Benutzer
</p>
)}
{optionsError && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{optionsError}
</p>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Verliehen seit
</label>
<input
type="date"
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={loanedFrom}
onChange={(e) => setLoanedFrom(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Verliehen bis (optional)
</label>
<input
type="date"
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={loanedUntil}
onChange={(e) => setLoanedUntil(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Hinweis / Kommentar (optional)
</label>
<textarea
rows={3}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={loanComment}
onChange={(e) => setLoanComment(e.target.value)}
/>
</div>
</div>
{error && (
<p className="text-xs text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
</Modal>
);
}

View File

@ -36,12 +36,17 @@ export type DeviceRow = {
tags: string[];
createdAt: string;
updatedAt: string;
loanedTo: string | null;
loanedFrom: string | null;
loanedUntil: string | null;
loanComment: string | null;
};
export type DeviceDetail = DeviceRow & {
createdAt?: string;
};
function formatDate(iso: string) {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',

View File

@ -2,7 +2,7 @@
'use client';
import { useState, type ReactNode } from 'react';
import { useState, type ReactNode, useEffect } from 'react';
import {
Dialog,
DialogBackdrop,
@ -23,6 +23,7 @@ import {
DocumentDuplicateIcon,
FolderIcon,
ComputerDesktopIcon,
UserIcon,
HomeIcon,
UsersIcon,
XMarkIcon,
@ -33,16 +34,13 @@ import { useSession, signOut } from 'next-auth/react';
import { Skeleton } from '@/components/ui/Skeleton';
import ScanModal from '@/components/ScanModal';
import DeviceDetailModal from './devices/DeviceDetailModal';
import PersonAvatar from '@/components/ui/UserAvatar';
import UserMenu from '@/components/UserMenu';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Geräte', href: '/devices', icon: ComputerDesktopIcon },
];
const teams = [
{ id: 1, name: 'Heroicons', href: '#', initial: 'H' },
{ id: 2, name: 'Tailwind Labs', href: '#', initial: 'T' },
{ id: 3, name: 'Workcation', href: '#', initial: 'W' },
{ name: 'Personen', href: '/users', icon: UserIcon },
];
const userNavigation = [
@ -54,93 +52,6 @@ function classNames(...classes: Array<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
// feste Liste an erlaubten Tailwind-Hintergrundfarben
const AVATAR_COLORS = [
'bg-indigo-500',
'bg-sky-500',
'bg-emerald-500',
'bg-amber-500',
'bg-rose-500',
'bg-purple-500',
];
function getInitial(name: string) {
const trimmed = name.trim();
if (!trimmed) return '';
return trimmed.charAt(0).toUpperCase();
}
// deterministische "zufällige" Farbe auf Basis des Namens
function getAvatarColor(name: string) {
if (!name) return AVATAR_COLORS[0];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash + name.charCodeAt(i)) % AVATAR_COLORS.length;
}
return AVATAR_COLORS[hash];
}
/* ───────── User-Menü, wenn Session geladen ───────── */
type UserMenuProps = {
displayName: string;
avatarInitial: string;
avatarColorClass: string;
};
function UserMenu({ displayName, avatarInitial, avatarColorClass }: UserMenuProps) {
return (
<Menu as="div" className="relative">
<MenuButton className="relative flex items-center">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
{/* Avatar mit Zufallsfarbe & Initial */}
<div
className={classNames(
'flex size-8 items-center justify-center rounded-full text-sm font-semibold text-white shadow-inner',
avatarColorClass,
)}
>
{avatarInitial}
</div>
<span className="hidden lg:flex lg:items-center">
<span aria-hidden="true" className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white">
{displayName}
</span>
<ChevronDownIcon aria-hidden="true" className="ml-2 size-5 text-gray-400 dark:text-gray-500" />
</span>
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline-1 outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{userNavigation.map((item) => (
<MenuItem key={item.name}>
{item.name === 'Abmelden' ? (
<button
type="button"
onClick={() => signOut({ callbackUrl: '/login' })}
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
>
{item.name}
</button>
) : (
<a
href={item.href}
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
>
{item.name}
</a>
)}
</MenuItem>
))}
</MenuItems>
</Menu>
);
}
/* ───────── Layout ───────── */
export default function AppLayout({ children }: { children: ReactNode }) {
@ -158,8 +69,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
: '';
const displayName = rawName;
const avatarInitial = getInitial(rawName);
const avatarColorClass = getAvatarColor(rawName);
const avatarName = rawName;
const avatarUrl = session?.user?.image ?? null;
const handleScanResult = (code: string) => {
const trimmed = code.trim();
@ -198,6 +109,30 @@ export default function AppLayout({ children }: { children: ReactNode }) {
setDetailOpen(true);
};
// Automatisches Logout, wenn unser eigenes Ablaufdatum erreicht ist
useEffect(() => {
if (status !== 'authenticated') return;
const s = session as any;
const expiresAt: number | undefined = s?.customExpires;
// Falls aus irgendeinem Grund noch nicht gesetzt → nichts tun
if (!expiresAt) return;
const msUntilExpire = expiresAt - Date.now();
if (msUntilExpire <= 0) {
// Session abgelaufen → ausloggen
signOut({ callbackUrl: '/login' });
return;
}
const timeoutId = window.setTimeout(() => {
signOut({ callbackUrl: '/login' });
}, msUntilExpire);
return () => window.clearTimeout(timeoutId);
}, [session, status]);
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
{/* Mobile Sidebar */}
@ -260,25 +195,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className="group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-[0.625rem] font-medium text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:group-hover:border-white/20 dark:group-hover:text-white">
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
@ -338,25 +254,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className="group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-[0.625rem] font-medium text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:group-hover:border-white/20 dark:group-hover:text-white">
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
@ -457,11 +354,11 @@ export default function AppLayout({ children }: { children: ReactNode }) {
)}
{status === 'authenticated' && rawName && (
<UserMenu
<UserMenu
displayName={displayName}
avatarInitial={avatarInitial}
avatarColorClass={avatarColorClass}
/>
avatarName={avatarName}
avatarUrl={avatarUrl}
/>
)}
</div>
</div>

View File

@ -0,0 +1,94 @@
// app/(app)/users/components/AssignGroupForm.tsx
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Dropdown from '@/components/ui/Dropdown';
import type { SimpleGroup, UserWithAvatar } from './types';
type Props = {
user: UserWithAvatar;
defaultGroupId: string | null;
allGroups: SimpleGroup[];
};
export default function AssignGroupForm({
user,
defaultGroupId,
allGroups,
}: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [groupId, setGroupId] = useState<string>(defaultGroupId ?? 'none');
async function updateGroup(nextGroupId: string) {
setGroupId(nextGroupId);
startTransition(async () => {
try {
const res = await fetch('/api/users/assign-group', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: user.nwkennung,
groupId: nextGroupId,
}),
});
if (!res.ok) {
console.error(
'Fehler beim Zuordnen der Gruppe',
await res.text(),
);
}
router.refresh();
} catch (err) {
console.error('Fehler beim Zuordnen der Gruppe', err);
}
});
}
const currentLabel =
groupId === 'none'
? 'Ohne Gruppe'
: allGroups.find((g) => g.id === groupId)?.name ?? 'Gruppe wählen';
return (
<div className="flex items-center gap-2">
<Dropdown
triggerVariant="button"
label={currentLabel}
ariaLabel="Gruppe auswählen"
align="left"
disabled={pending}
triggerClassName="inline-flex items-center rounded-md bg-white px-2 py-1 text-xs font-normal text-gray-900 shadow-sm inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-900 dark:text-gray-100 dark:inset-ring-gray-700"
sections={[
{
id: 'groups',
items: [
{
id: 'none',
label: 'Ohne Gruppe',
disabled: pending && groupId === 'none',
onClick: () => updateGroup('none'),
},
...allGroups.map((g) => ({
id: g.id,
label: g.name,
disabled: pending && groupId === g.id,
onClick: () => updateGroup(g.id),
})),
],
},
]}
/>
{pending && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Speichere&nbsp;
</span>
)}
</div>
);
}

View File

@ -0,0 +1,214 @@
// app/(app)/users/UsersCsvImportButton.tsx
'use client';
import { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
type SimpleGroup = {
id: string;
name: string;
};
type Props = {
groups: SimpleGroup[];
};
export default function UsersCsvImportButton({ groups }: Props) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importSummary, setImportSummary] = useState<string | null>(null);
// Hilfsfunktion: Gruppe sicherstellen (existiert oder neu anlegen)
async function ensureGroupId(
name: string,
cache: Map<string, string>,
): Promise<string | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const cached = cache.get(trimmed);
if (cached) return cached;
const res = await fetch('/api/user-groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
console.error('Fehler beim Anlegen der Gruppe', res.status, data);
return null;
}
const data = await res.json();
const id = data.id as string;
cache.set(trimmed, id);
return id;
}
async function handleImportCsv(
e: React.ChangeEvent<HTMLInputElement>,
): Promise<void> {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
setImportError(null);
setImportSummary(null);
try {
const text = await file.text();
// Gruppen-Cache (Name -> ID), Start mit bestehenden Gruppen
const groupCache = new Map<string, string>();
for (const g of groups) {
groupCache.set(g.name.trim(), g.id);
}
let createdCount = 0;
let skippedCount = 0;
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
// ⬅️ Erste Zeile immer ignorieren (Header)
if (index === 0) {
continue;
}
// Format: NwKennung;Nachname;Vorname;Arbeitsname;Gruppe
const parts = line.split(';');
if (parts.length < 4) {
console.warn('Zeile übersprungen (falsches Format):', line);
skippedCount++;
continue;
}
const [
nwkennungRaw,
lastNameRaw,
firstNameRaw,
arbeitsnameRaw,
groupRaw,
] = parts;
const nwkennung = (nwkennungRaw ?? '').trim().toLowerCase();
const lastName = (lastNameRaw ?? '').trim();
const firstName = (firstNameRaw ?? '').trim();
const arbeitsname = (arbeitsnameRaw ?? '').trim();
const groupName = (groupRaw ?? '').trim();
// NwKennung + Name + Arbeitsname als Pflichtfelder
if (!nwkennung || !lastName || !firstName || !arbeitsname) {
console.warn(
'Zeile übersprungen (Pflichtfelder leer):',
line,
);
skippedCount++;
continue;
}
let groupId: string | null = null;
if (groupName) {
groupId = await ensureGroupId(groupName, groupCache);
if (!groupId) {
console.warn(
'Zeile übersprungen (Gruppe konnte nicht angelegt werden):',
line,
);
skippedCount++;
continue;
}
}
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nwkennung, // ⬅ NEU
arbeitsname,
firstName,
lastName,
groupId,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
console.error(
'Fehler beim Anlegen der Person aus CSV:',
res.status,
data,
'Zeile:',
line,
);
skippedCount++;
continue;
}
createdCount++;
}
setImportSummary(
`Import abgeschlossen: ${createdCount} Personen importiert, ${skippedCount} Zeilen übersprungen.`,
);
router.refresh();
} catch (err: any) {
console.error('Fehler beim CSV-Import', err);
setImportError(
err instanceof Error
? err.message
: 'Fehler beim CSV-Import.',
);
} finally {
setImporting(false);
if (e.target) {
e.target.value = '';
}
}
}
return (
<div className="flex flex-col items-stretch gap-1">
<Button
type="button"
variant="soft"
tone="indigo"
size="lg"
disabled={importing}
onClick={() => fileInputRef.current?.click()}
>
{importing ? 'Importiere …' : 'Import aus CSV'}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleImportCsv}
/>
{importSummary && (
<p className="text-[11px] text-gray-600 dark:text-gray-300">
{importSummary}
</p>
)}
{importError && (
<p className="text-[11px] text-red-600 dark:text-red-400">
{importError}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,309 @@
// app/(app)/users/UsersHeaderClient.tsx
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import { PlusIcon } from '@heroicons/react/24/outline';
import UsersCsvImportButton from './UsersCsvImportButton';
type SimpleGroup = {
id: string;
name: string;
};
type Props = {
groups: SimpleGroup[];
};
export default function UsersHeaderClient({ groups }: Props) {
const router = useRouter();
const [personModalOpen, setUserModalOpen] = useState(false);
const [groupModalOpen, setGroupModalOpen] = useState(false);
// User-Form State (angepasst an neues Schema)
const [arbeitsname, setArbeitsname] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [personGroupId, setUserGroupId] = useState<'none' | string>('none');
const [savingUser, setSavingUser] = useState(false);
const [personError, setUserError] = useState<string | null>(null);
// Gruppen-Form State
const [groupName, setGroupName] = useState('');
const [savingGroup, setSavingGroup] = useState(false);
const [groupError, setGroupError] = useState<string | null>(null);
async function handleCreateUser(e: FormEvent) {
e.preventDefault();
setSavingUser(true);
setUserError(null);
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
arbeitsname,
firstName,
lastName,
groupId: personGroupId === 'none' ? null : personGroupId,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(
data?.error ?? `Fehler beim Anlegen (HTTP ${res.status})`,
);
}
setArbeitsname('');
setFirstName('');
setLastName('');
setUserGroupId('none');
setUserModalOpen(false);
router.refresh();
} catch (err: any) {
console.error('Error creating person', err);
setUserError(
err instanceof Error
? err.message
: 'Fehler beim Anlegen der User.',
);
} finally {
setSavingUser(false);
}
}
async function handleCreateGroup(e: FormEvent) {
e.preventDefault();
setSavingGroup(true);
setGroupError(null);
try {
const res = await fetch('/api/person-groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: groupName }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(
data?.error ?? `Fehler beim Anlegen (HTTP ${res.status})`,
);
}
setGroupName('');
setGroupModalOpen(false);
router.refresh();
} catch (err: any) {
console.error('Error creating group', err);
setGroupError(
err instanceof Error
? err.message
: 'Fehler beim Anlegen der Gruppe.',
);
} finally {
setSavingGroup(false);
}
}
return (
<>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Personen
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Personen verwalten und in Gruppen einteilen.
</p>
</div>
<div className="flex flex-wrap gap-2 items-start">
<Button
type="button"
variant="soft"
tone="indigo"
size="lg"
icon={<PlusIcon className="size-5" />}
onClick={() => setUserModalOpen(true)}
>
Neue Person
</Button>
<Button
type="button"
variant="soft"
tone="indigo"
size="lg"
icon={<PlusIcon className="size-5" />}
onClick={() => setGroupModalOpen(true)}
>
Neue Gruppe
</Button>
{/* Neuer CSV-Import als eigene Komponente */}
<UsersCsvImportButton groups={groups} />
</div>
</div>
{/* Modal: Neue Person */}
<Modal
open={personModalOpen}
onClose={() => setUserModalOpen(false)}
title="Neue Person anlegen"
tone="info"
variant="centered"
size="md"
primaryAction={{
label: savingUser ? 'Speichere …' : 'User anlegen',
onClick: () => {
const form = document.getElementById(
'new-person-form',
) as HTMLFormElement | null;
form?.requestSubmit();
},
variant: 'primary',
}}
secondaryAction={{
label: 'Abbrechen',
onClick: () => setUserModalOpen(false),
variant: 'secondary',
}}
>
<form
id="new-person-form"
className="space-y-3 text-sm"
onSubmit={handleCreateUser}
>
{/* Arbeitsname */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Arbeitsname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={arbeitsname}
onChange={(e) => setArbeitsname(e.target.value)}
/>
</div>
{/* Vorname / Nachname */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Vorname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Nachname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
</div>
{/* Gruppe */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Gruppe
</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={personGroupId}
onChange={(e) =>
setUserGroupId(e.target.value as 'none' | string)
}
>
<option value="none">Ohne Gruppe</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
{personError && (
<p className="text-xs text-red-600 dark:text-red-400">
{personError}
</p>
)}
<p className="text-[11px] text-gray-500 dark:text-gray-400">
Felder mit * sind Pflichtfelder.
</p>
</form>
</Modal>
{/* Modal: Neue Gruppe */}
<Modal
open={groupModalOpen}
onClose={() => setGroupModalOpen(false)}
title="Neue Gruppe anlegen"
tone="info"
variant="centered"
size="sm"
primaryAction={{
label: savingGroup ? 'Speichere …' : 'Gruppe anlegen',
onClick: () => {
const form = document.getElementById(
'new-group-form',
) as HTMLFormElement | null;
form?.requestSubmit();
},
variant: 'primary',
}}
secondaryAction={{
label: 'Abbrechen',
onClick: () => setGroupModalOpen(false),
variant: 'secondary',
}}
>
<form
id="new-group-form"
className="space-y-3 text-sm"
onSubmit={handleCreateGroup}
>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Gruppenname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
</div>
{groupError && (
<p className="text-xs text-red-600 dark:text-red-400">
{groupError}
</p>
)}
</form>
</Modal>
</>
);
}

View File

@ -0,0 +1,696 @@
// app/(app)/users/UsersTablesClient.tsx
'use client';
import { useEffect, useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import Table, { type TableColumn } from '@/components/ui/Table';
import Tabs, { type TabItem } from '@/components/ui/Tabs';
import Dropdown from '@/components/ui/Dropdown';
import UserAvatar from '@/components/ui/UserAvatar';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import Badge from '@/components/ui/Badge';
import type { User, UserGroup } from '@/generated/prisma/client';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
import type { GroupWithUsers, SimpleGroup, UserWithAvatar } from './types';
import AssignGroupForm from './AssignGroupForm';
type Props = {
groups: GroupWithUsers[];
ungrouped: User[];
allGroups: SimpleGroup[];
};
/* ───────── Helper: Cluster-Key aus Gruppennamen ───────── */
/**
* Idee:
* 1) Bis zum ersten '-' kürzen (z.B. "Test-Test" -> "Test", "Gruppe1-Test" -> "Gruppe1")
* 2) Danach Ziffern am Ende entfernen (z.B. "Test1" -> "Test", "Test2" -> "Test")
* 3) Fallback: wenn danach nichts übrig bleibt, den ursprünglichen Teil nehmen
*
* Beispiele:
* - "Gruppe1" -> beforeDash: "Gruppe1" -> ohne Ziffern: "Gruppe" -> Key: "Gruppe"
* - "Gruppe1-Test" -> beforeDash: "Gruppe1" -> ohne Ziffern: "Gruppe" -> Key: "Gruppe"
* - "Test1" -> beforeDash: "Test1" -> ohne Ziffern: "Test" -> Key: "Test"
* - "Test2" -> beforeDash: "Test2" -> ohne Ziffern: "Test" -> Key: "Test"
* - "Test-Test" -> beforeDash: "Test" -> ohne Ziffern: "Test" -> Key: "Test"
* - "Test" -> beforeDash: "Test" -> ohne Ziffern: "Test" -> Key: "Test"
*/
function getBaseGroupName(name: string): string {
const trimmed = name.trim();
if (!trimmed) return '';
const beforeDash = trimmed.split('-')[0].trim() || trimmed;
const withoutDigits = beforeDash.replace(/\d+$/, '').trim();
return withoutDigits || beforeDash;
}
type GroupCluster = {
baseKey: string; // z.B. "Gruppe" oder "Test"
label: string; // Anzeige-Label im Haupt-Tab
groups: GroupWithUsers[]; // alle Gruppen wie "Gruppe1", "Gruppe1-Test", "Test1", "Test2" ...
totalCount: number; // Summe aller User in diesem Cluster
};
/* ───────── Zeilen-Aktionen: Bearbeiten + Löschen ───────── */
type UserRowActionsProps = {
user: UserWithAvatar;
currentUserId: string | null;
};
function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
const router = useRouter();
const isCurrentUser =
!!currentUserId && user.nwkennung === currentUserId;
// Edit-Modal
const [editOpen, setEditOpen] = useState(false);
const [editNwKennung, setEditNwKennung] = useState(
user.nwkennung ?? '',
);
const [editArbeitsname, setEditArbeitsname] = useState(
user.arbeitsname ?? '',
);
const [editFirstName, setEditFirstName] = useState(
user.firstName ?? '',
);
const [editLastName, setEditLastName] = useState(user.lastName ?? '');
const [savingEdit, startEditTransition] = useTransition();
// Löschen
const [deleting, startDeleteTransition] = useTransition();
async function handleSaveEdit() {
startEditTransition(async () => {
try {
const res = await fetch(`/api/users/${user.nwkennung}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
arbeitsname: editArbeitsname,
firstName: editFirstName,
lastName: editLastName,
}),
});
if (!res.ok) {
console.error(
'Fehler beim Aktualisieren der User',
await res.text(),
);
return;
}
setEditOpen(false);
router.refresh();
} catch (err) {
console.error('Fehler beim Aktualisieren der User', err);
}
});
}
async function handleDelete() {
const ok = window.confirm(
`User "${user.arbeitsname || user.firstName || user.lastName}" wirklich löschen?`,
);
if (!ok) return;
startDeleteTransition(async () => {
try {
const res = await fetch(`/api/users/${user.nwkennung}`, {
method: 'DELETE',
});
if (!res.ok) {
console.error(
'Fehler beim Löschen der User',
await res.text(),
);
return;
}
router.refresh();
} catch (err) {
console.error('Fehler beim Löschen der User', err);
}
});
}
return (
<>
<div className="flex items-center gap-3">
{/* Desktop / Tablet: Buttons */}
<div className="hidden sm:flex items-center gap-2">
<Button
type="button"
size="lg"
variant="secondary"
icon={<PencilIcon className="size-5" />}
onClick={() => setEditOpen(true)}
/>
{/* Löschen nur anzeigen, wenn NICHT eigener User */}
{!isCurrentUser && (
<Button
type="button"
size="lg"
variant="soft"
tone="rose"
icon={<TrashIcon className="size-5" />}
onClick={handleDelete}
disabled={deleting}
/>
)}
</div>
{/* Mobile: Dropdown mit denselben Actions */}
<div className="sm:hidden">
<Dropdown
triggerVariant="icon"
ariaLabel="Weitere Aktionen"
align="right"
sections={[
{
id: 'main',
items: [
{
id: 'edit',
label: 'Bearbeiten',
onClick: () => setEditOpen(true),
},
// Delete nur, wenn nicht eigener User
...(!isCurrentUser
? [
{
id: 'delete',
label: deleting ? 'Lösche …' : 'Löschen',
tone: 'danger' as const,
onClick: handleDelete,
disabled: deleting,
},
]
: []),
],
},
]}
/>
</div>
</div>
{/* Edit-Modal */}
<Modal
open={editOpen}
onClose={() => setEditOpen(false)}
title="User bearbeiten"
tone="info"
variant="centered"
size="md"
primaryAction={{
label: savingEdit ? 'Speichere …' : 'Speichern',
onClick: handleSaveEdit,
variant: 'primary',
}}
secondaryAction={{
label: 'Abbrechen',
onClick: () => setEditOpen(false),
variant: 'secondary',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveEdit();
}}
className="space-y-3 text-sm"
>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
NW-Kennung *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 disabled"
value={editNwKennung}
onChange={(e) => setEditNwKennung(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Arbeitsname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={editArbeitsname}
onChange={(e) => setEditArbeitsname(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Vorname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={editFirstName}
onChange={(e) => setEditFirstName(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Nachname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={editLastName}
onChange={(e) => setEditLastName(e.target.value)}
/>
</div>
</div>
</form>
</Modal>
</>
);
}
/* ───────── Haupt-Client ───────── */
export default function UsersTablesClient({
groups,
ungrouped,
allGroups,
}: Props) {
const router = useRouter();
const { data: session } = useSession();
const [deleteGroupPending, startDeleteGroupTransition] = useTransition();
// User-ID aus der Session passe das ggf. an dein Session-Objekt an
const currentUserId =
(session?.user as any)?.userId ??
(session?.user as any)?.id ??
null;
// Cluster nach Basis-Key bauen, z.B. "Gruppe" für "Gruppe1"/"Gruppe1-Test",
// "Test" für "Test1", "Test2", "Test-Test", ...
const { clustersList, clustersByBaseKey } = useMemo(() => {
const byKey = new Map<string, GroupCluster>();
for (const g of groups) {
const baseKey = getBaseGroupName(g.name); // z.B. "Test" aus "Test1"
let cluster = byKey.get(baseKey);
if (!cluster) {
cluster = {
baseKey,
label: baseKey, // wird gleich noch ggf. verfeinert
groups: [],
totalCount: 0,
};
byKey.set(baseKey, cluster);
}
cluster.groups.push(g);
cluster.totalCount += g.users.length;
}
// Label bestimmen:
// - Wenn es eine Gruppe mit Name-Basis == baseKey gibt (z.B. "Test-Test" -> "Test"),
// nimm genau diesen Basisnamen ("Test").
// - Sonst: kürzester Name vor dem Bindestrich (z.B. "Gruppe1" bei "Gruppe1" + "Gruppe1-Test")
for (const cluster of byKey.values()) {
const root = cluster.baseKey; // z.B. "Test"
const dashedNames = cluster.groups.map((g) =>
g.name.split('-')[0].trim(),
);
if (dashedNames.includes(root)) {
// z.B. "Test1", "Test2", "Test-Test" -> root = "Test" vorhanden -> Label = "Test"
cluster.label = root;
} else {
// z.B. "Gruppe1", "Gruppe1-Test" -> root = "Gruppe"
// Kein "Gruppe" vorhanden, nimm den kürzesten "beforeDash": "Gruppe1"
const candidates =
dashedNames.length > 0
? dashedNames
: cluster.groups.map((g) => g.name);
cluster.label = candidates.reduce(
(shortest, curr) =>
curr.length < shortest.length ? curr : shortest,
candidates[0],
);
}
}
const list = Array.from(byKey.values()).sort((a, b) =>
a.label.localeCompare(b.label, 'de'),
);
return { clustersList: list, clustersByBaseKey: byKey };
}, [groups]);
// Haupt-Tabs: pro Cluster + "Ohne Gruppe"
const mainTabs: TabItem[] = useMemo(() => {
const baseTabs: TabItem[] = clustersList.map((cluster) => ({
id: cluster.baseKey,
label: cluster.label,
count: cluster.totalCount,
}));
baseTabs.push({
id: 'ungrouped',
label: 'Ohne Gruppe',
count: ungrouped.length,
});
return baseTabs;
}, [clustersList, ungrouped.length]);
// Aktiver Haupt-Tab (Cluster, z.B. "Test" oder "Gruppe" oder "ungrouped")
const [activeMainTab, setActiveMainTab] = useState<string>(
() => mainTabs[0]?.id ?? 'ungrouped',
);
useEffect(() => {
if (!mainTabs.some((t) => t.id === activeMainTab)) {
setActiveMainTab(mainTabs[0]?.id ?? 'ungrouped');
}
}, [mainTabs, activeMainTab]);
// Unter-Tabs: pro Cluster z.B. [Alle, Test1, Test2, Test-Test, …]
const subTabs: TabItem[] = useMemo(() => {
if (activeMainTab === 'ungrouped') return [];
const cluster = clustersByBaseKey.get(activeMainTab);
if (!cluster) return [];
const items: TabItem[] = [];
items.push({
id: '__all',
label: 'Alle',
count: cluster.totalCount,
});
const sortedGroups = [...cluster.groups].sort((a, b) =>
a.name.localeCompare(b.name, 'de'),
);
for (const g of sortedGroups) {
items.push({
id: g.id,
label: g.name,
count: g.users.length,
});
}
return items;
}, [activeMainTab, clustersByBaseKey]);
// Aktiver Unter-Tab
const [activeSubTab, setActiveSubTab] = useState<string>('__all');
useEffect(() => {
// Bei Wechsel auf "Ohne Gruppe" oder wenn es keine Sub-Tabs gibt: immer "__all"
if (activeMainTab === 'ungrouped' || subTabs.length === 0) {
if (activeSubTab !== '__all') {
setActiveSubTab('__all');
}
return;
}
// Sicherstellen, dass der aktive Sub-Tab existiert
if (!subTabs.some((t) => t.id === activeSubTab)) {
setActiveSubTab(subTabs[0].id);
}
}, [activeMainTab, subTabs, activeSubTab]);
// Tabelle: Daten nach aktivem Haupt-Tab + Sub-Tab filtern
const tableData: UserWithAvatar[] = useMemo(() => {
let rows: UserWithAvatar[] = [];
if (activeMainTab === 'ungrouped') {
rows = ungrouped as UserWithAvatar[];
} else {
const cluster = clustersByBaseKey.get(activeMainTab);
if (!cluster) return [];
if (activeSubTab === '__all') {
rows = cluster.groups.flatMap((g) => g.users) as UserWithAvatar[];
} else {
const group = cluster.groups.find((g) => g.id === activeSubTab);
if (!group) return [];
rows = group.users as UserWithAvatar[];
}
}
// Standard-Sortierung nach Arbeitsname (auf Kopie arbeiten)
return [...rows].sort((a, b) => {
const aName = (a.arbeitsname ?? '').toLocaleLowerCase('de-DE');
const bName = (b.arbeitsname ?? '').toLocaleLowerCase('de-DE');
return aName.localeCompare(bName, 'de', { sensitivity: 'base' });
});
}, [activeMainTab, activeSubTab, ungrouped, clustersByBaseKey]);
// Gruppe löschen (konkrete Untergruppe, nicht der ganze Cluster)
function handleDeleteActiveGroup() {
if (activeMainTab === 'ungrouped') return;
if (activeSubTab === '__all') {
alert(
'Bitte zuerst eine konkrete Untergruppe auswählen, bevor du sie löschen kannst.',
);
return;
}
const group = allGroups.find((g) => g.id === activeSubTab);
if (!group) return;
const ok = window.confirm(
`Gruppe "${group.name}" wirklich löschen?\n\nAlle Benutzer dieser Gruppe werden NICHT gelöscht, aber ihre Gruppen-Zuordnung wird entfernt (sie landen unter "Ohne Gruppe").`,
);
if (!ok) return;
startDeleteGroupTransition(async () => {
try {
const res = await fetch(`/api/user-groups/${group.id}`, {
method: 'DELETE',
});
if (!res.ok) {
console.error(
'Fehler beim Löschen der Gruppe',
await res.text(),
);
alert('Gruppe konnte nicht gelöscht werden.');
return;
}
router.refresh();
} catch (err) {
console.error('Fehler beim Löschen der Gruppe', err);
alert('Fehler beim Löschen der Gruppe.');
}
});
}
// NEU: ganzen Cluster (alle Gruppen darunter) löschen
function handleDeleteActiveCluster() {
if (activeMainTab === 'ungrouped') return;
const cluster = clustersByBaseKey.get(activeMainTab);
if (!cluster || cluster.groups.length === 0) return;
const groupNames = cluster.groups.map((g) => g.name).join(', ');
const ok = window.confirm(
`Cluster "${cluster.label}" wirklich löschen?\n\nEs werden alle folgenden Gruppen gelöscht:\n${groupNames}\n\nAlle Benutzer dieser Gruppen werden NICHT gelöscht, aber ihre Gruppen-Zuordnung wird entfernt (sie landen unter "Ohne Gruppe").`,
);
if (!ok) return;
startDeleteGroupTransition(async () => {
try {
// Alle Gruppen dieses Clusters nacheinander löschen
for (const g of cluster.groups) {
const res = await fetch(`/api/user-groups/${g.id}`, {
method: 'DELETE',
});
if (!res.ok) {
console.error(
'Fehler beim Löschen der Gruppe',
g.name,
await res.text(),
);
}
}
router.refresh();
} catch (err) {
console.error('Fehler beim Löschen des Clusters', err);
alert('Fehler beim Löschen des Clusters.');
}
});
}
// Columns inkl. Gruppen-Spalte mit Dropdown + "Du"-Badge anhand der ID
const userColumns: TableColumn<UserWithAvatar>[] = useMemo(
() => [
{
// Avatar-Spalte
key: 'avatarUrl',
header: '',
sortable: false,
headerClassName: 'w-10',
cellClassName: 'w-10',
render: (row) => (
<UserAvatar
name={row.arbeitsname as any}
avatarUrl={row.avatarUrl}
size="md"
/>
),
},
{
key: 'arbeitsname',
header: 'Arbeitsname',
sortable: true,
render: (row) => {
const isCurrent =
!!currentUserId && row.nwkennung === currentUserId;
return (
<div className="flex items-center gap-2">
<span>{row.arbeitsname}</span>
{isCurrent && (
<Badge size="sm" tone="green" variant="flat">
Du
</Badge>
)}
</div>
);
},
},
{
key: 'lastName',
header: 'Nachname',
sortable: true,
},
{
key: 'firstName',
header: 'Vorname',
sortable: true,
},
{
key: 'groupId',
header: 'Gruppe',
sortable: false,
render: (row) => (
<AssignGroupForm
user={row}
defaultGroupId={row.groupId ?? null}
allGroups={allGroups}
/>
),
},
],
[allGroups, currentUserId],
);
const canDeleteCurrentGroup =
activeMainTab !== 'ungrouped' &&
activeSubTab !== '__all' &&
subTabs.length > 0;
const canDeleteCurrentCluster =
activeMainTab !== 'ungrouped' &&
clustersByBaseKey.get(activeMainTab)?.groups.length;
return (
<div className="space-y-4">
{/* 1. Tab-Reihe: Hauptgruppen (Cluster) + Cluster-Löschen-Button */}
<div className="mt-2 space-y-2">
<div className="flex items-center justify-between gap-3">
<Tabs
tabs={mainTabs}
value={activeMainTab}
onChange={setActiveMainTab}
ariaLabel="Usergruppen (Cluster) auswählen"
/>
{canDeleteCurrentCluster && (
<Button
type="button"
variant="soft"
tone="rose"
size="lg"
onClick={handleDeleteActiveCluster}
disabled={deleteGroupPending}
>
{deleteGroupPending
? 'Lösche Cluster …'
: 'Cluster löschen'}
</Button>
)}
</div>
{/* 2. Tab-Reihe: Untergruppen + Untergruppe-löschen-Button */}
{activeMainTab !== 'ungrouped' && subTabs.length > 0 && (
<div className="flex items-center justify-between gap-3">
<Tabs
tabs={subTabs}
value={activeSubTab}
onChange={setActiveSubTab}
ariaLabel="Untergruppen auswählen"
/>
{canDeleteCurrentGroup && (
<Button
type="button"
variant="soft"
tone="rose"
size="lg"
onClick={handleDeleteActiveGroup}
disabled={deleteGroupPending}
>
{deleteGroupPending
? 'Lösche Gruppe …'
: 'Untergruppe löschen'}
</Button>
)}
</div>
)}
</div>
<div>
<Table<UserWithAvatar>
data={tableData}
columns={userColumns}
getRowId={(row) => row.nwkennung}
actionsHeader="Aktionen"
renderActions={(row) => (
<UserRowActions
user={row}
currentUserId={currentUserId}
/>
)}
defaultSortKey="arbeitsname"
defaultSortDirection="asc"
/>
</div>
</div>
);
}

42
app/(app)/users/page.tsx Normal file
View File

@ -0,0 +1,42 @@
// app/(app)/users/page.tsx
import UsersHeaderClient from './UsersHeaderClient';
import UsersTablesClient from './UsersTablesClient';
import { prisma } from '@/lib/prisma';
import type { User, UserGroup } from '@/generated/prisma/client';
export const dynamic = 'force-dynamic';
type GroupWithUsers = UserGroup & { users: User[] };
export default async function UsersPage() {
const allGroups = await prisma.userGroup.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
const groups = await prisma.userGroup.findMany({
orderBy: { name: 'asc' },
include: {
users: {
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
},
},
});
const ungrouped = await prisma.user.findMany({
where: { groupId: null },
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
});
return (
<div className="space-y-6">
<UsersHeaderClient groups={allGroups} />
<UsersTablesClient
groups={groups as GroupWithUsers[]}
ungrouped={ungrouped}
allGroups={allGroups}
/>
</div>
);
}

13
app/(app)/users/types.ts Normal file
View File

@ -0,0 +1,13 @@
// app/(app)/users/types.ts
import type { User, UserGroup } from '@/generated/prisma/client';
export type GroupWithUsers = UserGroup & { users: User[] };
export type SimpleGroup = {
id: string;
name: string;
};
export type UserWithAvatar = User & {
avatarUrl?: string | null;
};

View File

@ -22,14 +22,17 @@ export async function GET(_req: Request, ctx: RouteContext) {
});
const result = history.map((entry) => {
// 👇 hier baust du den Anzeigenamen
const changedByName =
entry.changedBy?.name ??
entry.changedBy?.username ??
entry.changedBy?.email ??
null;
const user = entry.changedBy;
// snapshot.changes aus dem JSON holen (bei CREATED leer)
// 👇 Anzeigename nach deiner neuen Logik:
// arbeitsname > "Vorname Nachname" > nwkennung > email
const changedByName =
user?.arbeitsname ??
(user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.nwkennung ?? user?.email ?? null);
// snapshot.changes aus dem JSON holen (bei CREATED evtl. leer)
let changes: {
field: string;
from: string | null;
@ -49,7 +52,7 @@ export async function GET(_req: Request, ctx: RouteContext) {
id: entry.id,
changeType: entry.changeType,
changedAt: entry.changedAt.toISOString(),
changedBy: changedByName, // 👈 passt zu deinem ApiHistoryEntry
changedBy: changedByName, // 👈 jetzt schön formatiert
changes,
};
});

View File

@ -1,20 +1,15 @@
// app/api/devices/[id]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client';
import type { Prisma } from '@/generated/prisma/client';
import { getCurrentUserId } from '@/lib/auth';
import type { Server as IOServer } from 'socket.io';
type RouteParams = { id: string };
// in Next 15+ ist params ein Promise
type RouteContext = { params: Promise<RouteParams> };
export async function GET(
_req: Request,
ctx: RouteContext,
) {
// params-Promise auflösen
export async function GET(_req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
if (!id) {
@ -27,7 +22,7 @@ export async function GET(
include: {
group: true,
location: true,
tags: true, // 🔹 NEU
tags: true,
},
});
@ -50,11 +45,15 @@ export async function GET(
// passwordHash bewusst weggelassen
group: device.group?.name ?? null,
location: device.location?.name ?? null,
tags: device.tags.map((t) => t.name), // 🔹
tags: device.tags.map((t) => t.name),
// Verleih
loanedTo: device.loanedTo,
loanedFrom: device.loanedFrom ? device.loanedFrom.toISOString() : null,
loanedUntil: device.loanedUntil ? device.loanedUntil.toISOString() : null,
loanComment: device.loanComment,
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 });
@ -73,14 +72,19 @@ export async function POST(req: Request) {
serialNumber,
productNumber,
comment,
group, // Name der Gruppe (string)
location, // Name des Standorts (string)
group,
location,
ipv4Address,
ipv6Address,
macAddress,
username,
passwordHash,
tags, // string[]
tags,
// Verleih-Felder
loanedTo,
loanedFrom,
loanedUntil,
loanComment,
} = body;
if (!inventoryNumber || !name) {
@ -90,7 +94,6 @@ export async function POST(req: Request) {
);
}
// prüfen, ob Inventar-Nr. schon existiert
const existing = await prisma.device.findUnique({
where: { inventoryNumber },
});
@ -101,26 +104,24 @@ export async function POST(req: Request) {
);
}
// 🔹 aktuell eingeloggten User ermitteln
const userId = await getCurrentUserId();
// 🔹 nur verknüpfen, wenn der User wirklich in der DB existiert
let canConnectUser = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
where: { nwkennung: userId },
select: { nwkennung: true },
});
if (user) {
canConnectUser = true;
} else {
console.warn(
`[POST /api/devices] User mit id=${userId} nicht gefunden createdBy/changedBy werden nicht gesetzt.`,
`[POST /api/devices] User mit nwkennung=${userId} nicht gefunden createdBy/changedBy werden nicht gesetzt.`,
);
}
}
// Gruppe nach Name auflösen / anlegen
// Gruppe
let groupId: string | null = null;
if (group && typeof group === 'string' && group.trim() !== '') {
const grp = await prisma.deviceGroup.upsert({
@ -131,7 +132,7 @@ export async function POST(req: Request) {
groupId = grp.id;
}
// ✅ Location nach Name auflösen / anlegen
// Standort
let locationId: string | null = null;
if (location && typeof location === 'string' && location.trim() !== '') {
const loc = await prisma.location.upsert({
@ -142,14 +143,12 @@ export async function POST(req: Request) {
locationId = loc.id;
}
// ✅ Tag-Namen vorbereiten
const tagNames: string[] = Array.isArray(tags)
? tags
.map((t: unknown) => String(t).trim())
.filter((t) => t.length > 0)
: [];
// ✅ Device anlegen (mit createdBy & Tags)
const created = await prisma.device.create({
data: {
inventoryNumber,
@ -164,17 +163,19 @@ export async function POST(req: Request) {
macAddress: macAddress ?? null,
username: username ?? null,
passwordHash: passwordHash ?? null,
// Verleih-Felder
loanedTo: loanedTo ?? null,
loanedFrom: loanedFrom ? new Date(loanedFrom) : null,
loanedUntil: loanedUntil ? new Date(loanedUntil) : null,
loanComment: loanComment ?? null,
groupId,
locationId,
// User, der das Gerät angelegt hat
...(canConnectUser && userId
? {
createdBy: {
connect: { id: userId },
},
}
: {}),
// Tags Many-to-Many
// ⬇️ statt createdBy.connect -> einfach FK setzen
createdById: canConnectUser && userId ? userId : null,
...(tagNames.length
? {
tags: {
@ -190,11 +191,10 @@ export async function POST(req: Request) {
group: true,
location: true,
tags: true,
createdBy: true,
createdBy: true, // darf trotzdem included werden, Prisma nutzt createdById
},
});
// 🔹 History-Eintrag "CREATED" mit changedById
const snapshot: Prisma.JsonObject = {
before: null,
after: {
@ -213,6 +213,14 @@ export async function POST(req: Request) {
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
@ -228,7 +236,6 @@ export async function POST(req: Request) {
},
});
// 🔊 optional: Socket.IO-Broadcast für Live-Listen
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:created', {
@ -246,11 +253,18 @@ export async function POST(req: Request) {
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
updatedAt: created.updatedAt.toISOString(),
});
}
// Antwort wie bei GET /api/devices
return NextResponse.json(
{
inventoryNumber: created.inventoryNumber,
@ -268,12 +282,20 @@ export async function POST(req: Request) {
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },
);
} catch (err) {
console.error('Error in POST /api/devices', err);
console.error('Error in POST /api/devices/[id]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Anlegen des Geräts.' },
{ status: 500 },
@ -281,10 +303,7 @@ export async function POST(req: Request) {
}
}
export async function PATCH(
req: Request,
ctx: RouteContext,
) {
export async function PATCH(req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
if (!id) {
@ -294,26 +313,24 @@ export async function PATCH(
try {
const body = await req.json();
// aktuell eingeloggten User ermitteln
const userId = await getCurrentUserId();
// nur verbinden, wenn der User in der DB existiert
let canConnectUpdatedBy = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
where: { nwkennung: userId }, // ✅ User.nwkennung ist @id
select: { nwkennung: true },
});
if (user) {
canConnectUpdatedBy = true;
} else {
console.warn(
`[PATCH /api/devices/${id}] User mit id=${userId} nicht gefunden updatedBy wird nicht gesetzt.`,
`[PATCH /api/devices/${id}] User mit nwkennung=${userId} nicht gefunden updatedBy wird nicht gesetzt.`,
);
}
}
// aktuelles Gerät inkl. Relations laden (für "before"-Snapshot)
const existing = await prisma.device.findUnique({
where: { inventoryNumber: id },
include: {
@ -327,7 +344,6 @@ export async function PATCH(
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
// Basis-Update-Daten
const data: Prisma.DeviceUpdateInput = {
name: body.name,
manufacturer: body.manufacturer,
@ -340,16 +356,20 @@ export async function PATCH(
macAddress: body.macAddress ?? null,
username: body.username ?? null,
passwordHash: body.passwordHash ?? null,
// Verleih
loanedTo: body.loanedTo ?? null,
loanedFrom: body.loanedFrom ? new Date(body.loanedFrom) : null,
loanedUntil: body.loanedUntil ? new Date(body.loanedUntil) : null,
loanComment: body.loanComment ?? null,
};
// updatedBy nur setzen, wenn User existiert
if (canConnectUpdatedBy && userId) {
data.updatedBy = {
connect: { id: userId },
connect: { nwkennung: userId },
};
}
// Gruppe (per Name) DeviceGroup.name ist @unique
// Gruppe
if (body.group != null && body.group !== '') {
data.group = {
connectOrCreate: {
@ -361,7 +381,7 @@ export async function PATCH(
data.group = { disconnect: true };
}
// Standort (per Name) Location.name ist @unique
// Standort
if (body.location != null && body.location !== '') {
data.location = {
connectOrCreate: {
@ -373,7 +393,7 @@ export async function PATCH(
data.location = { disconnect: true };
}
// Tags (Many-to-Many via Tag.name @unique)
// Tags
if (Array.isArray(body.tags)) {
const tagNames = (body.tags as string[])
.map((t) => String(t).trim())
@ -398,7 +418,6 @@ export async function PATCH(
};
}
// Update durchführen (für "after"-Snapshot)
const updated = await prisma.device.update({
where: { inventoryNumber: id },
data,
@ -427,7 +446,15 @@ export async function PATCH(
type TrackedField = (typeof trackedFields)[number];
const changes: {
field: TrackedField | 'group' | 'location' | 'tags';
field:
| TrackedField
| 'group'
| 'location'
| 'tags'
| 'loanedTo'
| 'loanedFrom'
| 'loanedUntil'
| 'loanComment';
before: string | null;
after: string | null;
}[] = [];
@ -441,7 +468,7 @@ export async function PATCH(
}
}
// group / location per Name vergleichen
// group / location
const beforeGroup = (existing.group?.name ?? null) as string | null;
const afterGroup = (updated.group?.name ?? null) as string | null;
if (beforeGroup !== afterGroup) {
@ -462,7 +489,7 @@ export async function PATCH(
});
}
// Tags vergleichen (als kommagetrennte Liste)
// Tags
const beforeTagsList = existing.tags.map((t) => t.name).sort();
const afterTagsList = updated.tags.map((t) => t.name).sort();
@ -479,7 +506,55 @@ export async function PATCH(
});
}
// Falls sich gar nichts geändert hat, kein History-Eintrag
// Verleih-Diffs
const beforeLoanedTo = existing.loanedTo ?? null;
const afterLoanedTo = updated.loanedTo ?? null;
if (beforeLoanedTo !== afterLoanedTo) {
changes.push({
field: 'loanedTo',
before: beforeLoanedTo,
after: afterLoanedTo,
});
}
const beforeLoanedFrom = existing.loanedFrom
? existing.loanedFrom.toISOString()
: null;
const afterLoanedFrom = updated.loanedFrom
? updated.loanedFrom.toISOString()
: null;
if (beforeLoanedFrom !== afterLoanedFrom) {
changes.push({
field: 'loanedFrom',
before: beforeLoanedFrom,
after: afterLoanedFrom,
});
}
const beforeLoanedUntil = existing.loanedUntil
? existing.loanedUntil.toISOString()
: null;
const afterLoanedUntil = updated.loanedUntil
? updated.loanedUntil.toISOString()
: null;
if (beforeLoanedUntil !== afterLoanedUntil) {
changes.push({
field: 'loanedUntil',
before: beforeLoanedUntil,
after: afterLoanedUntil,
});
}
const beforeLoanComment = existing.loanComment ?? null;
const afterLoanComment = updated.loanComment ?? null;
if (beforeLoanComment !== afterLoanComment) {
changes.push({
field: 'loanComment',
before: beforeLoanComment,
after: afterLoanComment,
});
}
if (changes.length > 0) {
const snapshot: Prisma.JsonObject = {
before: {
@ -498,6 +573,14 @@ export async function PATCH(
group: existing.group?.name ?? null,
location: existing.location?.name ?? null,
tags: existing.tags.map((t) => t.name),
loanedTo: existing.loanedTo,
loanedFrom: existing.loanedFrom
? existing.loanedFrom.toISOString()
: null,
loanedUntil: existing.loanedUntil
? existing.loanedUntil.toISOString()
: null,
loanComment: existing.loanComment,
createdAt: existing.createdAt.toISOString(),
updatedAt: existing.updatedAt.toISOString(),
},
@ -517,6 +600,14 @@ export async function PATCH(
group: updated.group?.name ?? null,
location: updated.location?.name ?? null,
tags: updated.tags.map((t) => t.name),
loanedTo: updated.loanedTo,
loanedFrom: updated.loanedFrom
? updated.loanedFrom.toISOString()
: null,
loanedUntil: updated.loanedUntil
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
},
@ -527,7 +618,6 @@ export async function PATCH(
})),
};
// 🔴 WICHTIG: changedById nur setzen, wenn der User wirklich existiert
await prisma.deviceHistory.create({
data: {
deviceId: updated.inventoryNumber,
@ -538,7 +628,6 @@ export async function PATCH(
});
}
// 🔊 Socket.IO-Broadcast für Live-Updates
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:updated', {
@ -555,11 +644,19 @@ export async function PATCH(
username: updated.username,
group: updated.group?.name ?? null,
location: updated.location?.name ?? null,
tags: updated.tags.map((t) => t.name),
loanedTo: updated.loanedTo,
loanedFrom: updated.loanedFrom
? updated.loanedFrom.toISOString()
: null,
loanedUntil: updated.loanedUntil
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
updatedAt: updated.updatedAt.toISOString(),
});
}
// Antwort an den Client
return NextResponse.json({
inventoryNumber: updated.inventoryNumber,
name: updated.name,
@ -575,6 +672,14 @@ export async function PATCH(
group: updated.group?.name ?? null,
location: updated.location?.name ?? null,
tags: updated.tags.map((t) => t.name),
loanedTo: updated.loanedTo,
loanedFrom: updated.loanedFrom
? updated.loanedFrom.toISOString()
: null,
loanedUntil: updated.loanedUntil
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
});

View File

@ -1,7 +1,7 @@
// app/api/devices/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client';
import type { Prisma } from '@/generated/prisma/client';
import { getCurrentUserId } from '@/lib/auth';
import type { Server as IOServer } from 'socket.io';
@ -32,6 +32,10 @@ export async function GET() {
group: d.group?.name ?? null,
location: d.location?.name ?? null,
tags: d.tags.map((t) => t.name),
loanedTo: d.loanedTo,
loanedFrom: d.loanedFrom ? d.loanedFrom.toISOString() : null,
loanedUntil: d.loanedUntil ? d.loanedUntil.toISOString() : null,
loanComment: d.loanComment,
updatedAt: d.updatedAt.toISOString(),
})),
);
@ -61,6 +65,11 @@ export async function POST(req: Request) {
username,
passwordHash,
tags,
// Verleih-Felder
loanedTo,
loanedFrom,
loanedUntil,
loanComment,
} = body;
if (!inventoryNumber || !name) {
@ -80,7 +89,6 @@ export async function POST(req: Request) {
);
}
// aktuell eingeloggten User ermitteln
const userId = await getCurrentUserId();
let canConnectUser = false;
@ -98,7 +106,7 @@ export async function POST(req: Request) {
}
}
// Gruppe / Standort (per Name) anlegen/finden
// Gruppe
let groupId: string | null = null;
if (group && typeof group === 'string' && group.trim() !== '') {
const grp = await prisma.deviceGroup.upsert({
@ -109,6 +117,7 @@ export async function POST(req: Request) {
groupId = grp.id;
}
// Standort
let locationId: string | null = null;
if (location && typeof location === 'string' && location.trim() !== '') {
const loc = await prisma.location.upsert({
@ -138,7 +147,12 @@ export async function POST(req: Request) {
username: username ?? null,
passwordHash: passwordHash ?? null,
// ⬇️ Relation über group/location, nicht groupId/locationId
// Verleih-Felder
loanedTo: loanedTo ?? null,
loanedFrom: loanedFrom ? new Date(loanedFrom) : null,
loanedUntil: loanedUntil ? new Date(loanedUntil) : null,
loanComment: loanComment ?? null,
...(groupId
? {
group: {
@ -155,12 +169,10 @@ export async function POST(req: Request) {
}
: {}),
// User, der das Gerät angelegt hat
...(canConnectUser && userId
? { createdBy: { connect: { id: userId } } }
: {}),
// Tags Many-to-Many
...(tagNames.length
? {
tags: {
@ -179,7 +191,6 @@ export async function POST(req: Request) {
},
});
// History-Eintrag "CREATED" mit changedById
const snapshot: Prisma.JsonObject = {
before: null,
after: {
@ -198,6 +209,14 @@ export async function POST(req: Request) {
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
@ -213,7 +232,6 @@ export async function POST(req: Request) {
},
});
// Socket-Event
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:created', {
@ -231,6 +249,14 @@ export async function POST(req: Request) {
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
updatedAt: created.updatedAt.toISOString(),
});
}
@ -252,6 +278,14 @@ export async function POST(req: Request) {
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },

View File

@ -0,0 +1,38 @@
// app/api/user-groups/[id]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
type RouteParams = { id: string };
type RouteContext = { params: Promise<RouteParams> };
export async function DELETE(_req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
if (!id || id === 'ungrouped') {
return NextResponse.json(
{ error: 'Ungültige Gruppen-ID.' },
{ status: 400 },
);
}
try {
// 1) Alle User aus dieser Gruppe in "Ohne Gruppe" schieben (groupId = null)
await prisma.user.updateMany({
where: { groupId: id },
data: { groupId: null },
});
// 2) Gruppe selbst löschen
await prisma.userGroup.delete({
where: { id },
});
return NextResponse.json({ ok: true }, { status: 200 });
} catch (err) {
console.error('[DELETE /api/user-groups/[id]]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Löschen der Gruppe.' },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,41 @@
// app/api/user-groups/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
try {
const { name } = await req.json();
if (!name || typeof name !== 'string') {
return NextResponse.json(
{ error: 'Gruppenname ist erforderlich.' },
{ status: 400 },
);
}
const trimmed = name.trim();
if (!trimmed) {
return NextResponse.json(
{ error: 'Gruppenname darf nicht leer sein.' },
{ status: 400 },
);
}
const group = await prisma.userGroup.upsert({
where: { name: trimmed },
update: {},
create: { name: trimmed },
});
return NextResponse.json(
{ id: group.id, name: group.name },
{ status: 200 },
);
} catch (err) {
console.error('[POST /api/user-groups]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Anlegen der User-Gruppe.' },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,92 @@
// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// Next 15/16: params ist ein Promise
type ParamsPromise = Promise<{ id: string }>;
export async function PATCH(
req: Request,
{ params }: { params: ParamsPromise },
) {
try {
const { id } = await params; // id == nwkennung
if (!id) {
return NextResponse.json(
{ error: 'User-ID (nwkennung) fehlt in der URL.' },
{ status: 400 },
);
}
const body = await req.json();
const { arbeitsname, firstName, lastName, groupId } = body ?? {};
const data: any = {};
if (arbeitsname !== undefined) data.arbeitsname = String(arbeitsname).trim();
if (firstName !== undefined) data.firstName = String(firstName).trim();
if (lastName !== undefined) data.lastName = String(lastName).trim();
if (groupId !== undefined) {
data.groupId = groupId === 'none' || groupId == null ? null : String(groupId);
}
if (Object.keys(data).length === 0) {
return NextResponse.json(
{ error: 'Keine Felder zum Aktualisieren übergeben.' },
{ status: 400 },
);
}
const updated = await prisma.user.update({
where: { nwkennung: id }, // 🔹
data,
});
return NextResponse.json(
{
nwkennung: updated.nwkennung,
arbeitsname: updated.arbeitsname,
firstName: updated.firstName,
lastName: updated.lastName,
groupId: updated.groupId,
createdAt: updated.createdAt?.toISOString?.(),
updatedAt: updated.updatedAt?.toISOString?.(),
},
{ status: 200 },
);
} catch (err) {
console.error('[PATCH /api/users/[id]]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Aktualisieren des Users.' },
{ status: 500 },
);
}
}
export async function DELETE(
req: Request,
{ params }: { params: ParamsPromise },
) {
try {
const { id } = await params; // id == nwkennung
if (!id || id === 'undefined') {
return NextResponse.json(
{ error: 'User-ID (nwkennung) fehlt oder ist ungültig.' },
{ status: 400 },
);
}
await prisma.user.delete({
where: { nwkennung: id }, // 🔹
});
return NextResponse.json({ ok: true }, { status: 200 });
} catch (err) {
console.error('[DELETE /api/users/[id]]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Löschen des Users.' },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,33 @@
// app/api/users/assign-group/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
try {
const body = await req.json();
const { userId, groupId } = body ?? {};
if (!userId) {
return NextResponse.json(
{ error: 'userId ist erforderlich.' },
{ status: 400 },
);
}
const normalizedGroupId =
!groupId || groupId === 'none' ? null : String(groupId);
await prisma.user.update({
where: { nwkennung: String(userId) },
data: { groupId: normalizedGroupId },
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[POST /api/users/assign-group]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Aktualisieren der Gruppe.' },
{ status: 500 },
);
}
}

108
app/api/users/route.ts Normal file
View File

@ -0,0 +1,108 @@
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { User, UserGroup } from '@/generated/prisma/client';
type GroupWithUsers = UserGroup & { users: User[] };
// GET bleibt wie gehabt
export async function GET() {
try {
const [groupsRaw, ungrouped] = await Promise.all([
prisma.userGroup.findMany({
orderBy: { name: 'asc' },
include: {
users: {
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
},
},
}),
prisma.user.findMany({
where: { groupId: null },
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
}),
]);
const groups = groupsRaw as GroupWithUsers[];
const allGroups = groups.map((g) => ({ id: g.id, name: g.name }));
return NextResponse.json(
{
groups,
ungrouped,
allGroups,
},
{ status: 200 },
);
} catch (err) {
console.error('[GET /api/users]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Laden der User.' },
{ status: 500 },
);
}
}
// 🔹 POST: CSV-Import + manuelle Anlage
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as {
nwkennung?: string | null; // aus CSV oder Formular
email?: string | null; // optional, falls du später Email mit importierst
arbeitsname: string;
firstName: string;
lastName: string;
groupId?: string | null;
};
const {
nwkennung,
email,
arbeitsname,
firstName,
lastName,
groupId,
} = body;
// Pflichtfelder: Name + Arbeitsname
if (!nwkennung || !lastName || !firstName || !arbeitsname) {
return NextResponse.json(
{
error:
'nwkennung, lastName, firstName und arbeitsname sind Pflichtfelder.',
},
{ status: 400 },
);
}
const normalizedNwkennung = nwkennung.trim().toLowerCase();
const user = await prisma.user.upsert({
where: { nwkennung: normalizedNwkennung },
update: {
lastName,
firstName,
arbeitsname,
groupId: groupId ?? null,
},
create: {
nwkennung: normalizedNwkennung,
lastName,
firstName,
arbeitsname,
groupId: groupId ?? null,
},
});
return NextResponse.json(
{ success: true, user },
{ status: 201 },
);
} catch (err) {
console.error('[POST /api/users]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim CSV-Import.' },
{ status: 500 },
);
}
}

View File

@ -1,24 +1,55 @@
// app/login/page.tsx
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import LoginForm from '@/components/auth/LoginForm';
type LoginValues = {
identifier: string;
password: string;
};
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const callbackUrl = searchParams?.get('callbackUrl') ?? '/dashboard';
const handleSubmit = async (values: LoginValues) => {
setIsSubmitting(true);
setErrorMessage(null);
const res = await signIn('credentials', {
redirect: false,
email: values.identifier,
password: values.password,
callbackUrl,
});
setIsSubmitting(false);
if (!res) {
setErrorMessage('Unbekannter Fehler.');
return;
}
if (res.error) {
setErrorMessage('Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.');
return;
}
router.push(res.url ?? callbackUrl);
};
return (
<LoginForm
showSocialLogin={false}
onSubmit={async ({ email, password }) => {
await signIn('credentials', {
email,
password,
callbackUrl,
});
}}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
errorMessage={errorMessage}
/>
);
}

View File

@ -1,3 +1,5 @@
// app/providers.tsx
'use client';
import { SessionProvider } from 'next-auth/react';

View File

@ -26,8 +26,6 @@ export function DeviceQrCode({ inventoryNumber, size = 180 }: DeviceQrCodeProps)
bgColor="#FFFFFF"
fgColor="#000000"
/>
{/* Optional: zum Debuggen den Wert anzeigen */}
{/* <p className="text-[10px] text-gray-500 break-all text-center">{qrValue}</p> */}
</div>
);
}

131
components/UserMenu.tsx Normal file
View File

@ -0,0 +1,131 @@
// components/UserMenu.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { signOut } from 'next-auth/react';
import PersonAvatar from '@/components/ui/UserAvatar';
export type UserMenuProps = {
displayName: string;
avatarName: string;
avatarUrl?: string | null;
};
const userNavigation = [
{ name: 'Your profile', href: '#' },
{ name: 'Abmelden', href: '#' },
];
export default function UserMenu({
displayName,
avatarName,
avatarUrl,
}: UserMenuProps) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// Klick außerhalb / Escape => Menü schließen
useEffect(() => {
if (!open) return;
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node | null;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
buttonRef.current &&
!buttonRef.current.contains(target)
) {
setOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
const handleToggle = () => {
setOpen((prev) => !prev);
};
const handleItemClick = (itemName: string) => {
setOpen(false);
if (itemName === 'Abmelden') {
void signOut({ callbackUrl: '/login' });
return;
}
// hier könntest du später noch Routing für "Your profile" o.ä. einbauen
};
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
onClick={handleToggle}
className="relative flex items-center focus:outline-none"
aria-haspopup="menu"
aria-expanded={open}
aria-controls="user-menu-dropdown"
>
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
{/* Avatar über gemeinsame Komponente */}
<PersonAvatar name={avatarName} avatarUrl={avatarUrl} size="md" />
<span className="hidden lg:flex lg:items-center">
<span
aria-hidden="true"
className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white"
>
{displayName}
</span>
<ChevronDownIcon
aria-hidden="true"
className="ml-2 size-5 text-gray-400 dark:text-gray-500"
/>
</span>
</button>
{open && (
<div
ref={menuRef}
id="user-menu-dropdown"
role="menu"
aria-orientation="vertical"
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline-1 outline-gray-900/5 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{userNavigation.map((item) => (
<button
key={item.name}
type="button"
role="menuitem"
onClick={() => handleItemClick(item.name)}
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 hover:bg-gray-50 focus:outline-none dark:text-white dark:hover:bg-white/5"
>
{item.name}
</button>
))}
</div>
)}
</div>
);
}

View File

@ -1,26 +1,20 @@
// src/components/auth/LoginForm.tsx
// components/auth/LoginForm.tsx
'use client';
import * as React from 'react';
import Button from '@/components/ui/Button';
type LoginValues = {
email: string;
identifier: string;
password: string;
rememberMe: boolean;
};
type SocialProvider = 'google' | 'github';
export interface LoginFormProps {
title?: string;
subtitle?: string;
onSubmit?: (values: LoginValues) => void | Promise<void>;
isSubmitting?: boolean;
errorMessage?: string | null;
showRememberMe?: boolean;
showSocialLogin?: boolean;
onSocialClick?: (provider: SocialProvider) => void;
}
const LoginForm: React.FC<LoginFormProps> = ({
@ -29,21 +23,17 @@ const LoginForm: React.FC<LoginFormProps> = ({
onSubmit,
isSubmitting,
errorMessage,
showRememberMe = true,
showSocialLogin = true,
onSocialClick,
}) => {
const [form, setForm] = React.useState<LoginValues>({
email: '',
identifier: '',
password: '',
rememberMe: false,
});
const handleChange =
(field: keyof LoginValues) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = field === 'rememberMe' ? e.target.checked : e.target.value;
setForm((prev) => ({ ...prev, [field]: value as never }));
const value = e.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
@ -83,26 +73,26 @@ const LoginForm: React.FC<LoginFormProps> = ({
{/* Benutzername / E-Mail */}
<div>
<label
htmlFor="email"
htmlFor="identifier"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername oder E-Mail
NW-Kennung oder E-Mail
</label>
<div className="mt-2">
<input
id="email"
name="email"
id="identifier"
name="identifier"
type="text"
required
autoComplete="username"
value={form.email}
onChange={handleChange('email')}
value={form.identifier}
onChange={handleChange('identifier')}
className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
{/* Password */}
{/* Passwort */}
<div>
<label
htmlFor="password"
@ -124,51 +114,8 @@ const LoginForm: React.FC<LoginFormProps> = ({
</div>
</div>
{/* Remember + Forgot */}
<div className="flex items-center justify-between">
{showRememberMe && (
<div className="flex gap-3">
<div className="flex h-6 shrink-0 items-center">
<div className="group grid size-4 grid-cols-1">
<input
id="remember-me"
name="remember-me"
type="checkbox"
checked={form.rememberMe}
onChange={handleChange('rememberMe')}
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 forced-colors:appearance-auto"
/>
<svg
fill="none"
viewBox="0 0 14 14"
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
>
<path
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-checked:opacity-100"
/>
<path
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-indeterminate:opacity-100"
/>
</svg>
</div>
</div>
<label
htmlFor="remember-me"
className="block text-sm/6 text-gray-900 dark:text-white"
>
Angemeldet bleiben
</label>
</div>
)}
{/* Optional: Passwort vergessen Link kannst du lassen oder entfernen */}
<div className="flex items-center justify-end">
<div className="text-sm/6">
<a
href="#"
@ -199,69 +146,6 @@ const LoginForm: React.FC<LoginFormProps> = ({
</Button>
</div>
</form>
{/* Social login */}
{showSocialLogin && (
<div>
<div className="mt-10 flex items-center gap-x-6">
<div className="w-full flex-1 border-t border-gray-200 dark:border-white/10" />
<p className="text-sm/6 font-medium text-nowrap text-gray-900 dark:text-white">
Oder anmelden mit
</p>
<div className="w-full flex-1 border-t border-gray-200 dark:border-white/10" />
</div>
<div className="mt-6 grid grid-cols-2 gap-4">
{/* Google */}
<button
type="button"
onClick={() => onSocialClick?.('google')}
className="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 focus-visible:inset-ring-transparent dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
<path
d="M12.0003 4.75C13.7703 4.75 15.3553 5.36002 16.6053 6.54998L20.0303 3.125C17.9502 1.19 15.2353 0 12.0003 0C7.31028 0 3.25527 2.69 1.28027 6.60998L5.27028 9.70498C6.21525 6.86002 8.87028 4.75 12.0003 4.75Z"
fill="#EA4335"
/>
<path
d="M23.49 12.275C23.49 11.49 23.415 10.73 23.3 10H12V14.51H18.47C18.18 15.99 17.34 17.25 16.08 18.1L19.945 21.1C22.2 19.01 23.49 15.92 23.49 12.275Z"
fill="#4285F4"
/>
<path
d="M5.26498 14.2949C5.02498 13.5699 4.88501 12.7999 4.88501 11.9999C4.88501 11.1999 5.01998 10.4299 5.26498 9.7049L1.275 6.60986C0.46 8.22986 0 10.0599 0 11.9999C0 13.9399 0.46 15.7699 1.28 17.3899L5.26498 14.2949Z"
fill="#FBBC05"
/>
<path
d="M12.0004 24.0001C15.2404 24.0001 17.9654 22.935 19.9454 21.095L16.0804 18.095C15.0054 18.82 13.6204 19.245 12.0004 19.245C8.8704 19.245 6.21537 17.135 5.2654 14.29L1.27539 17.385C3.25539 21.31 7.3104 24.0001 12.0004 24.0001Z"
fill="#34A853"
/>
</svg>
<span className="text-sm/6 font-semibold">Google</span>
</button>
{/* GitHub */}
<button
type="button"
onClick={() => onSocialClick?.('github')}
className="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 focus-visible:inset-ring-transparent dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
>
<svg
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
className="size-5 fill-[#24292F] dark:fill-white"
>
<path
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
fillRule="evenodd"
/>
</svg>
<span className="text-sm/6 font-semibold">GitHub</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>

View File

@ -0,0 +1,73 @@
// components/ui/ButtonGroup.tsx
'use client';
import * as React from 'react';
import clsx from 'clsx';
export type ButtonGroupOption = {
value: string;
label: React.ReactNode;
icon?: React.ReactNode;
};
type ButtonGroupProps = {
options: ButtonGroupOption[];
value: string;
onChange?: (next: string) => void;
className?: string;
/** Wenn true: nur Anzeige, keine Klicks */
readOnly?: boolean;
};
export default function ButtonGroup({
options,
value,
onChange,
className,
readOnly = false,
}: ButtonGroupProps) {
return (
<span
className={clsx(
'isolate inline-flex rounded-md shadow-xs dark:shadow-none',
className,
)}
>
{options.map((opt, idx) => {
const isFirst = idx === 0;
const isLast = idx === options.length - 1;
const isActive = opt.value === value; // aktuell nur für aria, nicht für Style
return (
<button
key={opt.value}
type="button"
disabled={readOnly}
aria-pressed={isActive}
className={clsx(
// 👇 1:1 aus deinem Snippet:
'relative inline-flex items-center bg-white px-3 py-2 text-sm font-semibold text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 focus:z-10 dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
!isFirst && '-ml-px',
isFirst && 'rounded-l-md',
isLast && 'rounded-r-md',
readOnly && 'cursor-default opacity-90',
)}
onClick={() => {
if (readOnly) return;
if (!onChange) return;
if (opt.value === value) return;
onChange(opt.value);
}}
>
{opt.icon && (
<span className="mr-1.5 inline-flex items-center">
{opt.icon}
</span>
)}
<span>{opt.label}</span>
</button>
);
})}
</span>
);
}

View File

@ -2,7 +2,13 @@
'use client';
import * as React from 'react';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
Portal,
} from '@headlessui/react';
import {
ChevronDownIcon,
EllipsisVerticalIcon,
@ -23,46 +29,52 @@ export type DropdownItem = {
export type DropdownSection = {
id?: string;
label?: string;
items: DropdownItem[];
};
export type DropdownTriggerVariant = 'button' | 'icon';
export interface DropdownProps {
/** Button-Label (für Trigger "button") */
label?: string;
/** aria-label für Trigger "icon" */
ariaLabel?: string;
/** Ausrichtung des Menüs */
align?: 'left' | 'right';
/** Darstellung des Triggers (normaler Button oder nur Icon) */
triggerVariant?: DropdownTriggerVariant;
/** Optionaler Header im Dropdown (z.B. "Signed in as …") */
header?: React.ReactNode;
/** Sektionen (werden bei >1 Sektion automatisch mit Divider getrennt) */
sections: DropdownSection[];
/** Optional: zusätzliche Klassen für den Trigger-Button */
triggerClassName?: string;
/** Optional: zusätzliche Klassen für das MenuItems-Panel */
menuClassName?: string;
/** Dropdown komplett deaktivieren (Trigger klickt nicht) */
disabled?: boolean;
}
/* ───────── interne Helfer ───────── */
const itemBaseClasses =
'block px-4 py-2 text-sm text-gray-700 ' +
'data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden ' +
'dark:text-gray-300 dark:data-focus:bg-white/5 dark:data-focus:text-white';
'block px-4 py-2 text-sm ' +
// Default Text
'text-gray-700 dark:text-gray-300 ' +
// Hover (Maus)
'hover:bg-gray-100 hover:text-gray-900 ' +
'dark:hover:bg-white/5 dark:hover:text-white ' +
// Focus Outline weglassen
'focus:outline-none';
const itemWithIconClasses =
'group flex items-center gap-x-3 px-4 py-2 text-sm text-gray-700 ' +
'data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden ' +
'dark:text-gray-300 dark:data-focus:bg-white/5 dark:data-focus:text-white';
'group flex items-center gap-x-3 px-4 py-2 text-sm ' +
// Default Text
'text-gray-700 dark:text-gray-300 ' +
// Hover
'hover:bg-gray-100 hover:text-gray-900 ' +
'dark:hover:bg-white/5 dark:hover:text-white ' +
'focus:outline-none';
const iconClasses =
'flex size-5 shrink-0 items-center justify-center text-gray-400 group-data-focus:text-gray-500 ' +
'dark:text-gray-500 dark:group-data-focus:text-white';
'flex size-5 shrink-0 items-center justify-center text-gray-400 ' +
'group-hover:text-gray-500 ' +
'dark:text-gray-500 dark:group-hover:text-white';
const toneClasses: Record<DropdownTone, string> = {
default: '',
@ -75,7 +87,12 @@ function renderItemContent(item: DropdownItem) {
if (hasIcon) {
return (
<span className={clsx(itemWithIconClasses, item.tone && toneClasses[item.tone])}>
<span
className={clsx(
itemWithIconClasses,
item.tone && toneClasses[item.tone],
)}
>
<span aria-hidden="true" className={iconClasses}>
{item.icon}
</span>
@ -85,13 +102,18 @@ function renderItemContent(item: DropdownItem) {
}
return (
<span className={clsx(itemBaseClasses, item.tone && toneClasses[item.tone])}>
<span
className={clsx(
itemBaseClasses,
item.tone && toneClasses[item.tone],
)}
>
{item.label}
</span>
);
}
/* ───────── Dropdown-Komponente ───────── */
/* ───────── Dropdown-Komponente (mit Portal) ───────── */
export function Dropdown({
label = 'Options',
@ -102,95 +124,141 @@ export function Dropdown({
sections,
triggerClassName,
menuClassName,
disabled = false,
}: DropdownProps) {
const hasDividers = sections.length > 1;
const alignmentClasses =
align === 'left'
? 'left-0 origin-top-left'
: 'right-0 origin-top-right';
const triggerIsButton = triggerVariant === 'button';
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = React.useState<{
top: number;
left: number;
} | null>(null);
return (
<Menu as="div" className="relative inline-block">
<MenuButton
className={clsx(
triggerIsButton
? 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20'
: 'flex items-center rounded-full text-gray-400 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:text-gray-300 dark:focus-visible:outline-indigo-500',
triggerClassName,
)}
>
{triggerIsButton ? (
<Menu as="div" className="relative inline-block text-left">
{({ open }) => {
React.useEffect(() => {
if (!open || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const scrollX =
window.pageXOffset || document.documentElement.scrollLeft;
const scrollY =
window.pageYOffset || document.documentElement.scrollTop;
const left =
align === 'left' ? rect.left + scrollX : rect.right + scrollX;
setPosition({
top: rect.bottom + scrollY + 4,
left,
});
}, [open, align]);
return (
<>
{label}
<ChevronDownIcon
aria-hidden="true"
className="-mr-1 size-5 text-gray-400 dark:text-gray-500"
/>
</>
) : (
<>
<span className="sr-only">{ariaLabel}</span>
<EllipsisVerticalIcon aria-hidden="true" className="size-5" />
</>
)}
</MenuButton>
<MenuButton
ref={buttonRef}
disabled={disabled}
className={clsx(
triggerIsButton
? 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20'
: 'flex items-center rounded-full text-gray-400 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:text-gray-300 dark:focus-visible:outline-indigo-500',
disabled && 'opacity-50 cursor-not-allowed hover:bg-white dark:hover:bg-white/10',
triggerClassName,
)}
>
{triggerIsButton ? (
<>
{label}
<ChevronDownIcon
aria-hidden="true"
className="-mr-1 size-5 text-gray-400 dark:text-gray-500"
/>
</>
) : (
<>
<span className="sr-only">{ariaLabel}</span>
<EllipsisVerticalIcon
aria-hidden="true"
className="size-5"
/>
</>
)}
</MenuButton>
<MenuItems
transition
className={clsx(
'absolute z-20 mt-2 w-56 rounded-md bg-white shadow-lg outline-1 outline-black/5 ' +
'transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 ' +
'data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in ' +
'dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
hasDividers && 'divide-y divide-gray-100 dark:divide-white/10',
alignmentClasses,
menuClassName,
)}
>
{/* Optionaler Header (z.B. "Signed in as …") */}
{header && (
<div className="px-4 py-3">
{header}
</div>
)}
{/* Sektionen mit/ohne Divider */}
{sections.map((section, sectionIndex) => (
<div key={section.id ?? sectionIndex} className="py-1">
{section.items.map((item, itemIndex) => {
const key = item.id ?? `${sectionIndex}-${itemIndex}`;
const commonProps = {
className: itemBaseClasses,
};
return (
<MenuItem key={key} disabled={item.disabled}>
{item.href ? (
<a
href={item.href}
onClick={item.onClick}
className="block"
>
{renderItemContent(item)}
</a>
) : (
<button
type="button"
onClick={item.onClick}
className="block w-full text-left"
>
{renderItemContent(item)}
</button>
{open && position && !disabled && (
<Portal>
<MenuItems
static
style={{
position: 'fixed',
top: position.top,
left: align === 'left' ? position.left : undefined,
right:
align === 'right'
? window.innerWidth - position.left
: undefined,
}}
className={clsx(
'z-[9999] w-56 max-h-[60vh] overflow-y-auto rounded-md bg-white shadow-lg outline-1 outline-black/5 ' +
'dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
hasDividers &&
'divide-y divide-gray-100 dark:divide-white/10',
menuClassName,
)}
</MenuItem>
);
})}
</div>
))}
</MenuItems>
>
{header && <div className="px-4 py-3">{header}</div>}
{sections.map((section, sectionIndex) => (
<div
key={section.id ?? sectionIndex}
className="py-1"
>
{/* NEU: Gruppen-Label als "Trenner" */}
{section.label && (
<div className="px-4 py-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{section.label}
</div>
)}
{section.items.map((item, itemIndex) => {
const key = item.id ?? `${sectionIndex}-${itemIndex}`;
return (
<MenuItem
key={key}
disabled={item.disabled}
>
{item.href ? (
<a
href={item.href}
onClick={item.onClick}
className="block"
>
{renderItemContent(item)}
</a>
) : (
<button
type="button"
onClick={item.onClick}
className="block w-full text-left"
>
{renderItemContent(item)}
</button>
)}
</MenuItem>
);
})}
</div>
))}
</MenuItems>
</Portal>
)}
</>
);
}}
</Menu>
);
}

View File

@ -2,20 +2,27 @@
'use client';
import * as React from 'react';
import {
TagIcon,
PlusIcon,
TrashIcon,
PencilIcon
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
import PersonAvatar from '@/components/ui/UserAvatar';
/* ───────── Types ───────── */
export type FeedPerson = {
/**
* Fallback-Anzeigename z.B. E-Mail, wenn sonst nichts da ist.
* In der Anzeige wird aber bevorzugt:
* arbeitsname > "Vorname Nachname" > nwkennung > email > name
*/
name: string;
href?: string;
imageUrl?: string; // optional: Avatar-Bild
// Zusätzliche Infos, damit wir einen schönen Display-Namen bauen können
arbeitsname?: string | null;
firstName?: string | null;
lastName?: string | null;
nwkennung?: string | null;
email?: string | null;
};
export type FeedTag = {
@ -40,9 +47,8 @@ export type FeedItem =
person: FeedPerson;
imageUrl?: string;
comment: string;
date: string;
/** Art des Kommentars steuert Icon/Farbe */
commentKind?: 'created' | 'deleted' | 'generic';
/** Art des Kommentars steuert nur noch Text, nicht mehr das Icon */
commentKind?: 'created' | 'deleted' | 'generic' | 'loaned' | 'returned';
}
| {
id: string | number;
@ -73,28 +79,39 @@ export interface FeedProps {
/* ───────── Helper ───────── */
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ');
// DE Datum+Uhrzeit für Änderungen
const dtfDateTime = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
/**
* Erzeugt den eigentlichen Anzeigenamen für eine Person:
* arbeitsname > "Vorname Nachname" > nwkennung > email > name
*/
function getDisplayName(person: FeedPerson): string {
return (
person.arbeitsname ??
(person.firstName && person.lastName
? `${person.firstName} ${person.lastName}`
: person.nwkennung ?? person.email ?? person.name)
);
}
// 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;
/** Werte für "change"-Einträge formatiert darstellen */
function formatChangeValue(field: string, value: string | null): string {
if (!value) return '—';
// Für Verleih-Daten: schön als Datum + Uhrzeit anzeigen
if (field === 'loanedFrom' || field === 'loanedUntil') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return dtfDateTime.format(d);
}
}
const idx = Math.abs(hash) % palette.length;
return palette[idx];
// alles andere unverändert
return value;
}
// sprechende Zusammenfassung für "change"
@ -185,6 +202,42 @@ function getChangeSummary(
);
}
function formatRelativeDate(raw: string): string {
const d = new Date(raw);
if (isNaN(d.getTime())) {
// Falls kein gültiges Datum (z.B. schon formatiert), einfach roh anzeigen
return raw;
}
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 5) return 'gerade eben';
if (diffSec < 60) return `vor ${diffSec} Sekunden`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `vor ${diffMin} Minute${diffMin === 1 ? '' : 'n'}`;
}
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) {
return `vor ${diffH} Stunde${diffH === 1 ? '' : 'n'}`;
}
const diffD = Math.floor(diffH / 24);
if (diffD < 30) {
return `vor ${diffD} Tag${diffD === 1 ? '' : 'en'}`;
}
// Fallback: für ältere Einträge wieder absolutes Datum
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(d);
}
/* ───────── Component ───────── */
export default function Feed({ items, className }: FeedProps) {
@ -202,45 +255,19 @@ export default function Feed({ items, className }: FeedProps) {
}
return (
<ul
role="list"
className={clsx('pb-4', className)}
>
<ul role="list" className={clsx('pb-4', className)}>
{items.map((item, idx) => {
// Icon + Hintergrund
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
PencilIcon;
let iconBg = 'bg-gray-400 dark:bg-gray-600';
const actor = item.person;
const actorDisplayName = getDisplayName(actor);
if (item.type === 'tags') {
Icon = TagIcon;
iconBg = 'bg-amber-500';
} else if (item.type === 'change') {
const isTagsOnly =
item.changes.length === 1 && item.changes[0].field === 'tags';
Icon = isTagsOnly ? TagIcon : PencilIcon;
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-cyan-500';
} else if (item.type === 'comment') {
if (item.commentKind === 'created') {
Icon = PlusIcon;
iconBg = 'bg-emerald-500';
} else if (item.commentKind === 'deleted') {
Icon = TrashIcon;
iconBg = 'bg-rose-500';
} else {
iconBg = colorFromName(item.person.name);
}
} else if (item.type === 'assignment') {
iconBg = 'bg-indigo-500';
}
// Textinhalt (content) dein bisheriger Code unverändert:
// Textinhalt (content)
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}
{actorDisplayName}
</span>{' '}
<span className="text-gray-300 dark:text-gray-200">
{item.comment}
@ -248,14 +275,16 @@ export default function Feed({ items, className }: FeedProps) {
</p>
);
} else if (item.type === 'assignment') {
const assignedDisplayName = getDisplayName(item.assigned);
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}
{actorDisplayName}
</span>{' '}
hat{' '}
<span className="font-medium text-gray-900 dark:text-white">
{item.assigned.name}
{assignedDisplayName}
</span>{' '}
zugewiesen.
</p>
@ -264,7 +293,7 @@ export default function Feed({ items, className }: FeedProps) {
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}
{actorDisplayName}
</span>{' '}
hat Tags hinzugefügt:{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">
@ -278,7 +307,7 @@ export default function Feed({ items, className }: FeedProps) {
<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}
{actorDisplayName}
</span>{' '}
{summary}
</p>
@ -290,11 +319,11 @@ export default function Feed({ items, className }: FeedProps) {
className="flex flex-wrap items-baseline gap-x-1"
>
<span className="line-through text-red-500/80 dark:text-red-400/90">
{c.from ?? '—'}
{formatChangeValue(c.field, c.from)}
</span>
<span className="text-gray-400"></span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">
{c.to ?? '—'}
{formatChangeValue(c.field, c.to)}
</span>
{i < Math.min(2, item.changes.length) - 1 && (
<span className="mx-1 text-gray-500 dark:text-gray-600">
@ -325,21 +354,22 @@ export default function Feed({ items, className }: FeedProps) {
) : null}
<div className="relative flex space-x-3">
<div>
<span
className={classNames(
iconBg,
'flex size-8 items-center justify-center rounded-full',
)}
>
<Icon aria-hidden="true" className="size-4 text-white" />
</span>
{/* Avatar statt Icon-Badge */}
<div className="mt-0.5">
<PersonAvatar
name={actorDisplayName}
avatarUrl={actor.imageUrl}
size="md"
/>
</div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>{content}</div>
<div className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400">
{item.date}
<div
className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400"
title={item.date}
>
{formatRelativeDate(item.date)}
</div>
</div>
</div>

View File

@ -175,7 +175,7 @@ export function Modal({
<DialogPanel
transition
className={clsx(
'relative flex w-full max-h-[calc(100vh-8rem)] lg:max-h-[800px] flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
'relative flex w-full max-h-[calc(100vh-8rem)] lg:max-h-[950px] flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
'data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out ' +
'data-leave:duration-200 data-leave:ease-in sm:my-8 data-closed:sm:translate-y-0 data-closed:sm:scale-95 ' +
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
@ -283,7 +283,7 @@ export function Modal({
{/* Rechte Sidebar (QR + Verlauf) */}
{sidebar && (
<aside className="sm:min-h-0 sm:overflow-hidden">
<aside className="sm:min-h-0 sm:overflow-hidden sm:w-60 lg:w-80 sm:shrink-0">
{sidebar}
</aside>
)}

View File

@ -40,6 +40,10 @@ export interface TableProps<T> {
renderActions?: (row: T) => React.ReactNode;
/** Optional: Header-Text für die Actions-Spalte */
actionsHeader?: string;
/** Optional: Standard-Sortierspalte */
defaultSortKey?: keyof T;
/** Optional: Standard-Sortierrichtung */
defaultSortDirection?: SortDirection;
}
type SortState<T> = {
@ -56,11 +60,13 @@ export default function Table<T>(props: TableProps<T>) {
onSelectionChange,
renderActions,
actionsHeader = '',
defaultSortKey,
defaultSortDirection = 'asc',
} = props;
const [sort, setSort] = React.useState<SortState<T>>({
key: null,
direction: 'asc',
key: defaultSortKey ?? null,
direction: defaultSortDirection,
});
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
@ -158,10 +164,12 @@ export default function Table<T>(props: TableProps<T>) {
}
return (
<div className="rounded-lg border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/40">
<div className="relative overflow-visible rounded-lg border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/40">
{/* Wichtig: auf kleinen Screens overflow-x-visible, erst ab lg overflow-x-auto */}
<div className="overflow-x-visible lg:overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-white/10">
<div className="overflow-x-auto">
<table
className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm dark:divide-white/10"
>
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
{selectable && (
@ -209,9 +217,7 @@ export default function Table<T>(props: TableProps<T>) {
className={classNames(
'py-3.5 px-2 text-left text-sm font-semibold text-gray-900 dark:text-white',
col.headerClassName,
isHideable
? 'hidden lg:table-cell'
: '',
isHideable ? 'hidden lg:table-cell' : '',
)}
>
{isSortable ? (
@ -256,17 +262,19 @@ export default function Table<T>(props: TableProps<T>) {
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-900/40">
{sortedData.map((row) => {
const id = getRowId(row);
const isSelected = selectedIds.includes(id);
{sortedData.map((row) => {
const id = getRowId(row);
const isSelected = selectedIds.includes(id);
return (
<tr
key={id}
className={classNames(isSelected && 'bg-gray-50 dark:bg-gray-800/60')}
>
{selectable && (
<td className="px-2">
return (
<tr
key={id}
className={classNames(
isSelected && 'bg-gray-50 dark:bg-gray-800/60',
)}
>
{selectable && (
<td className="px-4">
<div className="group grid size-4 grid-cols-1">
<input
type="checkbox"
@ -304,10 +312,12 @@ export default function Table<T>(props: TableProps<T>) {
className={classNames(
'px-2 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
col.cellClassName,
col.canHide && 'hidden lg:table-cell', // <-- HIER
col.canHide && 'hidden lg:table-cell',
)}
>
{col.render ? col.render(row) : String((row as any)[col.key] ?? '—')}
{col.render
? col.render(row)
: String((row as any)[col.key] ?? '—')}
</td>
))}
@ -326,7 +336,7 @@ export default function Table<T>(props: TableProps<T>) {
colSpan={columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0)}
className="px-2 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
Keine Daten vorhanden.
Keine Einträge vorhanden.
</td>
</tr>
)}

View File

@ -1,24 +1,22 @@
// components/ui/Tabs.tsx
'use client';
import { ChevronDownIcon } from '@heroicons/react/16/solid';
import clsx from 'clsx';
import * as React from 'react';
import Badge from '@/components/ui/Badge';
export type TabItem = {
id: string;
label: string;
/** optional: Anzahl (z.B. Personen in der Gruppe) */
count?: number;
};
type TabsProps = {
tabs: TabItem[];
/** aktuell ausgewählter Tab (id) */
value: string;
/** Callback bei Wechsel */
onChange: (id: string) => void;
className?: string;
/** Optional eigenes aria-label */
ariaLabel?: string;
};
@ -36,14 +34,16 @@ export default function Tabs({
{/* Mobile: Select + Chevron */}
<div className="grid grid-cols-1 sm:hidden">
<select
value={current.id}
value={current?.id}
onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel}
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
{tab.label}
{tab.count != null
? `${tab.label} (${tab.count})`
: tab.label}
</option>
))}
</select>
@ -58,7 +58,7 @@ export default function Tabs({
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const isCurrent = tab.id === current.id;
const isCurrent = tab.id === current?.id;
return (
<button
key={tab.id}
@ -68,10 +68,13 @@ export default function Tabs({
isCurrent
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
'border-b-2 px-1 py-3 text-sm font-medium whitespace-nowrap',
'border-b-2 px-1 py-3 text-sm font-medium whitespace-nowrap flex items-center gap-2',
)}
>
{tab.label}
<span>{tab.label}</span>
{typeof tab.count === 'number' && (
<Badge tone="gray" variant="flat" size="sm">{tab.count}</Badge>
)}
</button>
);
})}

View File

@ -0,0 +1,73 @@
// components/ui/UserAvatar.tsx
'use client';
import * as React from 'react';
import clsx from 'clsx';
const AVATAR_COLORS = [
'bg-orange-500',
'bg-indigo-500',
'bg-emerald-500',
'bg-sky-500',
'bg-rose-500',
'bg-amber-500',
'bg-violet-500',
];
function getAvatarColor(seed: string) {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
}
const index = Math.abs(hash) % AVATAR_COLORS.length;
return AVATAR_COLORS[index];
}
type Size = 'sm' | 'md' | 'lg';
const sizeClasses: Record<Size, string> = {
sm: 'h-6 w-6 text-[10px]',
md: 'h-8 w-8 text-xs',
lg: 'h-10 w-10 text-sm',
};
export type UserAvatarProps = {
/** Für Initialen + Farbberechnung, z.B. Arbeitsname */
name?: string | null;
/** Optionales Bild wenn gesetzt, wird das Bild angezeigt */
avatarUrl?: string | null;
/** Größe des Avatars */
size?: Size;
};
export default function UserAvatar({
name,
avatarUrl,
size = 'md',
}: UserAvatarProps) {
const displayName = (name ?? '').trim();
const initial = displayName.charAt(0)?.toUpperCase() || '?';
const colorClass = getAvatarColor(displayName || initial || 'x');
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={displayName || 'Avatar'}
className={clsx('rounded-full object-cover', sizeClasses[size])}
/>
);
}
return (
<div
className={clsx(
'flex items-center justify-center rounded-full font-semibold text-white',
sizeClasses[size],
colorClass,
)}
>
{initial}
</div>
);
}

View File

@ -0,0 +1,64 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }
export * as $Enums from './enums.ts'
export * from './enums.ts';
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Role
*
*/
export type Role = Prisma.RoleModel
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel
/**
* Model UserGroup
*
*/
export type UserGroup = Prisma.UserGroupModel
/**
* Model DeviceGroup
*
*/
export type DeviceGroup = Prisma.DeviceGroupModel
/**
* Model Location
*
*/
export type Location = Prisma.LocationModel
/**
* Model Device
*
*/
export type Device = Prisma.DeviceModel
/**
* Model Tag
*
*/
export type Tag = Prisma.TagModel
/**
* Model DeviceHistory
*
*/
export type DeviceHistory = Prisma.DeviceHistoryModel

View File

@ -0,0 +1,86 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import * as $Class from "./internal/class.ts"
import * as Prisma from "./internal/prismaNamespace.ts"
export * as $Enums from './enums.ts'
export * from "./enums.ts"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Role
*
*/
export type Role = Prisma.RoleModel
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel
/**
* Model UserGroup
*
*/
export type UserGroup = Prisma.UserGroupModel
/**
* Model DeviceGroup
*
*/
export type DeviceGroup = Prisma.DeviceGroupModel
/**
* Model Location
*
*/
export type Location = Prisma.LocationModel
/**
* Model Device
*
*/
export type Device = Prisma.DeviceModel
/**
* Model Tag
*
*/
export type Tag = Prisma.TagModel
/**
* Model DeviceHistory
*
*/
export type DeviceHistory = Prisma.DeviceHistoryModel

View File

@ -0,0 +1,381 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import type * as Prisma from "./internal/prismaNamespace.ts"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type EnumDeviceChangeTypeFilter<$PrismaModel = never> = {
equals?: $Enums.DeviceChangeType | Prisma.EnumDeviceChangeTypeFieldRefInput<$PrismaModel>
in?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumDeviceChangeTypeFilter<$PrismaModel> | $Enums.DeviceChangeType
}
export type JsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
export type JsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type EnumDeviceChangeTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.DeviceChangeType | Prisma.EnumDeviceChangeTypeFieldRefInput<$PrismaModel>
in?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumDeviceChangeTypeWithAggregatesFilter<$PrismaModel> | $Enums.DeviceChangeType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumDeviceChangeTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumDeviceChangeTypeFilter<$PrismaModel>
}
export type JsonWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedJsonFilter<$PrismaModel>
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedEnumDeviceChangeTypeFilter<$PrismaModel = never> = {
equals?: $Enums.DeviceChangeType | Prisma.EnumDeviceChangeTypeFieldRefInput<$PrismaModel>
in?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumDeviceChangeTypeFilter<$PrismaModel> | $Enums.DeviceChangeType
}
export type NestedEnumDeviceChangeTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.DeviceChangeType | Prisma.EnumDeviceChangeTypeFieldRefInput<$PrismaModel>
in?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.DeviceChangeType[] | Prisma.ListEnumDeviceChangeTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumDeviceChangeTypeWithAggregatesFilter<$PrismaModel> | $Enums.DeviceChangeType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumDeviceChangeTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumDeviceChangeTypeFilter<$PrismaModel>
}
export type NestedJsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}

18
generated/prisma/enums.ts Normal file
View File

@ -0,0 +1,18 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
export const DeviceChangeType = {
CREATED: 'CREATED',
UPDATED: 'UPDATED',
DELETED: 'DELETED'
} as const
export type DeviceChangeType = (typeof DeviceChangeType)[keyof typeof DeviceChangeType]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,223 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.ts'
export type * from './prismaNamespace.ts'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Role: 'Role',
UserRole: 'UserRole',
UserGroup: 'UserGroup',
DeviceGroup: 'DeviceGroup',
Location: 'Location',
Device: 'Device',
Tag: 'Tag',
DeviceHistory: 'DeviceHistory'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = {
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
nwkennung: 'nwkennung',
email: 'email',
arbeitsname: 'arbeitsname',
firstName: 'firstName',
lastName: 'lastName',
passwordHash: 'passwordHash',
groupId: 'groupId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const RoleScalarFieldEnum = {
id: 'id',
name: 'name'
} as const
export type RoleScalarFieldEnum = (typeof RoleScalarFieldEnum)[keyof typeof RoleScalarFieldEnum]
export const UserRoleScalarFieldEnum = {
userId: 'userId',
roleId: 'roleId',
assignedAt: 'assignedAt'
} as const
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
export const UserGroupScalarFieldEnum = {
id: 'id',
name: 'name'
} as const
export type UserGroupScalarFieldEnum = (typeof UserGroupScalarFieldEnum)[keyof typeof UserGroupScalarFieldEnum]
export const DeviceGroupScalarFieldEnum = {
id: 'id',
name: 'name'
} as const
export type DeviceGroupScalarFieldEnum = (typeof DeviceGroupScalarFieldEnum)[keyof typeof DeviceGroupScalarFieldEnum]
export const LocationScalarFieldEnum = {
id: 'id',
name: 'name'
} as const
export type LocationScalarFieldEnum = (typeof LocationScalarFieldEnum)[keyof typeof LocationScalarFieldEnum]
export const DeviceScalarFieldEnum = {
inventoryNumber: 'inventoryNumber',
name: 'name',
manufacturer: 'manufacturer',
model: 'model',
serialNumber: 'serialNumber',
productNumber: 'productNumber',
comment: 'comment',
ipv4Address: 'ipv4Address',
ipv6Address: 'ipv6Address',
macAddress: 'macAddress',
username: 'username',
passwordHash: 'passwordHash',
groupId: 'groupId',
locationId: 'locationId',
loanedTo: 'loanedTo',
loanedFrom: 'loanedFrom',
loanedUntil: 'loanedUntil',
loanComment: 'loanComment',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdById: 'createdById',
updatedById: 'updatedById'
} as const
export type DeviceScalarFieldEnum = (typeof DeviceScalarFieldEnum)[keyof typeof DeviceScalarFieldEnum]
export const TagScalarFieldEnum = {
id: 'id',
name: 'name'
} as const
export type TagScalarFieldEnum = (typeof TagScalarFieldEnum)[keyof typeof TagScalarFieldEnum]
export const DeviceHistoryScalarFieldEnum = {
id: 'id',
deviceId: 'deviceId',
changeType: 'changeType',
snapshot: 'snapshot',
changedAt: 'changedAt',
changedById: 'changedById'
} as const
export type DeviceHistoryScalarFieldEnum = (typeof DeviceHistoryScalarFieldEnum)[keyof typeof DeviceHistoryScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const JsonNullValueInput = {
JsonNull: 'JsonNull'
} as const
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
} as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const JsonNullValueFilter = {
DbNull: 'DbNull',
JsonNull: 'JsonNull',
AnyNull: 'AnyNull'
} as const
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]

View File

@ -0,0 +1,20 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/User.ts'
export type * from './models/Role.ts'
export type * from './models/UserRole.ts'
export type * from './models/UserGroup.ts'
export type * from './models/DeviceGroup.ts'
export type * from './models/Location.ts'
export type * from './models/Device.ts'
export type * from './models/Tag.ts'
export type * from './models/DeviceHistory.ts'
export type * from './commonInputTypes.ts'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ export const authOptions: NextAuthOptions = {
name: 'Credentials',
credentials: {
email: {
label: 'Benutzername oder E-Mail',
label: 'NW-Kennung oder E-Mail',
type: 'text',
},
password: {
@ -25,27 +25,42 @@ export const authOptions: NextAuthOptions = {
const identifier = credentials.email.trim();
// User per E-Mail ODER Benutzername suchen
const user = await prisma.user.findFirst({
where: {
OR: [{ email: identifier }, { username: identifier }],
OR: [
{
email: {
equals: identifier,
mode: 'insensitive',
},
},
{
nwkennung: {
equals: identifier,
mode: 'insensitive',
},
},
],
},
});
if (!user || !user.passwordHash) {
return null;
}
if (!user || !user.passwordHash) return null;
const isValid = await compare(credentials.password, user.passwordHash);
if (!isValid) {
return null;
}
if (!isValid) return null;
const displayName =
user.arbeitsname ??
(user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.email ?? user.nwkennung ?? 'Unbekannt');
return {
id: user.id,
name: user.name ?? user.username ?? user.email,
id: user.nwkennung,
name: displayName,
email: user.email,
};
nwkennung: user.nwkennung,
} as any;
},
}),
],
@ -54,17 +69,21 @@ export const authOptions: NextAuthOptions = {
},
session: {
strategy: 'jwt',
// Login wird standardmäßig gemerkt (hier explizit: 30 Tage)
maxAge: 60 * 60 * 24 * 30,
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.id = (user as any).id;
token.nwkennung = (user as any).nwkennung;
}
return token;
},
async session({ session, token }) {
if (session.user && token.id) {
(session.user as any).id = token.id;
(session.user as any).nwkennung = token.nwkennung;
}
return session;
},

View File

@ -11,25 +11,23 @@ type SessionUser = {
name?: string | null;
};
// lib/auth.ts
export async function getCurrentUserId(): Promise<string | null> {
const session = await getServerSession(authOptions);
const user = session?.user as SessionUser | undefined;
const user = session?.user as { id?: string; email?: string | null } | undefined;
// 1) ID direkt aus dem Token
if (user?.id) {
return user.id;
}
// 2) Fallback über E-Mail aus der Session
if (user?.email) {
const dbUser = await prisma.user.findUnique({
where: { email: user.email },
select: { id: true },
select: { nwkennung: true },
});
if (dbUser) return dbUser.id;
if (dbUser) return dbUser.nwkennung;
}
// 3) keine Session -> kein User
return null;
}
@ -38,13 +36,12 @@ export async function getCurrentUser() {
if (!id) return null;
return prisma.user.findUnique({
where: { id },
where: { nwkennung: id },
include: {
roles: {
include: {
role: true,
},
include: { role: true },
},
},
});
}

View File

@ -1,16 +1,11 @@
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '@/generated/prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
// Driver Adapter for Postgres
import { PrismaPg } from '@prisma/adapter-pg';
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error', 'warn'],
});
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export const prisma = new PrismaClient({ adapter });

View File

@ -1,7 +1,8 @@
import type { NextConfig } from "next";
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ['https://geraete.local', 'http://localhost:3000'],
};
export default nextConfig;

1981
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,22 +2,25 @@
"name": "geraete",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"seed:test-user": "ts-node prisma/create-test-user.ts"
"seed": "prisma db seed"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
"@prisma/adapter-better-sqlite3": "^7.0.0",
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"@zxing/browser": "^0.1.5",
"@zxing/library": "^0.21.3",
"bcryptjs": "^3.0.3",
"next": "16.0.3",
"next-auth": "^4.24.13",
"pg": "^8.16.3",
"postcss": "^8.5.6",
"qrcode.react": "^4.2.0",
"react": "19.2.0",
@ -28,13 +31,16 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^20",
"@types/node": "^20.19.25",
"@types/pg": "^8.15.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.2.3",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"prisma": "^6.19.0",
"prisma": "^7.0.0",
"tailwindcss": "^4.1.17",
"typescript": "^5"
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

13
prisma.config.ts Normal file
View File

@ -0,0 +1,13 @@
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

Binary file not shown.

View File

@ -1,97 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "roleId"),
CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "DeviceGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL PRIMARY KEY,
"inventoryNumber" TEXT NOT NULL,
"serialNumber" TEXT,
"productNumber" TEXT,
"comment" TEXT,
"groupId" TEXT,
"locationId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdById" TEXT,
"updatedById" TEXT,
CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DeviceGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "DeviceHistory" (
"id" TEXT NOT NULL PRIMARY KEY,
"deviceId" TEXT NOT NULL,
"changeType" TEXT NOT NULL,
"snapshot" JSONB NOT NULL,
"changedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"changedById" TEXT,
CONSTRAINT "DeviceHistory_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "DeviceGroup_name_key" ON "DeviceGroup"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Device_inventoryNumber_key" ON "Device"("inventoryNumber");
-- CreateIndex
CREATE INDEX "Device_inventoryNumber_idx" ON "Device"("inventoryNumber");
-- CreateIndex
CREATE INDEX "Device_groupId_idx" ON "Device"("groupId");
-- CreateIndex
CREATE INDEX "Device_locationId_idx" ON "Device"("locationId");
-- CreateIndex
CREATE INDEX "DeviceHistory_deviceId_changedAt_idx" ON "DeviceHistory"("deviceId", "changedAt");

View File

@ -1,50 +0,0 @@
/*
Warnings:
- Added the required column `manufacturer` to the `Device` table without a default value. This is not possible if the table is not empty.
- Added the required column `model` to the `Device` table without a default value. This is not possible if the table is not empty.
- Added the required column `name` to the `Device` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Device" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"manufacturer" TEXT NOT NULL,
"model" TEXT NOT NULL,
"inventoryNumber" TEXT NOT NULL,
"serialNumber" TEXT,
"productNumber" TEXT,
"comment" TEXT,
"ipv4Address" TEXT,
"ipv6Address" TEXT,
"macAddress" TEXT,
"username" TEXT,
"passwordHash" TEXT,
"groupId" TEXT,
"locationId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdById" TEXT,
"updatedById" TEXT,
CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DeviceGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Device" ("comment", "createdAt", "createdById", "groupId", "id", "inventoryNumber", "locationId", "productNumber", "serialNumber", "updatedAt", "updatedById") SELECT "comment", "createdAt", "createdById", "groupId", "id", "inventoryNumber", "locationId", "productNumber", "serialNumber", "updatedAt", "updatedById" FROM "Device";
DROP TABLE "Device";
ALTER TABLE "new_Device" RENAME TO "Device";
CREATE UNIQUE INDEX "Device_inventoryNumber_key" ON "Device"("inventoryNumber");
CREATE UNIQUE INDEX "Device_ipv4Address_key" ON "Device"("ipv4Address");
CREATE UNIQUE INDEX "Device_ipv6Address_key" ON "Device"("ipv6Address");
CREATE UNIQUE INDEX "Device_macAddress_key" ON "Device"("macAddress");
CREATE UNIQUE INDEX "Device_username_key" ON "Device"("username");
CREATE UNIQUE INDEX "Device_passwordHash_key" ON "Device"("passwordHash");
CREATE INDEX "Device_inventoryNumber_idx" ON "Device"("inventoryNumber");
CREATE INDEX "Device_groupId_idx" ON "Device"("groupId");
CREATE INDEX "Device_locationId_idx" ON "Device"("locationId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -1,62 +0,0 @@
/*
Warnings:
- The primary key for the `Device` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `id` on the `Device` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Device" (
"inventoryNumber" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"manufacturer" TEXT NOT NULL,
"model" TEXT NOT NULL,
"serialNumber" TEXT,
"productNumber" TEXT,
"comment" TEXT,
"ipv4Address" TEXT,
"ipv6Address" TEXT,
"macAddress" TEXT,
"username" TEXT,
"passwordHash" TEXT,
"groupId" TEXT,
"locationId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdById" TEXT,
"updatedById" TEXT,
CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DeviceGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Device" ("comment", "createdAt", "createdById", "groupId", "inventoryNumber", "ipv4Address", "ipv6Address", "locationId", "macAddress", "manufacturer", "model", "name", "passwordHash", "productNumber", "serialNumber", "updatedAt", "updatedById", "username") SELECT "comment", "createdAt", "createdById", "groupId", "inventoryNumber", "ipv4Address", "ipv6Address", "locationId", "macAddress", "manufacturer", "model", "name", "passwordHash", "productNumber", "serialNumber", "updatedAt", "updatedById", "username" FROM "Device";
DROP TABLE "Device";
ALTER TABLE "new_Device" RENAME TO "Device";
CREATE UNIQUE INDEX "Device_inventoryNumber_key" ON "Device"("inventoryNumber");
CREATE UNIQUE INDEX "Device_ipv4Address_key" ON "Device"("ipv4Address");
CREATE UNIQUE INDEX "Device_ipv6Address_key" ON "Device"("ipv6Address");
CREATE UNIQUE INDEX "Device_macAddress_key" ON "Device"("macAddress");
CREATE UNIQUE INDEX "Device_username_key" ON "Device"("username");
CREATE UNIQUE INDEX "Device_passwordHash_key" ON "Device"("passwordHash");
CREATE INDEX "Device_inventoryNumber_idx" ON "Device"("inventoryNumber");
CREATE INDEX "Device_groupId_idx" ON "Device"("groupId");
CREATE INDEX "Device_locationId_idx" ON "Device"("locationId");
CREATE TABLE "new_DeviceHistory" (
"id" TEXT NOT NULL PRIMARY KEY,
"deviceId" TEXT NOT NULL,
"changeType" TEXT NOT NULL,
"snapshot" JSONB NOT NULL,
"changedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"changedById" TEXT,
CONSTRAINT "DeviceHistory_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("inventoryNumber") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_DeviceHistory" ("changeType", "changedAt", "changedById", "deviceId", "id", "snapshot") SELECT "changeType", "changedAt", "changedById", "deviceId", "id", "snapshot" FROM "DeviceHistory";
DROP TABLE "DeviceHistory";
ALTER TABLE "new_DeviceHistory" RENAME TO "DeviceHistory";
CREATE INDEX "DeviceHistory_deviceId_changedAt_idx" ON "DeviceHistory"("deviceId", "changedAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `Location` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Location_name_key" ON "Location"("name");

View File

@ -1,22 +0,0 @@
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_DeviceToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_DeviceToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Device" ("inventoryNumber") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_DeviceToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE UNIQUE INDEX "_DeviceToTag_AB_unique" ON "_DeviceToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_DeviceToTag_B_index" ON "_DeviceToTag"("B");

View File

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

View File

@ -1,5 +0,0 @@
-- DropIndex
DROP INDEX "DeviceHistory_changedById_idx";
-- DropIndex
DROP INDEX "DeviceHistory_deviceId_changedAt_idx";

View File

@ -0,0 +1,203 @@
-- CreateEnum
CREATE TYPE "DeviceChangeType" AS ENUM ('CREATED', 'UPDATED', 'DELETED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT,
"username" TEXT,
"name" TEXT,
"arbeitsname" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"passwordHash" TEXT,
"groupId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","roleId")
);
-- CreateTable
CREATE TABLE "UserGroup" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "UserGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DeviceGroup" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "DeviceGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"inventoryNumber" TEXT NOT NULL,
"name" TEXT NOT NULL,
"manufacturer" TEXT NOT NULL,
"model" TEXT NOT NULL,
"serialNumber" TEXT,
"productNumber" TEXT,
"comment" TEXT,
"ipv4Address" TEXT,
"ipv6Address" TEXT,
"macAddress" TEXT,
"username" TEXT,
"passwordHash" TEXT,
"groupId" TEXT,
"locationId" TEXT,
"loanedTo" TEXT,
"loanedFrom" TIMESTAMP(3),
"loanedUntil" TIMESTAMP(3),
"loanComment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" TEXT,
"updatedById" TEXT,
CONSTRAINT "Device_pkey" PRIMARY KEY ("inventoryNumber")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DeviceHistory" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"changeType" "DeviceChangeType" NOT NULL,
"snapshot" JSONB NOT NULL,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"changedById" TEXT,
CONSTRAINT "DeviceHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_DeviceToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_DeviceToTag_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE INDEX "User_groupId_idx" ON "User"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "UserGroup_name_key" ON "UserGroup"("name");
-- CreateIndex
CREATE UNIQUE INDEX "DeviceGroup_name_key" ON "DeviceGroup"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Location_name_key" ON "Location"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Device_inventoryNumber_key" ON "Device"("inventoryNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Device_ipv4Address_key" ON "Device"("ipv4Address");
-- CreateIndex
CREATE UNIQUE INDEX "Device_ipv6Address_key" ON "Device"("ipv6Address");
-- CreateIndex
CREATE UNIQUE INDEX "Device_macAddress_key" ON "Device"("macAddress");
-- CreateIndex
CREATE UNIQUE INDEX "Device_username_key" ON "Device"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Device_passwordHash_key" ON "Device"("passwordHash");
-- CreateIndex
CREATE INDEX "Device_inventoryNumber_idx" ON "Device"("inventoryNumber");
-- CreateIndex
CREATE INDEX "Device_groupId_idx" ON "Device"("groupId");
-- CreateIndex
CREATE INDEX "Device_locationId_idx" ON "Device"("locationId");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE INDEX "_DeviceToTag_B_index" ON "_DeviceToTag"("B");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "UserGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DeviceGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("inventoryNumber") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_DeviceToTag" ADD CONSTRAINT "_DeviceToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Device"("inventoryNumber") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_DeviceToTag" ADD CONSTRAINT "_DeviceToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `name` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "name";

View File

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `username` on the `User` table. All the data in the column will be lost.
- A unique constraint covering the columns `[nwkennung]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "User_username_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "username",
ADD COLUMN "nwkennung" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_nwkennung_key" ON "User"("nwkennung");

View File

@ -0,0 +1,43 @@
/*
Warnings:
- The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `id` on the `User` table. All the data in the column will be lost.
- Made the column `nwkennung` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "Device" DROP CONSTRAINT "Device_createdById_fkey";
-- DropForeignKey
ALTER TABLE "Device" DROP CONSTRAINT "Device_updatedById_fkey";
-- DropForeignKey
ALTER TABLE "DeviceHistory" DROP CONSTRAINT "DeviceHistory_changedById_fkey";
-- DropForeignKey
ALTER TABLE "UserRole" DROP CONSTRAINT "UserRole_userId_fkey";
-- DropIndex
DROP INDEX "Device_inventoryNumber_key";
-- DropIndex
DROP INDEX "User_nwkennung_key";
-- AlterTable
ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
DROP COLUMN "id",
ALTER COLUMN "nwkennung" SET NOT NULL,
ADD CONSTRAINT "User_pkey" PRIMARY KEY ("nwkennung");
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("nwkennung") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
provider = "postgresql"

View File

@ -1,117 +1,107 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "../generated/prisma"
engineType = "client"
generatedFileExtension = "ts"
importFileExtension = "ts"
moduleFormat = "esm"
runtime = "nodejs"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
provider = "postgresql"
}
/* ──────────────────────────────────────────
Rollen / Rechte
────────────────────────────────────────── */
model User {
id String @id @default(cuid())
email String @unique
username String @unique
name String?
passwordHash String
nwkennung String @id
email String? @unique
arbeitsname String?
firstName String?
lastName String?
passwordHash String?
groupId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
devicesCreated Device[] @relation("DeviceCreatedBy")
devicesUpdated Device[] @relation("DeviceUpdatedBy")
historyEntries DeviceHistory[] @relation("DeviceHistoryChangedBy")
// Audit-Relations
devicesCreated Device[] @relation("DeviceCreatedBy")
devicesUpdated Device[] @relation("DeviceUpdatedBy")
historyEntries DeviceHistory[] @relation("DeviceHistoryChangedBy")
// UserGroup hat ein Feld "id" also darauf referenzieren
group UserGroup? @relation(fields: [groupId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
@@index([groupId])
}
model Role {
id String @id @default(cuid())
name String @unique // z.B. "ADMIN", "INVENTUR", "READONLY"
id String @id @default(uuid())
name String @unique
users UserRole[]
}
// Join-Tabelle User <-> Role (Many-to-Many)
model UserRole {
user User @relation(fields: [userId], references: [id])
userId String
role Role @relation(fields: [roleId], references: [id])
roleId String
userId String
roleId String
assignedAt DateTime @default(now())
role Role @relation(fields: [roleId], references: [id])
user User @relation(fields: [userId], references: [nwkennung])
@@id([userId, roleId])
}
/* ──────────────────────────────────────────
Stammdaten: Gruppen & Standorte
────────────────────────────────────────── */
model UserGroup {
id String @id @default(uuid())
name String @unique
users User[]
}
model DeviceGroup {
id String @id @default(cuid())
id String @id @default(uuid())
name String @unique
devices Device[]
}
model Location {
id String @id @default(cuid())
id String @id @default(uuid())
name String @unique
devices Device[]
}
/* ──────────────────────────────────────────
Geräte / Inventar
────────────────────────────────────────── */
model Device {
inventoryNumber String @id @unique // Inventar-Nummer
// Fachliche Felder
name String // Anzeigename / Bezeichnung des Geräts
manufacturer String // Hersteller
model String // Modellbezeichnung
serialNumber String? // Seriennummer
productNumber String? // Produktnummer
inventoryNumber String @id
name String
manufacturer String
model String
serialNumber String?
productNumber String?
comment String?
ipv4Address String? @unique
ipv6Address String? @unique
macAddress String? @unique
username String? @unique
passwordHash String? @unique
groupId String?
locationId String?
loanedTo String?
loanedFrom DateTime?
loanedUntil DateTime?
loanComment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
updatedById String?
ipv4Address String? @unique // IPv4-Adresse
ipv6Address String? @unique // IPv6-Adresse
macAddress String? @unique // MAC-Adresse
username String? @unique // Benutzername
passwordHash String? @unique // Passwort
createdBy User? @relation("DeviceCreatedBy", fields: [createdById], references: [nwkennung])
updatedBy User? @relation("DeviceUpdatedBy", fields: [updatedById], references: [nwkennung])
// Beziehungen
group DeviceGroup? @relation(fields: [groupId], references: [id])
groupId String?
group DeviceGroup? @relation(fields: [groupId], references: [id])
location Location? @relation(fields: [locationId], references: [id])
location Location? @relation(fields: [locationId], references: [id])
locationId String?
tags Tag[]
// Audit-Felder
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation("DeviceCreatedBy", fields: [createdById], references: [id])
createdById String?
updatedBy User? @relation("DeviceUpdatedBy", fields: [updatedById], references: [id])
updatedById String?
// Historie
history DeviceHistory[]
tags Tag[] @relation("DeviceToTag")
@@index([inventoryNumber])
@@index([groupId])
@ -119,31 +109,25 @@ model Device {
}
model Tag {
id String @id @default(cuid())
id String @id @default(uuid())
name String @unique
devices Device[] // implizite Join-Tabelle
devices Device[] @relation("DeviceToTag")
}
/* ──────────────────────────────────────────
History / Änderungsverlauf
────────────────────────────────────────── */
model DeviceHistory {
id String @id @default(uuid())
deviceId String
changeType DeviceChangeType
snapshot Json
changedAt DateTime @default(now())
changedById String?
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [nwkennung])
device Device @relation(fields: [deviceId], references: [inventoryNumber])
}
enum DeviceChangeType {
CREATED
UPDATED
DELETED
}
model DeviceHistory {
id String @id @default(cuid())
deviceId String
device Device @relation(fields: [deviceId], references: [inventoryNumber])
changeType DeviceChangeType
snapshot Json
changedAt DateTime @default(now())
changedById String?
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
}

View File

@ -1,8 +1,14 @@
// prisma/create-test-user.ts
import { PrismaClient } from '@prisma/client';
// prisma/seed.ts
import 'dotenv/config';
import { PrismaClient } from '@/generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { hash } from 'bcryptjs';
const prisma = new PrismaClient();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
const prisma = new PrismaClient({ adapter });
// Location.name ist @unique → upsert per name
async function ensureLocation(name: string) {
@ -15,22 +21,24 @@ async function ensureLocation(name: string) {
async function main() {
const email = 'christoph.rother@polizei.nrw.de';
const username = 'admin';
const password = 'tegvideo7010!';
const arbeitsname = 'Admin'; // ✔ Schreibweise wie im Schema
const nwkennung = 'nw083118'; // ✔ optional, aber sinnvoll
const password = 'Timmy0104199?';
const passwordHash = await hash(password, 10);
// User anlegen / aktualisieren
const user = await prisma.user.upsert({
where: { email },
where: { nwkennung }, // 🔹
update: {
username,
email,
arbeitsname,
passwordHash,
},
create: {
nwkennung,
email,
username,
name: 'Admin',
arbeitsname,
passwordHash,
},
});
@ -58,13 +66,13 @@ async function main() {
await prisma.userRole.upsert({
where: {
userId_roleId: {
userId: user.id,
userId: user.nwkennung,
roleId: adminRole.id,
},
},
update: {},
create: {
userId: user.id,
userId: user.nwkennung,
roleId: adminRole.id,
},
});
@ -82,16 +90,15 @@ async function main() {
create: { name: 'Monitore' },
});
// Standorte anlegen (Location.name ist @unique → ensureLocation nutzt upsert)
// Standorte anlegen
const raum112 = await ensureLocation('Raum 1.12');
const lagerKeller = await ensureLocation('Lager Keller');
// Geräte anlegen / aktualisieren (inventoryNumber ist @id)
// Geräte anlegen / aktualisieren (inventoryNumber ist @id/@unique)
const device1 = await prisma.device.upsert({
where: { inventoryNumber: '1' },
update: {
// falls du bei erneutem Aufruf auch z.B. Hersteller / Model aktualisieren willst,
// kannst du die Felder hier noch einmal setzen
// hier könntest du bei erneutem Seed z.B. Hersteller/Model aktualisieren
},
create: {
inventoryNumber: '1',
@ -107,10 +114,10 @@ async function main() {
username: 'sachb1',
groupId: dienstrechnerGroup.id,
locationId: raum112.id,
createdById: user.id,
updatedById: user.id,
createdById: user.nwkennung,
updatedById: user.nwkennung,
// 🔹 Beispiel-Tags für Gerät 1
// Tags für Gerät 1
tags: {
connectOrCreate: [
{
@ -150,7 +157,7 @@ async function main() {
createdById: user.id,
updatedById: user.id,
// 🔹 Beispiel-Tags für Gerät 2
// Tags für Gerät 2
tags: {
connectOrCreate: [
{
@ -167,9 +174,10 @@ async function main() {
});
console.log('Test-User und Beispieldaten angelegt/aktualisiert:');
console.log(` Email: ${user.email}`);
console.log(` Username: ${user.username}`);
console.log(` Passwort: ${password}`);
console.log(` Email: ${user.email}`);
console.log(` Arbeitsname: ${user.arbeitsname}`);
console.log(` NW-Kennung: ${user.nwkennung}`);
console.log(` Passwort: ${password}`);
console.log(' Devices: ', device1.inventoryNumber, device2.inventoryNumber);
}

View File

@ -1,14 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2023",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
@ -29,6 +29,6 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
, "app/page.tsx.bak" ],
, "app/page.tsx.bak", "prisma.config.ts.bak", "prisma/seed.js" ],
"exclude": ["node_modules"]
}