From 2f42c71fe92e5fc0192332c4f14f8f632d233c8c Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Mon, 24 Nov 2025 08:59:14 +0100 Subject: [PATCH] updated --- .env | 2 +- .gitignore | 4 + app/(app)/devices/DeviceDetailModal.tsx | 358 +- app/(app)/devices/DeviceEditModal.tsx | 74 +- app/(app)/devices/DeviceHistorySidebar.tsx | 67 +- app/(app)/devices/LoanDeviceModal.tsx | 547 +++ app/(app)/devices/page.tsx | 5 + app/(app)/layout.tsx | 173 +- app/(app)/users/AssignGroupForm.tsx | 94 + app/(app)/users/UsersCsvImportButton.tsx | 214 ++ app/(app)/users/UsersHeaderClient.tsx | 309 ++ app/(app)/users/UsersTablesClient.tsx | 696 ++++ app/(app)/users/page.tsx | 42 + app/(app)/users/types.ts | 13 + app/api/devices/[id]/history/route.ts | 19 +- app/api/devices/[id]/route.ts | 233 +- app/api/devices/route.ts | 52 +- app/api/user-groups/[id]/route.ts | 38 + app/api/user-groups/route.ts | 41 + app/api/users/[id]/route.ts | 92 + app/api/users/assign-group/route.ts | 33 + app/api/users/route.ts | 108 + app/login/page.tsx | 49 +- app/providers.tsx | 2 + components/DeviceQrCode.tsx | 2 - components/UserMenu.tsx | 131 + components/auth/LoginForm.tsx | 144 +- components/ui/ButtonGroup.tsx | 73 + components/ui/Dropdown.tsx | 270 +- components/ui/Feed.tsx | 186 +- components/ui/Modal.tsx | 4 +- components/ui/Table.tsx | 52 +- components/ui/Tabs.tsx | 23 +- components/ui/UserAvatar.tsx | 73 + generated/prisma/browser.ts | 64 + generated/prisma/client.ts | 86 + generated/prisma/commonInputTypes.ts | 381 ++ generated/prisma/enums.ts | 18 + generated/prisma/internal/class.ts | 270 ++ generated/prisma/internal/prismaNamespace.ts | 1489 ++++++++ .../prisma/internal/prismaNamespaceBrowser.ts | 223 ++ generated/prisma/models.ts | 20 + generated/prisma/models/Device.ts | 3150 +++++++++++++++++ generated/prisma/models/DeviceGroup.ts | 1228 +++++++ generated/prisma/models/DeviceHistory.ts | 1518 ++++++++ generated/prisma/models/Location.ts | 1228 +++++++ generated/prisma/models/Role.ts | 1226 +++++++ generated/prisma/models/Tag.ts | 1273 +++++++ generated/prisma/models/User.ts | 2101 +++++++++++ generated/prisma/models/UserGroup.ts | 1228 +++++++ generated/prisma/models/UserRole.ts | 1384 ++++++++ lib/auth-options.ts | 45 +- lib/auth.ts | 17 +- lib/prisma.ts | 19 +- next.config.ts | 5 +- package-lock.json | 1981 ++++++++++- package.json | 18 +- prisma.config.ts | 13 + prisma/dev.db | Bin 172032 -> 0 bytes .../migration.sql | 97 - .../migration.sql | 50 - .../migration.sql | 62 - .../migration.sql | 8 - .../migrations/20251117092638/migration.sql | 22 - .../migration.sql | 2 - .../migration.sql | 5 - .../migrations/20251120131542/migration.sql | 203 ++ .../20251121082646_init/migration.sql | 8 + .../20251121083642_init/migration.sql | 16 + .../20251121101051_init/migration.sql | 43 + prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 172 +- prisma/{create-test-user.ts => seed.ts} | 52 +- tsconfig.json | 8 +- 74 files changed, 22674 insertions(+), 1284 deletions(-) create mode 100644 app/(app)/devices/LoanDeviceModal.tsx create mode 100644 app/(app)/users/AssignGroupForm.tsx create mode 100644 app/(app)/users/UsersCsvImportButton.tsx create mode 100644 app/(app)/users/UsersHeaderClient.tsx create mode 100644 app/(app)/users/UsersTablesClient.tsx create mode 100644 app/(app)/users/page.tsx create mode 100644 app/(app)/users/types.ts create mode 100644 app/api/user-groups/[id]/route.ts create mode 100644 app/api/user-groups/route.ts create mode 100644 app/api/users/[id]/route.ts create mode 100644 app/api/users/assign-group/route.ts create mode 100644 app/api/users/route.ts create mode 100644 components/UserMenu.tsx create mode 100644 components/ui/ButtonGroup.tsx create mode 100644 components/ui/UserAvatar.tsx create mode 100644 generated/prisma/browser.ts create mode 100644 generated/prisma/client.ts create mode 100644 generated/prisma/commonInputTypes.ts create mode 100644 generated/prisma/enums.ts create mode 100644 generated/prisma/internal/class.ts create mode 100644 generated/prisma/internal/prismaNamespace.ts create mode 100644 generated/prisma/internal/prismaNamespaceBrowser.ts create mode 100644 generated/prisma/models.ts create mode 100644 generated/prisma/models/Device.ts create mode 100644 generated/prisma/models/DeviceGroup.ts create mode 100644 generated/prisma/models/DeviceHistory.ts create mode 100644 generated/prisma/models/Location.ts create mode 100644 generated/prisma/models/Role.ts create mode 100644 generated/prisma/models/Tag.ts create mode 100644 generated/prisma/models/User.ts create mode 100644 generated/prisma/models/UserGroup.ts create mode 100644 generated/prisma/models/UserRole.ts create mode 100644 prisma.config.ts delete mode 100644 prisma/dev.db delete mode 100644 prisma/migrations/20251114101117_add_user_password/migration.sql delete mode 100644 prisma/migrations/20251114150649_added_more_device_fields/migration.sql delete mode 100644 prisma/migrations/20251114164242_adjust_device_pk/migration.sql delete mode 100644 prisma/migrations/20251114182333_add_unique_to_location_name/migration.sql delete mode 100644 prisma/migrations/20251117092638/migration.sql delete mode 100644 prisma/migrations/20251117134852_add_device_history_changed_by/migration.sql delete mode 100644 prisma/migrations/20251118082710_add_device_history_changed_by/migration.sql create mode 100644 prisma/migrations/20251120131542/migration.sql create mode 100644 prisma/migrations/20251121082646_init/migration.sql create mode 100644 prisma/migrations/20251121083642_init/migration.sql create mode 100644 prisma/migrations/20251121101051_init/migration.sql rename prisma/{create-test-user.ts => seed.ts} (77%) diff --git a/.env b/.env index 91dff9a..4479f7b 100644 --- a/.env +++ b/.env @@ -9,7 +9,7 @@ # server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the # one found in a remote Prisma Postgres URL, does not contain any sensitive information. -DATABASE_URL="file:./dev.db" +DATABASE_URL="postgresql://postgres:tegvideo7010!@localhost:5433/postgres" NEXT_PUBLIC_APP_URL=https://10.0.1.25 NEXTAUTH_SECRET=tegvideo7010! \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9630fe4..50a164f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ yarn-error.log* next-env.d.ts /app/generated/prisma + +/lib/generated/prisma + +/lib/generated/prisma diff --git a/app/(app)/devices/DeviceDetailModal.tsx b/app/(app)/devices/DeviceDetailModal.tsx index bbe9955..450454d 100644 --- a/app/(app)/devices/DeviceDetailModal.tsx +++ b/app/(app)/devices/DeviceDetailModal.tsx @@ -9,6 +9,7 @@ import Button from '@/components/ui/Button'; import type { DeviceDetail } from './page'; import { DeviceQrCode } from '@/components/DeviceQrCode'; import Tabs from '@/components/ui/Tabs'; +import LoanDeviceModal from './LoanDeviceModal'; type DeviceDetailModalProps = { open: boolean; @@ -21,10 +22,40 @@ const dtf = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short', }); -function DeviceDetailsGrid({ device }: { device: DeviceDetail }) { +type DeviceDetailsGridProps = { + device: DeviceDetail; + onStartLoan?: () => void; +}; + +function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) { + const isLoaned = Boolean(device.loanedTo); + const now = new Date(); + const isOverdue = + isLoaned && + device.loanedUntil != null && + new Date(device.loanedUntil) < now; + + const statusLabel = !isLoaned + ? 'Verfügbar' + : isOverdue + ? 'Verliehen (überfällig)' + : 'Verliehen'; + + const statusClasses = !isLoaned + ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-100' + : isOverdue + ? 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-100' + : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-100'; + + const dotClasses = !isLoaned + ? 'bg-emerald-500' + : isOverdue + ? 'bg-rose-500' + : 'bg-amber-500'; + return (
- {/* Inventarnummer */} + {/* Inventarnummer (oben links) */}

Inventar-Nr. @@ -34,8 +65,67 @@ function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {

- {/* Bezeichnung */} + {/* Status */}
+

+ Status +

+ +
+ {/* linke „Spalte“: nur inhaltsbreit */} +
+ {/* Pill nur content-breit */} + + + {statusLabel} + + + {/* Infotext darunter */} + {device.loanedTo && ( + + an {device.loanedTo} + {device.loanedFrom && ( + <> + {' '}seit{' '} + {dtf.format(new Date(device.loanedFrom))} + + )} + {device.loanedUntil && ( + <> + {' '}bis{' '} + {dtf.format(new Date(device.loanedUntil))} + + )} + {device.loanComment && ( + <> + {' '}- Hinweis: {device.loanComment} + + )} + + )} +
+ + +
+
+ + {/* 🔹 Trenner nach Verleihstatus */} +
+
+
+ + {/* Bezeichnung jetzt UNTER dem Trenner */} +

Bezeichnung

@@ -101,27 +191,6 @@ function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {

- {/* Tags */} -
-

- Tags -

- {device.tags && device.tags.length > 0 ? ( -
- {device.tags.map((tag) => ( - - {tag} - - ))} -
- ) : ( -

- )} -
- {/* Netzwerkdaten */}

@@ -159,7 +228,7 @@ function DeviceDetailsGrid({ device }: { device: DeviceDetail }) { {device.username || '–'}

- +

Passwort (Hash) @@ -169,6 +238,27 @@ function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {

+ {/* Tags */} +
+

+ Tags +

+ {device.tags && device.tags.length > 0 ? ( +
+ {device.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( +

+ )} +
+ {/* Kommentar */}

@@ -216,6 +306,8 @@ export default function DeviceDetailModal({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<'details' | 'history'>('details'); + const [loanModalOpen, setLoanModalOpen] = useState(false); + const [historyRefresh, setHistoryRefresh] = useState(0); useEffect(() => { if (!open || !inventoryNumber) return; @@ -239,7 +331,9 @@ export default function DeviceDetailModal({ if (res.status === 404) { throw new Error('Gerät wurde nicht gefunden.'); } - throw new Error('Beim Laden der Gerätedaten ist ein Fehler aufgetreten.'); + throw new Error( + 'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.', + ); } const data = (await res.json()) as DeviceDetail; @@ -266,100 +360,138 @@ export default function DeviceDetailModal({ const handleClose = () => onClose(); + const handleStartLoan = () => { + if (!device) return; + setLoanModalOpen(true); + }; + return ( - } - tone="info" - variant="centered" - size="xl" - primaryAction={{ - label: 'Schließen', - onClick: handleClose, - variant: 'primary', - }} - headerExtras={ - device && ( -

- setActiveTab(id as 'details' | 'history')} - ariaLabel="Ansicht wählen" - /> -
- ) - } - sidebar={ - device ? ( -
- {/* QR-Code oben, nicht scrollend */} -
-
-
- + <> + } + tone="info" + variant="centered" + size="xl" + primaryAction={{ + label: 'Schließen', + onClick: handleClose, + variant: 'primary', + }} + headerExtras={ + device && ( +
+ setActiveTab(id as 'details' | 'history')} + ariaLabel="Ansicht wählen" + /> +
+ ) + } + sidebar={ + device ? ( +
+ {/* QR-Code oben, nicht scrollend */} +
+
+
+ +
+

+ {device.inventoryNumber} +

-

- #{device.inventoryNumber} -

+ +
+ + {/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */} +
+ +
+
+ ) : undefined + } + > + {loading && ( +

+ Gerätedaten werden geladen … +

+ )} + + {error && ( +

+ {error} +

+ )} + + {!loading && !error && device && ( + <> + {/* Mobile-Inhalt (Tabs steuern Ansicht) */} +
+ {activeTab === 'details' ? ( + + ) : ( + + )}
-
- - {/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */} -
- +
-
- ) : undefined - } - > - {loading && ( -

- Gerätedaten werden geladen … -

- )} + + )} + - {error && ( -

- {error} -

+ {device && ( + setLoanModalOpen(false)} + device={device} + onUpdated={(patch) => { + // lokalen State aktualisieren, damit Details sofort aktualisiert sind + setDevice((prev) => + prev + ? { + ...prev, + loanedTo: patch.loanedTo, + loanedFrom: patch.loanedFrom, + loanedUntil: patch.loanedUntil, + loanComment: patch.loanComment, + } + : prev, + ); + setHistoryRefresh((prev) => prev + 1); + }} + /> )} - - {!loading && !error && device && ( - <> - {/* Mobile-Inhalt (Tabs steuern Ansicht) */} -
- {activeTab === 'details' ? ( - - ) : ( - - )} -
- - {/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */} -
- -
- - )} - + ); -} \ No newline at end of file +} diff --git a/app/(app)/devices/DeviceEditModal.tsx b/app/(app)/devices/DeviceEditModal.tsx index 12d764f..2fcd724 100644 --- a/app/(app)/devices/DeviceEditModal.tsx +++ b/app/(app)/devices/DeviceEditModal.tsx @@ -37,6 +37,7 @@ export default function DeviceEditModal({ const [editError, setEditError] = useState(null); const [saveLoading, setSaveLoading] = useState(false); const [justSaved, setJustSaved] = useState(false); + const [historyRefresh, setHistoryRefresh] = useState(0); useEffect(() => { if (!open || !inventoryNumber) return; @@ -96,6 +97,16 @@ export default function DeviceEditModal({ }; }, [open, inventoryNumber]); + useEffect(() => { + if (!justSaved) return; + + const id = setTimeout(() => { + setJustSaved(false); + }, 1500); // Dauer nach Geschmack anpassen + + return () => clearTimeout(id); + }, [justSaved]); + const handleFieldChange = ( field: keyof DeviceDetail, e: ChangeEvent, @@ -148,10 +159,8 @@ export default function DeviceEditModal({ setEditDevice(updated); onSaved(updated); + // Nur Status setzen – NICHT schließen setJustSaved(true); - setTimeout(() => { - onClose(); - }, 1000); } catch (err: any) { console.error('Error saving device', err); setEditError( @@ -162,7 +171,7 @@ export default function DeviceEditModal({ } finally { setSaveLoading(false); } - }, [editDevice, onSaved, onClose]); + }, [editDevice, onSaved]); const handleClose = () => { if (saveLoading) return; @@ -229,6 +238,7 @@ export default function DeviceEditModal({ key={editDevice.updatedAt} inventoryNumber={editDevice.inventoryNumber} asSidebar + refreshToken={historyRefresh} /> ) : undefined } @@ -348,34 +358,6 @@ export default function DeviceEditModal({ />
- {/* Tags */} -
- ({ name }))} - onChange={(next) => { - const names = next.map((t) => t.name); - - setEditDevice((prev) => - prev ? ({ ...prev, tags: names } as DeviceDetail) : prev, - ); - - setAllTags((prev) => { - const map = new Map(prev.map((t) => [t.name.toLowerCase(), t])); - for (const t of next) { - const key = t.name.toLowerCase(); - if (!map.has(key)) { - map.set(key, t); - } - } - return Array.from(map.values()); - }); - }} - placeholder="z.B. Drucker, Serverraum, kritisch" - /> -
- {/* Netzwerkdaten */}

@@ -438,6 +420,34 @@ export default function DeviceEditModal({ />

+ {/* Tags */} +
+ ({ name }))} + onChange={(next) => { + const names = next.map((t) => t.name); + + setEditDevice((prev) => + prev ? ({ ...prev, tags: names } as DeviceDetail) : prev, + ); + + setAllTags((prev) => { + const map = new Map(prev.map((t) => [t.name.toLowerCase(), t])); + for (const t of next) { + const key = t.name.toLowerCase(); + if (!map.has(key)) { + map.set(key, t); + } + } + return Array.from(map.values()); + }); + }} + placeholder="z.B. Drucker, Serverraum, kritisch" + /> +
+ {/* Kommentar */}

diff --git a/app/(app)/devices/DeviceHistorySidebar.tsx b/app/(app)/devices/DeviceHistorySidebar.tsx index e0620bb..7583444 100644 --- a/app/(app)/devices/DeviceHistorySidebar.tsx +++ b/app/(app)/devices/DeviceHistorySidebar.tsx @@ -8,11 +8,12 @@ import Feed, { } from '@/components/ui/Feed'; import clsx from 'clsx'; - type Props = { inventoryNumber: string; /** Wenn true: wird als Inhalt für Modal.sidebar gerendert (ohne eigenes