Initial commit - copied workspace after database cleanup
This commit is contained in:
519
dashboard/src/infoscreen_groups.tsx
Normal file
519
dashboard/src/infoscreen_groups.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { KanbanComponent } from '@syncfusion/ej2-react-kanban';
|
||||
import { fetchClients, updateClientGroup } from './apiClients';
|
||||
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;
|
||||
Status: string; // Raumgruppe (Gruppenname)
|
||||
Summary: string; // Anzeigename
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
// weitere Felder möglich
|
||||
}
|
||||
|
||||
interface KanbanDragEventArgs {
|
||||
element: HTMLElement | HTMLElement[];
|
||||
data: KanbanClient | KanbanClient[];
|
||||
event?: { event?: MouseEvent };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface KanbanComponentWithClear extends KanbanComponentType {
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
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; id?: number }[]>([]);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
|
||||
const [renameDialog, setRenameDialog] = useState<{
|
||||
open: boolean;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}>({ open: false, oldName: '', newName: '' });
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; groupName: string }>({
|
||||
open: false,
|
||||
groupName: '',
|
||||
});
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const kanbanRef = useRef<KanbanComponentType | null>(null); // Ref für Kanban
|
||||
|
||||
// Lade Gruppen und Clients
|
||||
useEffect(() => {
|
||||
let groupMap: Record<number, string> = {};
|
||||
fetchGroups().then((groupData: Group[]) => {
|
||||
const kanbanGroups = groupData.map(g => ({
|
||||
keyField: g.name,
|
||||
headerText: g.name,
|
||||
id: g.id,
|
||||
}));
|
||||
setGroups(kanbanGroups);
|
||||
|
||||
groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
||||
|
||||
fetchClients().then(data => {
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
c.group_id === 1
|
||||
? 'Nicht zugeordnet'
|
||||
: typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||
? groupMap[c.group_id]
|
||||
: 'Nicht zugeordnet',
|
||||
Summary: c.description || `Client ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Neue Gruppe anlegen (persistiert per API)
|
||||
const handleAddGroup = async () => {
|
||||
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, id: newGroup.id },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
setShowDialog(false);
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
content: (err as Error).message,
|
||||
cssClass: 'e-toast-danger',
|
||||
timeOut: 0,
|
||||
showCloseButton: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Löschen einer Gruppe
|
||||
const handleDeleteGroup = async (groupName: string) => {
|
||||
try {
|
||||
// Clients der Gruppe in "Nicht zugeordnet" verschieben
|
||||
const groupClients = clients.filter(c => c.Status === groupName);
|
||||
if (groupClients.length > 0) {
|
||||
// Ermittle die ID der Zielgruppe "Nicht zugeordnet"
|
||||
const target = groups.find(g => g.headerText === 'Nicht zugeordnet');
|
||||
if (!target || !target.id) throw new Error('Zielgruppe "Nicht zugeordnet" nicht gefunden');
|
||||
await updateClientGroup(
|
||||
groupClients.map(c => c.Id),
|
||||
target.id
|
||||
);
|
||||
}
|
||||
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]));
|
||||
setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id })));
|
||||
const data = await fetchClients();
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||
? groupMap[c.group_id]
|
||||
: 'Nicht zugeordnet',
|
||||
Summary: c.description || `Client ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
content: (err as Error).message,
|
||||
cssClass: 'e-toast-danger',
|
||||
timeOut: 0,
|
||||
showCloseButton: true,
|
||||
});
|
||||
}
|
||||
setDeleteDialog({ open: false, groupName: '' });
|
||||
};
|
||||
|
||||
// Umbenennen einer Gruppe
|
||||
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]));
|
||||
setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id })));
|
||||
const data = await fetchClients();
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||
? groupMap[c.group_id]
|
||||
: 'Nicht zugeordnet',
|
||||
Summary: c.description || `Client ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
content: (err as Error).message,
|
||||
cssClass: 'e-toast-danger',
|
||||
timeOut: 0,
|
||||
showCloseButton: true,
|
||||
});
|
||||
}
|
||||
setRenameDialog({ open: false, oldName: '', newName: '' });
|
||||
};
|
||||
|
||||
const handleDragStart = (args: KanbanDragEventArgs) => {
|
||||
const element = Array.isArray(args.element) ? args.element[0] : args.element;
|
||||
const cardId = element.getAttribute('data-id');
|
||||
const fromColumn = element.getAttribute('data-key');
|
||||
setDraggedCard({ id: cardId || '', fromColumn: fromColumn || '' });
|
||||
};
|
||||
|
||||
const handleCardDrop = async (args: KanbanDragEventArgs) => {
|
||||
if (!draggedCard) return;
|
||||
|
||||
const mouseEvent = args.event?.event;
|
||||
let targetGroupName = '';
|
||||
|
||||
if (mouseEvent && mouseEvent.clientX && mouseEvent.clientY) {
|
||||
const targetElement = document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY);
|
||||
const kanbanColumn =
|
||||
targetElement?.closest('[data-key]') || targetElement?.closest('.e-content-row');
|
||||
if (kanbanColumn) {
|
||||
const columnKey = kanbanColumn.getAttribute('data-key');
|
||||
if (columnKey) {
|
||||
targetGroupName = columnKey;
|
||||
} else {
|
||||
const headerElement = kanbanColumn.querySelector('.e-header-text');
|
||||
targetGroupName = headerElement?.textContent?.trim() || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if (!targetGroupName) {
|
||||
const targetElement = Array.isArray(args.element) ? args.element[0] : args.element;
|
||||
const cardWrapper = targetElement.closest('.e-card-wrapper');
|
||||
const contentRow = cardWrapper?.closest('.e-content-row');
|
||||
const headerText = contentRow?.querySelector('.e-header-text');
|
||||
targetGroupName = headerText?.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
if (!targetGroupName || targetGroupName === draggedCard.fromColumn) {
|
||||
setDraggedCard(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const dropped = Array.isArray(args.data) ? args.data : [args.data];
|
||||
const clientIds = dropped.map((card: KanbanClient) => card.Id);
|
||||
|
||||
try {
|
||||
// Ermittle Zielgruppen-ID anhand des Namens
|
||||
const target = groups.find(g => g.headerText === targetGroupName);
|
||||
if (!target || !target.id) throw new Error('Zielgruppe nicht gefunden');
|
||||
await updateClientGroup(clientIds, target.id);
|
||||
fetchGroups().then((groupData: Group[]) => {
|
||||
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
||||
setGroups(
|
||||
groupData.map(g => ({
|
||||
keyField: g.name,
|
||||
headerText: g.name,
|
||||
id: g.id,
|
||||
}))
|
||||
);
|
||||
fetchClients().then(data => {
|
||||
setClients(
|
||||
data.map((c, i) => ({
|
||||
...c,
|
||||
Id: c.uuid,
|
||||
Status:
|
||||
typeof c.group_id === 'number' && groupMap[c.group_id]
|
||||
? groupMap[c.group_id]
|
||||
: 'Nicht zugeordnet',
|
||||
Summary: c.description || `Client ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
// Nach dem Laden: Karten deselektieren
|
||||
setTimeout(() => {
|
||||
(kanbanRef.current as KanbanComponentWithClear)?.clearSelection();
|
||||
setTimeout(() => {
|
||||
(kanbanRef.current as KanbanComponentWithClear)?.clearSelection();
|
||||
}, 100);
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
alert('Fehler beim Aktualisieren der Clients');
|
||||
}
|
||||
setDraggedCard(null);
|
||||
};
|
||||
|
||||
// Spalten-Array ohne Header-Buttons/Template
|
||||
const kanbanColumns = groups.map(group => ({
|
||||
keyField: group.keyField,
|
||||
headerText: group.headerText,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div id="dialog-target">
|
||||
<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)}
|
||||
>
|
||||
{de.newGroup}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-yellow-500 text-white rounded"
|
||||
onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}
|
||||
>
|
||||
{de.renameGroup}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded"
|
||||
onClick={() => setDeleteDialog({ open: true, groupName: '' })}
|
||||
>
|
||||
{de.deleteGroup}
|
||||
</button>
|
||||
</div>
|
||||
<KanbanComponent
|
||||
locale="de"
|
||||
id="kanban"
|
||||
keyField="Status"
|
||||
dataSource={clients}
|
||||
cardSettings={{
|
||||
headerField: 'Summary',
|
||||
selectionType: 'Multiple',
|
||||
}}
|
||||
allowDragAndDrop={true}
|
||||
dragStart={handleDragStart}
|
||||
dragStop={handleCardDrop}
|
||||
ref={kanbanRef}
|
||||
columns={kanbanColumns}
|
||||
/>
|
||||
{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">{de.newGroup}</h3>
|
||||
<input
|
||||
className="border p-2 mb-2 w-full"
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
placeholder="Raumname"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleAddGroup}>
|
||||
{de.add}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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">{de.renameGroup}</h3>
|
||||
<select
|
||||
className="border p-2 mb-2 w-full"
|
||||
value={renameDialog.oldName}
|
||||
onChange={e =>
|
||||
setRenameDialog({
|
||||
...renameDialog,
|
||||
oldName: e.target.value,
|
||||
newName: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{de.selectGroup}</option>
|
||||
{groups
|
||||
.filter(g => g.headerText !== 'Nicht zugeordnet')
|
||||
.map(g => (
|
||||
<option key={g.keyField} value={g.headerText}>
|
||||
{g.headerText}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="border p-2 mb-2 w-full"
|
||||
value={renameDialog.newName}
|
||||
onChange={e => setRenameDialog({ ...renameDialog, newName: e.target.value })}
|
||||
placeholder={de.newName}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||
onClick={handleRenameGroup}
|
||||
disabled={!renameDialog.oldName || !renameDialog.newName}
|
||||
>
|
||||
{de.rename}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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">{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="">{de.selectGroup}</option>
|
||||
{groups
|
||||
.filter(g => g.headerText !== 'Nicht zugeordnet')
|
||||
.map(g => (
|
||||
<option key={g.keyField} value={g.headerText}>
|
||||
{g.headerText}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p>{de.clientsMoved}</p>
|
||||
{deleteDialog.groupName && (
|
||||
<div className="bg-yellow-100 text-yellow-800 p-2 rounded mb-2 text-sm">
|
||||
<strong>{de.warning}</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b>{' '}
|
||||
wirklich löschen?
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
className="bg-red-500 text-white px-4 py-2 rounded"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={!deleteDialog.groupName}
|
||||
>
|
||||
{de.deleteGroup}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => setDeleteDialog({ open: false, groupName: '' })}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showDeleteConfirm && deleteDialog.groupName && (
|
||||
<DialogComponent
|
||||
width="350px"
|
||||
header={de.confirmDelete}
|
||||
visible={showDeleteConfirm}
|
||||
close={() => setShowDeleteConfirm(false)}
|
||||
footerTemplate={() => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
className="bg-red-500 text-white px-4 py-2 rounded"
|
||||
onClick={() => {
|
||||
handleDeleteGroup(deleteDialog.groupName);
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
>
|
||||
{de.yesDelete}
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteDialog({ open: false, groupName: '' });
|
||||
}}
|
||||
>
|
||||
{de.cancel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
|
||||
<br />
|
||||
<span className="text-sm text-gray-500">{de.clientsMoved}</span>
|
||||
</div>
|
||||
</DialogComponent>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Infoscreen_groups;
|
||||
Reference in New Issue
Block a user