- 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
341 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|