From 8ea1db257e081917d7a134722920e724487216d5 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:00:05 +0100 Subject: [PATCH] updated --- app/(app)/devices/DeviceDetailModal.tsx | 60 +- app/(app)/devices/LoanDeviceModal.tsx | 6 +- app/(app)/devices/[inventoryNumber]/page.tsx | 20 + app/(app)/devices/page.tsx | 94 +- app/(app)/users/ChangePasswordModal.tsx | 192 ++++ app/(app)/users/EditUserModal.tsx | 115 +++ app/(app)/users/UserRowActions.tsx | 108 +++ app/(app)/users/UsersHeaderClient.tsx | 84 +- app/(app)/users/UsersTablesClient.tsx | 913 ++++++++---------- app/(app)/users/passwordUtils.ts | 25 + app/api/me/route.ts | 42 + app/api/user-groups/route.ts | 58 +- components/DeviceQrCode.tsx | 31 +- components/ui/Card.tsx | 108 +++ components/ui/Checkbox.tsx | 155 +++ components/ui/RadioGroup.tsx | 307 ++++++ components/ui/Switch.tsx | 68 ++ generated/prisma/commonInputTypes.ts | 26 + generated/prisma/internal/class.ts | 4 +- generated/prisma/internal/prismaNamespace.ts | 10 +- .../prisma/internal/prismaNamespaceBrowser.ts | 3 +- generated/prisma/models/UserGroup.ts | 38 +- package-lock.json | 131 ++- package.json | 4 +- .../migration.sql | 2 + prisma/schema.prisma | 3 + 26 files changed, 1923 insertions(+), 684 deletions(-) create mode 100644 app/(app)/devices/[inventoryNumber]/page.tsx create mode 100644 app/(app)/users/ChangePasswordModal.tsx create mode 100644 app/(app)/users/EditUserModal.tsx create mode 100644 app/(app)/users/UserRowActions.tsx create mode 100644 app/(app)/users/passwordUtils.ts create mode 100644 app/api/me/route.ts create mode 100644 components/ui/Card.tsx create mode 100644 components/ui/Checkbox.tsx create mode 100644 components/ui/RadioGroup.tsx create mode 100644 components/ui/Switch.tsx create mode 100644 prisma/migrations/20251126102750_add_can_edit_devices_to_usergroup/migration.sql diff --git a/app/(app)/devices/DeviceDetailModal.tsx b/app/(app)/devices/DeviceDetailModal.tsx index f9922f4..6b55668 100644 --- a/app/(app)/devices/DeviceDetailModal.tsx +++ b/app/(app)/devices/DeviceDetailModal.tsx @@ -15,8 +15,15 @@ type DeviceDetailModalProps = { open: boolean; inventoryNumber: string | null; onClose: () => void; + + /** Darf der aktuelle Benutzer Geräte bearbeiten? */ + canEdit?: boolean; + + /** Wird aufgerufen, wenn im Detail-Modal "Bearbeiten" geklickt wird */ + onEdit?: (inventoryNumber: string) => void; }; + const dtf = new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short', @@ -25,9 +32,22 @@ const dtf = new Intl.DateTimeFormat('de-DE', { type DeviceDetailsGridProps = { device: DeviceDetail; onStartLoan?: () => void; + + /** Darf der aktuelle Benutzer Geräte bearbeiten? */ + canEdit?: boolean; + + /** Wird ausgelöst, wenn auf "Bearbeiten" geklickt wird */ + onEdit?: () => void; }; -function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) { + +function DeviceDetailsGrid({ + device, + onStartLoan, + canEdit, + onEdit, +}: DeviceDetailsGridProps) { + const [activeSection, setActiveSection] = useState<'info' | 'zubehoer'>('info'); @@ -125,7 +145,6 @@ function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
{/* linke „Spalte“: nur inhaltsbreit */}
- {/* Pill nur content-breit */} @@ -135,7 +154,6 @@ function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) { {statusLabel} - {/* Infotext darunter */} {device.loanedTo && ( an{' '} @@ -166,15 +184,27 @@ function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) { )}
- + {/* rechte Seite: Buttons */} +
+ + + {canEdit && onEdit && ( + + )} +
@@ -414,6 +444,8 @@ export default function DeviceDetailModal({ open, inventoryNumber, onClose, + canEdit = false, + onEdit, }: DeviceDetailModalProps) { const [device, setDevice] = useState(null); const [loading, setLoading] = useState(false); @@ -562,6 +594,8 @@ export default function DeviceDetailModal({ onEdit(device.inventoryNumber) : undefined} /> ) : ( onEdit(device.inventoryNumber) : undefined} /> diff --git a/app/(app)/devices/LoanDeviceModal.tsx b/app/(app)/devices/LoanDeviceModal.tsx index fa16e2c..9fe8f63 100644 --- a/app/(app)/devices/LoanDeviceModal.tsx +++ b/app/(app)/devices/LoanDeviceModal.tsx @@ -439,13 +439,9 @@ export default function LoanDeviceModal({ {/* Formularfelder */}
- -
- label={undefined} + label="Verliehen an" options={userOptions} value={currentSelected} onChange={(selected) => { diff --git a/app/(app)/devices/[inventoryNumber]/page.tsx b/app/(app)/devices/[inventoryNumber]/page.tsx new file mode 100644 index 0000000..3b97691 --- /dev/null +++ b/app/(app)/devices/[inventoryNumber]/page.tsx @@ -0,0 +1,20 @@ +// app/(app)/devices/[inventoryNumber]/page.tsx +import { redirect } from 'next/navigation'; + +type RouteParams = { inventoryNumber?: string }; + +// In Next 15: params ist ein Promise +type PageProps = { + params: Promise; +}; + +export default async function DeviceQrRedirectPage({ params }: PageProps) { + const { inventoryNumber } = await params; + + if (!inventoryNumber || inventoryNumber === 'undefined') { + // Fallback: keine gültige ID → Geräteübersicht + redirect('/devices'); + } + + redirect(`/devices?device=${encodeURIComponent(inventoryNumber)}`); +} diff --git a/app/(app)/devices/page.tsx b/app/(app)/devices/page.tsx index 7be8c5d..94be2de 100644 --- a/app/(app)/devices/page.tsx +++ b/app/(app)/devices/page.tsx @@ -2,11 +2,11 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; - +import { useSearchParams, useRouter } from 'next/navigation'; import Button from '@/components/ui/Button'; import Table, { TableColumn } from '@/components/ui/Table'; import { Dropdown } from '@/components/ui/Dropdown'; -import Tabs from '@/components/ui/Tabs'; // 🔹 NEU +import Tabs from '@/components/ui/Tabs'; import { BookOpenIcon, PencilIcon, @@ -148,6 +148,16 @@ export default function DevicesPage() { const [allTags, setAllTags] = useState([]); + const searchParams = useSearchParams(); + const router = useRouter(); + + // TODO: Ersetze das durch deinen echten User-/Gruppen-Mechanismus + // Beispiel: aktuelle Benutzergruppen (z.B. aus Context oder eigenem Hook) + const currentUserGroups: string[] = []; // Platzhalter + + // Nur User in dieser Gruppe sollen Geräte bearbeiten dürfen + const canEditDevices = currentUserGroups.includes('INVENTAR_ADMIN'); + // 🔹 Tab-Filter: Hauptgeräte / Zubehör / Alle const [activeTab, setActiveTab] = useState<'main' | 'accessories' | 'all'>('main'); @@ -199,6 +209,20 @@ export default function DevicesPage() { loadDevices(); }, [loadDevices]); + useEffect(() => { + if (!searchParams) return; // TS happy + + const fromDevice = searchParams.get('device'); + const fromInventory = + searchParams.get('inventoryNumber') ?? searchParams.get('inv'); + + const fromUrl = fromDevice || fromInventory; + + if (fromUrl) { + setDetailInventoryNumber(fromUrl); + } + }, [searchParams]); + /* ───────── Live-Updates via Socket.IO ───────── */ useEffect(() => { @@ -310,10 +334,6 @@ export default function DevicesPage() { setDetailInventoryNumber(inventoryNumber); }, []); - const closeDetailModal = useCallback(() => { - setDetailInventoryNumber(null); - }, []); - const openCreateModal = useCallback(() => { setCreateOpen(true); }, []); @@ -322,6 +342,40 @@ export default function DevicesPage() { setCreateOpen(false); }, []); + const closeDetailModal = useCallback(() => { + setDetailInventoryNumber(null); + + if (!searchParams) { + // Fallback: einfach auf /devices ohne Query + router.replace('/devices', { scroll: false }); + return; + } + + // ReadonlyURLSearchParams → string → URLSearchParams kopieren + const params = new URLSearchParams(searchParams.toString()); + + // alle möglichen Detail-Parameter entfernen + params.delete('device'); + params.delete('inventoryNumber'); + params.delete('inv'); + + const queryString = params.toString(); + const newUrl = queryString ? `/devices?${queryString}` : '/devices'; + + router.replace(newUrl, { scroll: false }); + }, [router, searchParams]); + + const handleEditFromDetail = useCallback( + (inventoryNumber: string) => { + // Detail-Modal schließen + URL /device-Query aufräumen + closeDetailModal(); + // danach Edit-Modal öffnen + setEditInventoryNumber(inventoryNumber); + }, + [closeDetailModal], + ); + + /* ───────── Filter nach Tab ───────── */ const filteredDevices = devices.filter((d) => { @@ -352,17 +406,19 @@ export default function DevicesPage() {

- + {canEditDevices && ( + + )}
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */} @@ -408,7 +464,7 @@ export default function DevicesPage() { {/* Tabelle */}
- data={filteredDevices} // 🔹 statt devices + data={filteredDevices} columns={columns} getRowId={(row) => row.inventoryNumber} selectable @@ -516,6 +572,8 @@ export default function DevicesPage() { open={detailInventoryNumber !== null} inventoryNumber={detailInventoryNumber} onClose={closeDetailModal} + canEdit={canEditDevices} + onEdit={handleEditFromDetail} /> ); diff --git a/app/(app)/users/ChangePasswordModal.tsx b/app/(app)/users/ChangePasswordModal.tsx new file mode 100644 index 0000000..af74f95 --- /dev/null +++ b/app/(app)/users/ChangePasswordModal.tsx @@ -0,0 +1,192 @@ +'use client'; + +import Modal from '@/components/ui/Modal'; +import { CheckIcon } from '@heroicons/react/24/outline'; +import type { UserWithAvatar } from './types'; +import type { PasswordChecks } from './passwordUtils'; + +type ChangePasswordModalProps = { + open: boolean; + user: UserWithAvatar; + newPassword: string; + newPasswordConfirm: string; + pwChecks: PasswordChecks; + pwError: string | null; + saving: boolean; + canSubmitPw: boolean; + onNewPasswordChange: (value: string) => void; + onNewPasswordConfirmChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; +}; + +export default function ChangePasswordModal({ + open, + user, + newPassword, + newPasswordConfirm, + pwChecks, + pwError, + saving, + canSubmitPw, + onNewPasswordChange, + onNewPasswordConfirmChange, + onClose, + onSubmit, +}: ChangePasswordModalProps) { + if (!open) return null; + + return ( + +
{ + e.preventDefault(); + onSubmit(); + }} + className="space-y-3 text-sm" + > +

+ Das neue Passwort gilt sofort für den Benutzer{' '} + {user.arbeitsname || user.nwkennung}. +

+ +
+ + onNewPasswordChange(e.target.value)} + /> +
+ +
+ + onNewPasswordConfirmChange(e.target.value)} + /> +
+ +
+

Sicherheitskriterien:

+
    +
  • + {pwChecks.lengthOk ? ( + + ) : ( + + )} + Mindestens 12 Zeichen +
  • + +
  • + {pwChecks.lowerOk ? ( + + ) : ( + + )} + Mindestens ein Kleinbuchstabe (a–z) +
  • + +
  • + {pwChecks.upperOk ? ( + + ) : ( + + )} + Mindestens ein Großbuchstabe (A–Z) +
  • + +
  • + {pwChecks.digitOk ? ( + + ) : ( + + )} + Mindestens eine Ziffer (0–9) +
  • + +
  • + {pwChecks.specialOk ? ( + + ) : ( + + )} + Mindestens ein Sonderzeichen (!, ?, #, …) +
  • +
+
+ + {pwError && ( +

+ {pwError} +

+ )} +
+
+ ); +} diff --git a/app/(app)/users/EditUserModal.tsx b/app/(app)/users/EditUserModal.tsx new file mode 100644 index 0000000..ead5d03 --- /dev/null +++ b/app/(app)/users/EditUserModal.tsx @@ -0,0 +1,115 @@ +'use client'; + +import Modal from '@/components/ui/Modal'; +import type { UserWithAvatar } from './types'; + +type EditUserModalProps = { + open: boolean; + user: UserWithAvatar; + arbeitsname: string; + firstName: string; + lastName: string; + saving: boolean; + onArbeitsnameChange: (value: string) => void; + onFirstNameChange: (value: string) => void; + onLastNameChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; +}; + +export default function EditUserModal({ + open, + user, + arbeitsname, + firstName, + lastName, + saving, + onArbeitsnameChange, + onFirstNameChange, + onLastNameChange, + onClose, + onSubmit, +}: EditUserModalProps) { + if (!open) return null; + + return ( + +
{ + e.preventDefault(); + onSubmit(); + }} + className="space-y-3 text-sm" + > +
+ + +
+ +
+ + onArbeitsnameChange(e.target.value)} + /> +
+ +
+
+ + onFirstNameChange(e.target.value)} + /> +
+
+ + onLastNameChange(e.target.value)} + /> +
+
+
+
+ ); +} diff --git a/app/(app)/users/UserRowActions.tsx b/app/(app)/users/UserRowActions.tsx new file mode 100644 index 0000000..15fff6e --- /dev/null +++ b/app/(app)/users/UserRowActions.tsx @@ -0,0 +1,108 @@ +'use client'; + +import Dropdown from '@/components/ui/Dropdown'; +import Button from '@/components/ui/Button'; +import { + PencilIcon, + TrashIcon, + KeyIcon, +} from '@heroicons/react/24/outline'; +import type { UserWithAvatar } from './types'; + +type UserRowActionsProps = { + user: UserWithAvatar; + currentUserId: string | null; + onEdit: () => void; + onChangePassword: () => void; + onDelete: () => void; + isDeleting?: boolean; + isSavingPassword?: boolean; +}; + +export default function UserRowActions({ + user, + currentUserId, + onEdit, + onChangePassword, + onDelete, + isDeleting = false, + isSavingPassword = false, +}: UserRowActionsProps) { + const isCurrentUser = + !!currentUserId && user.nwkennung === currentUserId; + + return ( +
+ {/* Desktop / Tablet: Buttons */} +
+
+ + {/* Mobile: Dropdown mit denselben Actions */} +
+ +
+
+ ); +} diff --git a/app/(app)/users/UsersHeaderClient.tsx b/app/(app)/users/UsersHeaderClient.tsx index 0f49ca6..e1ca3c8 100644 --- a/app/(app)/users/UsersHeaderClient.tsx +++ b/app/(app)/users/UsersHeaderClient.tsx @@ -7,6 +7,7 @@ import Modal from '@/components/ui/Modal'; import Button from '@/components/ui/Button'; import { PlusIcon } from '@heroicons/react/24/outline'; import UsersCsvImportButton from './UsersCsvImportButton'; +import Switch from '@/components/ui/Switch'; type SimpleGroup = { id: string; @@ -35,6 +36,8 @@ export default function UsersHeaderClient({ groups }: Props) { const [groupName, setGroupName] = useState(''); const [savingGroup, setSavingGroup] = useState(false); const [groupError, setGroupError] = useState(null); + + const [groupCanEditDevices, setGroupCanEditDevices] = useState(false); async function handleCreateUser(e: FormEvent) { e.preventDefault(); @@ -79,38 +82,42 @@ export default function UsersHeaderClient({ groups }: Props) { } async function handleCreateGroup(e: FormEvent) { - e.preventDefault(); - setSavingGroup(true); - setGroupError(null); + 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 }), - }); + try { + const res = await fetch('/api/user-groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: groupName, + canEditDevices: groupCanEditDevices, // 👈 NEU + }), + }); - 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.', + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error( + data?.error ?? `Fehler beim Anlegen (HTTP ${res.status})`, ); - } finally { - setSavingGroup(false); } + + setGroupName(''); + setGroupCanEditDevices(false); // Reset + 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 ( <> @@ -281,7 +288,7 @@ export default function UsersHeaderClient({ groups }: Props) { >
@@ -297,6 +304,27 @@ export default function UsersHeaderClient({ groups }: Props) { />
+
+
+ + Darf Geräte bearbeiten + + + Mitglieder dieser Gruppe können Geräte anlegen, bearbeiten und löschen. + +
+ + + {/* 👇 Hier deine Switch-Komponente */} + +
+ {groupError && (

{groupError} diff --git a/app/(app)/users/UsersTablesClient.tsx b/app/(app)/users/UsersTablesClient.tsx index 3b79d49..190e41f 100644 --- a/app/(app)/users/UsersTablesClient.tsx +++ b/app/(app)/users/UsersTablesClient.tsx @@ -1,21 +1,32 @@ // app/(app)/users/UsersTablesClient.tsx + 'use client'; -import { useEffect, useMemo, useState, useTransition } from 'react'; +import { + useMemo, + useState, + useTransition, + useCallback, +} 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, KeyIcon, CheckIcon } from '@heroicons/react/24/outline'; -import type { GroupWithUsers, SimpleGroup, UserWithAvatar } from './types'; +import type { User } from '@/generated/prisma/client'; +import type { + GroupWithUsers, + SimpleGroup, + UserWithAvatar, +} from './types'; import AssignGroupForm from './AssignGroupForm'; - +import UserRowActions from './UserRowActions'; +import EditUserModal from './EditUserModal'; +import ChangePasswordModal from './ChangePasswordModal'; +import { evaluatePassword } from './passwordUtils'; +import Card from '@/components/ui/Card'; type Props = { groups: GroupWithUsers[]; @@ -23,42 +34,14 @@ type Props = { allGroups: SimpleGroup[]; }; -type PasswordChecks = { - lengthOk: boolean; - lowerOk: boolean; - upperOk: boolean; - digitOk: boolean; - specialOk: boolean; - allOk: boolean; -}; - type GroupCluster = { - baseKey: string; // z.B. "Gruppe" oder "Test" - label: string; // Anzeige-Label im Haupt-Tab + 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 -}; - -type UserRowActionsProps = { - user: UserWithAvatar; - currentUserId: string | null; + totalCount: number; // Summe aller User in diesem Cluster }; /* ───────── 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 ''; @@ -69,56 +52,87 @@ function getBaseGroupName(name: string): string { return withoutDigits || beforeDash; } -function evaluatePassword(password: string): PasswordChecks { - const lengthOk = password.length >= 12; - const lowerOk = /[a-z]/.test(password); - const upperOk = /[A-Z]/.test(password); - const digitOk = /\d/.test(password); - const specialOk = /[^A-Za-z0-9]/.test(password); +/* ───────── Haupt-Client ───────── */ - return { - lengthOk, - lowerOk, - upperOk, - digitOk, - specialOk, - allOk: lengthOk && lowerOk && upperOk && digitOk && specialOk, - }; -} - -/* ───────── Zeilen-Aktionen: Bearbeiten + Löschen ───────── */ - -function UserRowActions({ user, currentUserId }: UserRowActionsProps) { +export default function UsersTablesClient({ + groups, + ungrouped, + allGroups, +}: Props) { const router = useRouter(); - - const isCurrentUser = - !!currentUserId && user.nwkennung === currentUserId; + const { data: session } = useSession(); - // 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 [deleteGroupPending, startDeleteGroupTransition] = + useTransition(); + + // 🔹 Ausgewählte Benutzer in der Tabelle + const [selectedUserIds, setSelectedUserIds] = useState([]); + + // 🔹 Bulk-Delete-Transition + const [bulkDeleting, startBulkDeleteTransition] = useTransition(); + + // 🔹 Edit-Dialog (global, nicht pro Zeile) + const [editUser, setEditUser] = useState(null); + const [editArbeitsname, setEditArbeitsname] = useState(''); + const [editFirstName, setEditFirstName] = useState(''); + const [editLastName, setEditLastName] = useState(''); const [savingEdit, startEditTransition] = useTransition(); - // Löschen - const [deleting, startDeleteTransition] = useTransition(); + const openEditForUser = useCallback((user: UserWithAvatar) => { + setEditUser(user); + setEditArbeitsname(user.arbeitsname ?? ''); + setEditFirstName(user.firstName ?? ''); + setEditLastName(user.lastName ?? ''); + }, []); - // 🔹 NEU: Passwort ändern - const [pwOpen, setPwOpen] = useState(false); + function handleSaveEdit() { + if (!editUser) return; + + startEditTransition(async () => { + try { + const res = await fetch( + `/api/users/${encodeURIComponent(editUser.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; + } + + setEditUser(null); + router.refresh(); + } catch (err) { + console.error('Fehler beim Aktualisieren der User', err); + } + }); + } + + // 🔹 Passwort-Dialog (global) + const [pwUser, setPwUser] = useState(null); const [newPassword, setNewPassword] = useState(''); const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); - const [pwError, setPwError] = useState(null); const [savingPw, startPwTransition] = useTransition(); + const openPwForUser = useCallback((user: UserWithAvatar) => { + setPwUser(user); + setNewPassword(''); + setNewPasswordConfirm(''); + setPwError(null); + }, []); + const pwChecks = useMemo( () => evaluatePassword(newPassword), [newPassword], @@ -129,65 +143,9 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) { const canSubmitPw = pwChecks.allOk && passwordsMatch && !savingPw; + function handleChangePassword() { + if (!pwUser) return; - - 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); - } - }); - } - - async function handleChangePassword() { setPwError(null); if (!pwChecks.allOk) { @@ -205,7 +163,7 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) { startPwTransition(async () => { try { const res = await fetch( - `/api/users/${encodeURIComponent(user.nwkennung)}/password`, + `/api/users/${encodeURIComponent(pwUser.nwkennung)}/password`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -227,8 +185,7 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) { return; } - // success - setPwOpen(false); + setPwUser(null); setNewPassword(''); setNewPasswordConfirm(''); setPwError(null); @@ -242,337 +199,43 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) { }); } - return ( - <> -

- {/* Desktop / Tablet: Buttons */} -
-
- - {/* Mobile: Dropdown mit denselben Actions */} -
- setEditOpen(true), - }, - { - id: 'change-password', - label: savingPw ? 'Passwort …' : 'Passwort ändern', - onClick: () => setPwOpen(true), - }, - // Delete nur, wenn nicht eigener User - ...(!isCurrentUser - ? [ - { - id: 'delete', - label: deleting ? 'Lösche …' : 'Löschen', - tone: 'danger' as const, - onClick: handleDelete, - disabled: deleting, - }, - ] - : []), - ], - }, - ]} - /> -
-
- - {/* Edit-Modal */} - 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', - }} - > - { - e.preventDefault(); - handleSaveEdit(); - }} - className="space-y-3 text-sm" - > -
- - setEditNwKennung(e.target.value)} - /> -
- -
- - setEditArbeitsname(e.target.value)} - /> -
- -
-
- - setEditFirstName(e.target.value)} - /> -
-
- - setEditLastName(e.target.value)} - /> -
-
- -
- - {/* 🔹 NEU: Passwort ändern-Modal */} - setPwOpen(false)} - title="Passwort ändern" - tone="warning" - variant="centered" - size="md" - primaryAction={{ - label: savingPw ? 'Speichere …' : 'Passwort setzen', - onClick: handleChangePassword, - variant: 'primary', - disabled: !canSubmitPw, - }} - secondaryAction={{ - label: 'Abbrechen', - onClick: () => setPwOpen(false), - variant: 'secondary', - }} - > -
{ - e.preventDefault(); - handleChangePassword(); - }} - className="space-y-3 text-sm" - > -

- Das neue Passwort gilt sofort für den Benutzer{' '} - {user.arbeitsname || user.nwkennung}. -

- -
- - { - setNewPassword(e.target.value); - setPwError(null); - }} - /> -
- -
- - { - setNewPasswordConfirm(e.target.value); - setPwError(null); - }} - /> -
- -
-

Sicherheitskriterien:

-
    -
  • - {pwChecks.lengthOk ? ( - - ) : ( - - )} - Mindestens 12 Zeichen -
  • - -
  • - {pwChecks.lowerOk ? ( - - ) : ( - - )} - Mindestens ein Kleinbuchstabe (a–z) -
  • - -
  • - {pwChecks.upperOk ? ( - - ) : ( - - )} - Mindestens ein Großbuchstabe (A–Z) -
  • - -
  • - {pwChecks.digitOk ? ( - - ) : ( - - )} - Mindestens eine Ziffer (0–9) -
  • - -
  • - {pwChecks.specialOk ? ( - - ) : ( - - )} - Mindestens ein Sonderzeichen (!, ?, #, …) -
  • -
-
- - {pwError && ( -

- {pwError} -

- )} -
-
- + // 🔹 Single-User-Delete (global, nicht pro Zeile) + const [deleteUserPending, startDeleteUserTransition] = useTransition(); + const [deletingUserId, setDeletingUserId] = useState( + null, ); -} -/* ───────── Haupt-Client ───────── */ + function handleDeleteUser(user: UserWithAvatar) { + const ok = window.confirm( + `User "${user.arbeitsname || user.firstName || user.lastName}" wirklich löschen?`, + ); + if (!ok) return; -export default function UsersTablesClient({ - groups, - ungrouped, - allGroups, -}: Props) { - const router = useRouter(); - const { data: session } = useSession(); + setDeletingUserId(user.nwkennung); - const [deleteGroupPending, startDeleteGroupTransition] = useTransition(); + startDeleteUserTransition(async () => { + try { + const res = await fetch( + `/api/users/${encodeURIComponent(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); + } finally { + setDeletingUserId(null); + } + }); + } // User-ID aus der Session – passe das ggf. an dein Session-Objekt an const currentUserId = @@ -580,19 +243,18 @@ export default function UsersTablesClient({ (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", ... + // Cluster nach Basis-Key bauen const { clustersList, clustersByBaseKey } = useMemo(() => { const byKey = new Map(); for (const g of groups) { - const baseKey = getBaseGroupName(g.name); // z.B. "Test" aus "Test1" + const baseKey = getBaseGroupName(g.name); let cluster = byKey.get(baseKey); if (!cluster) { cluster = { baseKey, - label: baseKey, // wird gleich noch ggf. verfeinert + label: baseKey, groups: [], totalCount: 0, }; @@ -603,22 +265,15 @@ export default function UsersTablesClient({ 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 root = cluster.baseKey; 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 @@ -656,22 +311,25 @@ export default function UsersTablesClient({ return baseTabs; }, [clustersList, ungrouped.length]); - // Aktiver Haupt-Tab (Cluster, z.B. "Test" oder "Gruppe" oder "ungrouped") - const [activeMainTab, setActiveMainTab] = useState( - () => mainTabs[0]?.id ?? 'ungrouped', + // Aktiver Haupt-Tab (State) + "sicherer" Wert + const [activeMainTab, setActiveMainTab] = useState(() => + mainTabs[0]?.id ? mainTabs[0].id : 'ungrouped', ); - useEffect(() => { - if (!mainTabs.some((t) => t.id === activeMainTab)) { - setActiveMainTab(mainTabs[0]?.id ?? 'ungrouped'); + const safeActiveMainTab = useMemo(() => { + if (!mainTabs.length) return 'ungrouped'; + const exists = mainTabs.some((t) => t.id === activeMainTab); + if (!exists) { + return mainTabs[0].id; } + return activeMainTab; }, [mainTabs, activeMainTab]); // Unter-Tabs: pro Cluster z.B. [Alle, Test1, Test2, Test-Test, …] const subTabs: TabItem[] = useMemo(() => { - if (activeMainTab === 'ungrouped') return []; + if (safeActiveMainTab === 'ungrouped') return []; - const cluster = clustersByBaseKey.get(activeMainTab); + const cluster = clustersByBaseKey.get(safeActiveMainTab); if (!cluster) return []; const items: TabItem[] = []; @@ -695,40 +353,43 @@ export default function UsersTablesClient({ } return items; - }, [activeMainTab, clustersByBaseKey]); + }, [safeActiveMainTab, clustersByBaseKey]); - // Aktiver Unter-Tab + // Aktiver Unter-Tab (State) + "sicherer" Wert const [activeSubTab, setActiveSubTab] = useState('__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; + const safeActiveSubTab = useMemo(() => { + // Wenn "Ohne Gruppe" oder keine Sub-Tabs: immer "__all" + if (safeActiveMainTab === 'ungrouped' || subTabs.length === 0) { + return '__all'; } - // Sicherstellen, dass der aktive Sub-Tab existiert - if (!subTabs.some((t) => t.id === activeSubTab)) { - setActiveSubTab(subTabs[0].id); - } - }, [activeMainTab, subTabs, activeSubTab]); + // Wenn der aktuelle Sub-Tab existiert, nutzen + const exists = subTabs.some((t) => t.id === activeSubTab); + if (exists) return activeSubTab; + + // Sonst erster Sub-Tab + return subTabs[0]?.id ?? '__all'; + }, [safeActiveMainTab, subTabs, activeSubTab]); // Tabelle: Daten nach aktivem Haupt-Tab + Sub-Tab filtern const tableData: UserWithAvatar[] = useMemo(() => { let rows: UserWithAvatar[] = []; - if (activeMainTab === 'ungrouped') { + if (safeActiveMainTab === 'ungrouped') { rows = ungrouped as UserWithAvatar[]; } else { - const cluster = clustersByBaseKey.get(activeMainTab); + const cluster = clustersByBaseKey.get(safeActiveMainTab); if (!cluster) return []; - if (activeSubTab === '__all') { - rows = cluster.groups.flatMap((g) => g.users) as UserWithAvatar[]; + if (safeActiveSubTab === '__all') { + rows = cluster.groups.flatMap( + (g) => g.users, + ) as UserWithAvatar[]; } else { - const group = cluster.groups.find((g) => g.id === activeSubTab); + const group = cluster.groups.find( + (g) => g.id === safeActiveSubTab, + ); if (!group) return []; rows = group.users as UserWithAvatar[]; } @@ -738,22 +399,28 @@ export default function UsersTablesClient({ 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' }); + return aName.localeCompare(bName, 'de', { + sensitivity: 'base', + }); }); - }, [activeMainTab, activeSubTab, ungrouped, clustersByBaseKey]); - + }, [ + safeActiveMainTab, + safeActiveSubTab, + ungrouped, + clustersByBaseKey, + ]); // Gruppe löschen (konkrete Untergruppe, nicht der ganze Cluster) function handleDeleteActiveGroup() { - if (activeMainTab === 'ungrouped') return; - if (activeSubTab === '__all') { + if (safeActiveMainTab === 'ungrouped') return; + if (safeActiveSubTab === '__all') { alert( 'Bitte zuerst eine konkrete Untergruppe auswählen, bevor du sie löschen kannst.', ); return; } - const group = allGroups.find((g) => g.id === activeSubTab); + const group = allGroups.find((g) => g.id === safeActiveSubTab); if (!group) return; const ok = window.confirm( @@ -784,11 +451,77 @@ export default function UsersTablesClient({ }); } - // NEU: ganzen Cluster (alle Gruppen darunter) löschen - function handleDeleteActiveCluster() { - if (activeMainTab === 'ungrouped') return; + // Ausgewählte Benutzer löschen + function handleDeleteSelectedUsers() { + if (selectedUserIds.length === 0) return; - const cluster = clustersByBaseKey.get(activeMainTab); + const selectedUsers = tableData.filter((u) => + selectedUserIds.includes(u.nwkennung), + ); + + const deletableUsers = selectedUsers.filter( + (u) => !currentUserId || u.nwkennung !== currentUserId, + ); + + if (deletableUsers.length === 0) { + alert( + 'Die Auswahl enthält nur deinen eigenen Benutzer. Dieser kann nicht gelöscht werden.', + ); + return; + } + + const previewNames = deletableUsers + .slice(0, 5) + .map( + (u) => + u.arbeitsname || + [u.firstName, u.lastName].filter(Boolean).join(' ') || + u.nwkennung, + ) + .join(', '); + + const moreCount = deletableUsers.length - 5; + + const msg = + `Möchtest du wirklich ${deletableUsers.length} Benutzer löschen?\n\n` + + previewNames + + (moreCount > 0 ? ` … und ${moreCount} weitere` : '') + + `\n\nHinweis: Dein eigener Benutzer wird, falls ausgewählt, nicht gelöscht.`; + + const ok = window.confirm(msg); + if (!ok) return; + + startBulkDeleteTransition(async () => { + try { + for (const u of deletableUsers) { + const res = await fetch( + `/api/users/${encodeURIComponent(u.nwkennung)}`, + { method: 'DELETE' }, + ); + + if (!res.ok) { + console.error( + 'Fehler beim Löschen des Users', + u.nwkennung, + await res.text(), + ); + } + } + + setSelectedUserIds([]); + router.refresh(); + } catch (err) { + console.error('Fehler beim Bulk-Löschen der Benutzer', err); + alert('Fehler beim Löschen der Benutzer.'); + } + }); + } + + // ganzen Cluster löschen + function handleDeleteActiveCluster() { + if (safeActiveMainTab === 'ungrouped') return; + + const cluster = clustersByBaseKey.get(safeActiveMainTab); if (!cluster || cluster.groups.length === 0) return; const groupNames = cluster.groups.map((g) => g.name).join(', '); @@ -800,7 +533,6 @@ export default function UsersTablesClient({ 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', @@ -823,11 +555,10 @@ export default function UsersTablesClient({ }); } - // Columns inkl. Gruppen-Spalte mit Dropdown + "Du"-Badge anhand der ID - const userColumns: TableColumn[] = useMemo( + // Columns inkl. Gruppen-Spalte + const userColumns: TableColumn[] = useMemo( () => [ { - // Avatar-Spalte key: 'avatarUrl', header: '', sortable: false, @@ -871,6 +602,41 @@ export default function UsersTablesClient({ header: 'Vorname', sortable: true, }, + + // 🔹 NEUE SPALTE: Darf Geräte bearbeiten + { + key: 'canEditDevices', + header: 'Kann Geräte bearbeiten', + sortable: false, + headerClassName: 'w-40', + cellClassName: 'w-40', + render: (row) => { + const group = allGroups.find((g) => g.id === row.groupId); + const canEdit = !!group?.canEditDevices; + + if (!row.groupId) { + return ( + + Keine Gruppe + + ); + } + + return ( + + {canEdit ? 'Ja' : 'Nein'} + + ); + }, + }, + { key: 'groupId', header: 'Gruppe', @@ -888,13 +654,28 @@ export default function UsersTablesClient({ ); const canDeleteCurrentGroup = - activeMainTab !== 'ungrouped' && - activeSubTab !== '__all' && + safeActiveMainTab !== 'ungrouped' && + safeActiveSubTab !== '__all' && subTabs.length > 0; const canDeleteCurrentCluster = - activeMainTab !== 'ungrouped' && - clustersByBaseKey.get(activeMainTab)?.groups.length; + safeActiveMainTab !== 'ungrouped' && + !!clustersByBaseKey.get(safeActiveMainTab)?.groups.length; + + const handleSelectionChange = useCallback((rows: unknown[]) => { + const typed = rows as UserWithAvatar[]; + const nextIds = typed.map((r) => r.nwkennung); + + setSelectedUserIds((prev) => { + if ( + prev.length === nextIds.length && + prev.every((id, idx) => id === nextIds[idx]) + ) { + return prev; + } + return nextIds; + }); + }, []); return (
@@ -903,7 +684,7 @@ export default function UsersTablesClient({
@@ -925,11 +706,11 @@ export default function UsersTablesClient({
{/* 2. Tab-Reihe: Untergruppen + Untergruppe-löschen-Button */} - {activeMainTab !== 'ungrouped' && subTabs.length > 0 && ( + {safeActiveMainTab !== 'ungrouped' && subTabs.length > 0 && (
@@ -952,22 +733,102 @@ export default function UsersTablesClient({ )}
-
+
data={tableData} columns={userColumns} getRowId={(row) => row.nwkennung} actionsHeader="Aktionen" + selectable + onSelectionChange={handleSelectionChange} renderActions={(row) => ( openEditForUser(row)} + onChangePassword={() => openPwForUser(row)} + onDelete={() => handleDeleteUser(row)} + isDeleting={ + deleteUserPending && deletingUserId === row.nwkennung + } + isSavingPassword={ + savingPw && pwUser?.nwkennung === row.nwkennung + } /> )} defaultSortKey="arbeitsname" defaultSortDirection="asc" /> + + {/* Floating Actions in Card, unten mittig über der Tabelle */} + {selectedUserIds.length > 0 && ( +
+ +
+ + {selectedUserIds.length === 1 + ? '1 Benutzer ausgewählt' + : `${selectedUserIds.length} Benutzer ausgewählt`} + + + +
+
+
+ )}
+ + {/* Edit-Modal */} + {editUser && ( + setEditUser(null)} + onSubmit={handleSaveEdit} + /> + )} + + {/* Passwort-Modal */} + {pwUser && ( + { + setNewPassword(value); + setPwError(null); + }} + onNewPasswordConfirmChange={(value) => { + setNewPasswordConfirm(value); + setPwError(null); + }} + onClose={() => setPwUser(null)} + onSubmit={handleChangePassword} + /> + )}
); } diff --git a/app/(app)/users/passwordUtils.ts b/app/(app)/users/passwordUtils.ts new file mode 100644 index 0000000..2bb17b4 --- /dev/null +++ b/app/(app)/users/passwordUtils.ts @@ -0,0 +1,25 @@ +export type PasswordChecks = { + lengthOk: boolean; + lowerOk: boolean; + upperOk: boolean; + digitOk: boolean; + specialOk: boolean; + allOk: boolean; +}; + +export function evaluatePassword(password: string): PasswordChecks { + const lengthOk = password.length >= 12; + const lowerOk = /[a-z]/.test(password); + const upperOk = /[A-Z]/.test(password); + const digitOk = /\d/.test(password); + const specialOk = /[^A-Za-z0-9]/.test(password); + + return { + lengthOk, + lowerOk, + upperOk, + digitOk, + specialOk, + allOk: lengthOk && lowerOk && upperOk && digitOk && specialOk, + }; +} diff --git a/app/api/me/route.ts b/app/api/me/route.ts new file mode 100644 index 0000000..76f9b9e --- /dev/null +++ b/app/api/me/route.ts @@ -0,0 +1,42 @@ +// app/api/me/route.ts +import { NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; + +export async function GET() { + try { + const user = await getCurrentUser(); + + if (!user) { + return NextResponse.json( + { user: null }, + { status: 200 }, + ); + } + + // Rollen-Namen aus der Relation ziehen + const roles = (user.roles ?? []).map((ur) => ur.role.name); + + // "Anzeigename" zusammenbauen + const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || null; + + const displayName = user.arbeitsname ?? fullName; + + return NextResponse.json( + { + user: { + id: user.nwkennung, // dein Primärschlüssel + email: user.email, + name: displayName, // <-- statt user.name + roles, // string[] + }, + }, + { status: 200 }, + ); + } catch (err) { + console.error('[GET /api/me]', err); + return NextResponse.json( + { user: null, error: 'INTERNAL_ERROR' }, + { status: 500 }, + ); + } +} diff --git a/app/api/user-groups/route.ts b/app/api/user-groups/route.ts index 5524aa7..25fc5f5 100644 --- a/app/api/user-groups/route.ts +++ b/app/api/user-groups/route.ts @@ -1,4 +1,3 @@ -// app/api/user-groups/route.ts import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; @@ -6,7 +5,7 @@ export async function POST(req: Request) { try { const body = await req.json(); - // 🔹 BULK: { names: string[] } + // 🔹 BULK: { names: string[], canEditDevices?: boolean } if (Array.isArray(body?.names)) { const rawNames = body.names as unknown[]; @@ -17,24 +16,39 @@ export async function POST(req: Request) { const uniqueNames = Array.from(new Set(trimmedNames)); + const defaultCanEditDevices = Boolean(body.canEditDevices ?? false); + const groups = []; for (const name of uniqueNames) { + const trimmedName = name.trim(); + const isTEG = trimmedName.toUpperCase() === 'TEG'; + + // 👇 TEG immer true, sonst Default + const canEdit = isTEG ? true : defaultCanEditDevices; + const group = await prisma.userGroup.upsert({ - where: { name }, - update: {}, - create: { name }, + where: { name: trimmedName }, + update: { + canEditDevices: canEdit, + }, + create: { + name: trimmedName, + canEditDevices: canEdit, + }, + }); + + groups.push({ + id: group.id, + name: group.name, + canEditDevices: group.canEditDevices, }); - groups.push({ id: group.id, name: group.name }); } - return NextResponse.json( - { groups }, - { status: 200 }, - ); + return NextResponse.json({ groups }, { status: 200 }); } - // 🔹 SINGLE: { name: string } – wie bisher - const { name } = body; + // 🔹 SINGLE: { name: string, canEditDevices?: boolean } + const { name, canEditDevices } = body; if (!name || typeof name !== 'string') { return NextResponse.json( @@ -51,14 +65,28 @@ export async function POST(req: Request) { ); } + const isTEG = trimmed.toUpperCase() === 'TEG'; + + // 👇 TEG immer true, sonst Flag aus Request (oder false) + const flag = isTEG ? true : Boolean(canEditDevices ?? false); + const group = await prisma.userGroup.upsert({ where: { name: trimmed }, - update: {}, - create: { name: trimmed }, + update: { + canEditDevices: flag, + }, + create: { + name: trimmed, + canEditDevices: flag, + }, }); return NextResponse.json( - { id: group.id, name: group.name }, + { + id: group.id, + name: group.name, + canEditDevices: group.canEditDevices, + }, { status: 200 }, ); } catch (err) { diff --git a/components/DeviceQrCode.tsx b/components/DeviceQrCode.tsx index a7d682b..b7a54c9 100644 --- a/components/DeviceQrCode.tsx +++ b/components/DeviceQrCode.tsx @@ -4,25 +4,44 @@ import { QRCodeSVG } from 'qrcode.react'; type DeviceQrCodeProps = { - inventoryNumber: string; + inventoryNumber: string | null | undefined; size?: number; }; export function DeviceQrCode({ inventoryNumber, size = 180 }: DeviceQrCodeProps) { + // 1. Guard: ohne Inventarnummer kein QR + if (!inventoryNumber) { + if (process.env.NODE_ENV === 'development') { + console.warn('DeviceQrCode: inventoryNumber fehlt oder ist undefined/null', { + inventoryNumber, + }); + } + return ( +

+ Kein Inventarcode vorhanden – QR-Code kann nicht erzeugt werden. +

+ ); + } + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''; // Immer vollständige URL für externe Scanner erzeugen - const qrValue = baseUrl - ? `${baseUrl.replace(/\/$/, '')}/devices/${encodeURIComponent(inventoryNumber)}` - : inventoryNumber; + const appBase = baseUrl.replace(/\/$/, ''); + + const qrValue = appBase + ? `${appBase}/devices?device=${encodeURIComponent(inventoryNumber)}` + : `/devices?device=${encodeURIComponent(inventoryNumber)}`; return (
+ {/* Debug-Hinweis, kannst du nach dem Testen entfernen */} + {/*
{qrValue}
*/} + diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx new file mode 100644 index 0000000..3e38877 --- /dev/null +++ b/components/ui/Card.tsx @@ -0,0 +1,108 @@ +// src/components/ui/Card.tsx +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; + +export type CardVariant = + | 'default' // normale Card, gerundet, Schatten + | 'edgeToEdge' // Kante-zu-Kante auf Mobile, rounded ab sm: + | 'well' // "Well" auf weißem Hintergrund + | 'wellOnGray' // Well auf grauem Hintergrund + | 'wellEdgeToEdge'; // Well, edge-to-edge auf Mobile + +export interface CardProps + extends React.HTMLAttributes { + variant?: CardVariant; + /** Trenne Header/Body/Footer mit divide-y */ + divided?: boolean; +} + +function CardRoot({ + variant = 'default', + divided = false, + className, + ...props +}: CardProps) { + const base = 'overflow-hidden'; + + const variantClasses = (() => { + switch (variant) { + case 'edgeToEdge': + return 'bg-white shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10'; + case 'well': + return 'rounded-lg bg-gray-50 dark:bg-gray-800/50'; + case 'wellOnGray': + return 'rounded-lg bg-gray-200 dark:bg-gray-800/50'; + case 'wellEdgeToEdge': + return 'bg-gray-50 sm:rounded-lg dark:bg-gray-800/50'; + case 'default': + default: + return 'rounded-lg bg-white shadow-sm dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10'; + } + })(); + + const divideClasses = divided + ? 'divide-y divide-gray-200 dark:divide-white/10' + : ''; + + return ( +
+ ); +} + +type SectionProps = React.HTMLAttributes & { + /** Grau hinterlegt (Body / Footer) wie in deinen Beispielen */ + muted?: boolean; +}; + +function CardHeader({ className, muted, ...props }: SectionProps) { + return ( +
+ ); +} + +function CardBody({ className, muted, ...props }: SectionProps) { + return ( +
+ ); +} + +function CardFooter({ className, muted, ...props }: SectionProps) { + return ( +
+ ); +} + +// Default-Export mit statischen Subkomponenten: , etc. +export const Card = Object.assign(CardRoot, { + Header: CardHeader, + Body: CardBody, + Footer: CardFooter, +}); + +export default Card; diff --git a/components/ui/Checkbox.tsx b/components/ui/Checkbox.tsx new file mode 100644 index 0000000..28b687e --- /dev/null +++ b/components/ui/Checkbox.tsx @@ -0,0 +1,155 @@ +// components/ui/Checkbox.tsx +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; + +export type CheckboxProps = Omit< + React.InputHTMLAttributes, + 'type' +> & { + /** + * Label neben der Checkbox + */ + label?: React.ReactNode; + /** + * Beschreibung unter/neben dem Label + */ + description?: React.ReactNode; + /** + * Visueller indeterminate-Zustand (Strich statt Haken) + */ + indeterminate?: boolean; + /** + * Zusätzliche Klassen für das Wrapper-Element (Label+Beschreibung+Checkbox) + */ + wrapperClassName?: string; + /** + * Zusätzliche Klassen für das Label + */ + labelClassName?: string; + /** + * Zusätzliche Klassen für die Beschreibung + */ + descriptionClassName?: string; +}; + +export const Checkbox = React.forwardRef( + function Checkbox( + { + label, + description, + className, + wrapperClassName, + labelClassName, + descriptionClassName, + indeterminate, + id, + ...inputProps + }, + ref, + ) { + const innerRef = React.useRef(null); + + // externe + interne Ref zusammenführen + React.useImperativeHandle(ref, () => innerRef.current as HTMLInputElement); + + React.useEffect(() => { + if (innerRef.current) { + innerRef.current.indeterminate = Boolean(indeterminate); + } + }, [indeterminate]); + + // Fallback-ID, falls keine übergeben wurde + const inputId = + id ?? + (typeof label === 'string' + ? label.toLowerCase().replace(/\s+/g, '-') + : undefined); + + const descriptionId = + description && inputId ? `${inputId}-description` : undefined; + + return ( +
+ {/* Checkbox-Icon */} +
+
+ + +
+
+ + {/* Label + Beschreibung (optional) */} + {(label || description) && ( +
+ {label && ( + + )} + {description && ( +

+ {description} +

+ )} +
+ )} +
+ ); + }, +); diff --git a/components/ui/RadioGroup.tsx b/components/ui/RadioGroup.tsx new file mode 100644 index 0000000..81a89f2 --- /dev/null +++ b/components/ui/RadioGroup.tsx @@ -0,0 +1,307 @@ +// components/ui/RadioGroup.tsx +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; + +export type RadioGroupOption = { + value: string; + label: React.ReactNode; + description?: React.ReactNode; + disabled?: boolean; + className?: string; +}; + +export type RadioGroupVariant = + | 'simple' // Radio links, Label rechts + | 'withDescription' // Label + Description untereinander + | 'right' // Label links, Radio rechts + | 'panel'; // Segment-Panel (Pricing/Privacy-Style) + +type RadioGroupProps = { + name?: string; + legend?: React.ReactNode; + helpText?: React.ReactNode; + options: RadioGroupOption[]; + + /** Kontrollierter Wert (oder null für nichts gewählt) */ + value: string | null; + onChange: (value: string) => void; + + orientation?: 'vertical' | 'horizontal'; // nur relevant für simple + variant?: RadioGroupVariant; + + className?: string; + optionClassName?: string; + idPrefix?: string; +}; + +const baseRadioClasses = + 'relative size-4 appearance-none rounded-full border border-gray-300 bg-white ' + + 'before:absolute before:inset-1 before:rounded-full before:bg-white ' + + 'not-checked:before:hidden checked:border-indigo-600 checked: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:before:bg-gray-400 ' + + 'dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 ' + + 'dark:focus-visible:outline-indigo-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 ' + + 'dark:disabled:before:bg-white/20 forced-colors:appearance-auto forced-colors:before:hidden'; + +export function RadioGroup({ + name, + legend, + helpText, + options, + value, + onChange, + orientation = 'vertical', + variant = 'simple', + className, + optionClassName, + idPrefix = 'rg', +}: RadioGroupProps) { + const internalName = React.useId(); + const groupName = name ?? internalName; + + const isHorizontal = orientation === 'horizontal'; + + const handleChange = (nextValue: string) => { + if (nextValue !== value) { + onChange(nextValue); + } + }; + + const renderSimple = () => ( +
+ {options.map((opt) => { + const id = `${idPrefix}-${groupName}-${opt.value}`; + return ( +
+ handleChange(opt.value)} + className={baseRadioClasses} + /> + +
+ ); + })} +
+ ); + + const renderWithDescription = () => ( +
+ {options.map((opt) => { + const id = `${idPrefix}-${groupName}-${opt.value}`; + const descId = opt.description ? `${id}-description` : undefined; + return ( +
+
+ handleChange(opt.value)} + className={baseRadioClasses} + /> +
+
+ + {opt.description && ( +

+ {opt.description} +

+ )} +
+
+ ); + })} +
+ ); + + const renderRight = () => ( +
+ {options.map((opt, idx) => { + const id = `${idPrefix}-${groupName}-${opt.value || idx}`; + const descId = opt.description ? `${id}-description` : undefined; + return ( +
+
+ + {opt.description && ( +

+ {opt.description} +

+ )} +
+
+ handleChange(opt.value)} + className={baseRadioClasses} + /> +
+
+ ); + })} +
+ ); + + const renderPanel = () => ( +
+ {options.map((opt, index) => { + const id = `${idPrefix}-${groupName}-${opt.value}`; + return ( + + ); + })} +
+ ); + + return ( +
+ {legend && ( + + {legend} + + )} + + {helpText && ( +

+ {helpText} +

+ )} + + {variant === 'withDescription' && renderWithDescription()} + {variant === 'right' && renderRight()} + {variant === 'panel' && renderPanel()} + {variant === 'simple' && renderSimple()} +
+ ); +} diff --git a/components/ui/Switch.tsx b/components/ui/Switch.tsx new file mode 100644 index 0000000..a076e8a --- /dev/null +++ b/components/ui/Switch.tsx @@ -0,0 +1,68 @@ +// components/ui/Switch.tsx +'use client'; + +import * as React from 'react'; +import clsx from 'clsx'; + +type SwitchProps = { + id?: string; + name?: string; + checked: boolean; + onChange: (checked: boolean) => void; + ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; + disabled?: boolean; + className?: string; +}; + +export default function Switch({ + id, + name, + checked, + onChange, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, + disabled = false, + className, +}: SwitchProps) { + const handleChange = (e: React.ChangeEvent) => { + if (disabled) return; + onChange(e.target.checked); + }; + + return ( +
+ + +
+ ); +} diff --git a/generated/prisma/commonInputTypes.ts b/generated/prisma/commonInputTypes.ts index b1dbb98..a5788e8 100644 --- a/generated/prisma/commonInputTypes.ts +++ b/generated/prisma/commonInputTypes.ts @@ -110,6 +110,19 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = { _max?: Prisma.NestedDateTimeFilter<$PrismaModel> } +export type BoolFilter<$PrismaModel = never> = { + equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> + not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean +} + +export type BoolWithAggregatesFilter<$PrismaModel = never> = { + equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> + not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean + _count?: Prisma.NestedIntFilter<$PrismaModel> + _min?: Prisma.NestedBoolFilter<$PrismaModel> + _max?: Prisma.NestedBoolFilter<$PrismaModel> +} + export type DateTimeNullableFilter<$PrismaModel = never> = { equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null @@ -312,6 +325,19 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = { _max?: Prisma.NestedDateTimeFilter<$PrismaModel> } +export type NestedBoolFilter<$PrismaModel = never> = { + equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> + not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean +} + +export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = { + equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> + not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean + _count?: Prisma.NestedIntFilter<$PrismaModel> + _min?: Prisma.NestedBoolFilter<$PrismaModel> + _max?: Prisma.NestedBoolFilter<$PrismaModel> +} + export type NestedDateTimeNullableFilter<$PrismaModel = never> = { equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null diff --git a/generated/prisma/internal/class.ts b/generated/prisma/internal/class.ts index a60b9b9..c18c0e2 100644 --- a/generated/prisma/internal/class.ts +++ b/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.0.0", "engineVersion": "0c19ccc313cf9911a90d99d2ac2eb0280c76c513", "activeProvider": "postgresql", - "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n engineType = \"client\"\n generatedFileExtension = \"ts\"\n importFileExtension = \"ts\"\n moduleFormat = \"esm\"\n runtime = \"nodejs\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n nwkennung String @id\n email String? @unique\n arbeitsname String?\n firstName String?\n lastName String?\n passwordHash String?\n groupId String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n devicesCreated Device[] @relation(\"DeviceCreatedBy\")\n devicesUpdated Device[] @relation(\"DeviceUpdatedBy\")\n historyEntries DeviceHistory[] @relation(\"DeviceHistoryChangedBy\")\n\n // UserGroup hat ein Feld \"id\" – also darauf referenzieren\n group UserGroup? @relation(fields: [groupId], references: [id])\n\n roles UserRole[]\n\n @@index([groupId])\n}\n\nmodel Role {\n id String @id @default(uuid())\n name String @unique\n users UserRole[]\n}\n\nmodel UserRole {\n userId String\n roleId String\n assignedAt DateTime @default(now())\n\n role Role @relation(fields: [roleId], references: [id])\n user User @relation(fields: [userId], references: [nwkennung])\n\n @@id([userId, roleId])\n}\n\nmodel UserGroup {\n id String @id @default(uuid())\n name String @unique\n users User[]\n}\n\nmodel DeviceGroup {\n id String @id @default(uuid())\n name String @unique\n devices Device[]\n}\n\nmodel Location {\n id String @id @default(uuid())\n name String @unique\n devices Device[]\n}\n\nmodel Device {\n inventoryNumber String @id\n name String\n manufacturer String\n model String\n serialNumber String?\n productNumber String?\n comment String?\n ipv4Address String? @unique\n ipv6Address String? @unique\n macAddress String? @unique\n username String? @unique\n passwordHash String? @unique\n groupId String?\n locationId String?\n loanedTo String?\n loanedFrom DateTime?\n loanedUntil DateTime?\n loanComment String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdById String?\n updatedById String?\n\n // 🔹 Self-Relation Hauptgerät/Zubehör\n parentDeviceId String?\n parentDevice Device? @relation(name: \"DeviceAccessories\", fields: [parentDeviceId], references: [inventoryNumber], onDelete: SetNull)\n accessories Device[] @relation(\"DeviceAccessories\")\n\n createdBy User? @relation(\"DeviceCreatedBy\", fields: [createdById], references: [nwkennung])\n updatedBy User? @relation(\"DeviceUpdatedBy\", fields: [updatedById], references: [nwkennung])\n\n group DeviceGroup? @relation(fields: [groupId], references: [id])\n location Location? @relation(fields: [locationId], references: [id])\n\n history DeviceHistory[]\n tags Tag[] @relation(\"DeviceToTag\")\n\n @@index([inventoryNumber])\n @@index([groupId])\n @@index([locationId])\n @@index([parentDeviceId])\n}\n\nmodel Tag {\n id String @id @default(uuid())\n name String @unique\n devices Device[] @relation(\"DeviceToTag\")\n}\n\nmodel DeviceHistory {\n id String @id @default(uuid())\n deviceId String?\n changeType DeviceChangeType\n snapshot Json\n changedAt DateTime @default(now())\n changedById String?\n\n changedBy User? @relation(\"DeviceHistoryChangedBy\", fields: [changedById], references: [nwkennung])\n device Device? @relation(fields: [deviceId], references: [inventoryNumber], onDelete: SetNull)\n}\n\nenum DeviceChangeType {\n CREATED\n UPDATED\n DELETED\n}\n", + "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n engineType = \"client\"\n generatedFileExtension = \"ts\"\n importFileExtension = \"ts\"\n moduleFormat = \"esm\"\n runtime = \"nodejs\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n nwkennung String @id\n email String? @unique\n arbeitsname String?\n firstName String?\n lastName String?\n passwordHash String?\n groupId String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n devicesCreated Device[] @relation(\"DeviceCreatedBy\")\n devicesUpdated Device[] @relation(\"DeviceUpdatedBy\")\n historyEntries DeviceHistory[] @relation(\"DeviceHistoryChangedBy\")\n\n // UserGroup hat ein Feld \"id\" – also darauf referenzieren\n group UserGroup? @relation(fields: [groupId], references: [id])\n\n roles UserRole[]\n\n @@index([groupId])\n}\n\nmodel Role {\n id String @id @default(uuid())\n name String @unique\n users UserRole[]\n}\n\nmodel UserRole {\n userId String\n roleId String\n assignedAt DateTime @default(now())\n\n role Role @relation(fields: [roleId], references: [id])\n user User @relation(fields: [userId], references: [nwkennung])\n\n @@id([userId, roleId])\n}\n\nmodel UserGroup {\n id String @id @default(uuid())\n name String @unique\n users User[]\n canEditDevices Boolean @default(false)\n}\n\nmodel DeviceGroup {\n id String @id @default(uuid())\n name String @unique\n devices Device[]\n}\n\nmodel Location {\n id String @id @default(uuid())\n name String @unique\n devices Device[]\n}\n\nmodel Device {\n inventoryNumber String @id\n name String\n manufacturer String\n model String\n serialNumber String?\n productNumber String?\n comment String?\n ipv4Address String? @unique\n ipv6Address String? @unique\n macAddress String? @unique\n username String? @unique\n passwordHash String? @unique\n groupId String?\n locationId String?\n loanedTo String?\n loanedFrom DateTime?\n loanedUntil DateTime?\n loanComment String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdById String?\n updatedById String?\n\n // 🔹 Self-Relation Hauptgerät/Zubehör\n parentDeviceId String?\n parentDevice Device? @relation(name: \"DeviceAccessories\", fields: [parentDeviceId], references: [inventoryNumber], onDelete: SetNull)\n accessories Device[] @relation(\"DeviceAccessories\")\n\n createdBy User? @relation(\"DeviceCreatedBy\", fields: [createdById], references: [nwkennung])\n updatedBy User? @relation(\"DeviceUpdatedBy\", fields: [updatedById], references: [nwkennung])\n\n group DeviceGroup? @relation(fields: [groupId], references: [id])\n location Location? @relation(fields: [locationId], references: [id])\n\n history DeviceHistory[]\n tags Tag[] @relation(\"DeviceToTag\")\n\n @@index([inventoryNumber])\n @@index([groupId])\n @@index([locationId])\n @@index([parentDeviceId])\n}\n\nmodel Tag {\n id String @id @default(uuid())\n name String @unique\n devices Device[] @relation(\"DeviceToTag\")\n}\n\nmodel DeviceHistory {\n id String @id @default(uuid())\n deviceId String?\n changeType DeviceChangeType\n snapshot Json\n changedAt DateTime @default(now())\n changedById String?\n\n changedBy User? @relation(\"DeviceHistoryChangedBy\", fields: [changedById], references: [nwkennung])\n device Device? @relation(fields: [deviceId], references: [inventoryNumber], onDelete: SetNull)\n}\n\nenum DeviceChangeType {\n CREATED\n UPDATED\n DELETED\n}\n", "runtimeDataModel": { "models": {}, "enums": {}, @@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = { } } -config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"nwkennung\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"arbeitsname\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"passwordHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"groupId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"devicesCreated\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceCreatedBy\"},{\"name\":\"devicesUpdated\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceUpdatedBy\"},{\"name\":\"historyEntries\",\"kind\":\"object\",\"type\":\"DeviceHistory\",\"relationName\":\"DeviceHistoryChangedBy\"},{\"name\":\"group\",\"kind\":\"object\",\"type\":\"UserGroup\",\"relationName\":\"UserToUserGroup\"},{\"name\":\"roles\",\"kind\":\"object\",\"type\":\"UserRole\",\"relationName\":\"UserToUserRole\"}],\"dbName\":null},\"Role\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"UserRole\",\"relationName\":\"RoleToUserRole\"}],\"dbName\":null},\"UserRole\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"assignedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"role\",\"kind\":\"object\",\"type\":\"Role\",\"relationName\":\"RoleToUserRole\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserRole\"}],\"dbName\":null},\"UserGroup\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserGroup\"}],\"dbName\":null},\"DeviceGroup\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"devices\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToDeviceGroup\"}],\"dbName\":null},\"Location\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"devices\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToLocation\"}],\"dbName\":null},\"Device\":{\"fields\":[{\"name\":\"inventoryNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"manufacturer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"model\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"serialNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"productNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"comment\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipv4Address\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipv6Address\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"macAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"passwordHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"groupId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"locationId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"loanedTo\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"loanedFrom\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"loanedUntil\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"loanComment\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdById\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedById\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentDeviceId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentDevice\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceAccessories\"},{\"name\":\"accessories\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceAccessories\"},{\"name\":\"createdBy\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DeviceCreatedBy\"},{\"name\":\"updatedBy\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DeviceUpdatedBy\"},{\"name\":\"group\",\"kind\":\"object\",\"type\":\"DeviceGroup\",\"relationName\":\"DeviceToDeviceGroup\"},{\"name\":\"location\",\"kind\":\"object\",\"type\":\"Location\",\"relationName\":\"DeviceToLocation\"},{\"name\":\"history\",\"kind\":\"object\",\"type\":\"DeviceHistory\",\"relationName\":\"DeviceToDeviceHistory\"},{\"name\":\"tags\",\"kind\":\"object\",\"type\":\"Tag\",\"relationName\":\"DeviceToTag\"}],\"dbName\":null},\"Tag\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"devices\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToTag\"}],\"dbName\":null},\"DeviceHistory\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deviceId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"changeType\",\"kind\":\"enum\",\"type\":\"DeviceChangeType\"},{\"name\":\"snapshot\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"changedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"changedById\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"changedBy\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DeviceHistoryChangedBy\"},{\"name\":\"device\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToDeviceHistory\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"nwkennung\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"arbeitsname\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"passwordHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"groupId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"devicesCreated\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceCreatedBy\"},{\"name\":\"devicesUpdated\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceUpdatedBy\"},{\"name\":\"historyEntries\",\"kind\":\"object\",\"type\":\"DeviceHistory\",\"relationName\":\"DeviceHistoryChangedBy\"},{\"name\":\"group\",\"kind\":\"object\",\"type\":\"UserGroup\",\"relationName\":\"UserToUserGroup\"},{\"name\":\"roles\",\"kind\":\"object\",\"type\":\"UserRole\",\"relationName\":\"UserToUserRole\"}],\"dbName\":null},\"Role\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"UserRole\",\"relationName\":\"RoleToUserRole\"}],\"dbName\":null},\"UserRole\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"assignedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"role\",\"kind\":\"object\",\"type\":\"Role\",\"relationName\":\"RoleToUserRole\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserRole\"}],\"dbName\":null},\"UserGroup\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserGroup\"},{\"name\":\"canEditDevices\",\"kind\":\"scalar\",\"type\":\"Boolean\"}],\"dbName\":null},\"DeviceGroup\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"devices\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToDeviceGroup\"}],\"dbName\":null},\"Location\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"devices\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToLocation\"}],\"dbName\":null},\"Device\":{\"fields\":[{\"name\":\"inventoryNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"manufacturer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"model\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"serialNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"productNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"comment\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipv4Address\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipv6Address\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"macAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"passwordHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"groupId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"locationId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"loanedTo\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"loanedFrom\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"loanedUntil\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"loanComment\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdById\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedById\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentDeviceId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentDevice\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceAccessories\"},{\"name\":\"accessories\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceAccessories\"},{\"name\":\"createdBy\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DeviceCreatedBy\"},{\"name\":\"updatedBy\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DeviceUpdatedBy\"},{\"name\":\"group\",\"kind\":\"object\",\"type\":\"DeviceGroup\",\"relationName\":\"DeviceToDeviceGroup\"},{\"name\":\"location\",\"kind\":\"object\",\"type\":\"Location\",\"relationName\":\"DeviceToLocation\"},{\"name\":\"history\",\"kind\":\"object\",\"type\":\"DeviceHistory\",\"relationName\":\"DeviceToDeviceHistory\"},{\"name\":\"tags\",\"kind\":\"object\",\"type\":\"Tag\",\"relationName\":\"DeviceToTag\"}],\"dbName\":null},\"Tag\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"devices\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToTag\"}],\"dbName\":null},\"DeviceHistory\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deviceId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"changeType\",\"kind\":\"enum\",\"type\":\"DeviceChangeType\"},{\"name\":\"snapshot\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"changedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"changedById\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"changedBy\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DeviceHistoryChangedBy\"},{\"name\":\"device\",\"kind\":\"object\",\"type\":\"Device\",\"relationName\":\"DeviceToDeviceHistory\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") async function decodeBase64AsWasm(wasmBase64: string): Promise { const { Buffer } = await import('node:buffer') diff --git a/generated/prisma/internal/prismaNamespace.ts b/generated/prisma/internal/prismaNamespace.ts index f0437f3..2593a18 100644 --- a/generated/prisma/internal/prismaNamespace.ts +++ b/generated/prisma/internal/prismaNamespace.ts @@ -1151,7 +1151,8 @@ export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typ export const UserGroupScalarFieldEnum = { id: 'id', - name: 'name' + name: 'name', + canEditDevices: 'canEditDevices' } as const export type UserGroupScalarFieldEnum = (typeof UserGroupScalarFieldEnum)[keyof typeof UserGroupScalarFieldEnum] @@ -1296,6 +1297,13 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM +/** + * Reference to a field of type 'Boolean' + */ +export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'> + + + /** * Reference to a field of type 'DeviceChangeType' */ diff --git a/generated/prisma/internal/prismaNamespaceBrowser.ts b/generated/prisma/internal/prismaNamespaceBrowser.ts index c6ba944..88a47a8 100644 --- a/generated/prisma/internal/prismaNamespaceBrowser.ts +++ b/generated/prisma/internal/prismaNamespaceBrowser.ts @@ -112,7 +112,8 @@ export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typ export const UserGroupScalarFieldEnum = { id: 'id', - name: 'name' + name: 'name', + canEditDevices: 'canEditDevices' } as const export type UserGroupScalarFieldEnum = (typeof UserGroupScalarFieldEnum)[keyof typeof UserGroupScalarFieldEnum] diff --git a/generated/prisma/models/UserGroup.ts b/generated/prisma/models/UserGroup.ts index 2430628..f01ba13 100644 --- a/generated/prisma/models/UserGroup.ts +++ b/generated/prisma/models/UserGroup.ts @@ -27,16 +27,19 @@ export type AggregateUserGroup = { export type UserGroupMinAggregateOutputType = { id: string | null name: string | null + canEditDevices: boolean | null } export type UserGroupMaxAggregateOutputType = { id: string | null name: string | null + canEditDevices: boolean | null } export type UserGroupCountAggregateOutputType = { id: number name: number + canEditDevices: number _all: number } @@ -44,16 +47,19 @@ export type UserGroupCountAggregateOutputType = { export type UserGroupMinAggregateInputType = { id?: true name?: true + canEditDevices?: true } export type UserGroupMaxAggregateInputType = { id?: true name?: true + canEditDevices?: true } export type UserGroupCountAggregateInputType = { id?: true name?: true + canEditDevices?: true _all?: true } @@ -132,6 +138,7 @@ export type UserGroupGroupByArgs | string name?: Prisma.StringFilter<"UserGroup"> | string + canEditDevices?: Prisma.BoolFilter<"UserGroup"> | boolean users?: Prisma.UserListRelationFilter } export type UserGroupOrderByWithRelationInput = { id?: Prisma.SortOrder name?: Prisma.SortOrder + canEditDevices?: Prisma.SortOrder users?: Prisma.UserOrderByRelationAggregateInput } @@ -173,12 +182,14 @@ export type UserGroupWhereUniqueInput = Prisma.AtLeast<{ AND?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[] OR?: Prisma.UserGroupWhereInput[] NOT?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[] + canEditDevices?: Prisma.BoolFilter<"UserGroup"> | boolean users?: Prisma.UserListRelationFilter }, "id" | "name"> export type UserGroupOrderByWithAggregationInput = { id?: Prisma.SortOrder name?: Prisma.SortOrder + canEditDevices?: Prisma.SortOrder _count?: Prisma.UserGroupCountOrderByAggregateInput _max?: Prisma.UserGroupMaxOrderByAggregateInput _min?: Prisma.UserGroupMinOrderByAggregateInput @@ -190,45 +201,53 @@ export type UserGroupScalarWhereWithAggregatesInput = { NOT?: Prisma.UserGroupScalarWhereWithAggregatesInput | Prisma.UserGroupScalarWhereWithAggregatesInput[] id?: Prisma.StringWithAggregatesFilter<"UserGroup"> | string name?: Prisma.StringWithAggregatesFilter<"UserGroup"> | string + canEditDevices?: Prisma.BoolWithAggregatesFilter<"UserGroup"> | boolean } export type UserGroupCreateInput = { id?: string name: string + canEditDevices?: boolean users?: Prisma.UserCreateNestedManyWithoutGroupInput } export type UserGroupUncheckedCreateInput = { id?: string name: string + canEditDevices?: boolean users?: Prisma.UserUncheckedCreateNestedManyWithoutGroupInput } export type UserGroupUpdateInput = { id?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string + canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean users?: Prisma.UserUpdateManyWithoutGroupNestedInput } export type UserGroupUncheckedUpdateInput = { id?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string + canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean users?: Prisma.UserUncheckedUpdateManyWithoutGroupNestedInput } export type UserGroupCreateManyInput = { id?: string name: string + canEditDevices?: boolean } export type UserGroupUpdateManyMutationInput = { id?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string + canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean } export type UserGroupUncheckedUpdateManyInput = { id?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string + canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean } export type UserGroupNullableScalarRelationFilter = { @@ -239,16 +258,19 @@ export type UserGroupNullableScalarRelationFilter = { export type UserGroupCountOrderByAggregateInput = { id?: Prisma.SortOrder name?: Prisma.SortOrder + canEditDevices?: Prisma.SortOrder } export type UserGroupMaxOrderByAggregateInput = { id?: Prisma.SortOrder name?: Prisma.SortOrder + canEditDevices?: Prisma.SortOrder } export type UserGroupMinOrderByAggregateInput = { id?: Prisma.SortOrder name?: Prisma.SortOrder + canEditDevices?: Prisma.SortOrder } export type UserGroupCreateNestedOneWithoutUsersInput = { @@ -267,14 +289,20 @@ export type UserGroupUpdateOneWithoutUsersNestedInput = { update?: Prisma.XOR, Prisma.UserGroupUncheckedUpdateWithoutUsersInput> } +export type BoolFieldUpdateOperationsInput = { + set?: boolean +} + export type UserGroupCreateWithoutUsersInput = { id?: string name: string + canEditDevices?: boolean } export type UserGroupUncheckedCreateWithoutUsersInput = { id?: string name: string + canEditDevices?: boolean } export type UserGroupCreateOrConnectWithoutUsersInput = { @@ -296,11 +324,13 @@ export type UserGroupUpdateToOneWithWhereWithoutUsersInput = { export type UserGroupUpdateWithoutUsersInput = { id?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string + canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean } export type UserGroupUncheckedUpdateWithoutUsersInput = { id?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string + canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean } @@ -337,6 +367,7 @@ export type UserGroupCountOutputTypeCountUsersArgs = runtime.Types.Extensions.GetSelect<{ id?: boolean name?: boolean + canEditDevices?: boolean users?: boolean | Prisma.UserGroup$usersArgs _count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs }, ExtArgs["result"]["userGroup"]> @@ -344,19 +375,22 @@ export type UserGroupSelect = runtime.Types.Extensions.GetSelect<{ id?: boolean name?: boolean + canEditDevices?: boolean }, ExtArgs["result"]["userGroup"]> export type UserGroupSelectUpdateManyAndReturn = runtime.Types.Extensions.GetSelect<{ id?: boolean name?: boolean + canEditDevices?: boolean }, ExtArgs["result"]["userGroup"]> export type UserGroupSelectScalar = { id?: boolean name?: boolean + canEditDevices?: boolean } -export type UserGroupOmit = runtime.Types.Extensions.GetOmit<"id" | "name", ExtArgs["result"]["userGroup"]> +export type UserGroupOmit = runtime.Types.Extensions.GetOmit<"id" | "name" | "canEditDevices", ExtArgs["result"]["userGroup"]> export type UserGroupInclude = { users?: boolean | Prisma.UserGroup$usersArgs _count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs @@ -372,6 +406,7 @@ export type $UserGroupPayload composites: {} } @@ -798,6 +833,7 @@ export interface Prisma__UserGroupClient readonly name: Prisma.FieldRef<"UserGroup", 'String'> + readonly canEditDevices: Prisma.FieldRef<"UserGroup", 'Boolean'> } diff --git a/package-lock.json b/package-lock.json index a22d208..169c381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@heroicons/react": "^2.2.0", "@prisma/adapter-better-sqlite3": "^7.0.0", "@prisma/adapter-pg": "^7.0.0", - "@prisma/client": "^7.0.0", + "@prisma/client": "^7.0.1", "@zxing/browser": "^0.1.5", "bcryptjs": "^3.0.3", "next": "16.0.3", @@ -35,7 +35,7 @@ "dotenv": "^17.2.3", "eslint": "^9", "eslint-config-next": "16.0.3", - "prisma": "^7.0.0", + "prisma": "^7.0.1", "tailwindcss": "^4.1.17", "tsx": "^4.20.6", "typescript": "^5.9.3" @@ -85,7 +85,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -368,8 +367,7 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.6", @@ -1924,15 +1922,15 @@ } }, "node_modules/@prisma/client": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.0.0.tgz", - "integrity": "sha512-FM1NtJezl0zH3CybLxcbJwShJt7xFGSRg+1tGhy3sCB8goUDnxnBR+RC/P35EAW8gjkzx7kgz7bvb0MerY2VSw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.0.1.tgz", + "integrity": "sha512-O74T6xcfaGAq5gXwCAvfTLvI6fmC3and2g5yLRMkNjri1K8mSpEgclDNuUWs9xj5AwNEMQ88NeD3asI+sovm1g==", "license": "Apache-2.0", "dependencies": { - "@prisma/client-runtime-utils": "7.0.0" + "@prisma/client-runtime-utils": "7.0.1" }, "engines": { - "node": "^20.19 || ^22.12 || ^24.0" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { "prisma": "*", @@ -1948,15 +1946,15 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.0.0.tgz", - "integrity": "sha512-PAiFgMBPrLSaakBwUpML5NevipuKSL3rtNr8pZ8CZ3OBXo0BFcdeGcBIKw/CxJP6H4GNa4+l5bzJPrk8Iq6tDw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.0.1.tgz", + "integrity": "sha512-R26BVX9D/iw4toUmZKZf3jniM/9pMGHHdZN5LVP2L7HNiCQKNQQx/9LuMtjepbgRqSqQO3oHN0yzojHLnKTGEw==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.0.0.tgz", - "integrity": "sha512-TDASB57hyGUwHB0IPCSkoJcXFrJOKA1+R/1o4np4PbS+E0F5MiY5aAyUttO0mSuNQaX7t8VH/GkDemffF1mQzg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.0.1.tgz", + "integrity": "sha512-MacIjXdo+hNKxPvtMzDXykIIc8HCRWoyjQ2nguJTFqLDzJBD5L6QRaANGTLOqbGtJ3sFvLRmfXhrFg3pWoK1BA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -2008,56 +2006,70 @@ } }, "node_modules/@prisma/engines": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.0.0.tgz", - "integrity": "sha512-ojCL3OFLMCz33UbU9XwH32jwaeM+dWb8cysTuY8eK6ZlMKXJdy6ogrdG3MGB3meKLGdQBmOpUUGJ7eLIaxbrcg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.0.1.tgz", + "integrity": "sha512-f+D/vdKeImqUHysd5Bgv8LQ1whl4sbLepHyYMQQMK61cp4WjwJVryophleLUrfEJRpBLGTBI/7fnLVENxxMFPQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.0.0", - "@prisma/engines-version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513", - "@prisma/fetch-engine": "7.0.0", - "@prisma/get-platform": "7.0.0" + "@prisma/debug": "7.0.1", + "@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6", + "@prisma/fetch-engine": "7.0.1", + "@prisma/get-platform": "7.0.1" } }, "node_modules/@prisma/engines-version": { - "version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513.tgz", - "integrity": "sha512-7bzyN8Gp9GbDFbTDzVUH9nFcgRWvsWmjrGgBJvIC/zEoAuv/lx62gZXgAKfjn/HoPkxz/dS+TtsnduFx8WA+cw==", + "version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6.tgz", + "integrity": "sha512-RA7pShKvijHib4USRB3YuLTQamHKJPkTRDc45AwxfahUQngiGVMlIj4ix4emUxkrum4o/jwn82WIwlG57EtgiQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/debug": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz", + "integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.0.tgz", - "integrity": "sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz", + "integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.0.0" + "@prisma/debug": "7.0.1" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.0.0.tgz", - "integrity": "sha512-qcyWTeWDjVDaDQSrVIymZU1xCYlvmwCzjA395lIuFjUESOH3YQCb8i/hpd4vopfq3fUR4v6+MjjtIGvnmErQgw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.0.1.tgz", + "integrity": "sha512-5DnSairYIYU7dcv/9pb1KCwIRHZfhVOd34855d01lUI5QdF9rdCkMywPQbBM67YP7iCgQoEZO0/COtOMpR4i9A==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.0.0", - "@prisma/engines-version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513", - "@prisma/get-platform": "7.0.0" + "@prisma/debug": "7.0.1", + "@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6", + "@prisma/get-platform": "7.0.1" } }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz", + "integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.0.tgz", - "integrity": "sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz", + "integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.0.0" + "@prisma/debug": "7.0.1" } }, "node_modules/@prisma/get-platform": { @@ -2084,10 +2096,10 @@ "devOptional": true, "license": "Apache-2.0" }, - "node_modules/@prisma/studio-core-licensed": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@prisma/studio-core-licensed/-/studio-core-licensed-0.8.0.tgz", - "integrity": "sha512-SXCcgFvo/SC6/11kEOaQghJgCWNEWZUvPYKn/gpvMB9HLSG/5M8If7dWZtEQHhchvl8bh9A89Hw6mEKpsXFimA==", + "node_modules/@prisma/studio-core": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.8.2.tgz", + "integrity": "sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ==", "devOptional": true, "license": "UNLICENSED", "peerDependencies": { @@ -2590,7 +2602,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2613,7 +2624,6 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2674,7 +2684,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3252,7 +3261,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3691,7 +3699,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4653,7 +4660,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4839,7 +4845,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5663,7 +5668,6 @@ "integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7441,7 +7445,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -7662,7 +7665,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7723,18 +7725,17 @@ "license": "MIT" }, "node_modules/prisma": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.0.0.tgz", - "integrity": "sha512-VZObZ1pQV/OScarYg68RYUx61GpFLH2mJGf9fUX4XxQxTst/6ZK7nkY86CSZ3zBW6U9lKRTsBrZWVz20X5G/KQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.0.1.tgz", + "integrity": "sha512-zp93MdFMSU1IHPEXbUHVUuD8wauh2BUm14OVxhxGrWJQQpXpda0rW4VSST2bci4raoldX64/wQxHKkl/wqDskQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { - "@prisma/config": "7.0.0", + "@prisma/config": "7.0.1", "@prisma/dev": "0.13.0", - "@prisma/engines": "7.0.0", - "@prisma/studio-core-licensed": "0.8.0", + "@prisma/engines": "7.0.1", + "@prisma/studio-core": "0.8.2", "mysql2": "3.15.3", "postgres": "3.4.7" }, @@ -7742,7 +7743,7 @@ "prisma": "build/index.js" }, "engines": { - "node": "^20.19 || ^22.12 || ^24.0" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", @@ -7895,7 +7896,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7905,7 +7905,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8943,7 +8942,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8982,6 +8980,7 @@ "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -9202,7 +9201,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9594,7 +9592,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 6838f37..362998e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@heroicons/react": "^2.2.0", "@prisma/adapter-better-sqlite3": "^7.0.0", "@prisma/adapter-pg": "^7.0.0", - "@prisma/client": "^7.0.0", + "@prisma/client": "^7.0.1", "@zxing/browser": "^0.1.5", "bcryptjs": "^3.0.3", "next": "16.0.3", @@ -41,7 +41,7 @@ "dotenv": "^17.2.3", "eslint": "^9", "eslint-config-next": "16.0.3", - "prisma": "^7.0.0", + "prisma": "^7.0.1", "tailwindcss": "^4.1.17", "tsx": "^4.20.6", "typescript": "^5.9.3" diff --git a/prisma/migrations/20251126102750_add_can_edit_devices_to_usergroup/migration.sql b/prisma/migrations/20251126102750_add_can_edit_devices_to_usergroup/migration.sql new file mode 100644 index 0000000..49c5f44 --- /dev/null +++ b/prisma/migrations/20251126102750_add_can_edit_devices_to_usergroup/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserGroup" ADD COLUMN "canEditDevices" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ddd7bc6..f41f7ca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,3 +1,5 @@ +// Prisma Schema + generator client { provider = "prisma-client" output = "../generated/prisma" @@ -56,6 +58,7 @@ model UserGroup { id String @id @default(uuid()) name String @unique users User[] + canEditDevices Boolean @default(false) } model DeviceGroup {