528 lines
18 KiB
TypeScript
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;
|