Create and save custom events in database
This commit is contained in:
@@ -97,6 +97,7 @@ const Appointments: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalInitialData, setModalInitialData] = useState({});
|
||||
const [schedulerKey, setSchedulerKey] = useState(0);
|
||||
|
||||
// Gruppen laden
|
||||
useEffect(() => {
|
||||
@@ -120,8 +121,8 @@ const Appointments: React.FC = () => {
|
||||
const mapped: Event[] = data.map(e => ({
|
||||
Id: e.Id,
|
||||
Subject: e.Subject,
|
||||
StartTime: new Date(e.StartTime),
|
||||
EndTime: new Date(e.EndTime),
|
||||
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
||||
IsAllDay: e.IsAllDay,
|
||||
}));
|
||||
setEvents(mapped);
|
||||
@@ -166,16 +167,28 @@ const Appointments: React.FC = () => {
|
||||
<CustomEventModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSave={eventData => {
|
||||
console.log('Event-Daten zum Speichern:', eventData);
|
||||
// Event speichern (API-Aufruf oder State-Update)
|
||||
onSave={async eventData => {
|
||||
setModalOpen(false);
|
||||
console.log('onSave wird aufgerufen');
|
||||
if (selectedGroupId) {
|
||||
const data = await fetchEvents(selectedGroupId);
|
||||
console.log('Events nach Save:', data);
|
||||
const mapped: Event[] = data.map(e => ({
|
||||
Id: e.Id,
|
||||
Subject: e.Subject,
|
||||
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
||||
IsAllDay: e.IsAllDay,
|
||||
}));
|
||||
setEvents(mapped);
|
||||
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
|
||||
}
|
||||
}}
|
||||
initialData={modalInitialData}
|
||||
groupName={groups.find(g => g.id === selectedGroupId)?.name ?? ''}
|
||||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||||
/>
|
||||
<ScheduleComponent
|
||||
key={selectedGroupId}
|
||||
key={schedulerKey} // <-- dynamischer Key
|
||||
height="750px"
|
||||
locale="de"
|
||||
currentView="Week"
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
type CustomEventData = {
|
||||
title: string;
|
||||
@@ -21,9 +22,9 @@ type CustomEventData = {
|
||||
type CustomEventModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (event: CustomEventData) => void;
|
||||
initialData?: Partial<CustomEventData>;
|
||||
groupName?: string; // <--- NEU
|
||||
onSave: (eventData: any) => void;
|
||||
initialData?: any;
|
||||
groupName: string | { id: string | null; name: string }; // <- angepasst
|
||||
};
|
||||
|
||||
const weekdayOptions = [
|
||||
@@ -42,7 +43,6 @@ const typeOptions = [
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'message', label: 'Nachricht' },
|
||||
{ value: 'webuntis', label: 'WebUntis' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
@@ -65,30 +65,57 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
|
||||
const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false);
|
||||
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
||||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(null);
|
||||
const [pendingMedia, setPendingMedia] = React.useState<{
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [slideshowInterval, setSlideshowInterval] = React.useState<number>(10);
|
||||
const [websiteUrl, setWebsiteUrl] = React.useState<string>('');
|
||||
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && initialData) {
|
||||
if (open) {
|
||||
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 || '');
|
||||
setType(initialData.type ?? 'presentation'); // Immer 'presentation' als Default
|
||||
setDescription(initialData.description || '');
|
||||
setRepeat(initialData.repeat || false);
|
||||
setWeekdays(initialData.weekdays || []);
|
||||
setRepeatUntil(initialData.repeatUntil || null);
|
||||
setSkipHolidays(initialData.skipHolidays || false);
|
||||
setMedia(null);
|
||||
setSlideshowInterval(10);
|
||||
setWebsiteUrl('');
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const handleSave = () => {
|
||||
React.useEffect(() => {
|
||||
if (!mediaModalOpen && pendingMedia) {
|
||||
setMedia(pendingMedia);
|
||||
setPendingMedia(null);
|
||||
}
|
||||
}, [mediaModalOpen, pendingMedia]);
|
||||
|
||||
const handleSave = async () => {
|
||||
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';
|
||||
// ggf. weitere Felder prüfen
|
||||
|
||||
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 (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
@@ -96,22 +123,71 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
onSave({
|
||||
|
||||
// group_id ist jetzt wirklich die ID (z.B. als prop übergeben)
|
||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||
|
||||
// Daten für das Backend zusammenstellen
|
||||
const payload: any = {
|
||||
group_id,
|
||||
title,
|
||||
startDate,
|
||||
startTime,
|
||||
endTime,
|
||||
type,
|
||||
description,
|
||||
repeat,
|
||||
weekdays,
|
||||
repeatUntil,
|
||||
skipHolidays,
|
||||
});
|
||||
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,
|
||||
event_type: type,
|
||||
is_active: 1,
|
||||
created_by: 1,
|
||||
};
|
||||
|
||||
if (type === 'presentation') {
|
||||
payload.event_media_id = media?.id;
|
||||
payload.slideshow_interval = slideshowInterval;
|
||||
}
|
||||
|
||||
if (type === 'website') {
|
||||
payload.website_url = websiteUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
onSave(payload); // <--- HIER ergänzen!
|
||||
onClose();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
setErrors({ api: err.error || 'Fehler beim Speichern' });
|
||||
}
|
||||
} catch {
|
||||
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
||||
}
|
||||
console.log('handleSave called');
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogComponent
|
||||
target="#root"
|
||||
visible={open}
|
||||
width="800px"
|
||||
header={() => (
|
||||
@@ -119,7 +195,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
Neuen Termin anlegen
|
||||
{groupName && (
|
||||
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#888' }}>
|
||||
für Raumgruppe: <b>{groupName}</b>
|
||||
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,6 +217,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
<div style={{ padding: '24px' }}>
|
||||
<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"
|
||||
@@ -196,18 +273,9 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<DropDownListComponent
|
||||
dataSource={typeOptions}
|
||||
fields={{ text: 'label', value: 'value' }}
|
||||
placeholder="Termintyp"
|
||||
value={type}
|
||||
change={e => setType(e.value as string)}
|
||||
/>
|
||||
{errors.type && <div style={{ color: 'red', fontSize: 12 }}>{errors.type}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CheckBoxComponent
|
||||
label="Wiederholender Termin"
|
||||
@@ -248,7 +316,77 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
</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>
|
||||
<TextBoxComponent
|
||||
placeholder="Slideshow-Intervall (Sekunden)"
|
||||
floatLabelType="Auto"
|
||||
type="number"
|
||||
value={String(slideshowInterval)}
|
||||
change={e => setSlideshowInterval(Number(e.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === 'website' && (
|
||||
<div>
|
||||
<TextBoxComponent
|
||||
placeholder="Webseiten-URL"
|
||||
floatLabelType="Always"
|
||||
value={websiteUrl}
|
||||
change={e => setWebsiteUrl(e.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mediaModalOpen && (
|
||||
<CustomSelectUploadEventModal
|
||||
open={mediaModalOpen}
|
||||
onClose={() => setMediaModalOpen(false)}
|
||||
onSelect={({ id, path, name }) => {
|
||||
setPendingMedia({ id, path, name });
|
||||
setMediaModalOpen(false);
|
||||
}}
|
||||
selectedFileId={null}
|
||||
/>
|
||||
)}
|
||||
</DialogComponent>
|
||||
);
|
||||
};
|
||||
|
||||
119
dashboard/src/components/CustomSelectUploadEventModal.tsx
Normal file
119
dashboard/src/components/CustomSelectUploadEventModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||
import {
|
||||
FileManagerComponent,
|
||||
Inject,
|
||||
NavigationPane,
|
||||
DetailsView,
|
||||
Toolbar,
|
||||
} from '@syncfusion/ej2-react-filemanager';
|
||||
|
||||
const hostUrl = '/api/eventmedia/filemanager/';
|
||||
|
||||
type CustomSelectUploadEventModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (file: { id: string; path: string; name: string }) => void; // name ergänzt
|
||||
selectedFileId?: string | null;
|
||||
};
|
||||
|
||||
const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps> = props => {
|
||||
const { open, onClose, onSelect } = props;
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<{
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
// Callback für Dateiauswahl
|
||||
interface FileSelectEventArgs {
|
||||
fileDetails: {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
size: number;
|
||||
// weitere Felder falls benötigt
|
||||
};
|
||||
}
|
||||
|
||||
const handleFileSelect = async (args: FileSelectEventArgs) => {
|
||||
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
|
||||
const filename = args.fileDetails.name;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/eventmedia/find_by_filename?filename=${encodeURIComponent(filename)}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
|
||||
} else {
|
||||
setSelectedFile({ id: filename, path: filename, name: filename });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching file details:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Button-Handler
|
||||
const handleSelectClick = () => {
|
||||
if (selectedFile) {
|
||||
onSelect(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogComponent
|
||||
target="#root"
|
||||
visible={open}
|
||||
width="700px"
|
||||
header="Medium auswählen/hochladen"
|
||||
showCloseIcon={true}
|
||||
close={onClose}
|
||||
isModal={true}
|
||||
footerTemplate={() => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="e-btn" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button className="e-btn e-primary" disabled={!selectedFile} onClick={handleSelectClick}>
|
||||
Auswählen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<FileManagerComponent
|
||||
ajaxSettings={{
|
||||
url: hostUrl + 'operations',
|
||||
getImageUrl: hostUrl + 'get-image',
|
||||
uploadUrl: hostUrl + 'upload',
|
||||
downloadUrl: hostUrl + 'download',
|
||||
}}
|
||||
toolbarSettings={{
|
||||
items: [
|
||||
'NewFolder',
|
||||
'Upload',
|
||||
'Download',
|
||||
'Rename',
|
||||
'Delete',
|
||||
'SortBy',
|
||||
'Refresh',
|
||||
'Details',
|
||||
],
|
||||
}}
|
||||
contextMenuSettings={{
|
||||
file: ['Open', '|', 'Download', '|', 'Rename', 'Delete', '|', 'Details'],
|
||||
folder: ['Open', '|', 'Rename', 'Delete', '|', 'Details'],
|
||||
layout: ['SortBy', 'Refresh', '|', 'View', 'Details'],
|
||||
}}
|
||||
allowMultiSelection={false}
|
||||
fileSelect={handleFileSelect}
|
||||
>
|
||||
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
||||
</FileManagerComponent>
|
||||
</DialogComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSelectUploadEventModal;
|
||||
@@ -143,3 +143,23 @@ def update_media(media_id):
|
||||
# Event-Zuordnung ggf. ergänzen
|
||||
session.commit()
|
||||
return jsonify(media.to_dict())
|
||||
|
||||
|
||||
@eventmedia_bp.route('/find_by_filename', methods=['GET'])
|
||||
def find_by_filename():
|
||||
filename = request.args.get('filename')
|
||||
if not filename:
|
||||
return jsonify({'error': 'Missing filename'}), 400
|
||||
session = Session()
|
||||
# Suche nach exaktem Dateinamen in url oder file_path
|
||||
media = session.query(EventMedia).filter(
|
||||
(EventMedia.url == filename) | (
|
||||
EventMedia.file_path.like(f"%{filename}"))
|
||||
).first()
|
||||
if not media:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return jsonify({
|
||||
'id': media.id,
|
||||
'file_path': media.file_path,
|
||||
'url': media.url
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from database import Session
|
||||
from sqlalchemy import and_
|
||||
from models import Event
|
||||
from flask import Blueprint, request, jsonify
|
||||
from database import Session
|
||||
from models import Event, EventMedia, MediaType
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import and_
|
||||
import sys
|
||||
sys.path.append('/workspace')
|
||||
|
||||
@@ -47,3 +48,70 @@ def delete_event(event_id):
|
||||
session.commit()
|
||||
session.close()
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@events_bp.route("", methods=["POST"])
|
||||
def create_event():
|
||||
data = request.json
|
||||
session = Session()
|
||||
|
||||
# Pflichtfelder prüfen
|
||||
required = ["group_id", "title", "description",
|
||||
"start", "end", "event_type", "created_by"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
return jsonify({"error": f"Missing field: {field}"}), 400
|
||||
|
||||
event_type = data["event_type"]
|
||||
event_media_id = None
|
||||
slideshow_interval = None
|
||||
|
||||
# Präsentation: event_media_id und slideshow_interval übernehmen
|
||||
if event_type == "presentation":
|
||||
event_media_id = data.get("event_media_id")
|
||||
slideshow_interval = data.get("slideshow_interval")
|
||||
if not event_media_id:
|
||||
return jsonify({"error": "event_media_id required for presentation"}), 400
|
||||
|
||||
# Website: Webseite als EventMedia anlegen und ID übernehmen
|
||||
if event_type == "website":
|
||||
website_url = data.get("website_url")
|
||||
if not website_url:
|
||||
return jsonify({"error": "website_url required for website"}), 400
|
||||
# EventMedia für Webseite anlegen
|
||||
media = EventMedia(
|
||||
media_type=MediaType.website,
|
||||
url=website_url,
|
||||
file_path=website_url
|
||||
)
|
||||
session.add(media)
|
||||
session.commit()
|
||||
event_media_id = media.id
|
||||
|
||||
# created_by aus den Daten holen, Default: None
|
||||
created_by = data.get("created_by")
|
||||
|
||||
# Start- und Endzeit in UTC umwandeln, falls kein Zulu-Zeitstempel
|
||||
start = datetime.fromisoformat(data["start"])
|
||||
end = datetime.fromisoformat(data["end"])
|
||||
if start.tzinfo is None:
|
||||
start = start.astimezone(timezone.utc)
|
||||
if end.tzinfo is None:
|
||||
end = end.astimezone(timezone.utc)
|
||||
|
||||
# Event anlegen
|
||||
event = Event(
|
||||
group_id=data["group_id"],
|
||||
title=data["title"],
|
||||
description=data["description"],
|
||||
start=start,
|
||||
end=end,
|
||||
event_type=event_type,
|
||||
is_active=True,
|
||||
event_media_id=event_media_id,
|
||||
slideshow_interval=slideshow_interval,
|
||||
created_by=created_by # <--- HIER hinzugefügt
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return jsonify({"success": True, "event_id": event.id})
|
||||
|
||||
Reference in New Issue
Block a user