updated
This commit is contained in:
parent
0f5d23eb9b
commit
8ea1db257e
@ -15,8 +15,15 @@ type DeviceDetailModalProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
inventoryNumber: string | null;
|
inventoryNumber: string | null;
|
||||||
onClose: () => void;
|
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', {
|
const dtf = new Intl.DateTimeFormat('de-DE', {
|
||||||
dateStyle: 'short',
|
dateStyle: 'short',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
@ -25,9 +32,22 @@ const dtf = new Intl.DateTimeFormat('de-DE', {
|
|||||||
type DeviceDetailsGridProps = {
|
type DeviceDetailsGridProps = {
|
||||||
device: DeviceDetail;
|
device: DeviceDetail;
|
||||||
onStartLoan?: () => void;
|
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] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<'info' | 'zubehoer'>('info');
|
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">
|
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
{/* linke „Spalte“: nur inhaltsbreit */}
|
{/* linke „Spalte“: nur inhaltsbreit */}
|
||||||
<div className="flex w-auto shrink-0 flex-col gap-1">
|
<div className="flex w-auto shrink-0 flex-col gap-1">
|
||||||
{/* Pill nur content-breit */}
|
|
||||||
<span
|
<span
|
||||||
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
|
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>{statusLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Infotext darunter */}
|
|
||||||
{device.loanedTo && (
|
{device.loanedTo && (
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-200">
|
<span className="text-xs text-gray-700 dark:text-gray-200">
|
||||||
an{' '}
|
an{' '}
|
||||||
@ -166,15 +184,27 @@ function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* rechte Seite: Buttons */}
|
||||||
size="md"
|
<div className="flex flex-row gap-2">
|
||||||
variant="primary"
|
<Button
|
||||||
onClick={onStartLoan}
|
size="md"
|
||||||
>
|
variant="primary"
|
||||||
{isLoaned
|
onClick={onStartLoan}
|
||||||
? 'Verleih bearbeiten'
|
>
|
||||||
: 'Gerät verleihen'}
|
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{canEdit && onEdit && (
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="soft"
|
||||||
|
tone="indigo"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -414,6 +444,8 @@ export default function DeviceDetailModal({
|
|||||||
open,
|
open,
|
||||||
inventoryNumber,
|
inventoryNumber,
|
||||||
onClose,
|
onClose,
|
||||||
|
canEdit = false,
|
||||||
|
onEdit,
|
||||||
}: DeviceDetailModalProps) {
|
}: DeviceDetailModalProps) {
|
||||||
const [device, setDevice] = useState<DeviceDetail | null>(null);
|
const [device, setDevice] = useState<DeviceDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -562,6 +594,8 @@ export default function DeviceDetailModal({
|
|||||||
<DeviceDetailsGrid
|
<DeviceDetailsGrid
|
||||||
device={device}
|
device={device}
|
||||||
onStartLoan={handleStartLoan}
|
onStartLoan={handleStartLoan}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DeviceHistorySidebar
|
<DeviceHistorySidebar
|
||||||
@ -577,6 +611,8 @@ export default function DeviceDetailModal({
|
|||||||
<DeviceDetailsGrid
|
<DeviceDetailsGrid
|
||||||
device={device}
|
device={device}
|
||||||
onStartLoan={handleStartLoan}
|
onStartLoan={handleStartLoan}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -439,13 +439,9 @@ export default function LoanDeviceModal({
|
|||||||
{/* Formularfelder */}
|
{/* Formularfelder */}
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Verliehen an
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<AppCombobox<LoanUserOption>
|
<AppCombobox<LoanUserOption>
|
||||||
label={undefined}
|
label="Verliehen an"
|
||||||
options={userOptions}
|
options={userOptions}
|
||||||
value={currentSelected}
|
value={currentSelected}
|
||||||
onChange={(selected) => {
|
onChange={(selected) => {
|
||||||
|
|||||||
20
app/(app)/devices/[inventoryNumber]/page.tsx
Normal file
20
app/(app)/devices/[inventoryNumber]/page.tsx
Normal 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)}`);
|
||||||
|
}
|
||||||
@ -2,11 +2,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Table, { TableColumn } from '@/components/ui/Table';
|
import Table, { TableColumn } from '@/components/ui/Table';
|
||||||
import { Dropdown } from '@/components/ui/Dropdown';
|
import { Dropdown } from '@/components/ui/Dropdown';
|
||||||
import Tabs from '@/components/ui/Tabs'; // 🔹 NEU
|
import Tabs from '@/components/ui/Tabs';
|
||||||
import {
|
import {
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
@ -148,6 +148,16 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const [allTags, setAllTags] = useState<TagOption[]>([]);
|
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
|
// 🔹 Tab-Filter: Hauptgeräte / Zubehör / Alle
|
||||||
const [activeTab, setActiveTab] =
|
const [activeTab, setActiveTab] =
|
||||||
useState<'main' | 'accessories' | 'all'>('main');
|
useState<'main' | 'accessories' | 'all'>('main');
|
||||||
@ -199,6 +209,20 @@ export default function DevicesPage() {
|
|||||||
loadDevices();
|
loadDevices();
|
||||||
}, [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 ───────── */
|
/* ───────── Live-Updates via Socket.IO ───────── */
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -310,10 +334,6 @@ export default function DevicesPage() {
|
|||||||
setDetailInventoryNumber(inventoryNumber);
|
setDetailInventoryNumber(inventoryNumber);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeDetailModal = useCallback(() => {
|
|
||||||
setDetailInventoryNumber(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openCreateModal = useCallback(() => {
|
const openCreateModal = useCallback(() => {
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
@ -322,6 +342,40 @@ export default function DevicesPage() {
|
|||||||
setCreateOpen(false);
|
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 ───────── */
|
/* ───────── Filter nach Tab ───────── */
|
||||||
|
|
||||||
const filteredDevices = devices.filter((d) => {
|
const filteredDevices = devices.filter((d) => {
|
||||||
@ -352,17 +406,19 @@ export default function DevicesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{canEditDevices && (
|
||||||
variant="soft"
|
<Button
|
||||||
tone="indigo"
|
variant="soft"
|
||||||
size="md"
|
tone="indigo"
|
||||||
icon={<PlusIcon className="size-5" />}
|
size="md"
|
||||||
aria-label="Neues Gerät anlegen"
|
icon={<PlusIcon className="size-5" />}
|
||||||
onClick={openCreateModal}
|
aria-label="Neues Gerät anlegen"
|
||||||
title="Neues Gerät anlegen"
|
onClick={openCreateModal}
|
||||||
>
|
title="Neues Gerät anlegen"
|
||||||
Neues Gerät anlegen
|
>
|
||||||
</Button>
|
Neues Gerät anlegen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */}
|
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */}
|
||||||
@ -408,7 +464,7 @@ export default function DevicesPage() {
|
|||||||
{/* Tabelle */}
|
{/* Tabelle */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Table<DeviceDetail>
|
<Table<DeviceDetail>
|
||||||
data={filteredDevices} // 🔹 statt devices
|
data={filteredDevices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(row) => row.inventoryNumber}
|
getRowId={(row) => row.inventoryNumber}
|
||||||
selectable
|
selectable
|
||||||
@ -516,6 +572,8 @@ export default function DevicesPage() {
|
|||||||
open={detailInventoryNumber !== null}
|
open={detailInventoryNumber !== null}
|
||||||
inventoryNumber={detailInventoryNumber}
|
inventoryNumber={detailInventoryNumber}
|
||||||
onClose={closeDetailModal}
|
onClose={closeDetailModal}
|
||||||
|
canEdit={canEditDevices}
|
||||||
|
onEdit={handleEditFromDetail}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
192
app/(app)/users/ChangePasswordModal.tsx
Normal file
192
app/(app)/users/ChangePasswordModal.tsx
Normal 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 (a–z)</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 (A–Z)</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 (0–9)</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
app/(app)/users/EditUserModal.tsx
Normal file
115
app/(app)/users/EditUserModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
app/(app)/users/UserRowActions.tsx
Normal file
108
app/(app)/users/UserRowActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import UsersCsvImportButton from './UsersCsvImportButton';
|
import UsersCsvImportButton from './UsersCsvImportButton';
|
||||||
|
import Switch from '@/components/ui/Switch';
|
||||||
|
|
||||||
type SimpleGroup = {
|
type SimpleGroup = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -35,6 +36,8 @@ export default function UsersHeaderClient({ groups }: Props) {
|
|||||||
const [groupName, setGroupName] = useState('');
|
const [groupName, setGroupName] = useState('');
|
||||||
const [savingGroup, setSavingGroup] = useState(false);
|
const [savingGroup, setSavingGroup] = useState(false);
|
||||||
const [groupError, setGroupError] = useState<string | null>(null);
|
const [groupError, setGroupError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [groupCanEditDevices, setGroupCanEditDevices] = useState<boolean>(false);
|
||||||
|
|
||||||
async function handleCreateUser(e: FormEvent) {
|
async function handleCreateUser(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -79,38 +82,42 @@ export default function UsersHeaderClient({ groups }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateGroup(e: FormEvent) {
|
async function handleCreateGroup(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSavingGroup(true);
|
setSavingGroup(true);
|
||||||
setGroupError(null);
|
setGroupError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/person-groups', {
|
const res = await fetch('/api/user-groups', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: groupName }),
|
body: JSON.stringify({
|
||||||
});
|
name: groupName,
|
||||||
|
canEditDevices: groupCanEditDevices, // 👈 NEU
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
data?.error ?? `Fehler beim Anlegen (HTTP ${res.status})`,
|
data?.error ?? `Fehler beim Anlegen (HTTP ${res.status})`,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setGroupName('');
|
|
||||||
setGroupModalOpen(false);
|
|
||||||
router.refresh();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error creating group', err);
|
|
||||||
setGroupError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: 'Fehler beim Anlegen der Gruppe.',
|
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
setSavingGroup(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -281,7 +288,7 @@ export default function UsersHeaderClient({ groups }: Props) {
|
|||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
id="new-group-form"
|
id="new-group-form"
|
||||||
className="space-y-3 text-sm"
|
className="space-y-4 text-sm"
|
||||||
onSubmit={handleCreateGroup}
|
onSubmit={handleCreateGroup}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@ -297,6 +304,27 @@ export default function UsersHeaderClient({ groups }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{groupError && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400">
|
<p className="text-xs text-red-600 dark:text-red-400">
|
||||||
{groupError}
|
{groupError}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
25
app/(app)/users/passwordUtils.ts
Normal file
25
app/(app)/users/passwordUtils.ts
Normal 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
42
app/api/me/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,3 @@
|
|||||||
// app/api/user-groups/route.ts
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
@ -6,7 +5,7 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
// 🔹 BULK: { names: string[] }
|
// 🔹 BULK: { names: string[], canEditDevices?: boolean }
|
||||||
if (Array.isArray(body?.names)) {
|
if (Array.isArray(body?.names)) {
|
||||||
const rawNames = body.names as unknown[];
|
const rawNames = body.names as unknown[];
|
||||||
|
|
||||||
@ -17,24 +16,39 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const uniqueNames = Array.from(new Set(trimmedNames));
|
const uniqueNames = Array.from(new Set(trimmedNames));
|
||||||
|
|
||||||
|
const defaultCanEditDevices = Boolean(body.canEditDevices ?? false);
|
||||||
|
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for (const name of uniqueNames) {
|
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({
|
const group = await prisma.userGroup.upsert({
|
||||||
where: { name },
|
where: { name: trimmedName },
|
||||||
update: {},
|
update: {
|
||||||
create: { name },
|
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(
|
return NextResponse.json({ groups }, { status: 200 });
|
||||||
{ groups },
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔹 SINGLE: { name: string } – wie bisher
|
// 🔹 SINGLE: { name: string, canEditDevices?: boolean }
|
||||||
const { name } = body;
|
const { name, canEditDevices } = body;
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return NextResponse.json(
|
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({
|
const group = await prisma.userGroup.upsert({
|
||||||
where: { name: trimmed },
|
where: { name: trimmed },
|
||||||
update: {},
|
update: {
|
||||||
create: { name: trimmed },
|
canEditDevices: flag,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: trimmed,
|
||||||
|
canEditDevices: flag,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ id: group.id, name: group.name },
|
{
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
canEditDevices: group.canEditDevices,
|
||||||
|
},
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -4,25 +4,44 @@
|
|||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
type DeviceQrCodeProps = {
|
type DeviceQrCodeProps = {
|
||||||
inventoryNumber: string;
|
inventoryNumber: string | null | undefined;
|
||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DeviceQrCode({ inventoryNumber, size = 180 }: DeviceQrCodeProps) {
|
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 ?? '';
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
|
||||||
|
|
||||||
// Immer vollständige URL für externe Scanner erzeugen
|
// Immer vollständige URL für externe Scanner erzeugen
|
||||||
const qrValue = baseUrl
|
const appBase = baseUrl.replace(/\/$/, '');
|
||||||
? `${baseUrl.replace(/\/$/, '')}/devices/${encodeURIComponent(inventoryNumber)}`
|
|
||||||
: inventoryNumber;
|
const qrValue = appBase
|
||||||
|
? `${appBase}/devices?device=${encodeURIComponent(inventoryNumber)}`
|
||||||
|
: `/devices?device=${encodeURIComponent(inventoryNumber)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-col items-center gap-2">
|
<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
|
<QRCodeSVG
|
||||||
value={qrValue}
|
value={qrValue}
|
||||||
size={size}
|
size={size}
|
||||||
level="M" // Fehlertoleranz
|
level="M"
|
||||||
includeMargin // wichtiger weißer Rand (= Quiet Zone)
|
includeMargin
|
||||||
bgColor="#FFFFFF"
|
bgColor="#FFFFFF"
|
||||||
fgColor="#000000"
|
fgColor="#000000"
|
||||||
/>
|
/>
|
||||||
|
|||||||
108
components/ui/Card.tsx
Normal file
108
components/ui/Card.tsx
Normal 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
155
components/ui/Checkbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
307
components/ui/RadioGroup.tsx
Normal file
307
components/ui/RadioGroup.tsx
Normal 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
68
components/ui/Switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -110,6 +110,19 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
_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> = {
|
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
@ -312,6 +325,19 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
_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> = {
|
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1151,7 +1151,8 @@ export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typ
|
|||||||
|
|
||||||
export const UserGroupScalarFieldEnum = {
|
export const UserGroupScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name'
|
name: 'name',
|
||||||
|
canEditDevices: 'canEditDevices'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type UserGroupScalarFieldEnum = (typeof UserGroupScalarFieldEnum)[keyof typeof UserGroupScalarFieldEnum]
|
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'
|
* Reference to a field of type 'DeviceChangeType'
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -112,7 +112,8 @@ export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typ
|
|||||||
|
|
||||||
export const UserGroupScalarFieldEnum = {
|
export const UserGroupScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name'
|
name: 'name',
|
||||||
|
canEditDevices: 'canEditDevices'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type UserGroupScalarFieldEnum = (typeof UserGroupScalarFieldEnum)[keyof typeof UserGroupScalarFieldEnum]
|
export type UserGroupScalarFieldEnum = (typeof UserGroupScalarFieldEnum)[keyof typeof UserGroupScalarFieldEnum]
|
||||||
|
|||||||
@ -27,16 +27,19 @@ export type AggregateUserGroup = {
|
|||||||
export type UserGroupMinAggregateOutputType = {
|
export type UserGroupMinAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
name: string | null
|
name: string | null
|
||||||
|
canEditDevices: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupMaxAggregateOutputType = {
|
export type UserGroupMaxAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
name: string | null
|
name: string | null
|
||||||
|
canEditDevices: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupCountAggregateOutputType = {
|
export type UserGroupCountAggregateOutputType = {
|
||||||
id: number
|
id: number
|
||||||
name: number
|
name: number
|
||||||
|
canEditDevices: number
|
||||||
_all: number
|
_all: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,16 +47,19 @@ export type UserGroupCountAggregateOutputType = {
|
|||||||
export type UserGroupMinAggregateInputType = {
|
export type UserGroupMinAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
name?: true
|
name?: true
|
||||||
|
canEditDevices?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupMaxAggregateInputType = {
|
export type UserGroupMaxAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
name?: true
|
name?: true
|
||||||
|
canEditDevices?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupCountAggregateInputType = {
|
export type UserGroupCountAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
name?: true
|
name?: true
|
||||||
|
canEditDevices?: true
|
||||||
_all?: true
|
_all?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +138,7 @@ export type UserGroupGroupByArgs<ExtArgs extends runtime.Types.Extensions.Intern
|
|||||||
export type UserGroupGroupByOutputType = {
|
export type UserGroupGroupByOutputType = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
canEditDevices: boolean
|
||||||
_count: UserGroupCountAggregateOutputType | null
|
_count: UserGroupCountAggregateOutputType | null
|
||||||
_min: UserGroupMinAggregateOutputType | null
|
_min: UserGroupMinAggregateOutputType | null
|
||||||
_max: UserGroupMaxAggregateOutputType | null
|
_max: UserGroupMaxAggregateOutputType | null
|
||||||
@ -158,12 +165,14 @@ export type UserGroupWhereInput = {
|
|||||||
NOT?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[]
|
NOT?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[]
|
||||||
id?: Prisma.StringFilter<"UserGroup"> | string
|
id?: Prisma.StringFilter<"UserGroup"> | string
|
||||||
name?: Prisma.StringFilter<"UserGroup"> | string
|
name?: Prisma.StringFilter<"UserGroup"> | string
|
||||||
|
canEditDevices?: Prisma.BoolFilter<"UserGroup"> | boolean
|
||||||
users?: Prisma.UserListRelationFilter
|
users?: Prisma.UserListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupOrderByWithRelationInput = {
|
export type UserGroupOrderByWithRelationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
|
canEditDevices?: Prisma.SortOrder
|
||||||
users?: Prisma.UserOrderByRelationAggregateInput
|
users?: Prisma.UserOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,12 +182,14 @@ export type UserGroupWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
AND?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[]
|
AND?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[]
|
||||||
OR?: Prisma.UserGroupWhereInput[]
|
OR?: Prisma.UserGroupWhereInput[]
|
||||||
NOT?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[]
|
NOT?: Prisma.UserGroupWhereInput | Prisma.UserGroupWhereInput[]
|
||||||
|
canEditDevices?: Prisma.BoolFilter<"UserGroup"> | boolean
|
||||||
users?: Prisma.UserListRelationFilter
|
users?: Prisma.UserListRelationFilter
|
||||||
}, "id" | "name">
|
}, "id" | "name">
|
||||||
|
|
||||||
export type UserGroupOrderByWithAggregationInput = {
|
export type UserGroupOrderByWithAggregationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
|
canEditDevices?: Prisma.SortOrder
|
||||||
_count?: Prisma.UserGroupCountOrderByAggregateInput
|
_count?: Prisma.UserGroupCountOrderByAggregateInput
|
||||||
_max?: Prisma.UserGroupMaxOrderByAggregateInput
|
_max?: Prisma.UserGroupMaxOrderByAggregateInput
|
||||||
_min?: Prisma.UserGroupMinOrderByAggregateInput
|
_min?: Prisma.UserGroupMinOrderByAggregateInput
|
||||||
@ -190,45 +201,53 @@ export type UserGroupScalarWhereWithAggregatesInput = {
|
|||||||
NOT?: Prisma.UserGroupScalarWhereWithAggregatesInput | Prisma.UserGroupScalarWhereWithAggregatesInput[]
|
NOT?: Prisma.UserGroupScalarWhereWithAggregatesInput | Prisma.UserGroupScalarWhereWithAggregatesInput[]
|
||||||
id?: Prisma.StringWithAggregatesFilter<"UserGroup"> | string
|
id?: Prisma.StringWithAggregatesFilter<"UserGroup"> | string
|
||||||
name?: Prisma.StringWithAggregatesFilter<"UserGroup"> | string
|
name?: Prisma.StringWithAggregatesFilter<"UserGroup"> | string
|
||||||
|
canEditDevices?: Prisma.BoolWithAggregatesFilter<"UserGroup"> | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupCreateInput = {
|
export type UserGroupCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
canEditDevices?: boolean
|
||||||
users?: Prisma.UserCreateNestedManyWithoutGroupInput
|
users?: Prisma.UserCreateNestedManyWithoutGroupInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupUncheckedCreateInput = {
|
export type UserGroupUncheckedCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
canEditDevices?: boolean
|
||||||
users?: Prisma.UserUncheckedCreateNestedManyWithoutGroupInput
|
users?: Prisma.UserUncheckedCreateNestedManyWithoutGroupInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupUpdateInput = {
|
export type UserGroupUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
users?: Prisma.UserUpdateManyWithoutGroupNestedInput
|
users?: Prisma.UserUpdateManyWithoutGroupNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupUncheckedUpdateInput = {
|
export type UserGroupUncheckedUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
users?: Prisma.UserUncheckedUpdateManyWithoutGroupNestedInput
|
users?: Prisma.UserUncheckedUpdateManyWithoutGroupNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupCreateManyInput = {
|
export type UserGroupCreateManyInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
canEditDevices?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupUpdateManyMutationInput = {
|
export type UserGroupUpdateManyMutationInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupUncheckedUpdateManyInput = {
|
export type UserGroupUncheckedUpdateManyInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupNullableScalarRelationFilter = {
|
export type UserGroupNullableScalarRelationFilter = {
|
||||||
@ -239,16 +258,19 @@ export type UserGroupNullableScalarRelationFilter = {
|
|||||||
export type UserGroupCountOrderByAggregateInput = {
|
export type UserGroupCountOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
|
canEditDevices?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupMaxOrderByAggregateInput = {
|
export type UserGroupMaxOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
|
canEditDevices?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupMinOrderByAggregateInput = {
|
export type UserGroupMinOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
|
canEditDevices?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupCreateNestedOneWithoutUsersInput = {
|
export type UserGroupCreateNestedOneWithoutUsersInput = {
|
||||||
@ -267,14 +289,20 @@ export type UserGroupUpdateOneWithoutUsersNestedInput = {
|
|||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.UserGroupUpdateToOneWithWhereWithoutUsersInput, Prisma.UserGroupUpdateWithoutUsersInput>, Prisma.UserGroupUncheckedUpdateWithoutUsersInput>
|
update?: Prisma.XOR<Prisma.XOR<Prisma.UserGroupUpdateToOneWithWhereWithoutUsersInput, Prisma.UserGroupUpdateWithoutUsersInput>, Prisma.UserGroupUncheckedUpdateWithoutUsersInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BoolFieldUpdateOperationsInput = {
|
||||||
|
set?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type UserGroupCreateWithoutUsersInput = {
|
export type UserGroupCreateWithoutUsersInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
canEditDevices?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupUncheckedCreateWithoutUsersInput = {
|
export type UserGroupUncheckedCreateWithoutUsersInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
canEditDevices?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupCreateOrConnectWithoutUsersInput = {
|
export type UserGroupCreateOrConnectWithoutUsersInput = {
|
||||||
@ -296,11 +324,13 @@ export type UserGroupUpdateToOneWithWhereWithoutUsersInput = {
|
|||||||
export type UserGroupUpdateWithoutUsersInput = {
|
export type UserGroupUpdateWithoutUsersInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
canEditDevices?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserGroupUncheckedUpdateWithoutUsersInput = {
|
export type UserGroupUncheckedUpdateWithoutUsersInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: 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<{
|
export type UserGroupSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
name?: boolean
|
name?: boolean
|
||||||
|
canEditDevices?: boolean
|
||||||
users?: boolean | Prisma.UserGroup$usersArgs<ExtArgs>
|
users?: boolean | Prisma.UserGroup$usersArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["userGroup"]>
|
}, 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<{
|
export type UserGroupSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
name?: boolean
|
name?: boolean
|
||||||
|
canEditDevices?: boolean
|
||||||
}, ExtArgs["result"]["userGroup"]>
|
}, ExtArgs["result"]["userGroup"]>
|
||||||
|
|
||||||
export type UserGroupSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type UserGroupSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
name?: boolean
|
name?: boolean
|
||||||
|
canEditDevices?: boolean
|
||||||
}, ExtArgs["result"]["userGroup"]>
|
}, ExtArgs["result"]["userGroup"]>
|
||||||
|
|
||||||
export type UserGroupSelectScalar = {
|
export type UserGroupSelectScalar = {
|
||||||
id?: boolean
|
id?: boolean
|
||||||
name?: 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> = {
|
export type UserGroupInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
users?: boolean | Prisma.UserGroup$usersArgs<ExtArgs>
|
users?: boolean | Prisma.UserGroup$usersArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserGroupCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
@ -372,6 +406,7 @@ export type $UserGroupPayload<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
canEditDevices: boolean
|
||||||
}, ExtArgs["result"]["userGroup"]>
|
}, ExtArgs["result"]["userGroup"]>
|
||||||
composites: {}
|
composites: {}
|
||||||
}
|
}
|
||||||
@ -798,6 +833,7 @@ export interface Prisma__UserGroupClient<T, Null = never, ExtArgs extends runtim
|
|||||||
export interface UserGroupFieldRefs {
|
export interface UserGroupFieldRefs {
|
||||||
readonly id: Prisma.FieldRef<"UserGroup", 'String'>
|
readonly id: Prisma.FieldRef<"UserGroup", 'String'>
|
||||||
readonly name: Prisma.FieldRef<"UserGroup", 'String'>
|
readonly name: Prisma.FieldRef<"UserGroup", 'String'>
|
||||||
|
readonly canEditDevices: Prisma.FieldRef<"UserGroup", 'Boolean'>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
131
package-lock.json
generated
131
package-lock.json
generated
@ -12,7 +12,7 @@
|
|||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
||||||
"@prisma/adapter-pg": "^7.0.0",
|
"@prisma/adapter-pg": "^7.0.0",
|
||||||
"@prisma/client": "^7.0.0",
|
"@prisma/client": "^7.0.1",
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
@ -35,7 +35,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"prisma": "^7.0.0",
|
"prisma": "^7.0.1",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
@ -85,7 +85,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@ -368,8 +367,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||||
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@electric-sql/pglite-socket": {
|
"node_modules/@electric-sql/pglite-socket": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
@ -1924,15 +1922,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.0.1.tgz",
|
||||||
"integrity": "sha512-FM1NtJezl0zH3CybLxcbJwShJt7xFGSRg+1tGhy3sCB8goUDnxnBR+RC/P35EAW8gjkzx7kgz7bvb0MerY2VSw==",
|
"integrity": "sha512-O74T6xcfaGAq5gXwCAvfTLvI6fmC3and2g5yLRMkNjri1K8mSpEgclDNuUWs9xj5AwNEMQ88NeD3asI+sovm1g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client-runtime-utils": "7.0.0"
|
"@prisma/client-runtime-utils": "7.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19 || ^22.12 || ^24.0"
|
"node": "^20.19 || ^22.12 || >=24.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"prisma": "*",
|
"prisma": "*",
|
||||||
@ -1948,15 +1946,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/client-runtime-utils": {
|
"node_modules/@prisma/client-runtime-utils": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.0.1.tgz",
|
||||||
"integrity": "sha512-PAiFgMBPrLSaakBwUpML5NevipuKSL3rtNr8pZ8CZ3OBXo0BFcdeGcBIKw/CxJP6H4GNa4+l5bzJPrk8Iq6tDw==",
|
"integrity": "sha512-R26BVX9D/iw4toUmZKZf3jniM/9pMGHHdZN5LVP2L7HNiCQKNQQx/9LuMtjepbgRqSqQO3oHN0yzojHLnKTGEw==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/config": {
|
"node_modules/@prisma/config": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.0.1.tgz",
|
||||||
"integrity": "sha512-TDASB57hyGUwHB0IPCSkoJcXFrJOKA1+R/1o4np4PbS+E0F5MiY5aAyUttO0mSuNQaX7t8VH/GkDemffF1mQzg==",
|
"integrity": "sha512-MacIjXdo+hNKxPvtMzDXykIIc8HCRWoyjQ2nguJTFqLDzJBD5L6QRaANGTLOqbGtJ3sFvLRmfXhrFg3pWoK1BA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2008,56 +2006,70 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.0.1.tgz",
|
||||||
"integrity": "sha512-ojCL3OFLMCz33UbU9XwH32jwaeM+dWb8cysTuY8eK6ZlMKXJdy6ogrdG3MGB3meKLGdQBmOpUUGJ7eLIaxbrcg==",
|
"integrity": "sha512-f+D/vdKeImqUHysd5Bgv8LQ1whl4sbLepHyYMQQMK61cp4WjwJVryophleLUrfEJRpBLGTBI/7fnLVENxxMFPQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.0.0",
|
"@prisma/debug": "7.0.1",
|
||||||
"@prisma/engines-version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513",
|
"@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6",
|
||||||
"@prisma/fetch-engine": "7.0.0",
|
"@prisma/fetch-engine": "7.0.1",
|
||||||
"@prisma/get-platform": "7.0.0"
|
"@prisma/get-platform": "7.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines-version": {
|
"node_modules/@prisma/engines-version": {
|
||||||
"version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513",
|
"version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6.tgz",
|
||||||
"integrity": "sha512-7bzyN8Gp9GbDFbTDzVUH9nFcgRWvsWmjrGgBJvIC/zEoAuv/lx62gZXgAKfjn/HoPkxz/dS+TtsnduFx8WA+cw==",
|
"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,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz",
|
||||||
"integrity": "sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg==",
|
"integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.0.0"
|
"@prisma/debug": "7.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.0.1.tgz",
|
||||||
"integrity": "sha512-qcyWTeWDjVDaDQSrVIymZU1xCYlvmwCzjA395lIuFjUESOH3YQCb8i/hpd4vopfq3fUR4v6+MjjtIGvnmErQgw==",
|
"integrity": "sha512-5DnSairYIYU7dcv/9pb1KCwIRHZfhVOd34855d01lUI5QdF9rdCkMywPQbBM67YP7iCgQoEZO0/COtOMpR4i9A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.0.0",
|
"@prisma/debug": "7.0.1",
|
||||||
"@prisma/engines-version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513",
|
"@prisma/engines-version": "7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6",
|
||||||
"@prisma/get-platform": "7.0.0"
|
"@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": {
|
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.0.1.tgz",
|
||||||
"integrity": "sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg==",
|
"integrity": "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.0.0"
|
"@prisma/debug": "7.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/get-platform": {
|
"node_modules/@prisma/get-platform": {
|
||||||
@ -2084,10 +2096,10 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/studio-core-licensed": {
|
"node_modules/@prisma/studio-core": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/studio-core-licensed/-/studio-core-licensed-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.8.2.tgz",
|
||||||
"integrity": "sha512-SXCcgFvo/SC6/11kEOaQghJgCWNEWZUvPYKn/gpvMB9HLSG/5M8If7dWZtEQHhchvl8bh9A89Hw6mEKpsXFimA==",
|
"integrity": "sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -2590,7 +2602,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
||||||
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
|
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@ -2613,7 +2624,6 @@
|
|||||||
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@ -2674,7 +2684,6 @@
|
|||||||
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
|
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.47.0",
|
"@typescript-eslint/scope-manager": "8.47.0",
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.47.0",
|
||||||
@ -3252,7 +3261,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -3691,7 +3699,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.8.25",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001754",
|
||||||
@ -4653,7 +4660,6 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@ -4839,7 +4845,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@ -5663,7 +5668,6 @@
|
|||||||
"integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==",
|
"integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@ -7441,7 +7445,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
@ -7662,7 +7665,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@ -7723,18 +7725,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.0.1.tgz",
|
||||||
"integrity": "sha512-VZObZ1pQV/OScarYg68RYUx61GpFLH2mJGf9fUX4XxQxTst/6ZK7nkY86CSZ3zBW6U9lKRTsBrZWVz20X5G/KQ==",
|
"integrity": "sha512-zp93MdFMSU1IHPEXbUHVUuD8wauh2BUm14OVxhxGrWJQQpXpda0rW4VSST2bci4raoldX64/wQxHKkl/wqDskQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "7.0.0",
|
"@prisma/config": "7.0.1",
|
||||||
"@prisma/dev": "0.13.0",
|
"@prisma/dev": "0.13.0",
|
||||||
"@prisma/engines": "7.0.0",
|
"@prisma/engines": "7.0.1",
|
||||||
"@prisma/studio-core-licensed": "0.8.0",
|
"@prisma/studio-core": "0.8.2",
|
||||||
"mysql2": "3.15.3",
|
"mysql2": "3.15.3",
|
||||||
"postgres": "3.4.7"
|
"postgres": "3.4.7"
|
||||||
},
|
},
|
||||||
@ -7742,7 +7743,7 @@
|
|||||||
"prisma": "build/index.js"
|
"prisma": "build/index.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19 || ^22.12 || ^24.0"
|
"node": "^20.19 || ^22.12 || >=24.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"better-sqlite3": ">=9.0.0",
|
"better-sqlite3": ">=9.0.0",
|
||||||
@ -7895,7 +7896,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -7905,7 +7905,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -8943,7 +8942,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -8982,6 +8980,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||||
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@ -9202,7 +9201,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -9594,7 +9592,6 @@
|
|||||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
||||||
"@prisma/adapter-pg": "^7.0.0",
|
"@prisma/adapter-pg": "^7.0.0",
|
||||||
"@prisma/client": "^7.0.0",
|
"@prisma/client": "^7.0.1",
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
@ -41,7 +41,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"prisma": "^7.0.0",
|
"prisma": "^7.0.1",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "UserGroup" ADD COLUMN "canEditDevices" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
// Prisma Schema
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../generated/prisma"
|
output = "../generated/prisma"
|
||||||
@ -56,6 +58,7 @@ model UserGroup {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
users User[]
|
users User[]
|
||||||
|
canEditDevices Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model DeviceGroup {
|
model DeviceGroup {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user