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