Create FRONTEND_DESIGN_RULES.md as the single source of truth for all dashboard UI conventions, including component library (Syncfusion first), component defaults table, layout structure, buttons, dialogs, badges, toasts, form fields, tabs, statistics cards, warnings, color palette, CSS files, loading states, locale rules, and icon conventions (TentTree for skip-holidays events). Move embedded design rules from ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md to the new file and replace with a reference link for maintainability. Update copilot-instructions.md to point to FRONTEND_DESIGN_RULES.md and remove redundant Syncfusion/Tailwind prose from the Theming section. Add reference blockquote to README.md under Frontend Features directing readers to FRONTEND_DESIGN_RULES.md. Bug fix: Presentation events now reliably persist page_progress and auto_progress flags across create, update, and detached occurrence flows so display settings survive round-trips to the API. Files changed: - Created: FRONTEND_DESIGN_RULES.md (15 sections, 340+ lines) - Modified: ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md (extract rules, consolidate) - Modified: .github/copilot-instructions.md (link to new rules file) - Modified: README.md (reference blockquote)
799 lines
30 KiB
TypeScript
799 lines
30 KiB
TypeScript
import React from 'react';
|
||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
||
import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-calendars';
|
||
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
|
||
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
||
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
||
import { updateEvent, detachEventOccurrence } from '../apiEvents';
|
||
// Holiday exceptions are now created in the backend
|
||
|
||
type CustomEventData = {
|
||
title: string;
|
||
startDate: Date | null;
|
||
startTime: Date | null;
|
||
endTime: Date | null;
|
||
type: string;
|
||
description: string;
|
||
repeat: boolean;
|
||
weekdays: number[];
|
||
repeatUntil: Date | null;
|
||
skipHolidays: boolean;
|
||
media?: { id: string; path: string; name: string } | null;
|
||
slideshowInterval?: number;
|
||
pageProgress?: boolean;
|
||
autoProgress?: boolean;
|
||
websiteUrl?: string;
|
||
// Video-specific fields
|
||
autoplay?: boolean;
|
||
loop?: boolean;
|
||
volume?: number;
|
||
muted?: boolean;
|
||
};
|
||
|
||
// Typ für initialData erweitern, damit Id unterstützt wird
|
||
type CustomEventModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSave: (eventData: CustomEventData) => void;
|
||
initialData?: Partial<CustomEventData> & {
|
||
Id?: string;
|
||
OccurrenceOfId?: string;
|
||
isSingleOccurrence?: boolean;
|
||
occurrenceDate?: Date;
|
||
};
|
||
groupName: string | { id: string | null; name: string };
|
||
groupColor?: string;
|
||
editMode?: boolean;
|
||
// Removed unused blockHolidays and isHolidayRange
|
||
};
|
||
|
||
const weekdayOptions = [
|
||
{ value: 0, label: 'Montag' },
|
||
{ value: 1, label: 'Dienstag' },
|
||
{ value: 2, label: 'Mittwoch' },
|
||
{ value: 3, label: 'Donnerstag' },
|
||
{ value: 4, label: 'Freitag' },
|
||
{ value: 5, label: 'Samstag' },
|
||
{ value: 6, label: 'Sonntag' },
|
||
];
|
||
|
||
const typeOptions = [
|
||
{ value: 'presentation', label: 'Präsentation' },
|
||
{ value: 'website', label: 'Website' },
|
||
{ value: 'video', label: 'Video' },
|
||
{ value: 'message', label: 'Nachricht' },
|
||
{ value: 'webuntis', label: 'WebUntis' },
|
||
];
|
||
|
||
const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||
open,
|
||
onClose,
|
||
onSave,
|
||
initialData = {},
|
||
groupName,
|
||
groupColor,
|
||
editMode,
|
||
}) => {
|
||
const [title, setTitle] = React.useState(initialData.title || '');
|
||
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
|
||
const [startTime, setStartTime] = React.useState(
|
||
initialData.startTime || new Date(0, 0, 0, 9, 0)
|
||
);
|
||
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||
const [type, setType] = React.useState(initialData.type ?? 'presentation');
|
||
const [description, setDescription] = React.useState(initialData.description || '');
|
||
// Initialize recurrence state - force to false/empty for single occurrence editing
|
||
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
||
const [repeat, setRepeat] = React.useState(isSingleOccurrence ? false : (initialData.repeat || false));
|
||
const [weekdays, setWeekdays] = React.useState<number[]>(isSingleOccurrence ? [] : (initialData.weekdays || []));
|
||
const [repeatUntil, setRepeatUntil] = React.useState(isSingleOccurrence ? null : (initialData.repeatUntil || null));
|
||
// Default to true so recurrences skip holidays by default, but false for single occurrences
|
||
const [skipHolidays, setSkipHolidays] = React.useState(
|
||
isSingleOccurrence ? false : (initialData.skipHolidays !== undefined ? initialData.skipHolidays : true)
|
||
);
|
||
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
||
initialData.media ?? null
|
||
);
|
||
// General settings state for presentation
|
||
// Removed unused generalLoaded and setGeneralLoaded
|
||
// Removed unused generalLoaded/generalSlideshowInterval/generalPageProgress/generalAutoProgress
|
||
|
||
// Per-event state
|
||
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(
|
||
initialData.slideshowInterval ?? 10
|
||
);
|
||
const [pageProgress, setPageProgress] = React.useState<boolean>(
|
||
initialData.pageProgress ?? true
|
||
);
|
||
const [autoProgress, setAutoProgress] = React.useState<boolean>(
|
||
initialData.autoProgress ?? true
|
||
);
|
||
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
|
||
|
||
// Video-specific state with system defaults loading
|
||
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
|
||
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? true);
|
||
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
||
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
|
||
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
|
||
const [isSaving, setIsSaving] = React.useState(false);
|
||
|
||
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
||
|
||
// Load system video defaults once when opening for a new video event
|
||
React.useEffect(() => {
|
||
if (open && !editMode && !videoDefaultsLoaded) {
|
||
(async () => {
|
||
try {
|
||
const api = await import('../apiSystemSettings');
|
||
const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const;
|
||
const [autoplayRes, loopRes, volumeRes, mutedRes] = await Promise.all(
|
||
keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null })))
|
||
);
|
||
|
||
// Only apply defaults if not already set from initialData
|
||
if (initialData.autoplay === undefined) {
|
||
setAutoplay(autoplayRes.value == null ? true : autoplayRes.value === 'true');
|
||
}
|
||
if (initialData.loop === undefined) {
|
||
setLoop(loopRes.value == null ? true : loopRes.value === 'true');
|
||
}
|
||
if (initialData.volume === undefined) {
|
||
const volParsed = volumeRes.value == null ? 0.8 : parseFloat(String(volumeRes.value));
|
||
setVolume(Number.isFinite(volParsed) ? volParsed : 0.8);
|
||
}
|
||
if (initialData.muted === undefined) {
|
||
setMuted(mutedRes.value == null ? false : mutedRes.value === 'true');
|
||
}
|
||
|
||
setVideoDefaultsLoaded(true);
|
||
} catch {
|
||
// Silently fall back to hard-coded defaults
|
||
setVideoDefaultsLoaded(true);
|
||
}
|
||
})();
|
||
}
|
||
}, [open, editMode, videoDefaultsLoaded, initialData]);
|
||
|
||
React.useEffect(() => {
|
||
if (open) {
|
||
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
||
|
||
setTitle(initialData.title || '');
|
||
setStartDate(initialData.startDate || null);
|
||
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
|
||
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||
setType(initialData.type ?? 'presentation');
|
||
setDescription(initialData.description || '');
|
||
|
||
// For single occurrence editing, force recurrence settings to be disabled
|
||
if (isSingleOccurrence) {
|
||
setRepeat(false);
|
||
setWeekdays([]);
|
||
setRepeatUntil(null);
|
||
setSkipHolidays(false);
|
||
} else {
|
||
setRepeat(initialData.repeat || false);
|
||
setWeekdays(initialData.weekdays || []);
|
||
setRepeatUntil(initialData.repeatUntil || null);
|
||
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
|
||
}
|
||
|
||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||
setMedia(initialData.media ?? null);
|
||
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
||
setPageProgress(initialData.pageProgress ?? true);
|
||
setAutoProgress(initialData.autoProgress ?? true);
|
||
setWebsiteUrl(initialData.websiteUrl ?? '');
|
||
|
||
// Video fields - use initialData values when editing
|
||
if (editMode) {
|
||
setAutoplay(initialData.autoplay ?? true);
|
||
setLoop(initialData.loop ?? true);
|
||
setVolume(initialData.volume ?? 0.8);
|
||
setMuted(initialData.muted ?? false);
|
||
}
|
||
}
|
||
}, [open, initialData, editMode]);
|
||
|
||
const handleSave = async () => {
|
||
if (isSaving) {
|
||
return;
|
||
}
|
||
|
||
const newErrors: { [key: string]: string } = {};
|
||
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
|
||
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
|
||
if (!startTime) newErrors.startTime = 'Startzeit ist erforderlich';
|
||
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
|
||
if (!type) newErrors.type = 'Termintyp ist erforderlich';
|
||
|
||
// Vergangenheitsprüfung - für Einzeltermine strikt, für Serien flexibler
|
||
const startDateTime =
|
||
startDate && startTime
|
||
? new Date(
|
||
startDate.getFullYear(),
|
||
startDate.getMonth(),
|
||
startDate.getDate(),
|
||
startTime.getHours(),
|
||
startTime.getMinutes()
|
||
)
|
||
: null;
|
||
const isPast = startDateTime && startDateTime < new Date();
|
||
|
||
if (isPast) {
|
||
if (isSingleOccurrence || !repeat) {
|
||
// Einzeltermine oder nicht-wiederkehrende Events dürfen nicht in der Vergangenheit liegen
|
||
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
|
||
} else if (repeat && repeatUntil && repeatUntil < new Date()) {
|
||
// Wiederkehrende Events sind erlaubt, wenn das End-Datum in der Zukunft liegt
|
||
newErrors.repeatUntil = 'Terminserien mit End-Datum in der Vergangenheit sind nicht erlaubt!';
|
||
}
|
||
// Andernfalls: Wiederkehrende Serie ohne End-Datum oder mit End-Datum in der Zukunft ist erlaubt
|
||
}
|
||
|
||
if (type === 'presentation') {
|
||
if (!media) newErrors.media = 'Bitte eine Präsentation auswählen';
|
||
if (!slideshowInterval || slideshowInterval < 1)
|
||
newErrors.slideshowInterval = 'Intervall angeben';
|
||
}
|
||
if (type === 'website') {
|
||
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
|
||
}
|
||
if (type === 'video') {
|
||
if (!media) newErrors.media = 'Bitte ein Video auswählen';
|
||
}
|
||
|
||
const parsedMediaId = media?.id ? Number(media.id) : null;
|
||
if (
|
||
(type === 'presentation' || type === 'video') &&
|
||
(!Number.isFinite(parsedMediaId) || (parsedMediaId as number) <= 0)
|
||
) {
|
||
newErrors.media = 'Ausgewähltes Medium ist ungültig. Bitte Datei erneut auswählen.';
|
||
}
|
||
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
|
||
|
||
if (Object.keys(newErrors).length > 0) {
|
||
setErrors(newErrors);
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
|
||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||
|
||
// Build recurrence rule if repeat is enabled
|
||
let recurrenceRule = null;
|
||
let recurrenceEnd = null;
|
||
if (repeat && weekdays.length > 0) {
|
||
// Convert weekdays to RRULE format (0=Monday -> MO)
|
||
const rruleDays = weekdays.map(day => {
|
||
const dayNames = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
|
||
return dayNames[day];
|
||
}).join(',');
|
||
|
||
recurrenceRule = `FREQ=WEEKLY;BYDAY=${rruleDays}`;
|
||
if (repeatUntil) {
|
||
const untilDate = new Date(repeatUntil);
|
||
untilDate.setHours(23, 59, 59);
|
||
recurrenceEnd = untilDate.toISOString();
|
||
// Note: RRULE UNTIL should be in UTC format for consistency
|
||
const untilUTC = untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||
recurrenceRule += `;UNTIL=${untilUTC}`;
|
||
}
|
||
}
|
||
|
||
const payload: CustomEventData & { [key: string]: unknown } = {
|
||
group_id,
|
||
title,
|
||
description,
|
||
start:
|
||
startDate && startTime
|
||
? new Date(
|
||
startDate.getFullYear(),
|
||
startDate.getMonth(),
|
||
startDate.getDate(),
|
||
startTime.getHours(),
|
||
startTime.getMinutes()
|
||
).toISOString()
|
||
: null,
|
||
end:
|
||
startDate && endTime
|
||
? new Date(
|
||
startDate.getFullYear(),
|
||
startDate.getMonth(),
|
||
startDate.getDate(),
|
||
endTime.getHours(),
|
||
endTime.getMinutes()
|
||
).toISOString()
|
||
: null,
|
||
type,
|
||
startDate,
|
||
startTime,
|
||
endTime,
|
||
repeat: isSingleOccurrence ? false : repeat,
|
||
weekdays: isSingleOccurrence ? [] : weekdays,
|
||
repeatUntil: isSingleOccurrence ? null : repeatUntil,
|
||
skipHolidays: isSingleOccurrence ? false : skipHolidays,
|
||
event_type: type,
|
||
is_active: 1,
|
||
created_by: 1,
|
||
recurrence_rule: isSingleOccurrence ? null : recurrenceRule,
|
||
recurrence_end: isSingleOccurrence ? null : recurrenceEnd,
|
||
};
|
||
|
||
if (type === 'presentation') {
|
||
payload.event_media_id = parsedMediaId as number;
|
||
payload.slideshow_interval = slideshowInterval;
|
||
payload.page_progress = pageProgress;
|
||
payload.auto_progress = autoProgress;
|
||
}
|
||
|
||
if (type === 'website') {
|
||
payload.website_url = websiteUrl;
|
||
}
|
||
|
||
if (type === 'video') {
|
||
payload.event_media_id = parsedMediaId as number;
|
||
payload.autoplay = autoplay;
|
||
payload.loop = loop;
|
||
payload.volume = volume;
|
||
payload.muted = muted;
|
||
}
|
||
|
||
try {
|
||
let res;
|
||
if (editMode && initialData && typeof initialData.Id === 'string') {
|
||
// Check if this is a recurring event occurrence that should be detached
|
||
const shouldDetach = isSingleOccurrence &&
|
||
initialData.OccurrenceOfId &&
|
||
!isNaN(Number(initialData.OccurrenceOfId));
|
||
|
||
if (shouldDetach) {
|
||
// DETACH single occurrence from recurring series
|
||
|
||
// Use occurrenceDate from initialData, or fall back to startDate
|
||
const sourceDate = initialData.occurrenceDate || startDate;
|
||
if (!sourceDate) {
|
||
setErrors({ api: 'Fehler: Kein Datum für Einzeltermin verfügbar' });
|
||
return;
|
||
}
|
||
|
||
const occurrenceDate = sourceDate instanceof Date
|
||
? sourceDate.toISOString().split('T')[0]
|
||
: new Date(sourceDate).toISOString().split('T')[0];
|
||
|
||
try {
|
||
// Use the master event ID (OccurrenceOfId) for detaching
|
||
const masterId = Number(initialData.OccurrenceOfId);
|
||
res = await detachEventOccurrence(masterId, occurrenceDate, payload);
|
||
} catch (error) {
|
||
console.error('Detach operation failed:', error);
|
||
setErrors({ api: `API Fehler: ${error instanceof Error ? error.message : String(error)}` });
|
||
return;
|
||
}
|
||
} else {
|
||
// UPDATE entire event/series OR standalone event
|
||
res = await updateEvent(initialData.Id, payload);
|
||
}
|
||
} else {
|
||
// CREATE
|
||
const createResponse = await fetch('/api/events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
let createData: { success?: boolean; error?: string } = {};
|
||
try {
|
||
createData = await createResponse.json();
|
||
} catch {
|
||
createData = { error: `HTTP ${createResponse.status}` };
|
||
}
|
||
|
||
if (!createResponse.ok) {
|
||
setErrors({
|
||
api:
|
||
createData.error ||
|
||
`Fehler beim Speichern (HTTP ${createResponse.status})`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
res = createData;
|
||
}
|
||
|
||
if (res.success) {
|
||
onSave(payload);
|
||
onClose(); // <--- Box nach erfolgreichem Speichern schließen
|
||
} else {
|
||
setErrors({ api: res.error || 'Fehler beim Speichern' });
|
||
}
|
||
} catch {
|
||
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
|
||
};
|
||
|
||
// Vergangenheitsprüfung (außerhalb von handleSave, damit im Render verfügbar)
|
||
const startDateTime =
|
||
startDate && startTime
|
||
? new Date(
|
||
startDate.getFullYear(),
|
||
startDate.getMonth(),
|
||
startDate.getDate(),
|
||
startTime.getHours(),
|
||
startTime.getMinutes()
|
||
)
|
||
: null;
|
||
const isPast = !!(startDateTime && startDateTime < new Date());
|
||
|
||
// Button sollte nur für Einzeltermine in der Vergangenheit deaktiviert werden
|
||
// Wiederkehrende Serien können bearbeitet werden, auch wenn der Starttermin vergangen ist
|
||
const shouldDisableButton = isPast && (isSingleOccurrence || !repeat);
|
||
|
||
return (
|
||
<DialogComponent
|
||
target="#root"
|
||
visible={open}
|
||
width="800px"
|
||
header={() => (
|
||
<div
|
||
style={{
|
||
background: groupColor || '#f5f5f5',
|
||
padding: '8px 16px',
|
||
borderRadius: '6px 6px 0 0',
|
||
color: '#fff',
|
||
fontWeight: 600,
|
||
fontSize: 20,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
{editMode
|
||
? (isSingleOccurrence ? 'Einzeltermin bearbeiten' : 'Terminserie bearbeiten')
|
||
: 'Neuen Termin anlegen'}
|
||
{groupName && (
|
||
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
|
||
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
showCloseIcon={true}
|
||
close={onClose}
|
||
isModal={true}
|
||
footerTemplate={() => (
|
||
<div className="flex gap-2 justify-end">
|
||
<button className="e-btn e-danger" onClick={onClose}>
|
||
Schließen
|
||
</button>
|
||
<button
|
||
className="e-btn e-success"
|
||
onClick={handleSave}
|
||
disabled={shouldDisableButton || isSaving} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
|
||
>
|
||
{isSaving ? 'Speichert...' : 'Termin(e) speichern'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
>
|
||
<div style={{ padding: '24px' }}>
|
||
{errors.api && (
|
||
<div
|
||
style={{
|
||
marginBottom: 12,
|
||
color: '#721c24',
|
||
background: '#f8d7da',
|
||
border: '1px solid #f5c6cb',
|
||
borderRadius: 4,
|
||
padding: '8px 12px',
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{errors.api}
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||
<div style={{ flex: 1, minWidth: 260 }}>
|
||
{/* ...Titel, Beschreibung, Datum, Zeit... */}
|
||
<div style={{ marginBottom: 12 }}>
|
||
<TextBoxComponent
|
||
placeholder="Titel"
|
||
floatLabelType="Auto"
|
||
value={title}
|
||
change={e => setTitle(e.value)}
|
||
/>
|
||
{errors.title && <div style={{ color: 'red', fontSize: 12 }}>{errors.title}</div>}
|
||
</div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<TextBoxComponent
|
||
placeholder="Beschreibung"
|
||
floatLabelType="Auto"
|
||
multiline={true}
|
||
value={description}
|
||
change={e => setDescription(e.value)}
|
||
/>
|
||
</div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<DatePickerComponent
|
||
placeholder="Startdatum"
|
||
floatLabelType="Auto"
|
||
value={startDate ?? undefined}
|
||
change={e => setStartDate(e.value)}
|
||
/>
|
||
{errors.startDate && (
|
||
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
|
||
)}
|
||
{isPast && (isSingleOccurrence || !repeat) && (
|
||
<span
|
||
style={{
|
||
color: 'orange',
|
||
fontWeight: 600,
|
||
marginLeft: 8,
|
||
display: 'inline-block',
|
||
background: '#fff3cd',
|
||
borderRadius: 4,
|
||
padding: '2px 8px',
|
||
border: '1px solid #ffeeba',
|
||
}}
|
||
>
|
||
⚠️ Termin liegt in der Vergangenheit!
|
||
</span>
|
||
)}
|
||
{isPast && repeat && !isSingleOccurrence && (
|
||
<span
|
||
style={{
|
||
color: 'blue',
|
||
fontWeight: 600,
|
||
marginLeft: 8,
|
||
display: 'inline-block',
|
||
background: '#e3f2fd',
|
||
borderRadius: 4,
|
||
padding: '2px 8px',
|
||
border: '1px solid #90caf9',
|
||
}}
|
||
>
|
||
ℹ️ Bearbeitung einer Terminserie (Startdatum kann in Vergangenheit liegen)
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<TimePickerComponent
|
||
placeholder="Startzeit"
|
||
floatLabelType="Auto"
|
||
value={startTime}
|
||
step={30}
|
||
change={e => setStartTime(e.value)}
|
||
/>
|
||
{errors.startTime && (
|
||
<div style={{ color: 'red', fontSize: 12 }}>{errors.startTime}</div>
|
||
)}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<TimePickerComponent
|
||
placeholder="Endzeit"
|
||
floatLabelType="Auto"
|
||
value={endTime}
|
||
step={30}
|
||
change={e => setEndTime(e.value)}
|
||
/>
|
||
{errors.endTime && (
|
||
<div style={{ color: 'red', fontSize: 12 }}>{errors.endTime}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 260 }}>
|
||
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
|
||
{isSingleOccurrence && (
|
||
<div style={{ marginBottom: 12, padding: '8px 12px', backgroundColor: '#e8f4fd', borderRadius: 4, border: '1px solid #bee5eb' }}>
|
||
<span style={{ fontSize: '14px', color: '#0c5460', fontWeight: 500 }}>
|
||
ℹ️ Bearbeitung eines Einzeltermins - Wiederholungsoptionen nicht verfügbar
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div style={{ marginBottom: 12 }}>
|
||
<CheckBoxComponent
|
||
label="Wiederholender Termin"
|
||
checked={repeat}
|
||
change={e => setRepeat(e.checked)}
|
||
disabled={isSingleOccurrence}
|
||
/>
|
||
</div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<MultiSelectComponent
|
||
key={repeat ? 'enabled' : 'disabled'}
|
||
dataSource={weekdayOptions}
|
||
fields={{ text: 'label', value: 'value' }}
|
||
placeholder="Wochentage"
|
||
value={weekdays}
|
||
change={e => setWeekdays(e.value as number[])}
|
||
disabled={!repeat || isSingleOccurrence}
|
||
showDropDownIcon={true}
|
||
closePopupOnSelect={false}
|
||
/>
|
||
</div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<DatePickerComponent
|
||
key={repeat ? 'enabled' : 'disabled'}
|
||
placeholder="Wiederholung bis"
|
||
floatLabelType="Auto"
|
||
value={repeatUntil ?? undefined}
|
||
change={e => setRepeatUntil(e.value)}
|
||
disabled={!repeat || isSingleOccurrence}
|
||
/>
|
||
</div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<CheckBoxComponent
|
||
label="Ferientage berücksichtigen"
|
||
checked={skipHolidays}
|
||
change={e => setSkipHolidays(e.checked)}
|
||
disabled={!repeat || isSingleOccurrence}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* NEUER ZWEISPALTIGER BEREICH für Termintyp und Zusatzfelder */}
|
||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginTop: 8 }}>
|
||
<div style={{ flex: 1, minWidth: 260 }}>
|
||
<div style={{ marginBottom: 12, marginTop: 16 }}>
|
||
<DropDownListComponent
|
||
dataSource={typeOptions}
|
||
fields={{ text: 'label', value: 'value' }}
|
||
placeholder="Termintyp"
|
||
value={type}
|
||
change={e => setType(e.value as string)}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
{errors.type && <div style={{ color: 'red', fontSize: 12 }}>{errors.type}</div>}
|
||
</div>
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 260 }}>
|
||
<div style={{ marginBottom: 12, minHeight: 60 }}>
|
||
{type === 'presentation' && (
|
||
<div>
|
||
<div style={{ marginBottom: 8, marginTop: 16 }}>
|
||
<button
|
||
className="e-btn"
|
||
onClick={() => setMediaModalOpen(true)}
|
||
style={{ width: '100%' }}
|
||
>
|
||
Medium auswählen/hochladen
|
||
</button>
|
||
</div>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<b>Ausgewähltes Medium:</b>{' '}
|
||
{media ? (
|
||
media.path
|
||
) : (
|
||
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
|
||
)}
|
||
</div>
|
||
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||
{errors.slideshowInterval && (
|
||
<div style={{ color: 'red', fontSize: 12 }}>{errors.slideshowInterval}</div>
|
||
)}
|
||
<TextBoxComponent
|
||
placeholder="Slideshow-Intervall (Sekunden)"
|
||
floatLabelType="Auto"
|
||
type="number"
|
||
value={String(slideshowInterval)}
|
||
change={e => setSlideshowInterval(Number(e.value))}
|
||
/>
|
||
<div style={{ marginTop: 8 }}>
|
||
<CheckBoxComponent
|
||
label="Seitenfortschritt anzeigen"
|
||
checked={pageProgress}
|
||
change={e => setPageProgress(e.checked || false)}
|
||
/>
|
||
</div>
|
||
<div style={{ marginTop: 8 }}>
|
||
<CheckBoxComponent
|
||
label="Automatischer Fortschritt"
|
||
checked={autoProgress}
|
||
change={e => setAutoProgress(e.checked || false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{type === 'website' && (
|
||
<div>
|
||
<TextBoxComponent
|
||
placeholder="Webseiten-URL"
|
||
floatLabelType="Always"
|
||
value={websiteUrl}
|
||
change={e => setWebsiteUrl(e.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{type === 'video' && (
|
||
<div>
|
||
<div style={{ marginBottom: 8, marginTop: 16 }}>
|
||
<button
|
||
className="e-btn"
|
||
onClick={() => setMediaModalOpen(true)}
|
||
style={{ width: '100%' }}
|
||
>
|
||
Video auswählen/hochladen
|
||
</button>
|
||
</div>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<b>Ausgewähltes Video:</b>{' '}
|
||
{media ? (
|
||
media.path
|
||
) : (
|
||
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
|
||
)}
|
||
</div>
|
||
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||
<div style={{ marginTop: 8 }}>
|
||
<CheckBoxComponent
|
||
label="Automatisch abspielen"
|
||
checked={autoplay}
|
||
change={e => setAutoplay(e.checked || false)}
|
||
/>
|
||
</div>
|
||
<div style={{ marginTop: 8 }}>
|
||
<CheckBoxComponent
|
||
label="In Schleife abspielen"
|
||
checked={loop}
|
||
change={e => setLoop(e.checked || false)}
|
||
/>
|
||
</div>
|
||
<div style={{ marginTop: 8 }}>
|
||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '14px' }}>
|
||
Lautstärke
|
||
</label>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<TextBoxComponent
|
||
placeholder="0.0 - 1.0"
|
||
floatLabelType="Never"
|
||
type="number"
|
||
value={String(volume)}
|
||
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<CheckBoxComponent
|
||
label="Ton aus"
|
||
checked={muted}
|
||
change={e => setMuted(e.checked || false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{mediaModalOpen && (
|
||
<CustomSelectUploadEventModal
|
||
open={mediaModalOpen}
|
||
onClose={() => setMediaModalOpen(false)}
|
||
onSelect={({ id, path, name }) => {
|
||
setMedia({ id, path, name });
|
||
setErrors(prev => {
|
||
if (!prev.media) return prev;
|
||
const next = { ...prev };
|
||
delete next.media;
|
||
return next;
|
||
});
|
||
setMediaModalOpen(false);
|
||
}}
|
||
selectedFileId={null}
|
||
/>
|
||
)}
|
||
</DialogComponent>
|
||
);
|
||
};
|
||
|
||
export default CustomEventModal;
|