Toast-feedback feature for dashboard

This commit is contained in:
2025-06-29 08:25:19 +00:00
parent 6639006d65
commit 7d7204e7c6
8 changed files with 177 additions and 54 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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 = () => (
<Router>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} />
<Route path="Infoscreens" element={<Infoscreens />} />
<Route path="infoscr_groups" element={<Infoscreen_groups />} />
<Route path="medien" element={<Medien />} />
<Route path="benutzer" element={<Benutzer />} />
<Route path="einstellungen" element={<Einstellungen />} />
</Route>
</Routes>
<ToastProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} />
<Route path="Infoscreens" element={<Infoscreens />} />
<Route path="infoscr_groups" element={<Infoscreen_groups />} />
<Route path="medien" element={<Medien />} />
<Route path="benutzer" element={<Benutzer />} />
<Route path="einstellungen" element={<Einstellungen />} />
</Route>
</Routes>
</ToastProvider>
</Router>
);

View File

@@ -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:

View File

@@ -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<ToastComponent>(null);
const show = (opts: ToastModel) => toastRef.current?.show(opts);
return (
<ToastContext.Provider value={{ show }}>
{children}
<ToastComponent
ref={toastRef}
position={{ X: 'Right', Y: 'Top' }}
timeOut={5000} // Standard: 5 Sekunden
showCloseButton={false}
/>
</ToastContext.Provider>
);
};

View File

@@ -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 <b>${name}</b> 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<KanbanClient[]>([]);
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 (
<div id="dialog-target">
<h2 className="text-xl font-bold mb-4">Gruppen</h2>
<h2 className="text-xl font-bold mb-4">{de.title}</h2>
<div className="flex gap-2 mb-4">
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => setShowDialog(true)}
>
Neue Raumgruppe
{de.newGroup}
</button>
<button
className="px-4 py-2 bg-yellow-500 text-white rounded"
onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}
>
Gruppe umbenennen
{de.renameGroup}
</button>
<button
className="px-4 py-2 bg-red-500 text-white rounded"
onClick={() => setDeleteDialog({ open: true, groupName: '' })}
>
Gruppe löschen
{de.deleteGroup}
</button>
</div>
<KanbanComponent
locale="de"
id="kanban"
keyField="Status"
dataSource={clients}
@@ -255,7 +330,7 @@ const Infoscreen_groups: React.FC = () => {
{showDialog && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow">
<h3 className="mb-2 font-bold">Neue Raumgruppe</h3>
<h3 className="mb-2 font-bold">{de.newGroup}</h3>
<input
className="border p-2 mb-2 w-full"
value={newGroupName}
@@ -264,13 +339,13 @@ const Infoscreen_groups: React.FC = () => {
/>
<div className="flex gap-2">
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleAddGroup}>
Hinzufügen
{de.add}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setShowDialog(false)}
>
Abbrechen
{de.cancel}
</button>
</div>
</div>
@@ -279,7 +354,7 @@ const Infoscreen_groups: React.FC = () => {
{renameDialog.open && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow">
<h3 className="mb-2 font-bold">Raumgruppe umbenennen</h3>
<h3 className="mb-2 font-bold">{de.renameGroup}</h3>
<select
className="border p-2 mb-2 w-full"
value={renameDialog.oldName}
@@ -291,7 +366,7 @@ const Infoscreen_groups: React.FC = () => {
})
}
>
<option value="">Gruppe wählen</option>
<option value="">{de.selectGroup}</option>
{groups
.filter(g => g.headerText !== 'Nicht zugeordnet')
.map(g => (
@@ -304,7 +379,7 @@ const Infoscreen_groups: React.FC = () => {
className="border p-2 mb-2 w-full"
value={renameDialog.newName}
onChange={e => setRenameDialog({ ...renameDialog, newName: e.target.value })}
placeholder="Neuer Name"
placeholder={de.newName}
/>
<div className="flex gap-2">
<button
@@ -312,13 +387,13 @@ const Infoscreen_groups: React.FC = () => {
onClick={handleRenameGroup}
disabled={!renameDialog.oldName || !renameDialog.newName}
>
Umbenennen
{de.rename}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
>
Abbrechen
{de.cancel}
</button>
</div>
</div>
@@ -327,13 +402,13 @@ const Infoscreen_groups: React.FC = () => {
{deleteDialog.open && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow">
<h3 className="mb-2 font-bold">Gruppe löschen</h3>
<h3 className="mb-2 font-bold">{de.deleteGroup}</h3>
<select
className="border p-2 mb-2 w-full"
value={deleteDialog.groupName}
onChange={e => setDeleteDialog({ ...deleteDialog, groupName: e.target.value })}
>
<option value="">Gruppe wählen</option>
<option value="">{de.selectGroup}</option>
{groups
.filter(g => g.headerText !== 'Nicht zugeordnet')
.map(g => (
@@ -342,10 +417,10 @@ const Infoscreen_groups: React.FC = () => {
</option>
))}
</select>
<p>Alle Clients werden in "Nicht zugeordnet" verschoben.</p>
<p>{de.clientsMoved}</p>
{deleteDialog.groupName && (
<div className="bg-yellow-100 text-yellow-800 p-2 rounded mb-2 text-sm">
<strong>Achtung:</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b>{' '}
<strong>{de.warning}</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b>{' '}
wirklich löschen?
</div>
)}
@@ -355,20 +430,20 @@ const Infoscreen_groups: React.FC = () => {
onClick={() => setShowDeleteConfirm(true)}
disabled={!deleteDialog.groupName}
>
Löschen
{de.deleteGroup}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={() => setDeleteDialog({ open: false, groupName: '' })}
>
Abbrechen
{de.cancel}
</button>
</div>
</div>
{showDeleteConfirm && deleteDialog.groupName && (
<DialogComponent
width="350px"
header="Löschbestätigung"
header={de.confirmDelete}
visible={showDeleteConfirm}
close={() => setShowDeleteConfirm(false)}
footerTemplate={() => (
@@ -380,7 +455,7 @@ const Infoscreen_groups: React.FC = () => {
setShowDeleteConfirm(false);
}}
>
Ja, löschen
{de.yesDelete}
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
@@ -389,7 +464,7 @@ const Infoscreen_groups: React.FC = () => {
setDeleteDialog({ open: false, groupName: '' });
}}
>
Abbrechen
{de.cancel}
</button>
</div>
)}
@@ -397,9 +472,7 @@ const Infoscreen_groups: React.FC = () => {
<div>
Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
<br />
<span className="text-sm text-gray-500">
Alle Clients werden in "Nicht zugeordnet" verschoben.
</span>
<span className="text-sm text-gray-500">{de.clientsMoved}</span>
</div>
</DialogComponent>
)}