Files
infoscreen/dashboard/src/infoscreen_groups.tsx
RobbStarkAustria 7ab4ea14c4 Polish up clients ui
2025-10-12 05:23:10 +00:00

528 lines
18 KiB
TypeScript

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 { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import type { ChangedEventArgs as TextBoxChangedArgs } from '@syncfusion/ej2-react-inputs';
import type { ChangeEventArgs as DropDownChangeArgs } from '@syncfusion/ej2-react-dropdowns';
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 nach "Nicht zugeordnet" verschoben.',
groupCreated: 'Gruppe angelegt',
groupDeleted: 'Gruppe gelöscht. Alle Clients wurden nach "Nicht zugeordnet" verschoben.',
groupRenamed: 'Gruppe umbenannt',
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 {
toast.show({
content: 'Fehler beim Aktualisieren der Clients',
cssClass: 'e-toast-danger',
timeOut: 0,
showCloseButton: true,
});
}
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 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: 16 }}>{de.title}</h2>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
marginBottom: '16px',
}}
>
<ButtonComponent cssClass="e-primary" onClick={() => setShowDialog(true)}>
{de.newGroup}
</ButtonComponent>
<ButtonComponent cssClass="e-warning" onClick={() => setRenameDialog({ open: true, oldName: '', newName: '' })}>
{de.renameGroup}
</ButtonComponent>
<ButtonComponent cssClass="e-danger" onClick={() => setDeleteDialog({ open: true, groupName: '' })}>
{de.deleteGroup}
</ButtonComponent>
</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 && (
<DialogComponent
visible={showDialog}
header={de.newGroup}
close={() => setShowDialog(false)}
target="#dialog-target"
width="420px"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent cssClass="e-primary" onClick={handleAddGroup} disabled={!newGroupName.trim()}>
{de.add}
</ButtonComponent>
<ButtonComponent onClick={() => setShowDialog(false)}>{de.cancel}</ButtonComponent>
</div>
)}
>
<div className="mt-2">
<TextBoxComponent
value={newGroupName}
placeholder="Raumname"
floatLabelType="Auto"
change={(args: TextBoxChangedArgs) => setNewGroupName(String(args.value ?? ''))}
/>
</div>
</DialogComponent>
)}
{renameDialog.open && (
<DialogComponent
visible={renameDialog.open}
header={de.renameGroup}
showCloseIcon={true}
close={() => setRenameDialog({ open: false, oldName: '', newName: '' })}
target="#dialog-target"
width="480px"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent
cssClass="e-primary"
onClick={handleRenameGroup}
disabled={!renameDialog.oldName || !renameDialog.newName}
>
{de.rename}
</ButtonComponent>
<ButtonComponent onClick={() => setRenameDialog({ open: false, oldName: '', newName: '' })}>
{de.cancel}
</ButtonComponent>
</div>
)}
>
<div className="flex flex-col gap-3 mt-2">
<DropDownListComponent
placeholder={de.selectGroup}
dataSource={groups.filter(g => g.headerText !== 'Nicht zugeordnet').map(g => g.headerText)}
value={renameDialog.oldName}
change={(e: DropDownChangeArgs) =>
setRenameDialog({
...renameDialog,
oldName: String(e.value ?? ''),
newName: String(e.value ?? ''),
})
}
/>
<TextBoxComponent
placeholder={de.newName}
value={renameDialog.newName}
floatLabelType="Auto"
change={(args: TextBoxChangedArgs) =>
setRenameDialog({ ...renameDialog, newName: String(args.value ?? '') })
}
/>
</div>
</DialogComponent>
)}
{deleteDialog.open && (
<DialogComponent
visible={deleteDialog.open}
header={de.deleteGroup}
showCloseIcon={true}
close={() => setDeleteDialog({ open: false, groupName: '' })}
target="#dialog-target"
width="520px"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent
cssClass="e-danger"
onClick={() => setShowDeleteConfirm(true)}
disabled={!deleteDialog.groupName}
>
{de.deleteGroup}
</ButtonComponent>
<ButtonComponent onClick={() => setDeleteDialog({ open: false, groupName: '' })}>
{de.cancel}
</ButtonComponent>
</div>
)}
>
<div className="flex flex-col gap-3 mt-2">
<DropDownListComponent
placeholder={de.selectGroup}
dataSource={groups.filter(g => g.headerText !== 'Nicht zugeordnet').map(g => g.headerText)}
value={deleteDialog.groupName}
change={(e: DropDownChangeArgs) =>
setDeleteDialog({ ...deleteDialog, groupName: String(e.value ?? '') })
}
/>
<p className="text-sm text-gray-600">{de.clientsMoved}</p>
{deleteDialog.groupName && (
<div className="bg-yellow-100 text-yellow-800 p-2 rounded text-sm">
<strong>{de.warning}</strong> Möchten Sie die Gruppe <b>{deleteDialog.groupName}</b> wirklich löschen?
</div>
)}
</div>
</DialogComponent>
)}
{showDeleteConfirm && deleteDialog.groupName && (
<DialogComponent
width="380px"
header={de.confirmDelete}
visible={showDeleteConfirm}
showCloseIcon={true}
close={() => setShowDeleteConfirm(false)}
target="#dialog-target"
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<ButtonComponent
cssClass="e-danger"
onClick={() => {
handleDeleteGroup(deleteDialog.groupName!);
setShowDeleteConfirm(false);
}}
>
{de.yesDelete}
</ButtonComponent>
<ButtonComponent
onClick={() => {
setShowDeleteConfirm(false);
setDeleteDialog({ open: false, groupName: '' });
}}
>
{de.cancel}
</ButtonComponent>
</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>
);
};
export default Infoscreen_groups;