546 lines
17 KiB
TypeScript
546 lines
17 KiB
TypeScript
// 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>
|
||
<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}
|
||
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>
|
||
);
|
||
}
|