feat(admin): move integration URLs and credentials to GUI settings
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
AccordionDetails,
|
||||
Tabs,
|
||||
Tab,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
PictureAsPdf as PdfIcon,
|
||||
Settings as SettingsIcon,
|
||||
Checkroom as CheckroomIcon,
|
||||
Extension as ExtensionIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -156,6 +158,27 @@ function AdminSettings() {
|
||||
// State for app logo
|
||||
const [appLogo, setAppLogo] = useState('');
|
||||
|
||||
// State for integration settings — URL fields
|
||||
const [bookstackUrl, setBookstackUrl] = useState('');
|
||||
const [nextcloudUrl, setNextcloudUrl] = useState('');
|
||||
const [vikunjaUrl, setVikunjaUrl] = useState('');
|
||||
const [icalBaseUrl, setIcalBaseUrl] = useState('');
|
||||
const [fdiskBaseUrl, setFdiskBaseUrl] = useState('');
|
||||
const [fdiskIdFeuerwehren, setFdiskIdFeuerwehren] = useState('');
|
||||
const [fdiskIdInstanzen, setFdiskIdInstanzen] = useState('');
|
||||
|
||||
// State for integration settings — secret fields (value + isSet flag)
|
||||
const [bookstackTokenId, setBookstackTokenId] = useState('');
|
||||
const [bookstackTokenIdIsSet, setBookstackTokenIdIsSet] = useState(false);
|
||||
const [bookstackTokenSecret, setBookstackTokenSecret] = useState('');
|
||||
const [bookstackTokenSecretIsSet, setBookstackTokenSecretIsSet] = useState(false);
|
||||
const [vikunjaApiToken, setVikunjaApiToken] = useState('');
|
||||
const [vikunjaApiTokenIsSet, setVikunjaApiTokenIsSet] = useState(false);
|
||||
const [fdiskUsername, setFdiskUsername] = useState('');
|
||||
const [fdiskUsernameIsSet, setFdiskUsernameIsSet] = useState(false);
|
||||
const [fdiskPassword, setFdiskPassword] = useState('');
|
||||
const [fdiskPasswordIsSet, setFdiskPasswordIsSet] = useState(false);
|
||||
|
||||
// Fetch all settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
@@ -195,6 +218,40 @@ function AdminSettings() {
|
||||
if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value);
|
||||
const appLogoSetting = settings.find((s) => s.key === 'app_logo');
|
||||
if (appLogoSetting?.value != null) setAppLogo(appLogoSetting.value);
|
||||
|
||||
// Integration settings — URL fields
|
||||
for (const s of settings) {
|
||||
switch (s.key) {
|
||||
case 'integration_bookstack_url': setBookstackUrl((s.value as string) || ''); break;
|
||||
case 'integration_nextcloud_url': setNextcloudUrl((s.value as string) || ''); break;
|
||||
case 'integration_vikunja_url': setVikunjaUrl((s.value as string) || ''); break;
|
||||
case 'integration_ical_base_url': setIcalBaseUrl((s.value as string) || ''); break;
|
||||
case 'fdisk_base_url': setFdiskBaseUrl((s.value as string) || ''); break;
|
||||
case 'fdisk_id_feuerwehren': setFdiskIdFeuerwehren((s.value as string) || ''); break;
|
||||
case 'fdisk_id_instanzen': setFdiskIdInstanzen((s.value as string) || ''); break;
|
||||
// Secret fields — never pre-fill value, only track is_set
|
||||
case 'integration_bookstack_token_id':
|
||||
setBookstackTokenId('');
|
||||
setBookstackTokenIdIsSet(s.is_set ?? false);
|
||||
break;
|
||||
case 'integration_bookstack_token_secret':
|
||||
setBookstackTokenSecret('');
|
||||
setBookstackTokenSecretIsSet(s.is_set ?? false);
|
||||
break;
|
||||
case 'integration_vikunja_api_token':
|
||||
setVikunjaApiToken('');
|
||||
setVikunjaApiTokenIsSet(s.is_set ?? false);
|
||||
break;
|
||||
case 'fdisk_username':
|
||||
setFdiskUsername('');
|
||||
setFdiskUsernameIsSet(s.is_set ?? false);
|
||||
break;
|
||||
case 'fdisk_password':
|
||||
setFdiskPassword('');
|
||||
setFdiskPasswordIsSet(s.is_set ?? false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@@ -310,6 +367,98 @@ function AdminSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
// Helper: resolve secret value for save
|
||||
const resolveSecret = (newValue: string, isSet: boolean): string => {
|
||||
if (newValue !== '') return newValue;
|
||||
if (isSet) return '__UNCHANGED__';
|
||||
return '';
|
||||
};
|
||||
|
||||
// Integration save states
|
||||
const [isSavingBookstack, setIsSavingBookstack] = useState(false);
|
||||
const [isSavingNextcloud, setIsSavingNextcloud] = useState(false);
|
||||
const [isSavingVikunja, setIsSavingVikunja] = useState(false);
|
||||
const [isSavingIcal, setIsSavingIcal] = useState(false);
|
||||
const [isSavingFdisk, setIsSavingFdisk] = useState(false);
|
||||
|
||||
const handleSaveBookstack = async () => {
|
||||
setIsSavingBookstack(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
settingsApi.update('integration_bookstack_url', bookstackUrl),
|
||||
settingsApi.update('integration_bookstack_token_id', resolveSecret(bookstackTokenId, bookstackTokenIdIsSet)),
|
||||
settingsApi.update('integration_bookstack_token_secret', resolveSecret(bookstackTokenSecret, bookstackTokenSecretIsSet)),
|
||||
]);
|
||||
showSuccess('BookStack-Einstellungen gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
} catch {
|
||||
showError('Fehler beim Speichern der BookStack-Einstellungen');
|
||||
} finally {
|
||||
setIsSavingBookstack(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNextcloud = async () => {
|
||||
setIsSavingNextcloud(true);
|
||||
try {
|
||||
await settingsApi.update('integration_nextcloud_url', nextcloudUrl);
|
||||
showSuccess('Nextcloud-Einstellungen gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
} catch {
|
||||
showError('Fehler beim Speichern der Nextcloud-Einstellungen');
|
||||
} finally {
|
||||
setIsSavingNextcloud(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveVikunja = async () => {
|
||||
setIsSavingVikunja(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
settingsApi.update('integration_vikunja_url', vikunjaUrl),
|
||||
settingsApi.update('integration_vikunja_api_token', resolveSecret(vikunjaApiToken, vikunjaApiTokenIsSet)),
|
||||
]);
|
||||
showSuccess('Vikunja-Einstellungen gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
} catch {
|
||||
showError('Fehler beim Speichern der Vikunja-Einstellungen');
|
||||
} finally {
|
||||
setIsSavingVikunja(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveIcal = async () => {
|
||||
setIsSavingIcal(true);
|
||||
try {
|
||||
await settingsApi.update('integration_ical_base_url', icalBaseUrl);
|
||||
showSuccess('iCal-Einstellungen gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
} catch {
|
||||
showError('Fehler beim Speichern der iCal-Einstellungen');
|
||||
} finally {
|
||||
setIsSavingIcal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFdisk = async () => {
|
||||
setIsSavingFdisk(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
settingsApi.update('fdisk_base_url', fdiskBaseUrl),
|
||||
settingsApi.update('fdisk_id_feuerwehren', fdiskIdFeuerwehren),
|
||||
settingsApi.update('fdisk_id_instanzen', fdiskIdInstanzen),
|
||||
settingsApi.update('fdisk_username', resolveSecret(fdiskUsername, fdiskUsernameIsSet)),
|
||||
settingsApi.update('fdisk_password', resolveSecret(fdiskPassword, fdiskPasswordIsSet)),
|
||||
]);
|
||||
showSuccess('FDISK-Einstellungen gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
} catch {
|
||||
showError('Fehler beim Speichern der FDISK-Einstellungen');
|
||||
} finally {
|
||||
setIsSavingFdisk(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!canAccess) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
@@ -677,7 +826,238 @@ function AdminSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 5: Info */}
|
||||
{/* Section 5: Integrationen */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<ExtensionIcon color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Integrationen</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
URLs und Zugangsdaten für externe Dienste. Leere URL-Felder verwenden die Umgebungsvariable als Fallback.
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* BookStack */}
|
||||
<Typography variant="subtitle2" gutterBottom>BookStack</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="URL"
|
||||
value={bookstackUrl}
|
||||
onChange={(e) => setBookstackUrl(e.target.value)}
|
||||
placeholder="Aus Umgebungsvariable"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Token ID"
|
||||
type="password"
|
||||
value={bookstackTokenId}
|
||||
onChange={(e) => setBookstackTokenId(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={bookstackTokenIdIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||
color={bookstackTokenIdIsSet ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Token Secret"
|
||||
type="password"
|
||||
value={bookstackTokenSecret}
|
||||
onChange={(e) => setBookstackTokenSecret(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={bookstackTokenSecretIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||
color={bookstackTokenSecretIsSet ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||
<Button
|
||||
onClick={handleSaveBookstack}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={isSavingBookstack}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Nextcloud */}
|
||||
<Typography variant="subtitle2" gutterBottom>Nextcloud</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="URL"
|
||||
value={nextcloudUrl}
|
||||
onChange={(e) => setNextcloudUrl(e.target.value)}
|
||||
placeholder="Aus Umgebungsvariable"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||
<Button
|
||||
onClick={handleSaveNextcloud}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={isSavingNextcloud}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Vikunja */}
|
||||
<Typography variant="subtitle2" gutterBottom>Vikunja</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="URL"
|
||||
value={vikunjaUrl}
|
||||
onChange={(e) => setVikunjaUrl(e.target.value)}
|
||||
placeholder="Aus Umgebungsvariable"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="API Token"
|
||||
type="password"
|
||||
value={vikunjaApiToken}
|
||||
onChange={(e) => setVikunjaApiToken(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={vikunjaApiTokenIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||
color={vikunjaApiTokenIsSet ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||
<Button
|
||||
onClick={handleSaveVikunja}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={isSavingVikunja}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* iCal */}
|
||||
<Typography variant="subtitle2" gutterBottom>iCal Abonnements</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Basis-URL"
|
||||
value={icalBaseUrl}
|
||||
onChange={(e) => setIcalBaseUrl(e.target.value)}
|
||||
placeholder="Aus Umgebungsvariable"
|
||||
helperText="Wird als Basis-URL für Kalender-Abonnements genutzt. Leer = Basis-URL der App."
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||
<Button
|
||||
onClick={handleSaveIcal}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={isSavingIcal}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* FDISK */}
|
||||
<Typography variant="subtitle2" gutterBottom>FDISK Sync</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Basis-URL"
|
||||
value={fdiskBaseUrl}
|
||||
onChange={(e) => setFdiskBaseUrl(e.target.value)}
|
||||
placeholder="Aus Umgebungsvariable"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Feuerwehr-ID"
|
||||
value={fdiskIdFeuerwehren}
|
||||
onChange={(e) => setFdiskIdFeuerwehren(e.target.value)}
|
||||
placeholder="Aus Umgebungsvariable"
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Instanz-ID"
|
||||
value={fdiskIdInstanzen}
|
||||
onChange={(e) => setFdiskIdInstanzen(e.target.value)}
|
||||
placeholder="Aus Umgebungsvariable"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Benutzername"
|
||||
type="password"
|
||||
value={fdiskUsername}
|
||||
onChange={(e) => setFdiskUsername(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={fdiskUsernameIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||
color={fdiskUsernameIsSet ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={fdiskPassword}
|
||||
onChange={(e) => setFdiskPassword(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={fdiskPasswordIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||
color={fdiskPasswordIsSet ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={handleSaveFdisk}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={isSavingFdisk}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 6: Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
|
||||
@@ -5,17 +5,14 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Paper,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, CheckCircle, Cancel, RemoveCircle } from '@mui/icons-material';
|
||||
import { ArrowBack, CheckCircle, Cancel } from '@mui/icons-material';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
@@ -34,7 +31,6 @@ const formatDate = (iso?: string) =>
|
||||
const ERGEBNIS_ICONS: Record<string, JSX.Element> = {
|
||||
ok: <CheckCircle fontSize="small" color="success" />,
|
||||
nok: <Cancel fontSize="small" color="error" />,
|
||||
na: <RemoveCircle fontSize="small" color="disabled" />,
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -86,7 +82,7 @@ export default function ChecklistAusfuehrung() {
|
||||
if (!execution?.items) return;
|
||||
const results: Record<number, { ergebnis: 'ok' | 'nok' | 'na'; kommentar: string }> = {};
|
||||
for (const item of execution.items) {
|
||||
results[item.id] = { ergebnis: item.ergebnis ?? 'ok', kommentar: item.kommentar ?? '' };
|
||||
results[item.id] = { ergebnis: item.ergebnis ?? 'nok', kommentar: item.kommentar ?? '' };
|
||||
}
|
||||
setItemResults(results);
|
||||
setNotizen(execution.notizen ?? '');
|
||||
@@ -109,9 +105,14 @@ export default function ChecklistAusfuehrung() {
|
||||
.filter((i) => i.parent_ausfuehrung_item_id != null)
|
||||
.map((i) => i.parent_ausfuehrung_item_id!)
|
||||
);
|
||||
// Validate: all nok items must have comments
|
||||
const leafItems = Object.entries(itemResults).filter(([id]) => !parentIds.has(Number(id)));
|
||||
const missingComments = leafItems.filter(([, r]) => r.ergebnis === 'nok' && !r.kommentar?.trim());
|
||||
if (missingComments.length > 0) {
|
||||
throw new Error('VALIDATION');
|
||||
}
|
||||
return checklistenApi.submitExecution(id!, {
|
||||
items: Object.entries(itemResults)
|
||||
.filter(([itemId]) => !parentIds.has(Number(itemId)))
|
||||
items: leafItems
|
||||
.map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })),
|
||||
notizen: notizen || undefined,
|
||||
});
|
||||
@@ -122,7 +123,13 @@ export default function ChecklistAusfuehrung() {
|
||||
queryClient.invalidateQueries({ queryKey: ['checklisten-faellig'] });
|
||||
showSuccess('Checkliste abgeschlossen');
|
||||
},
|
||||
onError: () => showError('Fehler beim Abschließen'),
|
||||
onError: (err) => {
|
||||
if (err instanceof Error && err.message === 'VALIDATION') {
|
||||
showError('Alle nicht bestandenen Prüfpunkte benötigen einen Kommentar');
|
||||
return;
|
||||
}
|
||||
showError('Fehler beim Abschließen');
|
||||
},
|
||||
});
|
||||
|
||||
// ── Approve ──
|
||||
@@ -185,34 +192,36 @@ export default function ChecklistAusfuehrung() {
|
||||
{isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]}
|
||||
</Box>
|
||||
{isReadOnly ? (
|
||||
<Box>
|
||||
<Chip
|
||||
label={result?.ergebnis === 'ok' ? 'OK' : result?.ergebnis === 'nok' ? 'Nicht OK' : 'N/A'}
|
||||
color={result?.ergebnis === 'ok' ? 'success' : result?.ergebnis === 'nok' ? 'error' : 'default'}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Checkbox
|
||||
checked={result?.ergebnis === 'ok'}
|
||||
disabled
|
||||
size="small"
|
||||
/>
|
||||
{result?.kommentar && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{result.kommentar}</Typography>
|
||||
)}
|
||||
<Box>
|
||||
{result?.ergebnis === 'na' && (
|
||||
<Typography variant="caption" color="text.secondary">(N/A)</Typography>
|
||||
)}
|
||||
{result?.kommentar && (
|
||||
<Typography variant="body2" color="text.secondary">{result.kommentar}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<RadioGroup
|
||||
row
|
||||
value={result?.ergebnis ?? ''}
|
||||
onChange={(e) => setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')}
|
||||
>
|
||||
<FormControlLabel value="ok" control={<Radio size="small" />} label="OK" />
|
||||
<FormControlLabel value="nok" control={<Radio size="small" />} label="Nicht OK" />
|
||||
<FormControlLabel value="na" control={<Radio size="small" />} label="N/A" />
|
||||
</RadioGroup>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Checkbox
|
||||
checked={result?.ergebnis === 'ok'}
|
||||
onChange={(e) => setItemResult(item.id, e.target.checked ? 'ok' : 'nok')}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Kommentar (optional)"
|
||||
placeholder={result?.ergebnis === 'nok' ? 'Kommentar (erforderlich)' : 'Kommentar (optional)'}
|
||||
fullWidth
|
||||
value={result?.kommentar ?? ''}
|
||||
onChange={(e) => setItemComment(item.id, e.target.value)}
|
||||
sx={{ mt: 0.5 }}
|
||||
error={result?.ergebnis === 'nok' && !result?.kommentar?.trim()}
|
||||
helperText={result?.ergebnis === 'nok' && !result?.kommentar?.trim() ? 'Kommentar erforderlich' : undefined}
|
||||
required={result?.ergebnis === 'nok'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -257,13 +257,85 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro
|
||||
return <Alert severity="info" sx={{ mt: 1 }}>Keine Checklisten zugewiesen</Alert>;
|
||||
}
|
||||
|
||||
// Flatten all checklists and detect open checks
|
||||
const allChecklists = [
|
||||
...vehicles.flatMap((v) => v.checklists.map((cl) => ({ ...cl, targetName: v.name, targetId: v.id, targetType: 'fahrzeug' as const }))),
|
||||
...equipment.flatMap((e) => e.checklists.map((cl) => ({ ...cl, targetName: e.name, targetId: e.id, targetType: 'ausruestung' as const }))),
|
||||
];
|
||||
const openChecks = allChecklists.filter((cl) => cl.ist_faellig);
|
||||
const hasOpenChecks = openChecks.length > 0;
|
||||
|
||||
// ── Open checks flat list ──
|
||||
if (hasOpenChecks) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 1.5 }}>
|
||||
Offene Prüfungen ({openChecks.length})
|
||||
</Typography>
|
||||
<List disablePadding>
|
||||
{openChecks.map((cl, idx) => {
|
||||
const color = getDueColor(cl.next_due, cl.intervall);
|
||||
const label = getDueLabel(cl.next_due, cl.intervall);
|
||||
const param = cl.ausruestung_id
|
||||
? `ausruestung=${cl.ausruestung_id}`
|
||||
: cl.targetType === 'ausruestung'
|
||||
? `ausruestung=${cl.targetId}`
|
||||
: `fahrzeug=${cl.targetId}`;
|
||||
const primaryText = cl.targetName + ' — ' + cl.vorlage_name + (cl.ausruestung_name ? ` (${cl.ausruestung_name})` : '');
|
||||
const secondaryText = cl.letzte_ausfuehrung_am
|
||||
? `Letzte Prüfung: ${formatDate(cl.letzte_ausfuehrung_am)}`
|
||||
: 'Noch nie geprüft';
|
||||
return (
|
||||
<ListItemButton
|
||||
key={`${cl.targetId}-${cl.vorlage_id}-${cl.ausruestung_id ?? ''}`}
|
||||
onClick={() => canExecute && navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 2,
|
||||
bgcolor: idx % 2 === 0 ? 'action.hover' : 'transparent',
|
||||
cursor: canExecute ? 'pointer' : 'default',
|
||||
'&:hover': canExecute ? undefined : { bgcolor: idx % 2 === 0 ? 'action.hover' : 'transparent' },
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={primaryText}
|
||||
secondary={secondaryText}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Chip
|
||||
label={label}
|
||||
color={color}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ ml: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
{canExecute && <PlayArrow fontSize="small" color="action" sx={{ ml: 1, opacity: 0.5 }} />}
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Normal accordion view (no open checks) ──
|
||||
|
||||
const renderChecklistRow = (cl: ChecklistOverviewChecklist, itemId: string, type: 'fahrzeug' | 'ausruestung', index: number) => {
|
||||
const color = getDueColor(cl.next_due, cl.intervall);
|
||||
const label = getDueLabel(cl.next_due, cl.intervall);
|
||||
const param = type === 'fahrzeug' ? `fahrzeug=${itemId}` : `ausruestung=${itemId}`;
|
||||
const param = cl.ausruestung_id
|
||||
? `ausruestung=${cl.ausruestung_id}`
|
||||
: type === 'fahrzeug'
|
||||
? `fahrzeug=${itemId}`
|
||||
: `ausruestung=${itemId}`;
|
||||
const primaryText = cl.vorlage_name + (cl.ausruestung_name ? ` (${cl.ausruestung_name})` : '');
|
||||
const secondaryText = cl.letzte_ausfuehrung_am
|
||||
? `Letzte Prüfung: ${formatDate(cl.letzte_ausfuehrung_am)}`
|
||||
: 'Noch nie geprüft';
|
||||
return (
|
||||
<ListItemButton
|
||||
key={cl.vorlage_id}
|
||||
key={`${cl.vorlage_id}-${cl.ausruestung_id ?? ''}`}
|
||||
onClick={() => canExecute && navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)}
|
||||
sx={{
|
||||
py: 0.75,
|
||||
@@ -274,8 +346,10 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={cl.vorlage_name}
|
||||
primary={primaryText}
|
||||
secondary={secondaryText}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Chip
|
||||
label={label}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from './api';
|
||||
interface AppSetting {
|
||||
key: string;
|
||||
value: any;
|
||||
is_set?: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,11 @@ export interface ChecklistOverviewChecklist {
|
||||
vorlage_name: string;
|
||||
intervall?: string | null;
|
||||
next_due?: string | null;
|
||||
verfuegbar_ab?: string | null;
|
||||
letzte_ausfuehrung_am?: string | null;
|
||||
ist_faellig?: boolean;
|
||||
ausruestung_id?: string;
|
||||
ausruestung_name?: string;
|
||||
}
|
||||
|
||||
export interface ChecklistOverviewItem {
|
||||
|
||||
Reference in New Issue
Block a user