new features
This commit is contained in:
@@ -50,7 +50,7 @@ function AdminDashboard() {
|
||||
<Typography variant="h4" sx={{ mb: 3 }}>Administration</Typography>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/admin?tab=${v}`, { replace: true }); }}>
|
||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/admin?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
||||
<Tab label="Services" />
|
||||
<Tab label="System" />
|
||||
<Tab label="Benutzer" />
|
||||
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
@@ -34,6 +40,7 @@ import {
|
||||
DeleteOutline,
|
||||
Edit,
|
||||
Error as ErrorIcon,
|
||||
History,
|
||||
MoreHoriz,
|
||||
PauseCircle,
|
||||
RemoveCircle,
|
||||
@@ -97,6 +104,65 @@ function fmtDate(iso: string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDatetime(iso: string | null | undefined): string {
|
||||
if (!iso) return '---';
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// -- Status History Section ---------------------------------------------------
|
||||
|
||||
const StatusHistorySection: React.FC<{ equipmentId: string }> = ({ equipmentId }) => {
|
||||
const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
equipmentApi.getStatusHistory(equipmentId)
|
||||
.then(setHistory)
|
||||
.catch(() => setHistory([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [equipmentId]);
|
||||
|
||||
if (loading || history.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<History fontSize="small" /> Status-Verlauf
|
||||
</Typography>
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Von</TableCell>
|
||||
<TableCell>Nach</TableCell>
|
||||
<TableCell>Bemerkung</TableCell>
|
||||
<TableCell>Geändert von</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{history.map((h, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{fmtDatetime(h.erstellt_am)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={AusruestungStatusLabel[h.alter_status as AusruestungStatus] || h.alter_status} color={STATUS_CHIP_COLOR[h.alter_status as AusruestungStatus] || 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={AusruestungStatusLabel[h.neuer_status as AusruestungStatus] || h.neuer_status} color={STATUS_CHIP_COLOR[h.neuer_status as AusruestungStatus] || 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>{h.bemerkung || '—'}</TableCell>
|
||||
<TableCell>{h.geaendert_von_name || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// -- Wartungslog Art config ---------------------------------------------------
|
||||
|
||||
const WARTUNG_ART_CHIP_COLOR: Record<AusruestungWartungslogArt, 'info' | 'warning' | 'default'> = {
|
||||
@@ -337,6 +403,8 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdate
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<StatusHistorySection equipmentId={equipment.id} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -430,7 +498,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||
{[
|
||||
entry.kosten != null && `${entry.kosten.toFixed(2)} EUR`,
|
||||
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`,
|
||||
entry.pruefende_stelle && entry.pruefende_stelle,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
@@ -563,7 +631,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
||||
function AusruestungDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, canManageCategory } = usePermissions();
|
||||
const { isAdmin, canManageCategory, canManageEquipmentMaintenance } = usePermissions();
|
||||
const notification = useNotification();
|
||||
|
||||
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
||||
@@ -706,6 +774,8 @@ function AusruestungDetailPage() {
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
aria-label="Ausrüstung Detailansicht"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="Übersicht" />
|
||||
<Tab
|
||||
@@ -733,7 +803,7 @@ function AusruestungDetailPage() {
|
||||
equipmentId={equipment.id}
|
||||
wartungslog={equipment.wartungslog ?? []}
|
||||
onAdded={fetchEquipment}
|
||||
canWrite={canWrite}
|
||||
canWrite={canManageEquipmentMaintenance}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -109,6 +109,11 @@ export default function Bestellungen() {
|
||||
queryFn: bestellungApi.getVendors,
|
||||
});
|
||||
|
||||
const { data: orderUsers = [] } = useQuery({
|
||||
queryKey: ['bestellungen', 'order-users'],
|
||||
queryFn: bestellungApi.getOrderUsers,
|
||||
});
|
||||
|
||||
// ── Mutations ──
|
||||
const createOrder = useMutation({
|
||||
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
|
||||
@@ -194,7 +199,7 @@ export default function Bestellungen() {
|
||||
<Typography variant="h4" sx={{ mb: 3 }}>Bestellungen</Typography>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }}>
|
||||
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
|
||||
<Tab label="Bestellungen" />
|
||||
<Tab label="Lieferanten" />
|
||||
</Tabs>
|
||||
@@ -366,10 +371,12 @@ export default function Bestellungen() {
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
<TextField
|
||||
label="Besteller"
|
||||
value={orderForm.besteller_id || ''}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, besteller_id: e.target.value }))}
|
||||
<Autocomplete
|
||||
options={orderUsers}
|
||||
getOptionLabel={(o) => o.name}
|
||||
value={orderUsers.find((u) => u.id === orderForm.besteller_id) || null}
|
||||
onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))}
|
||||
renderInput={(params) => <TextField {...params} label="Besteller" />}
|
||||
/>
|
||||
<TextField
|
||||
label="Budget"
|
||||
|
||||
@@ -234,22 +234,31 @@ function FahrzeugBuchungen() {
|
||||
setAvailability(null);
|
||||
return;
|
||||
}
|
||||
const beginn = new Date(form.beginn);
|
||||
const ende = new Date(form.ende);
|
||||
if (isNaN(beginn.getTime()) || isNaN(ende.getTime()) || ende <= beginn) {
|
||||
setAvailability(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
bookingApi
|
||||
.checkAvailability(
|
||||
form.fahrzeugId,
|
||||
new Date(form.beginn),
|
||||
new Date(form.ende),
|
||||
editingBooking?.id
|
||||
)
|
||||
.then((result) => {
|
||||
if (!cancelled) setAvailability(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvailability(null);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
bookingApi
|
||||
.checkAvailability(
|
||||
form.fahrzeugId,
|
||||
beginn,
|
||||
ende,
|
||||
editingBooking?.id
|
||||
)
|
||||
.then((result) => {
|
||||
if (!cancelled) setAvailability(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvailability(null);
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [form.fahrzeugId, form.beginn, form.ende, editingBooking?.id]);
|
||||
|
||||
@@ -698,6 +707,23 @@ function FahrzeugBuchungen() {
|
||||
Von: {detailBooking.gebucht_von_name}
|
||||
</Typography>
|
||||
)}
|
||||
{(() => {
|
||||
const mw = maintenanceWindows.find((w) => w.id === detailBooking.fahrzeug_id);
|
||||
if (mw?.ausser_dienst_von && mw?.ausser_dienst_bis) {
|
||||
const bookingStart = new Date(detailBooking.beginn);
|
||||
const bookingEnd = new Date(detailBooking.ende);
|
||||
const serviceStart = new Date(mw.ausser_dienst_von);
|
||||
const serviceEnd = new Date(mw.ausser_dienst_bis);
|
||||
if (bookingStart < serviceEnd && bookingEnd > serviceStart) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ mt: 1, py: 0, fontSize: '0.75rem' }}>
|
||||
Fahrzeug außer Dienst: {format(serviceStart, 'dd.MM.')} – {format(serviceEnd, 'dd.MM.yyyy')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && (
|
||||
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
|
||||
{canWrite && (
|
||||
|
||||
@@ -526,8 +526,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||
{[
|
||||
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
|
||||
entry.kraftstoff_liter != null && `${entry.kraftstoff_liter.toFixed(1)} L`,
|
||||
entry.kosten != null && `${entry.kosten.toFixed(2)} €`,
|
||||
entry.kraftstoff_liter != null && `${Number(entry.kraftstoff_liter).toFixed(1)} L`,
|
||||
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} €`,
|
||||
entry.externe_werkstatt && entry.externe_werkstatt,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
@@ -818,7 +818,7 @@ const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId: _
|
||||
function FahrzeugDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, canChangeStatus } = usePermissions();
|
||||
const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions();
|
||||
const notification = useNotification();
|
||||
|
||||
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
|
||||
@@ -959,6 +959,8 @@ function FahrzeugDetail() {
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
aria-label="Fahrzeug Detailansicht"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="Übersicht" />
|
||||
<Tab
|
||||
@@ -988,7 +990,7 @@ function FahrzeugDetail() {
|
||||
fahrzeugId={vehicle.id}
|
||||
wartungslog={vehicle.wartungslog ?? []}
|
||||
onAdded={fetchVehicle}
|
||||
canWrite={canChangeStatus}
|
||||
canWrite={canManageMaintenance}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -2172,6 +2172,8 @@ export default function Kalender() {
|
||||
value={activeTab}
|
||||
onChange={(_, v) => { setActiveTab(v); navigate(`/kalender?tab=${v}`, { replace: true }); }}
|
||||
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab icon={<EventIcon />} iconPosition="start" label="Dienste & Veranstaltungen" />
|
||||
<Tab icon={<CarIcon />} iconPosition="start" label="Fahrzeugbuchungen" />
|
||||
|
||||
@@ -575,6 +575,8 @@ function MitgliedDetail() {
|
||||
value={activeTab}
|
||||
onChange={(_e, v) => setActiveTab(v)}
|
||||
aria-label="Mitglied Details"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="Stammdaten" id="tab-0" aria-controls="tabpanel-0" />
|
||||
<Tab label="Qualifikationen" id="tab-1" aria-controls="tabpanel-1" />
|
||||
|
||||
@@ -23,8 +23,6 @@ import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types';
|
||||
import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus } from '../types/shop.types';
|
||||
import type { Bestellung } from '../types/bestellung.types';
|
||||
|
||||
const priceFormat = new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' });
|
||||
|
||||
// ─── Catalog Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
interface DraftItem {
|
||||
@@ -116,7 +114,7 @@ function KatalogTab() {
|
||||
};
|
||||
const openEditArtikel = (a: ShopArtikel) => {
|
||||
setEditArtikel(a);
|
||||
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie, geschaetzter_preis: a.geschaetzter_preis });
|
||||
setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie });
|
||||
setArtikelDialogOpen(true);
|
||||
};
|
||||
const saveArtikel = () => {
|
||||
@@ -173,12 +171,11 @@ function KatalogTab() {
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>{item.bezeichnung}</Typography>
|
||||
{item.beschreibung && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{item.beschreibung}</Typography>}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
|
||||
{item.kategorie && <Chip label={item.kategorie} size="small" />}
|
||||
{item.geschaetzter_preis != null && (
|
||||
<Typography variant="body2" color="text.secondary">ca. {priceFormat.format(item.geschaetzter_preis)}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{item.kategorie && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Chip label={item.kategorie} size="small" />
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'space-between' }}>
|
||||
{canCreate && (
|
||||
@@ -197,10 +194,12 @@ function KatalogTab() {
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Custom item + draft summary */}
|
||||
{canCreate && draft.length > 0 && (
|
||||
{/* Free-text item + draft summary */}
|
||||
{canCreate && (
|
||||
<Paper variant="outlined" sx={{ mt: 3, p: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Anfrage-Entwurf</Typography>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
{draft.length > 0 ? 'Anfrage-Entwurf' : 'Freitext-Position hinzufügen'}
|
||||
</Typography>
|
||||
{draft.map((d, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ flexGrow: 1 }}>{d.bezeichnung}</Typography>
|
||||
@@ -208,12 +207,14 @@ function KatalogTab() {
|
||||
<IconButton size="small" onClick={() => removeDraftItem(idx)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 1 }} />
|
||||
{draft.length > 0 && <Divider sx={{ my: 1 }} />}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField size="small" placeholder="Eigener Artikel (Freitext)" value={customText} onChange={e => setCustomText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCustomToDraft(); }} sx={{ flexGrow: 1 }} />
|
||||
<Button size="small" onClick={addCustomToDraft} disabled={!customText.trim()}>Hinzufügen</Button>
|
||||
</Box>
|
||||
<Button variant="contained" sx={{ mt: 1.5 }} startIcon={<ShoppingCart />} onClick={() => setSubmitOpen(true)}>Anfrage absenden</Button>
|
||||
{draft.length > 0 && (
|
||||
<Button variant="contained" sx={{ mt: 1.5 }} startIcon={<ShoppingCart />} onClick={() => setSubmitOpen(true)}>Anfrage absenden</Button>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@@ -246,13 +247,6 @@ function KatalogTab() {
|
||||
onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))}
|
||||
renderInput={params => <TextField {...params} label="Kategorie" />}
|
||||
/>
|
||||
<TextField
|
||||
label="Geschätzter Preis (EUR)"
|
||||
type="number"
|
||||
value={artikelForm.geschaetzter_preis ?? ''}
|
||||
onChange={e => setArtikelForm(f => ({ ...f, geschaetzter_preis: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setArtikelDialogOpen(false)}>Abbrechen</Button>
|
||||
@@ -607,7 +601,7 @@ export default function Shop() {
|
||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 2 }}>Shop</Typography>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs value={activeTab} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
|
||||
<Tab label="Katalog" />
|
||||
{canCreate && <Tab label="Meine Anfragen" />}
|
||||
{canApprove && <Tab label="Alle Anfragen" />}
|
||||
|
||||
@@ -598,6 +598,7 @@ function EventFormDialog({
|
||||
max_teilnehmer: null,
|
||||
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
|
||||
anmeldung_bis: null,
|
||||
wiederholung: editingEvent.wiederholung ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const now = new Date();
|
||||
@@ -927,10 +928,15 @@ function EventFormDialog({
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* Recurrence / Wiederholung — only for new events */}
|
||||
{!editingEvent && (
|
||||
{/* Recurrence / Wiederholung — for new events or when editing a parent event */}
|
||||
{(!editingEvent || (editingEvent.wiederholung && !editingEvent.wiederholung_parent_id)) && (
|
||||
<>
|
||||
<Divider />
|
||||
{editingEvent && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Änderungen an der Wiederholung werden alle bestehenden Instanzen löschen und neu generieren.
|
||||
</Typography>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
|
||||
Reference in New Issue
Block a user