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

@@ -28,9 +28,12 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- `scheduler/scheduler.py` — scheduler main loop and MQTT publisher - `scheduler/scheduler.py` — scheduler main loop and MQTT publisher
- `server/routes/eventmedia.py` — file uploads, streaming endpoint - `server/routes/eventmedia.py` — file uploads, streaming endpoint
- `server/routes/events.py` — event CRUD and recurrence handling - `server/routes/events.py` — event CRUD and recurrence handling
- `server/routes/groups.py` — group management, alive status, display order persistence
- `dashboard/src/components/CustomEventModal.tsx` — event creation UI - `dashboard/src/components/CustomEventModal.tsx` — event creation UI
- `dashboard/src/media.tsx` — FileManager / upload settings - `dashboard/src/media.tsx` — FileManager / upload settings
- `dashboard/src/settings.tsx` — settings UI (nested tabs; system defaults for presentations and videos) - `dashboard/src/settings.tsx` — settings UI (nested tabs; system defaults for presentations and videos)
- `dashboard/src/ressourcen.tsx` — timeline view showing all groups' active events in parallel
- `dashboard/src/ressourcen.css` — timeline and resource view styling
@@ -51,7 +54,21 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
## Recent changes since last commit ## Recent changes since last commit
### Latest (November 2025) ### Latest (January 2026)
- **Ressourcen Page (Timeline View)**:
- New 'Ressourcen' page with parallel timeline view showing active events for all room groups
- Compact timeline display with adjustable row height (65px per group)
- Real-time view of currently running events with type, title, and time window
- Customizable group ordering with visual reordering panel (drag up/down buttons)
- Group order persisted via `GET/POST /api/groups/order` endpoints
- Color-coded event bars matching group theme
- Timeline modes: Day and Week views (day view by default)
- Dynamic height calculation based on number of groups
- Syncfusion ScheduleComponent with TimelineViews, Resize, and DragAndDrop support
- Files: `dashboard/src/ressourcen.tsx` (page), `dashboard/src/ressourcen.css` (styles)
### Earlier (November 2025)
- **API Naming Convention Standardization (camelCase)**: - **API Naming Convention Standardization (camelCase)**:
- Backend: Created `server/serializers.py` with `dict_to_camel_case()` utility for consistent JSON serialization - Backend: Created `server/serializers.py` with `dict_to_camel_case()` utility for consistent JSON serialization
@@ -155,8 +172,8 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- Session usage: instantiate `Session()` per request, commit when mutating, and always `session.close()` before returning. - Session usage: instantiate `Session()` per request, commit when mutating, and always `session.close()` before returning.
- Examples: - Examples:
- Clients: `server/routes/clients.py` includes bulk group updates and MQTT sync (`publish_multiple_client_groups`). - Clients: `server/routes/clients.py` includes bulk group updates and MQTT sync (`publish_multiple_client_groups`).
- Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. - Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. - `GET /api/groups/order` — retrieve saved group display order
- Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. Recurring events are only deactivated after their recurrence_end (UNTIL); non-recurring events deactivate after their end time. Event exceptions are respected and rendered in scheduler output. - `POST /api/groups/order` — persist group display order (array of group IDs) - Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. Recurring events are only deactivated after their recurrence_end (UNTIL); non-recurring events deactivate after their end time. Event exceptions are respected and rendered in scheduler output.
- Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`. - Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`.
- System settings: `server/routes/system_settings.py` exposes keyvalue CRUD (`/api/system-settings`) and a convenience endpoint for WebUntis/Vertretungsplan supplement-table: `GET/POST /api/system-settings/supplement-table` (admin+). - System settings: `server/routes/system_settings.py` exposes keyvalue CRUD (`/api/system-settings`) and a convenience endpoint for WebUntis/Vertretungsplan supplement-table: `GET/POST /api/system-settings/supplement-table` (admin+).
- Academic periods: `server/routes/academic_periods.py` exposes: - Academic periods: `server/routes/academic_periods.py` exposes:
@@ -264,6 +281,18 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- Auto-refresh every 15 seconds; manual refresh button available - Auto-refresh every 15 seconds; manual refresh button available
- "Nicht zugeordnet" group always appears last in sorted list - "Nicht zugeordnet" group always appears last in sorted list
- Ressourcen page (`dashboard/src/ressourcen.tsx`):
- Timeline view showing all groups and their active events in parallel
- Uses Syncfusion ScheduleComponent with TimelineViews (day/week modes)
- Compact row display: 65px height per group, dynamically calculated total height
- Group ordering panel with drag up/down controls; order persisted to backend via `/api/groups/order`
- Filters out "Nicht zugeordnet" group from timeline display
- Fetches events per group for current date range; displays first active event per group
- Color-coded event bars using `getGroupColor()` from `groupColors.ts`
- Resource-based timeline: each group is a resource row, events mapped to `ResourceId`
- Real-time updates: loads events on mount and when view/date changes
- Custom CSS in `dashboard/src/ressourcen.css` for timeline styling and controls
- User dropdown technical notes: - User dropdown technical notes:
- Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed. - Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed.
- Vite: add both to `optimizeDeps.include` in `vite.config.ts` to avoid import-analysis errors. - Vite: add both to `optimizeDeps.include` in `vite.config.ts` to avoid import-analysis errors.

View File

@@ -330,6 +330,8 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- `GET /api/clients` - List all registered clients - `GET /api/clients` - List all registered clients
- `PUT /api/clients/{uuid}/group` - Assign client to group - `PUT /api/clients/{uuid}/group` - Assign client to group
- `GET /api/groups` - List client groups with alive status - `GET /api/groups` - List client groups with alive status
- `GET /api/groups/order` - Get saved group display order
- `POST /api/groups/order` - Save group display order (array of group IDs)
- `GET /api/events` - List events with filtering - `GET /api/events` - List events with filtering
- `POST /api/events` - Create new event - `POST /api/events` - Create new event
- `POST /api/events/{id}/occurrences/{date}/detach` - Detach single occurrence from recurring series - `POST /api/events/{id}/occurrences/{date}/detach` - Detach single occurrence from recurring series
@@ -434,6 +436,14 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- 🗓️ Events (admin+): WebUntis/Vertretungsplan URL enable/disable, save, preview. Presentations: general defaults for slideshow interval, page-progress, and auto-progress; persisted via `/api/system-settings` keys and applied on create in the event modal. Videos: system-wide defaults for `autoplay`, `loop`, `volume`, and `muted`; persisted via `/api/system-settings` keys and applied on create in the event modal. - 🗓️ Events (admin+): WebUntis/Vertretungsplan URL enable/disable, save, preview. Presentations: general defaults for slideshow interval, page-progress, and auto-progress; persisted via `/api/system-settings` keys and applied on create in the event modal. Videos: system-wide defaults for `autoplay`, `loop`, `volume`, and `muted`; persisted via `/api/system-settings` keys and applied on create in the event modal.
- ⚙️ System (superadmin): Organization info and Advanced configuration placeholders - ⚙️ System (superadmin): Organization info and Advanced configuration placeholders
- **Holidays**: Academic calendar management - **Holidays**: Academic calendar management
- **Ressourcen**: Timeline view of active events across all room groups
- Parallel timeline display showing all groups and their current events simultaneously
- Compact visualization: 65px row height per group with color-coded event bars
- Day and week views for flexible time range inspection
- Customizable group ordering with visual drag controls (order persisted to backend)
- Real-time event status: shows currently running events with type, title, and time window
- Filters out unassigned groups for focused view
- Resource-based Syncfusion timeline scheduler with resize and drag-drop support
- **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`) - **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`)
## 🔒 Security & Authentication ## 🔒 Security & Authentication

View File

@@ -5,6 +5,57 @@
This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`. This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`.
## 2026.1.0-alpha.14 (2026-01-28)
- 🗓️ **Ressourcen Page (Timeline View)**:
- New frontend page: `dashboard/src/ressourcen.tsx` (357 lines) Parallel timeline view showing active events for all room groups
- Uses Syncfusion ScheduleComponent with TimelineViews module for resource-based scheduling
- Compact visualization: 65px row height per group, dynamically calculated total container height
- Real-time event loading: Fetches events per group for current date range on mount and view/date changes
- Timeline modes: Day (default) and Week views with date range calculation
- Color-coded event bars: Uses `getGroupColor()` from `groupColors.ts` for group theme matching
- Displays first active event per group with type, title, and time window
- Filters out "Nicht zugeordnet" group from timeline display
- Resource mapping: Each group becomes a timeline resource row, events mapped via `ResourceId`
- Syncfusion modules: TimelineViews, Resize, DragAndDrop injected for rich interaction
- 🎨 **Ressourcen Styling**:
- New CSS file: `dashboard/src/ressourcen.css` (178 lines) with modern Material 3 design
- Fixed CSS lint errors: Converted `rgba()` to modern `rgb()` notation with percentage alpha values (`rgb(0 0 0 / 10%)`)
- Removed unnecessary quotes from font-family names (Roboto, Oxygen, Ubuntu, Cantarell)
- Fixed CSS selector specificity ordering (`.e-schedule` before `.ressourcen-timeline-wrapper .e-schedule`)
- Card-based controls layout with shadow and rounded corners
- Group ordering panel with scrollable list and action buttons
- Responsive timeline wrapper with flex layout
- 🔌 **Group Order API**:
- New backend endpoints in `server/routes/groups.py`:
- `GET /api/groups/order` Retrieve saved group display order (returns JSON with `order` array of group IDs)
- `POST /api/groups/order` Persist group display order (accepts JSON with `order` array)
- Order persistence: Stored in `system_settings` table with key `group_display_order` (JSON array of integers)
- Automatic synchronization: Missing group IDs added to order, removed IDs filtered out
- Frontend integration: Group order panel with drag up/down buttons, real-time reordering with backend sync
- 🖥️ **Frontend Technical**:
- State management: React hooks with unused setters removed (setTimelineView, setViewDate) to resolve lint warnings
- TypeScript: Changed `let` to `const` for immutable end date calculation
- UTC date parsing: Uses parseUTCDate callback to append 'Z' and ensure UTC interpretation
- Event formatting: Capitalizes first letter of event type for display (e.g., "Website - Title")
- Loading state: Shows loading indicator while fetching group/event data
- Schedule height: Dynamic calculation based on `groups.length * 65px + 100px` for header
- 📖 **Documentation**:
- Updated `.github/copilot-instructions.md`:
- Added Ressourcen page to "Recent changes" section (January 2026)
- Added `ressourcen.tsx` and `ressourcen.css` to "Important files" list
- Added Groups API order endpoints documentation
- Added comprehensive Ressourcen page section to "Frontend patterns"
- Updated `README.md`:
- Added Ressourcen page to "Pages Overview" section with feature details
- Added `GET/POST /api/groups/order` to Core Resources API section
- Bumped version in `dashboard/public/program-info.json` to `2026.1.0-alpha.14` with user-facing changelog
Notes for integrators:
- Group order API returns JSON with `{ "order": [1, 2, 3, ...] }` structure (array of group IDs)
- Timeline view automatically filters "Nicht zugeordnet" group for cleaner display
- CSS follows modern Material 3 color-function notation (`rgb(r g b / alpha%)`)
- Syncfusion ScheduleComponent requires TimelineViews, Resize, and DragAndDrop modules injected
## 2025.1.0-beta.1 (TBD) ## 2025.1.0-beta.1 (TBD)
- 🔐 **User Management & Role-Based Access Control**: - 🔐 **User Management & Role-Based Access Control**:
- Backend: Implemented comprehensive user management API (`server/routes/users.py`) with 6 endpoints (GET, POST, PUT, DELETE users + password reset). - Backend: Implemented comprehensive user management API (`server/routes/users.py`) with 6 endpoints (GET, POST, PUT, DELETE users + password reset).

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"@syncfusion/ej2-buttons": "^30.2.0", "@syncfusion/ej2-buttons": "^30.2.0",
"@syncfusion/ej2-calendars": "^30.2.0", "@syncfusion/ej2-calendars": "^30.2.0",
"@syncfusion/ej2-dropdowns": "^30.2.0", "@syncfusion/ej2-dropdowns": "^30.2.0",
"@syncfusion/ej2-gantt": "^32.1.23",
"@syncfusion/ej2-grids": "^30.2.0", "@syncfusion/ej2-grids": "^30.2.0",
"@syncfusion/ej2-icons": "^30.2.0", "@syncfusion/ej2-icons": "^30.2.0",
"@syncfusion/ej2-inputs": "^30.2.0", "@syncfusion/ej2-inputs": "^30.2.0",
@@ -28,6 +29,7 @@
"@syncfusion/ej2-react-calendars": "^30.2.0", "@syncfusion/ej2-react-calendars": "^30.2.0",
"@syncfusion/ej2-react-dropdowns": "^30.2.0", "@syncfusion/ej2-react-dropdowns": "^30.2.0",
"@syncfusion/ej2-react-filemanager": "^30.2.0", "@syncfusion/ej2-react-filemanager": "^30.2.0",
"@syncfusion/ej2-react-gantt": "^32.1.23",
"@syncfusion/ej2-react-grids": "^30.2.0", "@syncfusion/ej2-react-grids": "^30.2.0",
"@syncfusion/ej2-react-inputs": "^30.2.0", "@syncfusion/ej2-react-inputs": "^30.2.0",
"@syncfusion/ej2-react-kanban": "^30.2.0", "@syncfusion/ej2-react-kanban": "^30.2.0",

View File

@@ -1,6 +1,6 @@
{ {
"appName": "Infoscreen-Management", "appName": "Infoscreen-Management",
"version": "2026.1.0-alpha.13", "version": "2026.1.0-alpha.14",
"copyright": "© 2026 Third-Age-Applications", "copyright": "© 2026 Third-Age-Applications",
"supportContact": "support@third-age-applications.com", "supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -30,6 +30,17 @@
"commitId": "9f2ae8b44c3a" "commitId": "9f2ae8b44c3a"
}, },
"changelog": [ "changelog": [
{
"version": "2026.1.0-alpha.14",
"date": "2026-01-28",
"changes": [
"✨ UI: Neue 'Ressourcen'-Seite mit Timeline-Ansicht zeigt aktive Events für alle Raumgruppen parallel.",
"📊 Ressourcen: Kompakte Zeitachsen-Darstellung.",
"🎯 Ressourcen: Zeigt aktuell laufende Events mit Typ, Titel und Zeitfenster in Echtzeit.",
"🔄 Ressourcen: Gruppensortierung anpassbar mit visueller Reihenfolgen-Verwaltung.",
"🎨 Ressourcen: Farbcodierte Event-Balken entsprechend dem Gruppen-Theme."
]
},
{ {
"version": "2025.1.0-alpha.13", "version": "2025.1.0-alpha.13",
"date": "2025-12-29", "date": "2025-12-29",

View File

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

View File

@@ -141,6 +141,25 @@ const Infoscreen_groups: React.FC = () => {
]); ]);
setNewGroupName(''); setNewGroupName('');
setShowDialog(false); 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) { } catch (err) {
toast.show({ toast.show({
content: (err as Error).message, content: (err as Error).message,
@@ -154,6 +173,10 @@ const Infoscreen_groups: React.FC = () => {
// Löschen einer Gruppe // Löschen einer Gruppe
const handleDeleteGroup = async (groupName: string) => { const handleDeleteGroup = async (groupName: string) => {
try { 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 // Clients der Gruppe in "Nicht zugeordnet" verschieben
const groupClients = clients.filter(c => c.Status === groupName); const groupClients = clients.filter(c => c.Status === groupName);
if (groupClients.length > 0) { if (groupClients.length > 0) {
@@ -172,6 +195,27 @@ const Infoscreen_groups: React.FC = () => {
timeOut: 5000, timeOut: 5000,
showCloseButton: false, 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 // Gruppen und Clients neu laden
const groupData = await fetchGroups(); const groupData = await fetchGroups();
const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name])); 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'; import React, { useEffect, useState } from 'react';
const Ressourcen: React.FC = () => ( import {
<div> ScheduleComponent,
<h2 className="text-xl font-bold mb-4">Ressourcen</h2> ViewsDirective,
<p>Willkommen im Infoscreen-Management Ressourcen.</p> 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> </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; export default Ressourcen;

View File

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

View File

@@ -68,6 +68,7 @@ with engine.connect() as conn:
('holiday_banner_enabled', 'true', 'Ferienstatus-Banner auf Dashboard anzeigen'), ('holiday_banner_enabled', 'true', 'Ferienstatus-Banner auf Dashboard anzeigen'),
('organization_name', '', 'Name der Organisation (wird im Header angezeigt)'), ('organization_name', '', 'Name der Organisation (wird im Header angezeigt)'),
('refresh_seconds', '0', 'Scheduler Republish-Intervall (Sekunden; 0 deaktiviert)'), ('refresh_seconds', '0', 'Scheduler Republish-Intervall (Sekunden; 0 deaktiviert)'),
('group_order', '[]', 'Benutzerdefinierte Reihenfolge der Raumgruppen (JSON-Array mit Group-IDs)'),
] ]
for key, value, description in default_settings: for key, value, description in default_settings:

View File

@@ -208,3 +208,55 @@ def get_groups_with_clients():
}) })
session.close() session.close()
return jsonify(result) return jsonify(result)
@groups_bp.route("/order", methods=["GET"])
def get_group_order():
"""Retrieve the saved group order from system settings."""
from models.models import SystemSetting
session = Session()
try:
setting = session.query(SystemSetting).filter_by(key='group_order').first()
if setting and setting.value:
import json
order = json.loads(setting.value)
return jsonify({"order": order})
return jsonify({"order": None})
except Exception as e:
print(f"Error loading group order: {e}")
return jsonify({"order": None})
finally:
session.close()
@groups_bp.route("/order", methods=["POST"])
@require_role('admin')
def save_group_order():
"""Save the custom group order to system settings."""
from models.models import SystemSetting
session = Session()
try:
data = request.get_json()
order = data.get('order')
if not order or not isinstance(order, list):
return jsonify({"success": False, "error": "Invalid order data"}), 400
import json
order_json = json.dumps(order)
setting = session.query(SystemSetting).filter_by(key='group_order').first()
if setting:
setting.value = order_json
else:
setting = SystemSetting(key='group_order', value=order_json)
session.add(setting)
session.commit()
return jsonify({"success": True})
except Exception as e:
session.rollback()
print(f"Error saving group order: {e}")
return jsonify({"success": False, "error": str(e)}), 500
finally:
session.close()