Workflow/frontend/src/pages/TenantList.tsx
root d58d53fa3b feat: initial workflow portal - multi-tenant architecture
- Backend: Node.js + Express + TypeScript + Prisma + PostgreSQL
- Frontend: React + TypeScript + Tailwind CSS + Vite
- Multi-tenant with role-based access (SystemAdmin, Technician, Customer)
- Workflow editor with drag-and-drop steps
- JWT authentication with refresh tokens
- German UI throughout
2026-03-20 22:48:04 +00:00

341 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
Building2,
Check,
Edit,
Plus,
Power,
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { tenantsApi } from '../services/api';
import type { Tenant } from '../types';
import Modal from '../components/Modal';
import ConfirmDialog from '../components/ConfirmDialog';
import SearchBar from '../components/SearchBar';
import Pagination from '../components/Pagination';
import EmptyState from '../components/EmptyState';
export default function TenantList() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [formName, setFormName] = useState('');
const [formSlug, setFormSlug] = useState('');
const [formActive, setFormActive] = useState(true);
const [formSaving, setFormSaving] = useState(false);
// Deactivate dialog
const [deactivateTarget, setDeactivateTarget] = useState<Tenant | null>(null);
const [deactivating, setDeactivating] = useState(false);
useEffect(() => {
loadTenants();
}, [search, page]);
const loadTenants = async () => {
setLoading(true);
try {
const res = await tenantsApi.list({
page,
pageSize: 20,
search: search || undefined,
});
setTenants(res.data);
setTotalPages(res.totalPages);
} catch {
toast.error('Mandanten konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
const openCreate = () => {
setEditingTenant(null);
setFormName('');
setFormSlug('');
setFormActive(true);
setModalOpen(true);
};
const openEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
setFormName(tenant.name);
setFormSlug(tenant.slug);
setFormActive(tenant.active);
setModalOpen(true);
};
const handleSave = async () => {
if (!formName.trim() || !formSlug.trim()) {
toast.error('Name und Slug sind erforderlich');
return;
}
setFormSaving(true);
try {
if (editingTenant) {
await tenantsApi.update(editingTenant.id, {
name: formName,
slug: formSlug,
active: formActive,
});
toast.success('Mandant aktualisiert');
} else {
await tenantsApi.create({
name: formName,
slug: formSlug,
active: formActive,
});
toast.success('Mandant erstellt');
}
setModalOpen(false);
loadTenants();
} catch {
toast.error('Speichern fehlgeschlagen');
} finally {
setFormSaving(false);
}
};
const handleDeactivate = async () => {
if (!deactivateTarget) return;
setDeactivating(true);
try {
await tenantsApi.update(deactivateTarget.id, {
active: !deactivateTarget.active,
});
toast.success(
deactivateTarget.active
? 'Mandant deaktiviert'
: 'Mandant aktiviert'
);
setDeactivateTarget(null);
loadTenants();
} catch {
toast.error('Aktion fehlgeschlagen');
} finally {
setDeactivating(false);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
<p className="mt-1 text-sm text-gray-500">
Verwalten Sie die Mandanten des Portals.
</p>
</div>
<button onClick={openCreate} className="btn-primary gap-2">
<Plus size={18} />
Neuer Mandant
</button>
</div>
{/* Search */}
<div className="max-w-md">
<SearchBar
value={search}
onChange={(val) => {
setSearch(val);
setPage(1);
}}
placeholder="Mandanten suchen..."
/>
</div>
{/* Table */}
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
) : tenants.length === 0 ? (
<EmptyState
icon={Building2}
title="Keine Mandanten gefunden"
description="Erstellen Sie den ersten Mandanten."
action={
<button onClick={openCreate} className="btn-primary gap-2">
<Plus size={18} />
Neuer Mandant
</button>
}
/>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Slug
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{tenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-50 text-sm font-bold text-primary-700">
{tenant.name[0]}
</div>
<span className="font-medium text-gray-900">
{tenant.name}
</span>
</div>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{tenant.slug}
</td>
<td className="whitespace-nowrap px-6 py-4">
{tenant.active ? (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-300">
<Check size={12} />
Aktiv
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-300">
<X size={12} />
Inaktiv
</span>
)}
</td>
<td className="whitespace-nowrap px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEdit(tenant)}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Bearbeiten"
>
<Edit size={16} />
</button>
<button
onClick={() => setDeactivateTarget(tenant)}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title={tenant.active ? 'Deaktivieren' : 'Aktivieren'}
>
<Power size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{totalPages > 1 && (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
{/* Create/Edit Modal */}
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editingTenant ? 'Mandant bearbeiten' : 'Neuer Mandant'}
>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Name *
</label>
<input
type="text"
value={formName}
onChange={(e) => {
setFormName(e.target.value);
if (!editingTenant) {
setFormSlug(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
);
}
}}
placeholder="Firmenname GmbH"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Slug *
</label>
<input
type="text"
value={formSlug}
onChange={(e) => setFormSlug(e.target.value)}
placeholder="firmenname-gmbh"
className="input-field"
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formActive}
onChange={(e) => setFormActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Aktiv</span>
</label>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setModalOpen(false)}
className="btn-secondary"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={formSaving}
className="btn-primary"
>
{formSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</Modal>
{/* Deactivate Confirm */}
<ConfirmDialog
open={!!deactivateTarget}
onClose={() => setDeactivateTarget(null)}
onConfirm={handleDeactivate}
title={
deactivateTarget?.active
? 'Mandant deaktivieren?'
: 'Mandant aktivieren?'
}
message={
deactivateTarget?.active
? `Der Mandant "${deactivateTarget?.name}" wird deaktiviert. Benutzer des Mandanten können sich nicht mehr anmelden.`
: `Der Mandant "${deactivateTarget?.name}" wird wieder aktiviert.`
}
confirmLabel={deactivateTarget?.active ? 'Deaktivieren' : 'Aktivieren'}
variant={deactivateTarget?.active ? 'danger' : 'warning'}
loading={deactivating}
/>
</div>
);
}