geraete/app/(app)/devices/LoanDeviceModal.tsx
2025-11-26 15:00:05 +01:00

542 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 AppCombobox from '@/components/ui/Combobox';
import type { DeviceDetail } from './page';
type LoanDeviceModalProps = {
open: boolean;
onClose: () => void;
device: DeviceDetail;
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 oder Freitext)
label: string; // Anzeige-Text in der Combobox
group: string; // Hauptgruppe (BaseKey)
imageUrl?: string | null; // Avatar / Platzhalter
/** Gesamter Suchstring: arbeitsname, nwkennung, Vor-/Nachname, Gruppe */
searchText: string;
};
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');
return `${year}-${month}-${day}`;
}
function fromDateInputValue(v: string): string | null {
if (!v) return null;
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',
});
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}`;
}
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 Combobox laden
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(' '));
}
const fullName =
[u.firstName, u.lastName].filter(Boolean).join(' ') ||
u.arbeitsname;
const avatarName = fullName || u.arbeitsname;
const imageUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(
avatarName,
)}&background=4f46e5&color=fff&size=64&bold=true`;
const searchParts = [
u.arbeitsname,
u.nwkennung,
u.firstName,
u.lastName,
mainGroup,
g.name,
].filter(Boolean);
opts.push({
value: u.arbeitsname, // in loanedTo speichern wir weiterhin den Arbeitsnamen
label: nameParts.join(' '),
group: mainGroup,
imageUrl,
searchText: searchParts.join(' '),
});
}
}
// bestehenden Wert, der kein User ist, als "Andere"-Option anhängen (ohne Zusatz-Placeholder)
const currentLoanedTo = device.loanedTo ?? '';
if (
currentLoanedTo &&
!opts.some((o) => o.value === currentLoanedTo)
) {
const avatarName = currentLoanedTo;
const imageUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(
avatarName,
)}&background=6b7280&color=fff&size=64&bold=true`;
opts.push({
value: currentLoanedTo,
label: currentLoanedTo, // 👈 kein "(bisheriger Eintrag)" mehr
group: 'Andere',
imageUrl,
searchText: `${currentLoanedTo} 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]);
// aktuell ausgewählte Option für Combobox
const currentSelected = useMemo(
() => userOptions.find((o) => o.value === loanedTo) ?? null,
[userOptions, loanedTo],
);
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({
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 ?? [],
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({
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 ?? [],
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">
{/* Hinweis zum aktuellen Verleih */}
{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>
<div className="mt-1">
<AppCombobox<LoanUserOption>
label="Verliehen an"
options={userOptions}
value={currentSelected}
onChange={(selected) => {
if (!selected) {
setLoanedTo('');
return;
}
setLoanedTo(selected.value);
}}
getKey={(opt) => opt.value}
getPrimaryLabel={(opt) => opt.label}
getSecondaryLabel={(opt) => opt.group}
getSearchText={(opt) => opt.searchText}
getImageUrl={(opt) => opt.imageUrl ?? null}
placeholder={
optionsLoading
? 'Lade Benutzer …'
: userOptions.length > 0
? 'Bitte auswählen …'
: 'Keine Benutzer gefunden'
}
allowCreateFromQuery
onCreateFromQuery={(query) => {
const trimmed = query.trim();
return {
value: trimmed,
label: trimmed,
group: 'Andere',
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(
trimmed || 'Unbekannt',
)}&background=6b7280&color=fff&size=64&bold=true`,
searchText: `${trimmed} Andere`,
};
}}
/>
</div>
{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>
);
}