30e4bc3a4f
Einheitliche Seitenkoepfe mit Modul-Kachel, Knopf- und Linkklassen statt gelber Schrift, Karten ohne harte Rahmen, Tabellenkoepfe ohne Grossbuchstaben, Marktplatz mit Fluent-Reitern, Filterpillen und Kachel-Karten, Einstellungs-/Verwaltungsnavigation mit Auswahlpille, leere Zustaende mit Symbol im Kreis, Proxmox ohne leuchtende Schatten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
252 lines
8.8 KiB
TypeScript
252 lines
8.8 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
|
import { ActivateModuleDialog } from './components/ActivateModuleDialog';
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|
|
|
interface Module {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
version: string;
|
|
category: string;
|
|
description: Record<string, string> | string;
|
|
icon: string | null;
|
|
isSystem: boolean;
|
|
}
|
|
|
|
interface TenantActivation {
|
|
id: string;
|
|
moduleId: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
export default function AdminModulesPage() {
|
|
const t = useTranslations('adminModules');
|
|
const tCommon = useTranslations('common');
|
|
const currentUser = useAuthStore((s) => s.user);
|
|
// Shared signal that makes the sidebar refetch its active-module list
|
|
// (same mechanism the marketplace pages use). Without bumping this after a
|
|
// toggle here, an activation from the admin page only updates local state —
|
|
// the sidebar link would not appear until a full page reload.
|
|
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
|
|
|
|
const [modules, setModules] = useState<Module[]>([]);
|
|
const [activations, setActivations] = useState<Map<string, boolean>>(new Map());
|
|
const [loading, setLoading] = useState(true);
|
|
const [toggling, setToggling] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Deactivation stays on the direct toggleModule path below. Activation
|
|
// instead opens this dialog (D-10) -- the module named here is the one
|
|
// whose "Aktivieren"-switch was just clicked.
|
|
const [activateDialogModule, setActivateDialogModule] = useState<Module | null>(null);
|
|
|
|
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
|
|
|
const fetchModules = useCallback(async () => {
|
|
try {
|
|
const [allRes, activeRes] = await Promise.all([
|
|
fetch(`${API_URL}/modules`, { credentials: 'include' }),
|
|
fetch(`${API_URL}/modules/active`, { credentials: 'include' }),
|
|
]);
|
|
|
|
if (allRes.ok) {
|
|
const allModules: Module[] = await allRes.json();
|
|
setModules(allModules);
|
|
}
|
|
|
|
if (activeRes.ok) {
|
|
const activeModules: (Module & { activations?: TenantActivation[] })[] = await activeRes.json();
|
|
const map = new Map<string, boolean>();
|
|
for (const mod of activeModules) {
|
|
map.set(mod.id, true);
|
|
}
|
|
setActivations(map);
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isAdmin) {
|
|
fetchModules();
|
|
} else {
|
|
setLoading(false);
|
|
}
|
|
}, [isAdmin, fetchModules]);
|
|
|
|
const toggleModule = async (moduleId: string, currentlyActive: boolean) => {
|
|
setToggling(moduleId);
|
|
setError(null);
|
|
try {
|
|
const action = currentlyActive ? 'deactivate' : 'activate';
|
|
const res = await fetch(`${API_URL}/modules/${moduleId}/${action}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (res.ok) {
|
|
setActivations((prev) => {
|
|
const next = new Map(prev);
|
|
if (currentlyActive) {
|
|
next.delete(moduleId);
|
|
} else {
|
|
next.set(moduleId, true);
|
|
}
|
|
return next;
|
|
});
|
|
// Refresh the sidebar so the (de)activated module's link appears/
|
|
// disappears immediately, without a manual full page reload.
|
|
bumpSidebarRefresh();
|
|
} else {
|
|
const body = await res.text().catch(() => '');
|
|
setError(`${res.status}: ${body}`);
|
|
}
|
|
} catch (err) {
|
|
setError(String(err));
|
|
} finally {
|
|
setToggling(null);
|
|
}
|
|
};
|
|
|
|
const getDescription = (desc: Record<string, string> | string): string => {
|
|
if (typeof desc === 'string') return desc;
|
|
return desc.de || desc.en || Object.values(desc)[0] || '';
|
|
};
|
|
|
|
if (!isAdmin) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
|
</div>
|
|
<Link
|
|
href="/admin/modules/grants"
|
|
className="btn btn-secondary"
|
|
>
|
|
{t('grantsLink')}
|
|
</Link>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
|
) : modules.length === 0 ? (
|
|
<p className="text-muted-foreground">{t('noModules')}</p>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{modules.map((mod) => {
|
|
const isActive = activations.has(mod.id);
|
|
const isToggling = toggling === mod.id;
|
|
|
|
return (
|
|
<div
|
|
key={mod.id}
|
|
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 transition-colors"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium text-foreground">{mod.name}</h3>
|
|
<span className="text-xs text-muted-foreground">
|
|
v{mod.version}
|
|
</span>
|
|
{mod.isSystem && (
|
|
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
|
{t('system')}
|
|
</span>
|
|
)}
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
{mod.category}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground truncate">
|
|
{getDescription(mod.description)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="ml-4 flex items-center gap-3">
|
|
<span
|
|
className={`text-xs font-medium ${
|
|
isActive
|
|
? 'text-green-600 dark:text-green-400'
|
|
: 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
{isActive ? t('activated') : t('deactivated')}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (isActive) {
|
|
// Deactivation is unchanged -- direct toggle, no dialog.
|
|
toggleModule(mod.id, true);
|
|
} else {
|
|
// Activation opens the confirmation dialog instead of
|
|
// activating directly (D-10).
|
|
setActivateDialogModule(mod);
|
|
}
|
|
}}
|
|
disabled={isToggling}
|
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait disabled:opacity-50 ${
|
|
isActive ? 'bg-primary' : 'bg-input'
|
|
}`}
|
|
role="switch"
|
|
aria-checked={isActive}
|
|
>
|
|
<span
|
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200 ${
|
|
isActive ? 'translate-x-5' : 'translate-x-0'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{activateDialogModule && (
|
|
<ActivateModuleDialog
|
|
open
|
|
moduleId={activateDialogModule.id}
|
|
moduleName={activateDialogModule.name}
|
|
onCancel={() => setActivateDialogModule(null)}
|
|
onSuccess={(moduleId) => {
|
|
setActivations((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(moduleId, true);
|
|
return next;
|
|
});
|
|
bumpSidebarRefresh();
|
|
setActivateDialogModule(null);
|
|
}}
|
|
onError={(message) => setError(message)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|