updated
This commit is contained in:
parent
7f683d5828
commit
2f42c71fe9
2
.env
2
.env
@ -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
4
.gitignore
vendored
@ -41,3 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/app/generated/prisma
|
||||
|
||||
/lib/generated/prisma
|
||||
|
||||
/lib/generated/prisma
|
||||
|
||||
@ -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,7 +360,13 @@ export default function DeviceDetailModal({
|
||||
|
||||
const handleClose = () => onClose();
|
||||
|
||||
const handleStartLoan = () => {
|
||||
if (!device) return;
|
||||
setLoanModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
@ -309,8 +409,8 @@ export default function DeviceDetailModal({
|
||||
<DeviceQrCode inventoryNumber={device.inventoryNumber} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[12px] text-gray-500">
|
||||
#{device.inventoryNumber}
|
||||
<p className="mt-2 text-center text-[14px] text-gray-500">
|
||||
{device.inventoryNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -322,6 +422,7 @@ export default function DeviceDetailModal({
|
||||
key={device.updatedAt}
|
||||
inventoryNumber={device.inventoryNumber}
|
||||
asSidebar
|
||||
refreshToken={historyRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -345,21 +446,52 @@ export default function DeviceDetailModal({
|
||||
{/* Mobile-Inhalt (Tabs steuern Ansicht) */}
|
||||
<div className="sm:hidden pr-2">
|
||||
{activeTab === 'details' ? (
|
||||
<DeviceDetailsGrid device={device} />
|
||||
<DeviceDetailsGrid
|
||||
device={device}
|
||||
onStartLoan={handleStartLoan}
|
||||
/>
|
||||
) : (
|
||||
<DeviceHistorySidebar
|
||||
key={device.updatedAt + '-mobile'}
|
||||
inventoryNumber={device.inventoryNumber}
|
||||
refreshToken={historyRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */}
|
||||
<div className="hidden sm:block pr-2">
|
||||
<DeviceDetailsGrid device={device} />
|
||||
<DeviceDetailsGrid
|
||||
device={device}
|
||||
onStartLoan={handleStartLoan}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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} />
|
||||
|
||||
547
app/(app)/devices/LoanDeviceModal.tsx
Normal file
547
app/(app)/devices/LoanDeviceModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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="#"
|
||||
@ -459,8 +356,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
{status === 'authenticated' && rawName && (
|
||||
<UserMenu
|
||||
displayName={displayName}
|
||||
avatarInitial={avatarInitial}
|
||||
avatarColorClass={avatarColorClass}
|
||||
avatarName={avatarName}
|
||||
avatarUrl={avatarUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
94
app/(app)/users/AssignGroupForm.tsx
Normal file
94
app/(app)/users/AssignGroupForm.tsx
Normal 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 …
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
app/(app)/users/UsersCsvImportButton.tsx
Normal file
214
app/(app)/users/UsersCsvImportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
app/(app)/users/UsersHeaderClient.tsx
Normal file
309
app/(app)/users/UsersHeaderClient.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
696
app/(app)/users/UsersTablesClient.tsx
Normal file
696
app/(app)/users/UsersTablesClient.tsx
Normal 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
42
app/(app)/users/page.tsx
Normal 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
13
app/(app)/users/types.ts
Normal 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;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
|
||||
38
app/api/user-groups/[id]/route.ts
Normal file
38
app/api/user-groups/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
41
app/api/user-groups/route.ts
Normal file
41
app/api/user-groups/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
92
app/api/users/[id]/route.ts
Normal file
92
app/api/users/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
33
app/api/users/assign-group/route.ts
Normal file
33
app/api/users/assign-group/route.ts
Normal 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
108
app/api/users/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// app/providers.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
@ -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
131
components/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
73
components/ui/ButtonGroup.tsx
Normal file
73
components/ui/ButtonGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,23 +124,48 @@ 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">
|
||||
<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 (
|
||||
<>
|
||||
<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,
|
||||
)}
|
||||
>
|
||||
@ -133,41 +180,57 @@ export function Dropdown({
|
||||
) : (
|
||||
<>
|
||||
<span className="sr-only">{ariaLabel}</span>
|
||||
<EllipsisVerticalIcon aria-hidden="true" className="size-5" />
|
||||
<EllipsisVerticalIcon
|
||||
aria-hidden="true"
|
||||
className="size-5"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
{open && position && !disabled && (
|
||||
<Portal>
|
||||
<MenuItems
|
||||
transition
|
||||
static
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.top,
|
||||
left: align === 'left' ? position.left : undefined,
|
||||
right:
|
||||
align === 'right'
|
||||
? window.innerWidth - position.left
|
||||
: undefined,
|
||||
}}
|
||||
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 ' +
|
||||
'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',
|
||||
alignmentClasses,
|
||||
hasDividers &&
|
||||
'divide-y divide-gray-100 dark:divide-white/10',
|
||||
menuClassName,
|
||||
)}
|
||||
>
|
||||
{/* Optionaler Header (z.B. "Signed in as …") */}
|
||||
{header && (
|
||||
<div className="px-4 py-3">
|
||||
{header}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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}>
|
||||
<MenuItem
|
||||
key={key}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
@ -191,6 +254,11 @@ export function Dropdown({
|
||||
</div>
|
||||
))}
|
||||
</MenuItems>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 ? (
|
||||
@ -263,10 +269,12 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
className={classNames(isSelected && 'bg-gray-50 dark:bg-gray-800/60')}
|
||||
className={classNames(
|
||||
isSelected && 'bg-gray-50 dark:bg-gray-800/60',
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-2">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
73
components/ui/UserAvatar.tsx
Normal file
73
components/ui/UserAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
generated/prisma/browser.ts
Normal file
64
generated/prisma/browser.ts
Normal 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
|
||||
86
generated/prisma/client.ts
Normal file
86
generated/prisma/client.ts
Normal 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
|
||||
381
generated/prisma/commonInputTypes.ts
Normal file
381
generated/prisma/commonInputTypes.ts
Normal 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
18
generated/prisma/enums.ts
Normal 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]
|
||||
270
generated/prisma/internal/class.ts
Normal file
270
generated/prisma/internal/class.ts
Normal file
File diff suppressed because one or more lines are too long
1489
generated/prisma/internal/prismaNamespace.ts
Normal file
1489
generated/prisma/internal/prismaNamespace.ts
Normal file
File diff suppressed because it is too large
Load Diff
223
generated/prisma/internal/prismaNamespaceBrowser.ts
Normal file
223
generated/prisma/internal/prismaNamespaceBrowser.ts
Normal 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]
|
||||
|
||||
20
generated/prisma/models.ts
Normal file
20
generated/prisma/models.ts
Normal 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'
|
||||
3150
generated/prisma/models/Device.ts
Normal file
3150
generated/prisma/models/Device.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
generated/prisma/models/DeviceGroup.ts
Normal file
1228
generated/prisma/models/DeviceGroup.ts
Normal file
File diff suppressed because it is too large
Load Diff
1518
generated/prisma/models/DeviceHistory.ts
Normal file
1518
generated/prisma/models/DeviceHistory.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
generated/prisma/models/Location.ts
Normal file
1228
generated/prisma/models/Location.ts
Normal file
File diff suppressed because it is too large
Load Diff
1226
generated/prisma/models/Role.ts
Normal file
1226
generated/prisma/models/Role.ts
Normal file
File diff suppressed because it is too large
Load Diff
1273
generated/prisma/models/Tag.ts
Normal file
1273
generated/prisma/models/Tag.ts
Normal file
File diff suppressed because it is too large
Load Diff
2101
generated/prisma/models/User.ts
Normal file
2101
generated/prisma/models/User.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
generated/prisma/models/UserGroup.ts
Normal file
1228
generated/prisma/models/UserGroup.ts
Normal file
File diff suppressed because it is too large
Load Diff
1384
generated/prisma/models/UserRole.ts
Normal file
1384
generated/prisma/models/UserRole.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
},
|
||||
|
||||
17
lib/auth.ts
17
lib/auth.ts
@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
@ -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
1981
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -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
13
prisma.config.ts
Normal 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"),
|
||||
},
|
||||
});
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@ -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");
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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");
|
||||
@ -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");
|
||||
@ -1,2 +0,0 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DeviceHistory_changedById_idx" ON "DeviceHistory"("changedById");
|
||||
@ -1,5 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "DeviceHistory_changedById_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DeviceHistory_deviceId_changedAt_idx";
|
||||
203
prisma/migrations/20251120131542/migration.sql
Normal file
203
prisma/migrations/20251120131542/migration.sql
Normal 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;
|
||||
8
prisma/migrations/20251121082646_init/migration.sql
Normal file
8
prisma/migrations/20251121082646_init/migration.sql
Normal 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";
|
||||
16
prisma/migrations/20251121083642_init/migration.sql
Normal file
16
prisma/migrations/20251121083642_init/migration.sql
Normal 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");
|
||||
43
prisma/migrations/20251121101051_init/migration.sql
Normal file
43
prisma/migrations/20251121101051_init/migration.sql
Normal 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;
|
||||
@ -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"
|
||||
|
||||
@ -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[]
|
||||
|
||||
// Audit-Relations
|
||||
devicesCreated Device[] @relation("DeviceCreatedBy")
|
||||
devicesUpdated Device[] @relation("DeviceUpdatedBy")
|
||||
historyEntries DeviceHistory[] @relation("DeviceHistoryChangedBy")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// UserGroup hat ein Feld "id" – also darauf referenzieren
|
||||
group UserGroup? @relation(fields: [groupId], references: [id])
|
||||
|
||||
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
|
||||
roleId String
|
||||
assignedAt DateTime @default(now())
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
roleId String
|
||||
|
||||
assignedAt DateTime @default(now())
|
||||
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 // IPv4-Adresse
|
||||
ipv6Address String? @unique // IPv6-Adresse
|
||||
macAddress String? @unique // MAC-Adresse
|
||||
username String? @unique // Benutzername
|
||||
passwordHash String? @unique // Passwort
|
||||
|
||||
// Beziehungen
|
||||
group DeviceGroup? @relation(fields: [groupId], references: [id])
|
||||
ipv4Address String? @unique
|
||||
ipv6Address String? @unique
|
||||
macAddress String? @unique
|
||||
username String? @unique
|
||||
passwordHash String? @unique
|
||||
groupId String?
|
||||
|
||||
location Location? @relation(fields: [locationId], references: [id])
|
||||
locationId String?
|
||||
|
||||
tags Tag[]
|
||||
|
||||
// Audit-Felder
|
||||
loanedTo String?
|
||||
loanedFrom DateTime?
|
||||
loanedUntil DateTime?
|
||||
loanComment String?
|
||||
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
|
||||
createdBy User? @relation("DeviceCreatedBy", fields: [createdById], references: [nwkennung])
|
||||
updatedBy User? @relation("DeviceUpdatedBy", fields: [updatedById], references: [nwkennung])
|
||||
|
||||
group DeviceGroup? @relation(fields: [groupId], references: [id])
|
||||
location Location? @relation(fields: [locationId], references: [id])
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
@ -168,7 +175,8 @@ async function main() {
|
||||
|
||||
console.log('Test-User und Beispieldaten angelegt/aktualisiert:');
|
||||
console.log(` Email: ${user.email}`);
|
||||
console.log(` Username: ${user.username}`);
|
||||
console.log(` Arbeitsname: ${user.arbeitsname}`);
|
||||
console.log(` NW-Kennung: ${user.nwkennung}`);
|
||||
console.log(` Passwort: ${password}`);
|
||||
console.log(' Devices: ', device1.inventoryNumber, device2.inventoryNumber);
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user