This commit is contained in:
Linrador 2025-11-26 15:00:05 +01:00
parent 0f5d23eb9b
commit 8ea1db257e
26 changed files with 1923 additions and 684 deletions

View File

@ -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) {
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{/* linke „Spalte“: nur inhaltsbreit */}
<div className="flex w-auto shrink-0 flex-col gap-1">
{/* Pill nur content-breit */}
<span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
>
@ -135,7 +154,6 @@ function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
<span>{statusLabel}</span>
</span>
{/* Infotext darunter */}
{device.loanedTo && (
<span className="text-xs text-gray-700 dark:text-gray-200">
an{' '}
@ -166,15 +184,27 @@ function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
)}
</div>
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned
? 'Verleih bearbeiten'
: 'Gerät verleihen'}
</Button>
{/* rechte Seite: Buttons */}
<div className="flex flex-row gap-2">
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
</Button>
{canEdit && onEdit && (
<Button
size="md"
variant="soft"
tone="indigo"
onClick={onEdit}
>
Bearbeiten
</Button>
)}
</div>
</div>
</div>
@ -414,6 +444,8 @@ export default function DeviceDetailModal({
open,
inventoryNumber,
onClose,
canEdit = false,
onEdit,
}: DeviceDetailModalProps) {
const [device, setDevice] = useState<DeviceDetail | null>(null);
const [loading, setLoading] = useState(false);
@ -562,6 +594,8 @@ export default function DeviceDetailModal({
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
/>
) : (
<DeviceHistorySidebar
@ -577,6 +611,8 @@ export default function DeviceDetailModal({
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
/>
</div>
</>

View File

@ -439,13 +439,9 @@ export default function LoanDeviceModal({
{/* Formularfelder */}
<div className="space-y-3 text-sm">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Verliehen an
</label>
<div className="mt-1">
<AppCombobox<LoanUserOption>
label={undefined}
label="Verliehen an"
options={userOptions}
value={currentSelected}
onChange={(selected) => {

View File

@ -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<RouteParams>;
};
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)}`);
}

View File

@ -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<TagOption[]>([]);
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() {
</p>
</div>
<Button
variant="soft"
tone="indigo"
size="md"
icon={<PlusIcon className="size-5" />}
aria-label="Neues Gerät anlegen"
onClick={openCreateModal}
title="Neues Gerät anlegen"
>
Neues Gerät anlegen
</Button>
{canEditDevices && (
<Button
variant="soft"
tone="indigo"
size="md"
icon={<PlusIcon className="size-5" />}
aria-label="Neues Gerät anlegen"
onClick={openCreateModal}
title="Neues Gerät anlegen"
>
Neues Gerät anlegen
</Button>
)}
</div>
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */}
@ -408,7 +464,7 @@ export default function DevicesPage() {
{/* Tabelle */}
<div className="mt-8">
<Table<DeviceDetail>
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}
/>
</>
);

View File

@ -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 (
<Modal
open={open}
onClose={onClose}
title="Passwort ändern"
tone="warning"
variant="centered"
size="md"
primaryAction={{
label: saving ? 'Speichere …' : 'Passwort setzen',
onClick: onSubmit,
variant: 'primary',
disabled: !canSubmitPw,
}}
secondaryAction={{
label: 'Abbrechen',
onClick: onClose,
variant: 'secondary',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-3 text-sm"
>
<p className="text-xs text-gray-600 dark:text-gray-400">
Das neue Passwort gilt sofort für den Benutzer{' '}
<strong>{user.arbeitsname || user.nwkennung}</strong>.
</p>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Neues Passwort *
</label>
<input
type="password"
required
minLength={12}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={newPassword}
onChange={(e) => onNewPasswordChange(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Passwort bestätigen *
</label>
<input
type="password"
required
minLength={12}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={newPasswordConfirm}
onChange={(e) => onNewPasswordConfirmChange(e.target.value)}
/>
</div>
<div className="mt-2 rounded-md bg-gray-50 p-2 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<p className="font-medium mb-1">Sicherheitskriterien:</p>
<ul className="space-y-0.5">
<li
className={
(pwChecks.lengthOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.lengthOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens 12 Zeichen</span>
</li>
<li
className={
(pwChecks.lowerOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.lowerOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Kleinbuchstabe (az)</span>
</li>
<li
className={
(pwChecks.upperOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.upperOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Großbuchstabe (AZ)</span>
</li>
<li
className={
(pwChecks.digitOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.digitOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens eine Ziffer (09)</span>
</li>
<li
className={
(pwChecks.specialOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.specialOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Sonderzeichen (!, ?, #, )</span>
</li>
</ul>
</div>
{pwError && (
<p className="text-xs text-red-600 dark:text-red-400">
{pwError}
</p>
)}
</form>
</Modal>
);
}

View File

@ -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 (
<Modal
open={open}
onClose={onClose}
title="User bearbeiten"
tone="info"
variant="centered"
size="md"
primaryAction={{
label: saving ? 'Speichere …' : 'Speichern',
onClick: onSubmit,
variant: 'primary',
}}
secondaryAction={{
label: 'Abbrechen',
onClick: onClose,
variant: 'secondary',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-3 text-sm"
>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
NW-Kennung *
</label>
<input
type="text"
readOnly
className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-100 px-2.5 py-1.5 text-sm text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
value={user.nwkennung}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Arbeitsname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={arbeitsname}
onChange={(e) => onArbeitsnameChange(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Vorname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={firstName}
onChange={(e) => onFirstNameChange(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Nachname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={lastName}
onChange={(e) => onLastNameChange(e.target.value)}
/>
</div>
</div>
</form>
</Modal>
);
}

View File

@ -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 (
<div className="flex items-center gap-3">
{/* Desktop / Tablet: Buttons */}
<div className="hidden sm:flex items-center gap-2">
<Button
type="button"
size="lg"
variant="secondary"
icon={<PencilIcon className="size-5" />}
onClick={onEdit}
/>
<Button
type="button"
size="lg"
variant="soft"
tone="indigo"
icon={<KeyIcon className="size-5" />}
onClick={onChangePassword}
/>
{!isCurrentUser && (
<Button
type="button"
size="lg"
variant="soft"
tone="rose"
icon={<TrashIcon className="size-5" />}
onClick={onDelete}
disabled={isDeleting}
/>
)}
</div>
{/* Mobile: Dropdown mit denselben Actions */}
<div className="sm:hidden">
<Dropdown
triggerVariant="icon"
ariaLabel="Weitere Aktionen"
align="right"
sections={[
{
id: 'main',
items: [
{
id: 'edit',
label: 'Bearbeiten',
onClick: onEdit,
},
{
id: 'change-password',
label: isSavingPassword
? 'Passwort …'
: 'Passwort ändern',
onClick: onChangePassword,
},
...(!isCurrentUser
? [
{
id: 'delete',
label: isDeleting ? 'Lösche …' : 'Löschen',
tone: 'danger' as const,
onClick: onDelete,
disabled: isDeleting,
},
]
: []),
],
},
]}
/>
</div>
</div>
);
}

View File

@ -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<string | null>(null);
const [groupCanEditDevices, setGroupCanEditDevices] = useState<boolean>(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) {
>
<form
id="new-group-form"
className="space-y-3 text-sm"
className="space-y-4 text-sm"
onSubmit={handleCreateGroup}
>
<div>
@ -297,6 +304,27 @@ export default function UsersHeaderClient({ groups }: Props) {
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
Darf Geräte bearbeiten
</span>
<span className="text-[11px] text-gray-500 dark:text-gray-400">
Mitglieder dieser Gruppe können Geräte anlegen, bearbeiten und löschen.
</span>
</div>
{/* 👇 Hier deine Switch-Komponente */}
<Switch
id="group-can-edit-devices"
name="group-can-edit-devices"
checked={groupCanEditDevices}
onChange={setGroupCanEditDevices}
ariaLabel="Gruppe darf Geräte bearbeiten"
/>
</div>
{groupError && (
<p className="text-xs text-red-600 dark:text-red-400">
{groupError}

File diff suppressed because it is too large Load Diff

View File

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

42
app/api/me/route.ts Normal file
View File

@ -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 },
);
}
}

View File

@ -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) {

View File

@ -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 (
<p className="text-xs text-red-600">
Kein Inventarcode vorhanden QR-Code kann nicht erzeugt werden.
</p>
);
}
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 (
<div className="inline-flex flex-col items-center gap-2">
{/* Debug-Hinweis, kannst du nach dem Testen entfernen */}
{/* <div className="text-[10px] break-all text-gray-500">{qrValue}</div> */}
<QRCodeSVG
value={qrValue}
size={size}
level="M" // Fehlertoleranz
includeMargin // wichtiger weißer Rand (= Quiet Zone)
level="M"
includeMargin
bgColor="#FFFFFF"
fgColor="#000000"
/>

108
components/ui/Card.tsx Normal file
View File

@ -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<HTMLDivElement> {
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 (
<div
className={clsx(base, variantClasses, divideClasses, className)}
{...props}
/>
);
}
type SectionProps = React.HTMLAttributes<HTMLDivElement> & {
/** Grau hinterlegt (Body / Footer) wie in deinen Beispielen */
muted?: boolean;
};
function CardHeader({ className, muted, ...props }: SectionProps) {
return (
<div
className={clsx(
'px-4 py-5 sm:px-6',
muted && 'bg-gray-50 dark:bg-gray-800/50',
className,
)}
{...props}
/>
);
}
function CardBody({ className, muted, ...props }: SectionProps) {
return (
<div
className={clsx(
'px-4 py-5 sm:p-6',
muted && 'bg-gray-50 dark:bg-gray-800/50',
className,
)}
{...props}
/>
);
}
function CardFooter({ className, muted, ...props }: SectionProps) {
return (
<div
className={clsx(
'px-4 py-4 sm:px-6',
muted && 'bg-gray-50 dark:bg-gray-800/50',
className,
)}
{...props}
/>
);
}
// Default-Export mit statischen Subkomponenten: <Card>, <Card.Header> etc.
export const Card = Object.assign(CardRoot, {
Header: CardHeader,
Body: CardBody,
Footer: CardFooter,
});
export default Card;

155
components/ui/Checkbox.tsx Normal file
View File

@ -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<HTMLInputElement>,
'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<HTMLInputElement, CheckboxProps>(
function Checkbox(
{
label,
description,
className,
wrapperClassName,
labelClassName,
descriptionClassName,
indeterminate,
id,
...inputProps
},
ref,
) {
const innerRef = React.useRef<HTMLInputElement | null>(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 (
<div className={clsx('flex gap-3', wrapperClassName)}>
{/* Checkbox-Icon */}
<div className="flex h-6 shrink-0 items-center">
<div className="group grid size-4 grid-cols-1">
<input
id={inputId}
ref={innerRef}
type="checkbox"
aria-describedby={descriptionId}
className={clsx(
'col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white',
'checked:border-indigo-600 checked:bg-indigo-600',
'indeterminate:border-indigo-600 indeterminate:bg-indigo-600',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100',
'dark:border-white/10 dark:bg-white/5',
'dark:checked:border-indigo-500 dark:checked:bg-indigo-500',
'dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500',
'dark:focus-visible:outline-indigo-500',
'dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10',
'forced-colors:appearance-auto',
className,
)}
{...inputProps}
/>
<svg
fill="none"
viewBox="0 0 14 14"
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
>
{/* Haken */}
<path
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-checked:opacity-100"
/>
{/* Indeterminate-Strich */}
<path
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-indeterminate:opacity-100"
/>
</svg>
</div>
</div>
{/* Label + Beschreibung (optional) */}
{(label || description) && (
<div className="text-sm/6">
{label && (
<label
htmlFor={inputId}
className={clsx(
'font-medium text-gray-900 dark:text-white',
labelClassName,
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={clsx(
'text-gray-500 dark:text-gray-400',
descriptionClassName,
)}
>
{description}
</p>
)}
</div>
)}
</div>
);
},
);

View File

@ -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 = () => (
<div
className={clsx(
'mt-3',
isHorizontal
? 'space-y-3 sm:flex sm:items-center sm:space-y-0 sm:space-x-8'
: 'space-y-3',
)}
>
{options.map((opt) => {
const id = `${idPrefix}-${groupName}-${opt.value}`;
return (
<div
key={opt.value}
className={clsx('flex items-center', optionClassName, opt.className)}
>
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
onChange={() => handleChange(opt.value)}
className={baseRadioClasses}
/>
<label
htmlFor={id}
className={clsx(
'ml-3 block text-sm/6 font-medium',
opt.disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-900 dark:text-white',
)}
>
{opt.label}
</label>
</div>
);
})}
</div>
);
const renderWithDescription = () => (
<div className="mt-3 space-y-5">
{options.map((opt) => {
const id = `${idPrefix}-${groupName}-${opt.value}`;
const descId = opt.description ? `${id}-description` : undefined;
return (
<div
key={opt.value}
className={clsx(
'relative flex items-start',
optionClassName,
opt.className,
)}
>
<div className="flex h-6 items-center">
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
aria-describedby={descId}
onChange={() => handleChange(opt.value)}
className={baseRadioClasses}
/>
</div>
<div className="ml-3 text-sm/6">
<label
htmlFor={id}
className={clsx(
'font-medium',
opt.disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-900 dark:text-white',
)}
>
{opt.label}
</label>
{opt.description && (
<p
id={descId}
className={clsx(
'text-gray-500 dark:text-gray-400',
opt.disabled && 'opacity-70',
)}
>
{opt.description}
</p>
)}
</div>
</div>
);
})}
</div>
);
const renderRight = () => (
<div className="mt-3 divide-y divide-gray-200 border-t border-b border-gray-200 dark:divide-white/10 dark:border-white/10">
{options.map((opt, idx) => {
const id = `${idPrefix}-${groupName}-${opt.value || idx}`;
const descId = opt.description ? `${id}-description` : undefined;
return (
<div
key={opt.value}
className={clsx(
'relative flex items-start py-4',
optionClassName,
opt.className,
)}
>
<div className="min-w-0 flex-1 text-sm/6">
<label
htmlFor={id}
className={clsx(
'font-medium select-none',
opt.disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-900 dark:text-white',
)}
>
{opt.label}
</label>
{opt.description && (
<p
id={descId}
className="text-gray-500 dark:text-gray-400"
>
{opt.description}
</p>
)}
</div>
<div className="ml-3 flex h-6 items-center">
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
aria-describedby={descId}
onChange={() => handleChange(opt.value)}
className={baseRadioClasses}
/>
</div>
</div>
);
})}
</div>
);
const renderPanel = () => (
<div className="-space-y-px rounded-md bg-white dark:bg-gray-800/50">
{options.map((opt, index) => {
const id = `${idPrefix}-${groupName}-${opt.value}`;
return (
<label
key={opt.value}
htmlFor={id}
className={clsx(
'group flex border border-gray-200 p-4 focus:outline-hidden',
'first:rounded-tl-md first:rounded-tr-md last:rounded-br-md last:rounded-bl-md',
'has-checked:relative has-checked:border-indigo-200 has-checked:bg-indigo-50',
'dark:border-gray-700 dark:has-checked:border-indigo-800 dark:has-checked:bg-indigo-500/10',
optionClassName,
opt.className,
)}
>
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
onChange={() => handleChange(opt.value)}
className={clsx(
baseRadioClasses,
'mt-0.5 shrink-0',
opt.disabled && 'opacity-70',
)}
/>
<span className="ml-3 flex flex-col text-sm">
<span
className={clsx(
'font-medium',
'text-gray-900 group-has-checked:text-indigo-900',
'dark:text-white dark:group-has-checked:text-indigo-300',
opt.disabled && 'opacity-70',
)}
>
{opt.label}
</span>
{opt.description && (
<span
className={clsx(
'text-gray-500 group-has-checked:text-indigo-700',
'dark:text-gray-400 dark:group-has-checked:text-indigo-300/75',
opt.disabled && 'opacity-70',
)}
>
{opt.description}
</span>
)}
</span>
</label>
);
})}
</div>
);
return (
<fieldset className={className}>
{legend && (
<legend className="text-sm/6 font-semibold text-gray-900 dark:text-white">
{legend}
</legend>
)}
{helpText && (
<p className="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
{helpText}
</p>
)}
{variant === 'withDescription' && renderWithDescription()}
{variant === 'right' && renderRight()}
{variant === 'panel' && renderPanel()}
{variant === 'simple' && renderSimple()}
</fieldset>
);
}

68
components/ui/Switch.tsx Normal file
View File

@ -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<HTMLInputElement>) => {
if (disabled) return;
onChange(e.target.checked);
};
return (
<div
className={clsx(
'group relative inline-flex w-11 shrink-0 rounded-full bg-gray-200 p-0.5',
'inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600',
'transition-colors duration-200 ease-in-out',
'has-checked:bg-indigo-600 has-focus-visible:outline-2',
'dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500 dark:has-checked:bg-indigo-500',
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
className,
)}
>
<span
className={clsx(
'size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5',
'transition-transform duration-200 ease-in-out',
'group-has-checked:translate-x-5',
)}
/>
<input
id={id}
name={name}
type="checkbox"
className="absolute inset-0 appearance-none focus:outline-hidden"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
checked={checked}
onChange={handleChange}
disabled={disabled}
/>
</div>
);
}

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -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'
*/

View File

@ -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]

View File

@ -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<ExtArgs extends runtime.Types.Extensions.Intern
export type UserGroupGroupByOutputType = {
id: string
name: string
canEditDevices: boolean
_count: UserGroupCountAggregateOutputType | null
_min: UserGroupMinAggregateOutputType | null
_max: UserGroupMaxAggregateOutputType | null
@ -158,12 +165,14 @@ export type UserGroupWhereInput = {
NOT?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[]
id?: Prisma.StringFilter<"UserGroup"> | 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.XOR<Prisma.UserGroupUpdateToOneWithWhereWithoutUsersInput, Prisma.UserGroupUpdateWithoutUsersInput>, 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<ExtArgs extends runtime.Types
export type UserGroupSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
name?: boolean
canEditDevices?: boolean
users?: boolean | Prisma.UserGroup$usersArgs<ExtArgs>
_count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["userGroup"]>
@ -344,19 +375,22 @@ export type UserGroupSelect<ExtArgs extends runtime.Types.Extensions.InternalArg
export type UserGroupSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
name?: boolean
canEditDevices?: boolean
}, ExtArgs["result"]["userGroup"]>
export type UserGroupSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = 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<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name", ExtArgs["result"]["userGroup"]>
export type UserGroupOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "canEditDevices", ExtArgs["result"]["userGroup"]>
export type UserGroupInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
users?: boolean | Prisma.UserGroup$usersArgs<ExtArgs>
_count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs<ExtArgs>
@ -372,6 +406,7 @@ export type $UserGroupPayload<ExtArgs extends runtime.Types.Extensions.InternalA
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
name: string
canEditDevices: boolean
}, ExtArgs["result"]["userGroup"]>
composites: {}
}
@ -798,6 +833,7 @@ export interface Prisma__UserGroupClient<T, Null = never, ExtArgs extends runtim
export interface UserGroupFieldRefs {
readonly id: Prisma.FieldRef<"UserGroup", 'String'>
readonly name: Prisma.FieldRef<"UserGroup", 'String'>
readonly canEditDevices: Prisma.FieldRef<"UserGroup", 'Boolean'>
}

131
package-lock.json generated
View File

@ -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"
}

View File

@ -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"

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserGroup" ADD COLUMN "canEditDevices" BOOLEAN NOT NULL DEFAULT false;

View File

@ -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 {