diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 798d1d4..eed01bd 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -12,6 +12,7 @@ "@syncfusion/ej2-react-calendars": "^30.1.37", "@syncfusion/ej2-react-grids": "^30.1.37", "@syncfusion/ej2-react-kanban": "^30.1.37", + "@syncfusion/ej2-react-notifications": "^30.1.37", "@syncfusion/ej2-react-popups": "^30.1.37", "@syncfusion/ej2-react-schedule": "^30.1.37", "cldr-data": "^36.0.4", @@ -1237,6 +1238,17 @@ "@syncfusion/ej2-react-base": "~30.1.37" } }, + "node_modules/@syncfusion/ej2-react-notifications": { + "version": "30.1.37", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-notifications/-/ej2-react-notifications-30.1.37.tgz", + "integrity": "sha512-jNeoj/vV7Cie3eWYPCrtSvMADcgSMAOKm6ZxwgMogUHoB8tG94HgOTWGSDNEetf+f9deFKvwqgtaYGbJW/gAIg==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.1.37", + "@syncfusion/ej2-notifications": "30.1.37", + "@syncfusion/ej2-react-base": "~30.1.37" + } + }, "node_modules/@syncfusion/ej2-react-popups": { "version": "30.1.37", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-popups/-/ej2-react-popups-30.1.37.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 590e0c7..8972d2e 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,6 +14,7 @@ "@syncfusion/ej2-react-calendars": "^30.1.37", "@syncfusion/ej2-react-grids": "^30.1.37", "@syncfusion/ej2-react-kanban": "^30.1.37", + "@syncfusion/ej2-react-notifications": "^30.1.37", "@syncfusion/ej2-react-popups": "^30.1.37", "@syncfusion/ej2-react-schedule": "^30.1.37", "cldr-data": "^36.0.4", diff --git a/dashboard/src/App.css b/dashboard/src/App.css index 5dbdbcb..0ae9be5 100644 --- a/dashboard/src/App.css +++ b/dashboard/src/App.css @@ -9,6 +9,7 @@ @import "../node_modules/@syncfusion/ej2-splitbuttons/styles/material.css"; @import "../node_modules/@syncfusion/ej2-react-schedule/styles/material.css"; @import "../node_modules/@syncfusion/ej2-kanban/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-notifications/styles/material.css"; body { font-family: Inter, 'Segoe UI', Roboto, Arial, sans-serif; @@ -96,7 +97,7 @@ body { letter-spacing: 0.02em; } -/* Header-Text noch spezifischer und mit !important */ +/* Header-Text noch spezifischer and mit !important */ .e-kanban .e-kanban-table .e-header-cells .e-header-text { color: color-mix(in srgb, var(--sidebar-fg) 85%, #000 15%) !important; } diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index e30ceb4..2b019fa 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -15,6 +15,7 @@ import { MonitorDotIcon, LogOut, } from 'lucide-react'; +import { ToastProvider } from './components/ToastProvider'; const sidebarItems = [ { name: 'Dashboard', path: '/', icon: LayoutDashboard }, @@ -120,18 +121,20 @@ const Layout: React.FC = () => { const App: React.FC = () => ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); diff --git a/dashboard/src/apiGroups.ts b/dashboard/src/apiGroups.ts index 3408a6c..302ea12 100644 --- a/dashboard/src/apiGroups.ts +++ b/dashboard/src/apiGroups.ts @@ -4,28 +4,36 @@ export async function createGroup(name: string) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); - if (!res.ok) throw new Error('Fehler beim Erstellen der Gruppe'); - return await res.json(); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Gruppe'); + return data; } export async function fetchGroups() { const res = await fetch('/api/groups'); - if (!res.ok) throw new Error('Fehler beim Laden der Gruppen'); - return await res.json(); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Gruppen'); + return data; } export async function deleteGroup(groupName: string) { - // Passe ggf. an deine API an - return fetch(`/api/groups/byname/${encodeURIComponent(groupName)}`, { method: 'DELETE' }); + const res = await fetch(`/api/groups/byname/${encodeURIComponent(groupName)}`, { + method: 'DELETE', + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen der Gruppe'); + return data; } export async function renameGroup(oldName: string, newName: string) { - // Passe ggf. an deine API an - return fetch(`/api/groups/byname/${encodeURIComponent(oldName)}`, { + const res = await fetch(`/api/groups/byname/${encodeURIComponent(oldName)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newName }), + body: JSON.stringify({ newName: newName }), }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Umbenennen der Gruppe'); + return data; } // Hier kannst du später weitere Funktionen ergänzen: diff --git a/dashboard/src/components/ToastProvider.tsx b/dashboard/src/components/ToastProvider.tsx new file mode 100644 index 0000000..4654501 --- /dev/null +++ b/dashboard/src/components/ToastProvider.tsx @@ -0,0 +1,24 @@ +import React, { createContext, useRef, useContext } from 'react'; +import { ToastComponent, type ToastModel } from '@syncfusion/ej2-react-notifications'; + +const ToastContext = createContext<{ show: (opts: ToastModel) => void }>({ show: () => {} }); + +export const useToast = () => useContext(ToastContext); + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const toastRef = useRef(null); + + const show = (opts: ToastModel) => toastRef.current?.show(opts); + + return ( + + {children} + + + ); +}; diff --git a/dashboard/src/infoscreen_groups.tsx b/dashboard/src/infoscreen_groups.tsx index b10cb2a..1f3d5f0 100644 --- a/dashboard/src/infoscreen_groups.tsx +++ b/dashboard/src/infoscreen_groups.tsx @@ -5,6 +5,8 @@ import { fetchGroups, createGroup, deleteGroup, renameGroup } from './apiGroups' import type { Client } from './apiClients'; import type { KanbanComponent as KanbanComponentType } from '@syncfusion/ej2-react-kanban'; import { DialogComponent } from '@syncfusion/ej2-react-popups'; +import { useToast } from './components/ToastProvider'; +import { L10n } from '@syncfusion/ej2-base'; interface KanbanClient extends Client { Id: string; @@ -25,7 +27,46 @@ interface KanbanDragEventArgs { [key: string]: unknown; } +const de = { + title: 'Gruppen', + newGroup: 'Neue Raumgruppe', + renameGroup: 'Gruppe umbenennen', + deleteGroup: 'Gruppe löschen', + add: 'Hinzufügen', + cancel: 'Abbrechen', + rename: 'Umbenennen', + confirmDelete: 'Löschbestätigung', + reallyDelete: (name: string) => `Möchten Sie die Gruppe ${name} wirklich löschen?`, + clientsMoved: 'Alle Clients werden in "Nicht zugeordnet" verschoben.', + groupCreated: 'Gruppe angelegt', + groupDeleted: 'Gruppe gelöscht. Clients in "Nicht zugeordnet" verschoben', + groupRenamed: 'Gruppenname geändert', + selectGroup: 'Gruppe wählen', + newName: 'Neuer Name', + warning: 'Achtung:', + yesDelete: 'Ja, löschen', +}; + +L10n.load({ + de: { + kanban: { + items: 'Clients', + addTitle: 'Neue Karte hinzufügen', + editTitle: 'Karte bearbeiten', + deleteTitle: 'Karte löschen', + edit: 'Bearbeiten', + delete: 'Löschen', + save: 'Speichern', + cancel: 'Abbrechen', + yes: 'Ja', + no: 'Nein', + noCard: 'Keine Clients vorhanden', + }, + }, +}); + const Infoscreen_groups: React.FC = () => { + const toast = useToast(); const [clients, setClients] = useState([]); const [groups, setGroups] = useState<{ keyField: string; headerText: string }[]>([]); const [showDialog, setShowDialog] = useState(false); @@ -74,11 +115,22 @@ const Infoscreen_groups: React.FC = () => { if (!newGroupName.trim()) return; try { const newGroup = await createGroup(newGroupName); + toast.show({ + content: de.groupCreated, + cssClass: 'e-toast-success', + timeOut: 5000, + showCloseButton: false, + }); setGroups([...groups, { keyField: newGroup.name, headerText: newGroup.name }]); setNewGroupName(''); setShowDialog(false); - } catch { - alert('Fehler beim Erstellen der Gruppe'); + } catch (err) { + toast.show({ + content: (err as Error).message, + cssClass: 'e-toast-danger', + timeOut: 0, + showCloseButton: true, + }); } }; @@ -94,6 +146,12 @@ const Infoscreen_groups: React.FC = () => { ); } await deleteGroup(groupName); + toast.show({ + content: de.groupDeleted, + cssClass: 'e-toast-success', + timeOut: 5000, + showCloseButton: false, + }); // Gruppen und Clients neu laden const groupData = await fetchGroups(); const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name])); @@ -107,8 +165,13 @@ const Infoscreen_groups: React.FC = () => { Summary: c.location || `Client ${i + 1}`, })) ); - } catch { - alert('Fehler beim Löschen der Gruppe'); + } catch (err) { + toast.show({ + content: (err as Error).message, + cssClass: 'e-toast-danger', + timeOut: 0, + showCloseButton: true, + }); } setDeleteDialog({ open: false, groupName: '' }); }; @@ -117,6 +180,12 @@ const Infoscreen_groups: React.FC = () => { const handleRenameGroup = async () => { try { await renameGroup(renameDialog.oldName, renameDialog.newName); + toast.show({ + content: de.groupRenamed, + cssClass: 'e-toast-success', + timeOut: 5000, + showCloseButton: false, + }); // Gruppen und Clients neu laden const groupData = await fetchGroups(); const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name])); @@ -130,8 +199,13 @@ const Infoscreen_groups: React.FC = () => { Summary: c.location || `Client ${i + 1}`, })) ); - } catch { - alert('Fehler beim Umbenennen der Gruppe'); + } catch (err) { + toast.show({ + content: (err as Error).message, + cssClass: 'e-toast-danger', + timeOut: 0, + showCloseButton: true, + }); } setRenameDialog({ open: false, oldName: '', newName: '' }); }; @@ -217,28 +291,29 @@ const Infoscreen_groups: React.FC = () => { return (
-

Gruppen

+

{de.title}

{ {showDialog && (
-

Neue Raumgruppe

+

{de.newGroup}

{ />
@@ -279,7 +354,7 @@ const Infoscreen_groups: React.FC = () => { {renameDialog.open && (
-

Raumgruppe umbenennen

+

{de.renameGroup}

setDeleteDialog({ ...deleteDialog, groupName: e.target.value })} > - + {groups .filter(g => g.headerText !== 'Nicht zugeordnet') .map(g => ( @@ -342,10 +417,10 @@ const Infoscreen_groups: React.FC = () => { ))} -

Alle Clients werden in "Nicht zugeordnet" verschoben.

+

{de.clientsMoved}

{deleteDialog.groupName && (
- Achtung: Möchten Sie die Gruppe {deleteDialog.groupName}{' '} + {de.warning} Möchten Sie die Gruppe {deleteDialog.groupName}{' '} wirklich löschen?
)} @@ -355,20 +430,20 @@ const Infoscreen_groups: React.FC = () => { onClick={() => setShowDeleteConfirm(true)} disabled={!deleteDialog.groupName} > - Löschen + {de.deleteGroup}
{showDeleteConfirm && deleteDialog.groupName && ( setShowDeleteConfirm(false)} footerTemplate={() => ( @@ -380,7 +455,7 @@ const Infoscreen_groups: React.FC = () => { setShowDeleteConfirm(false); }} > - Ja, löschen + {de.yesDelete}
)} @@ -397,9 +472,7 @@ const Infoscreen_groups: React.FC = () => {
Möchten Sie die Gruppe {deleteDialog.groupName} wirklich löschen?
- - Alle Clients werden in "Nicht zugeordnet" verschoben. - + {de.clientsMoved}
)} diff --git a/server/routes/groups.py b/server/routes/groups.py index f4f8009..a3ef901 100644 --- a/server/routes/groups.py +++ b/server/routes/groups.py @@ -1,6 +1,7 @@ from database import Session from models import ClientGroup from flask import Blueprint, request, jsonify +from sqlalchemy import func import sys sys.path.append('/workspace') @@ -112,9 +113,9 @@ def rename_group_by_name(old_name): return jsonify({"error": "Gruppe nicht gefunden"}), 404 # Prüfe, ob der neue Name schon existiert - if session.query(ClientGroup).filter_by(name=new_name).first(): + if session.query(ClientGroup).filter(func.binary(ClientGroup.name) == new_name).first(): session.close() - return jsonify({"error": "Gruppe mit diesem Namen existiert bereits"}), 409 + return jsonify({"error": f'Gruppe mit dem Namen "{new_name}" existiert bereits', "duplicate_name": new_name}), 409 group.name = new_name session.commit()