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:
35
.github/copilot-instructions.md
vendored
35
.github/copilot-instructions.md
vendored
@@ -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
|
||||
- `server/routes/eventmedia.py` — file uploads, streaming endpoint
|
||||
- `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/media.tsx` — FileManager / upload settings
|
||||
- `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
|
||||
|
||||
### 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)**:
|
||||
- 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.
|
||||
- Examples:
|
||||
- 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`.
|
||||
- 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.
|
||||
- Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. - `GET /api/groups/order` — retrieve saved group display order
|
||||
- `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/`.
|
||||
- System settings: `server/routes/system_settings.py` exposes key–value 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:
|
||||
@@ -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
|
||||
- "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:
|
||||
- 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.
|
||||
|
||||
10
README.md
10
README.md
@@ -330,6 +330,8 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
|
||||
- `GET /api/clients` - List all registered clients
|
||||
- `PUT /api/clients/{uuid}/group` - Assign client to group
|
||||
- `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
|
||||
- `POST /api/events` - Create new event
|
||||
- `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.
|
||||
- ⚙️ System (superadmin): Organization info and Advanced configuration placeholders
|
||||
- **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`)
|
||||
|
||||
## 🔒 Security & Authentication
|
||||
|
||||
@@ -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`.
|
||||
|
||||
## 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)
|
||||
- 🔐 **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).
|
||||
|
||||
2303
dashboard/package-lock.json
generated
2303
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@syncfusion/ej2-buttons": "^30.2.0",
|
||||
"@syncfusion/ej2-calendars": "^30.2.0",
|
||||
"@syncfusion/ej2-dropdowns": "^30.2.0",
|
||||
"@syncfusion/ej2-gantt": "^32.1.23",
|
||||
"@syncfusion/ej2-grids": "^30.2.0",
|
||||
"@syncfusion/ej2-icons": "^30.2.0",
|
||||
"@syncfusion/ej2-inputs": "^30.2.0",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@syncfusion/ej2-react-calendars": "^30.2.0",
|
||||
"@syncfusion/ej2-react-dropdowns": "^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-inputs": "^30.2.0",
|
||||
"@syncfusion/ej2-react-kanban": "^30.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appName": "Infoscreen-Management",
|
||||
"version": "2026.1.0-alpha.13",
|
||||
"version": "2026.1.0-alpha.14",
|
||||
"copyright": "© 2026 Third-Age-Applications",
|
||||
"supportContact": "support@third-age-applications.com",
|
||||
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||
@@ -30,6 +30,17 @@
|
||||
"commitId": "9f2ae8b44c3a"
|
||||
},
|
||||
"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",
|
||||
"date": "2025-12-29",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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]));
|
||||
|
||||
177
dashboard/src/ressourcen.css
Normal file
177
dashboard/src/ressourcen.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -68,6 +68,7 @@ with engine.connect() as conn:
|
||||
('holiday_banner_enabled', 'true', 'Ferienstatus-Banner auf Dashboard anzeigen'),
|
||||
('organization_name', '', 'Name der Organisation (wird im Header angezeigt)'),
|
||||
('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:
|
||||
|
||||
@@ -208,3 +208,55 @@ def get_groups_with_clients():
|
||||
})
|
||||
session.close()
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user