feat: add Ressourcen timeline view with group ordering (alpha.14)

- New timeline page showing all groups and active events in parallel
- Group order API endpoints with persistence (GET/POST /api/groups/order)
- Customizable group ordering with visual controls
- Fix CSS and TypeScript lint errors
- Update documentation and bump version to 2026.1.0-alpha.14
This commit is contained in:
RobbStarkAustria
2026-01-28 18:59:11 +00:00
parent 10f446dfb5
commit 7746e26385
13 changed files with 2487 additions and 665 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ScheduleComponent,
Day,
@@ -198,6 +198,17 @@ const Appointments: React.FC = () => {
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
const getWeekMonday = (date: Date): Date => {
const d = new Date(date);
const day = d.getDay();
const diffToMonday = (day + 6) % 7; // Monday = 0
d.setDate(d.getDate() - diffToMonday);
d.setHours(12, 0, 0, 0); // use noon to avoid TZ shifting back a day
return d;
};
const [selectedDate, setSelectedDate] = useState<Date>(() => getWeekMonday(new Date()));
const navigationSynced = useRef(false);
// Confirmation dialog state
@@ -681,6 +692,7 @@ const Appointments: React.FC = () => {
change={async (e: { value: number }) => {
const id = Number(e.value);
if (!id) return;
if (activePeriodId === id) return; // avoid firing on initial mount
try {
const updated = await setActiveAcademicPeriod(id);
setActivePeriodId(updated.id);
@@ -692,6 +704,7 @@ const Appointments: React.FC = () => {
scheduleRef.current.selectedDate = target;
scheduleRef.current.dataBind?.();
}
setSelectedDate(target);
updateHolidaysInView();
} catch (err) {
console.error('Aktive Periode setzen fehlgeschlagen:', err);
@@ -814,8 +827,6 @@ const Appointments: React.FC = () => {
// The CustomEventModal already handled the API calls internally
// For now, just refresh the data (the recurring event logic is handled in the modal itself)
console.log('Modal operation completed, refreshing data');
setModalOpen(false);
setEditMode(false);
@@ -826,8 +837,6 @@ const Appointments: React.FC = () => {
setTimeout(() => {
scheduleRef.current?.refreshEvents?.();
}, 0);
console.log('Modal save cycle completed - data refreshed');
}}
initialData={modalInitialData}
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
@@ -835,10 +844,21 @@ const Appointments: React.FC = () => {
editMode={editMode} // NEU: Prop für Editiermodus
/>
<ScheduleComponent
key={`scheduler-${selectedDate.toISOString().slice(0, 10)}`}
ref={scheduleRef}
height="750px"
locale="de"
currentView="Week"
firstDayOfWeek={1}
enablePersistence={false}
selectedDate={selectedDate}
created={() => {
const inst = scheduleRef.current;
if (inst && selectedDate) {
inst.selectedDate = selectedDate;
inst.dataBind?.();
}
}}
eventSettings={{
dataSource: dataSource,
fields: {
@@ -857,6 +877,17 @@ const Appointments: React.FC = () => {
updateHolidaysInView();
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
if (!navigationSynced.current) {
navigationSynced.current = true;
if (scheduleRef.current && selectedDate) {
scheduleRef.current.selectedDate = selectedDate;
scheduleRef.current.dataBind?.();
}
return;
}
if (scheduleRef.current?.selectedDate) {
setSelectedDate(new Date(scheduleRef.current.selectedDate));
}
fetchAndSetEvents();
return;
}
@@ -1284,7 +1315,6 @@ const Appointments: React.FC = () => {
}
}
}}
firstDayOfWeek={1}
renderCell={(args: RenderCellEventArgs) => {
// Nur für Arbeitszellen (Stunden-/Tageszellen)
if (args.elementType === 'workCells') {

View File

@@ -141,6 +141,25 @@ const Infoscreen_groups: React.FC = () => {
]);
setNewGroupName('');
setShowDialog(false);
// Update group order to include the new group
try {
const orderResponse = await fetch('/api/groups/order');
if (orderResponse.ok) {
const orderData = await orderResponse.json();
const currentOrder = orderData.order || [];
// Add new group ID to the end if not already present
if (!currentOrder.includes(newGroup.id)) {
await fetch('/api/groups/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: [...currentOrder, newGroup.id] }),
});
}
}
} catch (err) {
console.error('Failed to update group order:', err);
}
} catch (err) {
toast.show({
content: (err as Error).message,
@@ -154,6 +173,10 @@ const Infoscreen_groups: React.FC = () => {
// Löschen einer Gruppe
const handleDeleteGroup = async (groupName: string) => {
try {
// Find the group ID before deleting
const groupToDelete = groups.find(g => g.headerText === groupName);
const deletedGroupId = groupToDelete?.id;
// Clients der Gruppe in "Nicht zugeordnet" verschieben
const groupClients = clients.filter(c => c.Status === groupName);
if (groupClients.length > 0) {
@@ -172,6 +195,27 @@ const Infoscreen_groups: React.FC = () => {
timeOut: 5000,
showCloseButton: false,
});
// Update group order to remove the deleted group
if (deletedGroupId) {
try {
const orderResponse = await fetch('/api/groups/order');
if (orderResponse.ok) {
const orderData = await orderResponse.json();
const currentOrder = orderData.order || [];
// Remove deleted group ID from order
const updatedOrder = currentOrder.filter((id: number) => id !== deletedGroupId);
await fetch('/api/groups/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: updatedOrder }),
});
}
} catch (err) {
console.error('Failed to update group order:', err);
}
}
// Gruppen und Clients neu laden
const groupData = await fetchGroups();
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name]));

View File

@@ -0,0 +1,177 @@
/* Ressourcen - Timeline Schedule Styles */
.ressourcen-container {
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
}
.ressourcen-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 20px;
color: #333;
}
.ressourcen-controls {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 30px;
align-items: center;
background-color: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.ressourcen-control-group {
display: flex;
align-items: center;
gap: 10px;
}
.ressourcen-label {
font-weight: 500;
color: #555;
white-space: nowrap;
}
.ressourcen-button-group {
display: flex;
gap: 8px;
}
.ressourcen-button {
border-radius: 4px !important;
font-weight: 500;
}
/* Group Order Panel */
.ressourcen-order-panel {
background: white;
padding: 15px;
margin-bottom: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.ressourcen-order-header {
width: 100%;
}
.ressourcen-order-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 250px;
overflow-y: auto;
padding: 8px;
background-color: #f9f9f9;
border-radius: 4px;
}
.ressourcen-order-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 13px;
}
.ressourcen-order-position {
font-weight: 600;
color: #666;
min-width: 24px;
text-align: right;
}
.ressourcen-order-name {
flex: 1;
color: #333;
}
.ressourcen-order-buttons {
display: flex;
gap: 4px;
}
.ressourcen-order-buttons .e-btn {
min-width: 32px !important;
}
.ressourcen-loading {
text-align: center;
padding: 40px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.ressourcen-loading p {
font-size: 16px;
color: #666;
}
.ressourcen-timeline-wrapper {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Scheduler Timeline Styling */
.e-schedule {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}
.e-schedule .e-timeline-view {
border: none;
}
.e-schedule .e-date-header {
background-color: #f9f9f9;
border-bottom: 1px solid #e0e0e0;
}
.e-schedule .e-header-cells {
font-weight: 600;
color: #333;
}
.ressourcen-timeline-wrapper .e-schedule {
flex: 1;
height: 100% !important;
}
.e-schedule .e-work-cells {
background-color: #fafafa;
border-color: #f0f0f0;
}
/* Set compact row height */
.e-schedule .e-timeline-view .e-content-wrap table tbody tr {
height: 65px;
}
.e-schedule .e-timeline-view .e-content-wrap .e-work-cells {
height: 65px;
}
/* Event bar styling */
.e-schedule .e-appointment {
border-radius: 4px;
color: white;
line-height: normal;
}
.e-schedule .e-appointment .e-subject {
font-size: 12px;
font-weight: 500;
}

View File

@@ -1,8 +1,356 @@
import React from 'react';
const Ressourcen: React.FC = () => (
<div>
<h2 className="text-xl font-bold mb-4">Ressourcen</h2>
<p>Willkommen im Infoscreen-Management Ressourcen.</p>
</div>
);
import React, { useEffect, useState } from 'react';
import {
ScheduleComponent,
ViewsDirective,
ViewDirective,
Inject,
TimelineViews,
Resize,
DragAndDrop,
ResourcesDirective,
ResourceDirective,
} from '@syncfusion/ej2-react-schedule';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { fetchGroupsWithClients, type Group } from './apiClients';
import { fetchEvents } from './apiEvents';
import { getGroupColor } from './groupColors';
import './ressourcen.css';
interface ScheduleEvent {
Id: number;
Subject: string;
StartTime: Date;
EndTime: Date;
ResourceId: number;
EventType?: string;
}
type TimelineView = 'day' | 'week';
const Ressourcen: React.FC = () => {
const [scheduleData, setScheduleData] = useState<ScheduleEvent[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [groupOrder, setGroupOrder] = useState<number[]>([]);
const [showOrderPanel, setShowOrderPanel] = useState<boolean>(false);
const [timelineView] = useState<TimelineView>('day');
const [viewDate] = useState<Date>(() => {
const now = new Date();
now.setHours(0, 0, 0, 0);
return now;
});
const [loading, setLoading] = useState<boolean>(false);
const scheduleRef = React.useRef<ScheduleComponent>(null);
// Calculate dynamic height based on number of groups
const calculatedHeight = React.useMemo(() => {
const rowHeight = 65; // px per row
const headerHeight = 100; // approx header height
const totalHeight = groups.length * rowHeight + headerHeight;
return `${totalHeight}px`;
}, [groups.length]);
// Load groups on mount
useEffect(() => {
const loadGroups = async () => {
try {
console.log('[Ressourcen] Loading groups...');
const fetchedGroups = await fetchGroupsWithClients();
console.log('[Ressourcen] Fetched groups:', fetchedGroups);
// Filter out "Nicht zugeordnet" but show all other groups even if empty
const filteredGroups = fetchedGroups.filter(
(group) => group.name !== 'Nicht zugeordnet'
);
console.log('[Ressourcen] Filtered groups:', filteredGroups);
setGroups(filteredGroups);
} catch (error) {
console.error('Fehler beim Laden der Gruppen:', error);
}
};
loadGroups();
}, []);
// Helper: Parse ISO date string
const parseUTCDate = React.useCallback((dateStr: string): Date => {
const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
return new Date(utcStr);
}, []);
// Calculate date range based on view
const getDateRange = React.useCallback((): { start: Date; end: Date } => {
const start = new Date(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
if (timelineView === 'day') {
end.setHours(23, 59, 59, 999);
} else if (timelineView === 'week') {
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
}
return { start, end };
}, [viewDate, timelineView]);
// Load events for all groups
useEffect(() => {
if (groups.length === 0) {
console.log('[Ressourcen] No groups to load events for');
setScheduleData([]);
return;
}
const loadEventsForAllGroups = async () => {
setLoading(true);
console.log('[Ressourcen] Loading events for', groups.length, 'groups');
try {
const { start, end } = getDateRange();
const events: ScheduleEvent[] = [];
let eventId = 1;
// Create events for each group
for (const group of groups) {
try {
console.log(`[Ressourcen] Fetching events for group "${group.name}" (ID: ${group.id})`);
const apiEvents = await fetchEvents(group.id.toString(), false, {
start,
end,
});
console.log(`[Ressourcen] Got ${apiEvents?.length || 0} events for group "${group.name}"`);
if (Array.isArray(apiEvents) && apiEvents.length > 0) {
const event = apiEvents[0];
const eventTitle = event.subject || event.title || 'Unnamed Event';
const eventType = event.type || event.event_type || 'other';
const eventStart = event.startTime || event.start;
const eventEnd = event.endTime || event.end;
if (eventStart && eventEnd) {
const parsedStart = parseUTCDate(eventStart);
const parsedEnd = parseUTCDate(eventEnd);
// Capitalize first letter of event type
const formattedType = eventType.charAt(0).toUpperCase() + eventType.slice(1);
events.push({
Id: eventId++,
Subject: `${formattedType} - ${eventTitle}`,
StartTime: parsedStart,
EndTime: parsedEnd,
ResourceId: group.id,
EventType: eventType,
});
console.log(`[Ressourcen] Group "${group.name}" has event: ${eventTitle}`);
}
}
} catch (error) {
console.error(`Fehler beim Laden von Ereignissen für Gruppe ${group.name}:`, error);
}
}
console.log('[Ressourcen] Final events:', events);
setScheduleData(events);
} finally {
setLoading(false);
}
};
loadEventsForAllGroups();
}, [groups, timelineView, viewDate, parseUTCDate, getDateRange]);
// Load saved group order from backend on mount
useEffect(() => {
const loadGroupOrder = async () => {
try {
console.log('[Ressourcen] Loading saved group order from backend...');
const response = await fetch('/api/groups/order');
if (response.ok) {
const data = await response.json();
console.log('[Ressourcen] Retrieved group order:', data);
if (data.order && Array.isArray(data.order)) {
// Filter order to only include IDs that exist in current groups
const existingGroupIds = groups.map(g => g.id);
const validOrder = data.order.filter((id: number) => existingGroupIds.includes(id));
// Add any missing group IDs that aren't in the saved order
const missingIds = existingGroupIds.filter(id => !validOrder.includes(id));
const finalOrder = [...validOrder, ...missingIds];
console.log('[Ressourcen] Synced order:', finalOrder);
setGroupOrder(finalOrder);
} else {
// No saved order, use default (current group order)
setGroupOrder(groups.map(g => g.id));
}
} else {
console.log('[Ressourcen] No saved order found, using default');
setGroupOrder(groups.map(g => g.id));
}
} catch (error) {
console.error('[Ressourcen] Error loading group order:', error);
// Fall back to default order
setGroupOrder(groups.map(g => g.id));
}
};
if (groups.length > 0 && groupOrder.length === 0) {
loadGroupOrder();
}
}, [groups, groupOrder.length]);
// Move group up in order
const moveGroupUp = (groupId: number) => {
const index = groupOrder.indexOf(groupId);
if (index > 0) {
const newOrder = [...groupOrder];
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
setGroupOrder(newOrder);
}
};
// Move group down in order
const moveGroupDown = (groupId: number) => {
const index = groupOrder.indexOf(groupId);
if (index < groupOrder.length - 1) {
const newOrder = [...groupOrder];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
setGroupOrder(newOrder);
}
};
// Save group order to backend
const saveGroupOrder = async () => {
try {
console.log('[Ressourcen] Saving group order:', groupOrder);
const response = await fetch('/api/groups/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: groupOrder }),
});
if (!response.ok) throw new Error('Failed to save group order');
console.log('[Ressourcen] Group order saved successfully');
} catch (error) {
console.error('Fehler beim Speichern der Reihenfolge:', error);
}
};
// Get sorted groups based on current order
const sortedGroups = React.useMemo(() => {
if (groupOrder.length === 0) return groups;
// Map order to actual groups
const ordered = groupOrder
.map(id => groups.find(g => g.id === id))
.filter((g): g is Group => g !== undefined);
// Add any groups not in the order (new groups)
const orderedIds = new Set(ordered.map(g => g.id));
const unordered = groups.filter(g => !orderedIds.has(g.id));
return [...ordered, ...unordered];
}, [groups, groupOrder]);
return (
<div className="ressourcen-container">
<h1 className="ressourcen-title">📊 Ressourcen - Übersicht</h1>
<div style={{ marginBottom: '15px' }}>
<ButtonComponent
cssClass={showOrderPanel ? 'e-success' : 'e-outline'}
onClick={() => setShowOrderPanel(!showOrderPanel)}
>
{showOrderPanel ? '✓ Reihenfolge' : 'Reihenfolge ändern'}
</ButtonComponent>
</div>
{/* Group Order Control Panel */}
{showOrderPanel && (
<div className="ressourcen-order-panel">
<div className="ressourcen-order-header">
<h3 style={{ margin: '0 0 12px 0', fontSize: '14px', fontWeight: 600 }}>
📋 Reihenfolge der Gruppen
</h3>
<div className="ressourcen-order-list">
{sortedGroups.map((group, index) => (
<div key={group.id} className="ressourcen-order-item">
<span className="ressourcen-order-position">{index + 1}.</span>
<span className="ressourcen-order-name">{group.name}</span>
<div className="ressourcen-order-buttons">
<ButtonComponent
cssClass="e-outline e-small"
onClick={() => moveGroupUp(group.id)}
disabled={index === 0}
title="Nach oben"
style={{ padding: '4px 8px', minWidth: '32px' }}
>
</ButtonComponent>
<ButtonComponent
cssClass="e-outline e-small"
onClick={() => moveGroupDown(group.id)}
disabled={index === sortedGroups.length - 1}
title="Nach unten"
style={{ padding: '4px 8px', minWidth: '32px' }}
>
</ButtonComponent>
</div>
</div>
))}
</div>
<ButtonComponent
cssClass="e-success"
onClick={saveGroupOrder}
style={{ marginTop: '12px', width: '100%' }}
>
💾 Reihenfolge speichern
</ButtonComponent>
</div>
</div>
)}
{/* Timeline Schedule */}
{loading ? (
<div className="ressourcen-loading">
<p>Wird geladen...</p>
</div>
) : (
<div className="ressourcen-timeline-wrapper">
<ScheduleComponent
ref={scheduleRef}
height={calculatedHeight}
width="100%"
eventSettings={{ dataSource: scheduleData }}
selectedDate={viewDate}
currentView={timelineView === 'day' ? 'TimelineDay' : 'TimelineWeek'}
group={{ resources: ['Groups'], allowGroupEdit: false }}
timeScale={{ interval: 60, slotCount: 1 }}
rowAutoHeight={false}
>
<ViewsDirective>
<ViewDirective option="TimelineDay" displayName="Tag"></ViewDirective>
<ViewDirective option="TimelineWeek" displayName="Woche"></ViewDirective>
</ViewsDirective>
<ResourcesDirective>
<ResourceDirective
field="ResourceId"
title="Gruppe"
name="Groups"
allowMultiple={false}
dataSource={sortedGroups.map((g) => ({
text: g.name,
id: g.id,
color: getGroupColor(g.id.toString(), groups.map(grp => ({ id: grp.id.toString() }))),
}))}
textField="text"
idField="id"
colorField="color"
/>
</ResourcesDirective>
<Inject services={[TimelineViews, Resize, DragAndDrop]} />
</ScheduleComponent>
</div>
)}
</div>
);
};
export default Ressourcen;

View File

@@ -9,8 +9,6 @@ import {
Toolbar,
Edit,
CommandColumn,
type EditSettingsModel,
type CommandModel,
} from '@syncfusion/ej2-react-grids';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { DialogComponent } from '@syncfusion/ej2-react-popups';