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:
37
.github/copilot-instructions.md
vendored
37
.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
|
- `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 key–value 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 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:
|
- 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.
|
||||||
|
|||||||
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
|
- `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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
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-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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
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';
|
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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user