new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 16:47:36 +01:00
parent 8c66492b27
commit 690f260b71
9 changed files with 80 additions and 36 deletions

View File

@@ -19,6 +19,7 @@ export interface FahrzeugBuchung {
beginn: Date; beginn: Date;
ende: Date; ende: Date;
buchungs_art: BuchungsArt; buchungs_art: BuchungsArt;
ganztaegig: boolean;
gebucht_von: string; gebucht_von: string;
kontakt_person?: string | null; kontakt_person?: string | null;
kontakt_telefon?: string | null; kontakt_telefon?: string | null;
@@ -42,6 +43,7 @@ export interface FahrzeugBuchungListItem {
buchungs_art: BuchungsArt; buchungs_art: BuchungsArt;
beginn: Date; beginn: Date;
ende: Date; ende: Date;
ganztaegig: boolean;
abgesagt: boolean; abgesagt: boolean;
gebucht_von: string; gebucht_von: string;
gebucht_von_name?: string | null; gebucht_von_name?: string | null;

View File

@@ -162,7 +162,7 @@ async function getOrderById(id: number) {
]); ]);
return { return {
...orderResult.rows[0], bestellung: orderResult.rows[0],
positionen: positionen.rows, positionen: positionen.rows,
dateien: dateien.rows, dateien: dateien.rows,
erinnerungen: erinnerungen.rows, erinnerungen: erinnerungen.rows,

View File

@@ -26,6 +26,7 @@ function rowToListItem(row: any): FahrzeugBuchungListItem {
buchungs_art: row.buchungs_art, buchungs_art: row.buchungs_art,
beginn: new Date(row.beginn), beginn: new Date(row.beginn),
ende: new Date(row.ende), ende: new Date(row.ende),
ganztaegig: row.ganztaegig ?? false,
abgesagt: row.abgesagt, abgesagt: row.abgesagt,
gebucht_von: row.gebucht_von, gebucht_von: row.gebucht_von,
gebucht_von_name: row.gebucht_von_name ?? null, gebucht_von_name: row.gebucht_von_name ?? null,
@@ -41,6 +42,7 @@ function rowToBuchung(row: any): FahrzeugBuchung {
beginn: new Date(row.beginn), beginn: new Date(row.beginn),
ende: new Date(row.ende), ende: new Date(row.ende),
buchungs_art: row.buchungs_art, buchungs_art: row.buchungs_art,
ganztaegig: row.ganztaegig ?? false,
gebucht_von: row.gebucht_von, gebucht_von: row.gebucht_von,
kontakt_person: row.kontakt_person ?? null, kontakt_person: row.kontakt_person ?? null,
kontakt_telefon: row.kontakt_telefon ?? null, kontakt_telefon: row.kontakt_telefon ?? null,
@@ -78,7 +80,7 @@ class BookingService {
const query = ` const query = `
SELECT SELECT
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt, b.gebucht_von, b.beginn, b.ende, b.abgesagt, b.gebucht_von, b.ganztaegig,
f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen,
u.name AS gebucht_von_name u.name AS gebucht_von_name
FROM fahrzeug_buchungen b FROM fahrzeug_buchungen b
@@ -103,7 +105,7 @@ class BookingService {
const query = ` const query = `
SELECT SELECT
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt, b.gebucht_von, b.beginn, b.ende, b.abgesagt, b.gebucht_von, b.ganztaegig,
f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen,
u.name AS gebucht_von_name u.name AS gebucht_von_name
FROM fahrzeug_buchungen b FROM fahrzeug_buchungen b
@@ -125,7 +127,7 @@ class BookingService {
SELECT SELECT
b.id, b.fahrzeug_id, b.titel, b.beschreibung, b.id, b.fahrzeug_id, b.titel, b.beschreibung,
b.buchungs_art::text AS buchungs_art, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.beginn, b.ende, b.ganztaegig,
b.gebucht_von, b.kontakt_person, b.kontakt_telefon, b.gebucht_von, b.kontakt_person, b.kontakt_telefon,
b.abgesagt, b.abgesagt_grund, b.abgesagt, b.abgesagt_grund,
b.erstellt_am, b.aktualisiert_am, b.erstellt_am, b.aktualisiert_am,
@@ -300,6 +302,7 @@ class BookingService {
if (data.buchungsArt !== undefined) addField('buchungs_art', data.buchungsArt, 'fahrzeug_buchung_art'); if (data.buchungsArt !== undefined) addField('buchungs_art', data.buchungsArt, 'fahrzeug_buchung_art');
if (data.kontaktPerson !== undefined) addField('kontakt_person', data.kontaktPerson); if (data.kontaktPerson !== undefined) addField('kontakt_person', data.kontaktPerson);
if (data.kontaktTelefon !== undefined) addField('kontakt_telefon', data.kontaktTelefon); if (data.kontaktTelefon !== undefined) addField('kontakt_telefon', data.kontaktTelefon);
if (data.ganztaegig !== undefined) addField('ganztaegig', data.ganztaegig);
if (setClauses.length === 0) { if (setClauses.length === 0) {
throw new Error('No fields to update'); throw new Error('No fields to update');

View File

@@ -393,7 +393,9 @@ class EventsService {
* Capped at 100 instances and 2 years from the start date. */ * Capped at 100 instances and 2 years from the start date. */
private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] { private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] {
const dates: Date[] = []; const dates: Date[] = [];
const limitDate = config.bis ? new Date(config.bis + 'T23:59:59Z') : new Date(0); const defaultLimit = new Date(startDate);
defaultLimit.setUTCFullYear(defaultLimit.getUTCFullYear() + 2);
const limitDate = config.bis ? new Date(config.bis + 'T23:59:59Z') : defaultLimit;
const interval = config.intervall ?? 1; const interval = config.intervall ?? 1;
// Cap at 100 instances max, and 2 years // Cap at 100 instances max, and 2 years
const maxDate = new Date(startDate); const maxDate = new Date(startDate);

View File

@@ -396,8 +396,9 @@ class VehicleService {
// Auto-update next service date on the vehicle when result is 'bestanden' // Auto-update next service date on the vehicle when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) { if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
const column = data.art === '§57a Prüfung' ? 'paragraph57a_faellig_am' : 'naechste_wartung_am';
await pool.query( await pool.query(
`UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`, `UPDATE fahrzeuge SET ${column} = $1 WHERE id = $2`,
[data.naechste_faelligkeit, fahrzeugId] [data.naechste_faelligkeit, fahrzeugId]
); );
} }
@@ -443,8 +444,9 @@ class VehicleService {
// Auto-update next service date on the vehicle when result is 'bestanden' // Auto-update next service date on the vehicle when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) { if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
const column = data.art === '§57a Prüfung' ? 'paragraph57a_faellig_am' : 'naechste_wartung_am';
await pool.query( await pool.query(
`UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`, `UPDATE fahrzeuge SET ${column} = $1 WHERE id = $2`,
[data.naechste_faelligkeit, fahrzeugId] [data.naechste_faelligkeit, fahrzeugId]
); );
} }

View File

@@ -56,12 +56,17 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => { mutationFn: () => {
const beginnDate = new Date(beginn);
const endeDate = new Date(ende);
if (isNaN(beginnDate.getTime()) || isNaN(endeDate.getTime())) {
return Promise.reject(new Error('Ungültiges Datum'));
}
const data: CreateBuchungInput = { const data: CreateBuchungInput = {
fahrzeugId, fahrzeugId,
titel: titel.trim(), titel: titel.trim(),
beschreibung: beschreibung.trim() || null, beschreibung: beschreibung.trim() || null,
beginn: new Date(beginn).toISOString(), beginn: beginnDate.toISOString(),
ende: new Date(ende).toISOString(), ende: endeDate.toISOString(),
buchungsArt: 'intern', buchungsArt: 'intern',
}; };
return bookingApi.create(data); return bookingApi.create(data);
@@ -120,7 +125,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
> >
{(vehicles ?? []).map((v) => ( {(vehicles ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}> <MenuItem key={v.id} value={v.id}>
{v.bezeichnung}{v.amtliches_kennzeichen ? ` (${v.amtliches_kennzeichen})` : ''} {v.bezeichnung}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>

View File

@@ -287,10 +287,17 @@ function FahrzeugBuchungen() {
setDialogLoading(true); setDialogLoading(true);
setDialogError(null); setDialogError(null);
try { try {
const beginnDate = new Date(form.beginn);
const endeDate = new Date(form.ende);
if (isNaN(beginnDate.getTime()) || isNaN(endeDate.getTime())) {
setDialogError('Ungültiges Datum. Bitte Beginn und Ende prüfen.');
setDialogLoading(false);
return;
}
const payload: CreateBuchungInput = { const payload: CreateBuchungInput = {
...form, ...form,
beginn: new Date(form.beginn).toISOString(), beginn: beginnDate.toISOString(),
ende: new Date(form.ende).toISOString(), ende: endeDate.toISOString(),
ganztaegig: form.ganztaegig || false, ganztaegig: form.ganztaegig || false,
}; };
if (editingBooking) { if (editingBooking) {
@@ -374,6 +381,7 @@ function FahrzeugBuchungen() {
buchungsArt: detailBooking.buchungs_art, buchungsArt: detailBooking.buchungs_art,
kontaktPerson: '', kontaktPerson: '',
kontaktTelefon: '', kontaktTelefon: '',
ganztaegig: detailBooking.ganztaegig || false,
}); });
setDialogError(null); setDialogError(null);
setAvailability(null); setAvailability(null);
@@ -698,9 +706,10 @@ function FahrzeugBuchungen() {
}} }}
/> />
<Typography variant="body2"> <Typography variant="body2">
{format(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} {detailBooking.ganztaegig
{' '} ? `${format(parseISO(detailBooking.beginn), 'dd.MM.yyyy')} ${format(parseISO(detailBooking.ende), 'dd.MM.yyyy')} (Ganztägig)`
{format(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')} : `${format(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} ${format(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}`
}
</Typography> </Typography>
{detailBooking.gebucht_von_name && ( {detailBooking.gebucht_von_name && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
@@ -778,7 +787,6 @@ function FahrzeugBuchungen() {
{vehicles.map((v) => ( {vehicles.map((v) => (
<MenuItem key={v.id} value={v.id}> <MenuItem key={v.id} value={v.id}>
{v.bezeichnung} {v.bezeichnung}
{v.amtliches_kennzeichen ? ` (${v.amtliches_kennzeichen})` : ''}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>

View File

@@ -158,6 +158,7 @@ const EMPTY_BOOKING_FORM: CreateBuchungInput = {
buchungsArt: 'intern', buchungsArt: 'intern',
kontaktPerson: '', kontaktPerson: '',
kontaktTelefon: '', kontaktTelefon: '',
ganztaegig: false,
}; };
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -1446,7 +1447,7 @@ function VeranstaltungFormDialog({
try { try {
const createPayload: CreateVeranstaltungInput = { const createPayload: CreateVeranstaltungInput = {
...form, ...form,
wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis) wiederholung: ((!editingEvent || (editingEvent.wiederholung && !editingEvent.wiederholung_parent_id)) && wiederholungAktiv && wiederholungBis)
? { ? {
typ: wiederholungTyp, typ: wiederholungTyp,
bis: wiederholungBis, bis: wiederholungBis,
@@ -1458,7 +1459,7 @@ function VeranstaltungFormDialog({
: null, : null,
}; };
if (editingEvent) { if (editingEvent) {
await eventsApi.updateEvent(editingEvent.id, form); await eventsApi.updateEvent(editingEvent.id, createPayload);
notification.showSuccess('Veranstaltung aktualisiert'); notification.showSuccess('Veranstaltung aktualisiert');
} else { } else {
await eventsApi.createEvent(createPayload); await eventsApi.createEvent(createPayload);
@@ -1636,10 +1637,10 @@ function VeranstaltungFormDialog({
{(!editingEvent || (editingEvent && editingEvent.wiederholung)) && ( {(!editingEvent || (editingEvent && editingEvent.wiederholung)) && (
<> <>
<Divider /> <Divider />
{editingEvent && editingEvent.wiederholung ? ( {editingEvent && editingEvent.wiederholung && editingEvent.wiederholung_parent_id ? (
<> <>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}> <Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Wiederholung kann nicht bearbeitet werden Wiederholung kann nicht bearbeitet werden (Einzeltermin einer Serie)
</Typography> </Typography>
<FormControlLabel <FormControlLabel
control={<Switch checked disabled />} control={<Switch checked disabled />}
@@ -1659,7 +1660,7 @@ function VeranstaltungFormDialog({
)} )}
{wiederholungAktiv && ( {wiederholungAktiv && (
<Stack spacing={2}> <Stack spacing={2}>
<FormControl fullWidth size="small" disabled={!!editingEvent}> <FormControl fullWidth size="small" disabled={!!editingEvent?.wiederholung_parent_id}>
<InputLabel id="wiederholung-typ-label">Wiederholung</InputLabel> <InputLabel id="wiederholung-typ-label">Wiederholung</InputLabel>
<Select <Select
labelId="wiederholung-typ-label" labelId="wiederholung-typ-label"
@@ -1684,12 +1685,12 @@ function VeranstaltungFormDialog({
onChange={(e) => setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))} onChange={(e) => setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))}
inputProps={{ min: 1, max: 52 }} inputProps={{ min: 1, max: 52 }}
fullWidth fullWidth
disabled={!!editingEvent} disabled={!!editingEvent?.wiederholung_parent_id}
/> />
)} )}
{(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && ( {(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && (
<FormControl fullWidth size="small" disabled={!!editingEvent}> <FormControl fullWidth size="small" disabled={!!editingEvent?.wiederholung_parent_id}>
<InputLabel id="wiederholung-wochentag-label">Wochentag</InputLabel> <InputLabel id="wiederholung-wochentag-label">Wochentag</InputLabel>
<Select <Select
labelId="wiederholung-wochentag-label" labelId="wiederholung-wochentag-label"
@@ -1712,7 +1713,7 @@ function VeranstaltungFormDialog({
onChange={(e) => setWiederholungBis(e.target.value)} onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
fullWidth fullWidth
disabled={!!editingEvent} disabled={!!editingEvent?.wiederholung_parent_id}
helperText="Letztes Datum für Wiederholungen" helperText="Letztes Datum für Wiederholungen"
/> />
</Stack> </Stack>
@@ -2085,6 +2086,7 @@ export default function Kalender() {
buchungsArt: detailBooking.buchungs_art, buchungsArt: detailBooking.buchungs_art,
kontaktPerson: '', kontaktPerson: '',
kontaktTelefon: '', kontaktTelefon: '',
ganztaegig: (detailBooking as any).ganztaegig || false,
}); });
setBookingDialogError(null); setBookingDialogError(null);
setAvailability(null); setAvailability(null);
@@ -2128,7 +2130,17 @@ export default function Kalender() {
} }
const beginnIso = fromGermanDateTime(bookingForm.beginn)!; const beginnIso = fromGermanDateTime(bookingForm.beginn)!;
const endeIso = fromGermanDateTime(bookingForm.ende)!; const endeIso = fromGermanDateTime(bookingForm.ende)!;
if (new Date(endeIso) <= new Date(beginnIso)) { const beginnDate = new Date(beginnIso);
const endeDate = new Date(endeIso);
if (isNaN(beginnDate.getTime())) {
setBookingDialogError('Ungültiges Beginn-Datum');
return;
}
if (isNaN(endeDate.getTime())) {
setBookingDialogError('Ungültiges Ende-Datum');
return;
}
if (endeDate <= beginnDate) {
setBookingDialogError('Ende muss nach dem Beginn liegen'); setBookingDialogError('Ende muss nach dem Beginn liegen');
return; return;
} }
@@ -2137,8 +2149,8 @@ export default function Kalender() {
try { try {
const payload: CreateBuchungInput = { const payload: CreateBuchungInput = {
...bookingForm, ...bookingForm,
beginn: (() => { const iso = fromGermanDateTime(bookingForm.beginn); return iso ? new Date(iso).toISOString() : new Date(bookingForm.beginn).toISOString(); })(), beginn: beginnDate.toISOString(),
ende: (() => { const iso = fromGermanDateTime(bookingForm.ende); return iso ? new Date(iso).toISOString() : new Date(bookingForm.ende).toISOString(); })(), ende: endeDate.toISOString(),
}; };
if (editingBooking) { if (editingBooking) {
await bookingApi.update(editingBooking.id, payload); await bookingApi.update(editingBooking.id, payload);
@@ -2874,11 +2886,6 @@ export default function Kalender() {
<Typography variant="body2" fontWeight={600}> <Typography variant="body2" fontWeight={600}>
{vehicle.bezeichnung} {vehicle.bezeichnung}
</Typography> </Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="caption" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</TableCell> </TableCell>
{weekDays.map((day) => { {weekDays.map((day) => {
const cellBookings = getBookingsForCell(vehicle.id, day); const cellBookings = getBookingsForCell(vehicle.id, day);
@@ -3010,9 +3017,10 @@ export default function Kalender() {
}} }}
/> />
<Typography variant="body2"> <Typography variant="body2">
{fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} {(detailBooking as any).ganztaegig
{' '} ? `${fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy')} ${fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy')} (Ganztägig)`
{fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')} : `${fnsFormat(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} ${fnsFormat(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}`
}
</Typography> </Typography>
{detailBooking.gebucht_von_name && ( {detailBooking.gebucht_von_name && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
@@ -3074,7 +3082,7 @@ export default function Kalender() {
> >
{vehicles.map((v) => ( {vehicles.map((v) => (
<MenuItem key={v.id} value={v.id}> <MenuItem key={v.id} value={v.id}>
{v.bezeichnung}{v.amtliches_kennzeichen ? ` (${v.amtliches_kennzeichen})` : ''} {v.bezeichnung}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@@ -3088,6 +3096,19 @@ export default function Kalender() {
} }
/> />
<FormControlLabel
control={
<Switch
checked={bookingForm.ganztaegig || false}
onChange={(e) => {
const checked = e.target.checked;
setBookingForm((f) => ({ ...f, ganztaegig: checked }));
}}
/>
}
label="Ganztägig"
/>
<TextField <TextField
fullWidth size="small" label="Beginn" placeholder="TT.MM.JJJJ HH:MM" required fullWidth size="small" label="Beginn" placeholder="TT.MM.JJJJ HH:MM" required
value={bookingForm.beginn} value={bookingForm.beginn}

View File

@@ -27,6 +27,7 @@ export interface FahrzeugBuchungListItem {
buchungs_art: BuchungsArt; buchungs_art: BuchungsArt;
beginn: string; // ISO beginn: string; // ISO
ende: string; // ISO ende: string; // ISO
ganztaegig: boolean;
abgesagt: boolean; abgesagt: boolean;
gebucht_von: string; gebucht_von: string;
gebucht_von_name?: string | null; gebucht_von_name?: string | null;