From 82bf46725bb60b4af06cd1336683ee8e27738134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 6 Mar 2026 16:13:26 +0100 Subject: [PATCH] feat(B3): add tenant management UI (CRUD + tenant selector) - frontend/src/api/tenants.ts: Tenant CRUD API client (getTenants, getTenant, createTenant, updateTenant, deleteTenant) - frontend/src/pages/Tenants.tsx: Admin page with table, create/edit modals, delete confirm, and cross-tenant selector persisted in localStorage - App.tsx: /tenants route (AdminRoute-guarded) - Layout.tsx: Tenants sidebar link (admin-only, Building2 icon) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.tsx | 9 + frontend/src/api/tenants.ts | 46 +++ frontend/src/components/layout/Layout.tsx | 18 +- frontend/src/pages/Tenants.tsx | 442 ++++++++++++++++++++++ 4 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/tenants.ts create mode 100644 frontend/src/pages/Tenants.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 175e50b..18bf798 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import ProductDetailPage from './pages/ProductDetail' import NewProductOrderPage from './pages/NewProductOrder' import NotificationsPage from './pages/Notifications' import PreferencesPage from './pages/Preferences' +import TenantsPage from './pages/Tenants' function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token) @@ -57,6 +58,14 @@ export default function App() { } /> + + + + } + /> } /> } /> } /> diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts new file mode 100644 index 0000000..c82ce2e --- /dev/null +++ b/frontend/src/api/tenants.ts @@ -0,0 +1,46 @@ +import api from './client' + +export interface Tenant { + id: string + name: string + slug: string + is_active: boolean + user_count?: number + created_at: string +} + +export interface TenantCreate { + name: string + slug: string + is_active?: boolean +} + +export interface TenantUpdate { + name?: string + slug?: string + is_active?: boolean +} + +export async function getTenants(): Promise { + const res = await api.get('/tenants') + return res.data +} + +export async function getTenant(id: string): Promise { + const res = await api.get(`/tenants/${id}`) + return res.data +} + +export async function createTenant(data: TenantCreate): Promise { + const res = await api.post('/tenants', data) + return res.data +} + +export async function updateTenant(id: string, data: TenantUpdate): Promise { + const res = await api.patch(`/tenants/${id}`, data) + return res.data +} + +export async function deleteTenant(id: string): Promise { + await api.delete(`/tenants/${id}`) +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 60595ff..fb6cceb 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,5 +1,5 @@ import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom' -import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal } from 'lucide-react' +import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2 } from 'lucide-react' import { useAuthStore } from '../../store/auth' import { clsx } from 'clsx' import { useQuery } from '@tanstack/react-query' @@ -120,6 +120,22 @@ export default function Layout() { Admin )} + {user?.role === 'admin' && ( + + clsx( + 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', + isActive + ? 'bg-accent-light text-accent' + : 'text-content-secondary hover:bg-surface-hover', + ) + } + > + + Tenants + + )}
diff --git a/frontend/src/pages/Tenants.tsx b/frontend/src/pages/Tenants.tsx new file mode 100644 index 0000000..fb56963 --- /dev/null +++ b/frontend/src/pages/Tenants.tsx @@ -0,0 +1,442 @@ +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { + Plus, Trash2, Pencil, X, Building2, ChevronDown, Check, Users, +} from 'lucide-react' +import { + getTenants, createTenant, updateTenant, deleteTenant, +} from '../api/tenants' +import type { Tenant, TenantCreate, TenantUpdate } from '../api/tenants' + +const TENANT_CONTEXT_KEY = 'schaeffler_tenant_id' + +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) +} + +export default function TenantsPage() { + const qc = useQueryClient() + + // --- Tenant selector state (persisted in localStorage) --- + const [activeTenantId, setActiveTenantId] = useState( + () => localStorage.getItem(TENANT_CONTEXT_KEY), + ) + const [selectorOpen, setSelectorOpen] = useState(false) + + useEffect(() => { + if (activeTenantId) { + localStorage.setItem(TENANT_CONTEXT_KEY, activeTenantId) + } else { + localStorage.removeItem(TENANT_CONTEXT_KEY) + } + }, [activeTenantId]) + + // --- Create modal state --- + const [showCreate, setShowCreate] = useState(false) + const [createForm, setCreateForm] = useState({ name: '', slug: '', is_active: true }) + const [slugEdited, setSlugEdited] = useState(false) + + // --- Edit modal state --- + const [editingTenant, setEditingTenant] = useState(null) + const [editForm, setEditForm] = useState({}) + + // --- Delete confirm state --- + const [deletingId, setDeletingId] = useState(null) + + const { data: tenants = [], isLoading } = useQuery({ + queryKey: ['tenants'], + queryFn: getTenants, + }) + + const activeTenant = tenants.find((t) => t.id === activeTenantId) ?? null + + const createMut = useMutation({ + mutationFn: (data: TenantCreate) => createTenant(data), + onSuccess: () => { + toast.success('Tenant erstellt') + qc.invalidateQueries({ queryKey: ['tenants'] }) + setShowCreate(false) + setCreateForm({ name: '', slug: '', is_active: true }) + setSlugEdited(false) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Fehler beim Erstellen'), + }) + + const updateMut = useMutation({ + mutationFn: ({ id, data }: { id: string; data: TenantUpdate }) => updateTenant(id, data), + onSuccess: () => { + toast.success('Tenant aktualisiert') + qc.invalidateQueries({ queryKey: ['tenants'] }) + setEditingTenant(null) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Fehler beim Aktualisieren'), + }) + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteTenant(id), + onSuccess: (_data, id) => { + toast.success('Tenant gelöscht') + qc.invalidateQueries({ queryKey: ['tenants'] }) + if (activeTenantId === id) setActiveTenantId(null) + setDeletingId(null) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Fehler beim Löschen'), + }) + + // Auto-generate slug from name unless manually edited + const handleCreateNameChange = (name: string) => { + setCreateForm((prev) => ({ + ...prev, + name, + slug: slugEdited ? prev.slug : slugify(name), + })) + } + + const handleCreateSlugChange = (slug: string) => { + setSlugEdited(true) + setCreateForm((prev) => ({ ...prev, slug })) + } + + const openEdit = (tenant: Tenant) => { + setEditingTenant(tenant) + setEditForm({ name: tenant.name, slug: tenant.slug, is_active: tenant.is_active }) + } + + return ( +
+ {/* Header */} +
+
+ +
+

Tenants

+

Mandanten verwalten und Kontext wählen

+
+
+ +
+ + {/* Tenant Selector */} +
+

+ Admin Cross-Tenant-Ansicht +

+
+ + {selectorOpen && ( +
+ + {tenants.map((t) => ( + + ))} +
+ )} +
+ {activeTenant && ( +

+ API-Requests werden mit Header X-Tenant-ID: {activeTenant.id} gesendet. +

+ )} +
+ + {/* Table */} +
+ + + + + + + + + + + + {isLoading && ( + + + + )} + {!isLoading && tenants.length === 0 && ( + + + + )} + {tenants.map((tenant) => ( + + + + + + + + + ))} + +
NameSlugStatus + Nutzer + Erstellt +
Lade Tenants…
+ Noch keine Tenants vorhanden. +
+
+ {activeTenantId === tenant.id && ( + + )} + {tenant.name} +
+
{tenant.slug} + {tenant.is_active ? ( + + aktiv + + ) : ( + + inaktiv + + )} + + {tenant.user_count != null ? tenant.user_count : '—'} + {formatDate(tenant.created_at)} +
+ + +
+
+
+ + {/* Create Modal */} + {showCreate && ( +
+
+
+

Neuer Tenant

+ +
+ +
+
+ + handleCreateNameChange(e.target.value)} + placeholder="z.B. Schaeffler GmbH" + className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" + /> +
+ +
+ + handleCreateSlugChange(e.target.value)} + placeholder="z.B. schaeffler-gmbh" + className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" + /> +
+ +
+
+ +
+ + +
+
+
+ )} + + {/* Edit Modal */} + {editingTenant && ( +
+
+
+

Tenant bearbeiten

+ +
+ +
+
+ + setEditForm((prev) => ({ ...prev, name: e.target.value }))} + className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" + /> +
+ +
+ + setEditForm((prev) => ({ ...prev, slug: e.target.value }))} + className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" + /> +
+ +
+
+ +
+ + +
+
+
+ )} + + {/* Delete Confirm Modal */} + {deletingId && ( +
+
+
+
+ +
+
+

Tenant löschen?

+

+ Diese Aktion kann nicht rückgängig gemacht werden. +

+
+
+
+ + +
+
+
+ )} +
+ ) +}