geraete/app/(app)/devices/LoanDeviceModal.tsx
2025-11-24 08:59:14 +01:00

548 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/(app)/devices/LoanDeviceModal.tsx
'use client';
import { useEffect, useMemo, useState } from 'react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import Dropdown, { type DropdownSection } from '@/components/ui/Dropdown';
import type { DeviceDetail } from './page';
type LoanDeviceModalProps = {
open: boolean;
onClose: () => void;
device: DeviceDetail;
/**
* Wird nach erfolgreichem Speichern/Beenden aufgerufen,
* damit der Parent den lokalen State aktualisieren kann.
*/
onUpdated?: (patch: {
loanedTo: string | null;
loanedFrom: string | null;
loanedUntil: string | null;
loanComment: string | null;
}) => void;
};
// gleiche Logik wie bei den User-Gruppen:
function getBaseGroupName(name: string): string {
const trimmed = name.trim();
if (!trimmed) return '';
const beforeDash = trimmed.split('-')[0].trim() || trimmed;
const withoutDigits = beforeDash.replace(/\d+$/, '').trim();
return withoutDigits || beforeDash;
}
type LoanUserOption = {
value: string; // wird in loanedTo gespeichert (z.B. arbeitsname)
label: string; // Anzeige-Text im Dropdown
group: string; // Hauptgruppe (BaseKey)
};
type UsersApiGroup = {
id: string;
name: string;
users: {
nwkennung: string;
arbeitsname: string;
firstName: string | null;
lastName: string | null;
}[];
};
function toDateInputValue(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
// lokale Datumskomponenten -> passend für <input type="date">
return `${year}-${month}-${day}`;
}
function fromDateInputValue(v: string): string | null {
if (!v) return null;
// Wir nehmen 00:00 Uhr lokale Zeit; toISOString() speichert sauber in DB
const d = new Date(v + 'T00:00:00');
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
const dtf = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
});
// "heute" im <input type="date">-Format
function todayInputDate(): string {
const d = new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`; // z.B. 2025-02-19
}
export default function LoanDeviceModal({
open,
onClose,
device,
onUpdated,
}: LoanDeviceModalProps) {
const [loanedTo, setLoanedTo] = useState('');
const [loanedFrom, setLoanedFrom] = useState('');
const [loanedUntil, setLoanedUntil] = useState('');
const [loanComment, setLoanComment] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userOptions, setUserOptions] = useState<LoanUserOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [optionsError, setOptionsError] = useState<string | null>(null);
// Beim Öffnen / Gerätewechsel Felder setzen
useEffect(() => {
if (!open) return;
const existingFrom = toDateInputValue(device.loanedFrom);
const nextLoanedFrom = existingFrom || todayInputDate();
setLoanedTo(device.loanedTo ?? '');
setLoanedFrom(nextLoanedFrom);
setLoanedUntil(toDateInputValue(device.loanedUntil));
setLoanComment(device.loanComment ?? '');
setError(null);
}, [
open,
device.inventoryNumber,
device.loanedFrom,
device.loanedUntil,
device.loanComment,
device.loanedTo,
]);
// Beim Öffnen User für Dropdown laden (nur User aus Gruppen, gruppiert nach Hauptgruppen)
useEffect(() => {
if (!open) return;
async function loadUsers() {
setOptionsLoading(true);
setOptionsError(null);
try {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`Fehler beim Laden der Benutzer (HTTP ${res.status})`);
}
const data = await res.json();
const groups = (data.groups ?? []) as UsersApiGroup[];
const opts: LoanUserOption[] = [];
for (const g of groups) {
const mainGroup = getBaseGroupName(g.name) || g.name;
for (const u of g.users ?? []) {
if (!u.arbeitsname) continue;
const nameParts: string[] = [u.arbeitsname];
const extra: string[] = [];
if (u.firstName) extra.push(u.firstName);
if (u.lastName) extra.push(u.lastName);
if (extra.length) {
nameParts.push(' ' + extra.join(' '));
}
opts.push({
value: u.arbeitsname, // in loanedTo speichern wir weiterhin den Arbeitsnamen
label: nameParts.join(' '),
group: mainGroup,
});
}
}
// bestehenden Wert, der kein User ist, als "Andere"-Option anhängen
const currentLoanedTo = device.loanedTo ?? '';
if (
currentLoanedTo &&
!opts.some((o) => o.value === currentLoanedTo)
) {
opts.push({
value: currentLoanedTo,
label: `${currentLoanedTo} (bisheriger Eintrag)`,
group: 'Andere',
});
}
// sortieren: erst nach Gruppe, dann nach Label
opts.sort((a, b) => {
const gComp = a.group.localeCompare(b.group, 'de');
if (gComp !== 0) return gComp;
return a.label.localeCompare(b.label, 'de');
});
setUserOptions(opts);
} catch (err) {
console.error('Fehler beim Laden der Benutzerliste für Verleih', err);
setOptionsError('Empfängerliste konnte nicht geladen werden.');
} finally {
setOptionsLoading(false);
}
}
loadUsers();
}, [open, device.loanedTo]);
// Optionen nach Hauptgruppe gruppieren
const groupedOptions = useMemo(() => {
const map = new Map<string, LoanUserOption[]>();
for (const opt of userOptions) {
const key = opt.group || 'Andere';
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)!.push(opt);
}
return Array.from(map.entries()).sort(([a], [b]) =>
a.localeCompare(b, 'de'),
);
}, [userOptions]);
// Dropdown-Sektionen für deine Dropdown-Komponente
const dropdownSections = useMemo<DropdownSection[]>(() => {
const sections: DropdownSection[] = [];
// Erste Sektion: Zurücksetzen / keine Auswahl
sections.push({
id: 'base',
items: [
{
id: 'none',
label: '— Bitte auswählen —',
onClick: () => setLoanedTo(''),
},
],
});
// Danach je Hauptgruppe eine eigene Sektion mit Label als Trenner
for (const [groupName, opts] of groupedOptions) {
sections.push({
id: groupName,
label: groupName, // <-- wird als Trenner angezeigt
items: opts.map((opt) => ({
id: `${groupName}-${opt.value}`,
label: opt.label, // <-- nur der Name, ohne Gruppen-Präfix
onClick: () => setLoanedTo(opt.value),
})),
});
}
return sections;
}, [groupedOptions, setLoanedTo]);
// Aktuell ausgewähltes Label für den Trigger-Button
const currentSelected = useMemo(
() => userOptions.find((o) => o.value === loanedTo) ?? null,
[userOptions, loanedTo],
);
const dropdownLabel =
currentSelected?.label || (loanedTo || 'Bitte auswählen …');
const isLoaned = !!device.loanedTo;
async function saveLoan() {
if (!device.inventoryNumber) return;
if (!loanedTo.trim()) {
setError('Bitte einen Empfänger (an wen) auswählen.');
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(device.inventoryNumber)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// ⚠️ PATCH-Route erwartet alle Felder, nicht nur Verleihfelder!
name: device.name,
manufacturer: device.manufacturer,
model: device.model,
serialNumber: device.serialNumber,
productNumber: device.productNumber,
comment: device.comment,
ipv4Address: device.ipv4Address,
ipv6Address: device.ipv6Address,
macAddress: device.macAddress,
username: device.username,
passwordHash: (device as any).passwordHash ?? null,
group: device.group,
location: device.location,
tags: device.tags ?? [],
// Verleihfelder
loanedTo: loanedTo.trim(),
loanedFrom: fromDateInputValue(loanedFrom),
loanedUntil: fromDateInputValue(loanedUntil),
loanComment: loanComment.trim() || null,
}),
},
);
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(
data?.error ?? `Fehler beim Speichern (HTTP ${res.status})`,
);
}
const patch = {
loanedTo: loanedTo.trim(),
loanedFrom: fromDateInputValue(loanedFrom),
loanedUntil: fromDateInputValue(loanedUntil),
loanComment: loanComment.trim() || null,
};
onUpdated?.(patch);
onClose();
} catch (err: any) {
console.error('Error saving loan', err);
setError(
err instanceof Error
? err.message
: 'Fehler beim Speichern des Verleihstatus.',
);
} finally {
setSaving(false);
}
}
async function endLoan() {
if (!device.inventoryNumber) return;
setSaving(true);
setError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(device.inventoryNumber)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Alle bisherigen Felder durchreichen
name: device.name,
manufacturer: device.manufacturer,
model: device.model,
serialNumber: device.serialNumber,
productNumber: device.productNumber,
comment: device.comment,
ipv4Address: device.ipv4Address,
ipv6Address: device.ipv6Address,
macAddress: device.macAddress,
username: device.username,
passwordHash: (device as any).passwordHash ?? null,
group: device.group,
location: device.location,
tags: device.tags ?? [],
// Verleih zurücksetzen
loanedTo: null,
loanedFrom: null,
loanedUntil: null,
loanComment: null,
}),
},
);
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(
data?.error ?? `Fehler beim Beenden des Verleihs (HTTP ${res.status})`,
);
}
onUpdated?.({
loanedTo: null,
loanedFrom: null,
loanedUntil: null,
loanComment: null,
});
onClose();
} catch (err: any) {
console.error('Error ending loan', err);
setError(
err instanceof Error
? err.message
: 'Fehler beim Beenden des Verleihs.',
);
} finally {
setSaving(false);
}
}
const title = isLoaned
? `Verleih bearbeiten ${device.name}`
: `Gerät verleihen ${device.name}`;
return (
<Modal
open={open}
onClose={onClose}
title={title}
tone={isLoaned ? 'warning' : 'info'}
variant="centered"
size="md"
primaryAction={{
label: saving
? 'Speichere …'
: isLoaned
? 'Verleih aktualisieren'
: 'Gerät verleihen',
onClick: saveLoan,
variant: 'primary',
}}
secondaryAction={{
label: 'Abbrechen',
onClick: onClose,
variant: 'secondary',
}}
useGrayFooter
>
<div className="space-y-4 text-sm">
{/* Aktueller Verleih-Hinweis mit deutlich sichtbarem „Verleih beenden“ */}
{isLoaned && (
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-xs text-amber-900 dark:border-amber-700/70 dark:bg-amber-950/40 dark:text-amber-50">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide">
Verliehen an
</p>
<p className="mt-0.5">
<span className="font-medium">{device.loanedTo}</span>
{device.loanedFrom && (
<>
{' '}seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
)}
{device.loanedUntil && (
<>
{' '}bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '}- Hinweis: {device.loanComment}
</>
)}
</p>
</div>
<Button
size="sm"
variant="secondary"
tone="gray"
onClick={endLoan}
disabled={saving}
>
Verleih beenden
</Button>
</div>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
{isLoaned
? 'Passe die Verleihdaten an oder beende den Verleih, wenn das Gerät zurückgegeben wurde.'
: 'Lege fest, an wen das Gerät verliehen wird und in welchem Zeitraum.'}
</p>
{/* 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>
<Dropdown
label={dropdownLabel}
ariaLabel="Empfänger auswählen"
align="left"
triggerVariant="button"
disabled={optionsLoading || userOptions.length === 0}
sections={dropdownSections}
triggerClassName="mt-1 w-full justify-between"
/>
{optionsLoading && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Lade Benutzer
</p>
)}
{optionsError && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{optionsError}
</p>
)}
</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">
Verliehen seit
</label>
<input
type="date"
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={loanedFrom}
onChange={(e) => setLoanedFrom(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Verliehen bis (optional)
</label>
<input
type="date"
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={loanedUntil}
onChange={(e) => setLoanedUntil(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Hinweis / Kommentar (optional)
</label>
<textarea
rows={3}
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={loanComment}
onChange={(e) => setLoanComment(e.target.value)}
/>
</div>
</div>
{error && (
<p className="text-xs text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
</Modal>
);
}