Add i18n foundation (fa-IR/en-US) and localize landing + auth.

Introduce path-prefixed locale routing under app/[lang] with a middleware
that detects locale from cookie/Accept-Language (default fa-IR) and
redirects. Add fa-IR (source of truth) and en-US dictionaries, a server
getDictionary, a client I18nProvider/useT, locale-aware Link + router
helpers, and a language switcher. The root [lang] layout sets html
lang/dir and the per-locale font (Peyda for fa, Inter for en).

Landing sections and the login/register/auth shell now read all copy from
the dictionaries; dashboard localization follows in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-11 12:05:03 +03:30
parent 2b16846f67
commit 34993d417f
48 changed files with 785 additions and 200 deletions
@@ -0,0 +1,709 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, AppLifecycleStatus, ApplicationMigrationEvent, ApplicationMigrationJob, BillingCycle, Cluster } from '@/types';
import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { DeleteButtonLabel } from '@/components/delete-button-label';
import {
DeletingCardOverlay,
DeletingTableRowOverlay,
deletingResourceMessage,
deletingRowContentClass,
} from '@/components/deleting-overlay';
import { TruncatedText } from '@/components/truncated-text';
import { useDebounce } from '@/hooks/useDebounce';
import { useApplicationDelete } from '@/lib/use-application-delete';
const statusColors: Record<string, string> = {
running: 'badge-green',
pending: 'badge-yellow',
building: 'badge-blue',
deploying: 'badge-blue',
failed: 'badge-red',
build_failed: 'badge-red',
cancelled: 'badge-gray',
stopped: 'badge-gray',
};
const lifecycleColors: Record<string, string> = {
active: 'text-green-600 bg-green-50',
suspended: 'text-amber-700 bg-amber-50',
pending_deletion: 'text-red-700 bg-red-50',
deleted: 'text-gray-500 bg-gray-100',
};
const lifecycleLabels: Record<string, string> = {
active: 'Active',
suspended: 'Suspended — Unpaid',
pending_deletion: 'Pending Deletion',
deleted: 'Deleted',
};
const cycleLabels: Record<string, string> = {
hourly: 'Hourly',
monthly: 'Monthly',
yearly: 'Yearly',
};
function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
if (!expiresAt) return { text: '—', urgent: false };
const now = new Date();
const exp = new Date(expiresAt);
const diff = exp.getTime() - now.getTime();
if (diff <= 0) return { text: 'Expired', urgent: true };
const hours = Math.floor(diff / 3600000);
const days = Math.floor(hours / 24);
if (days > 0) return { text: `${days}d ${hours % 24}h`, urgent: days < 3 };
if (hours > 0) return { text: `${hours}h`, urgent: hours < 6 };
const mins = Math.floor(diff / 60000);
return { text: `${mins}m`, urgent: true };
}
function formatDeletionDate(date?: string): string {
if (!date) return '';
return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function migrationStatusBadgeClass(status: ApplicationMigrationJob['status']): string {
switch (status) {
case 'completed':
return 'badge-green';
case 'failed':
case 'rolled_back':
return 'badge-red';
case 'running':
case 'rolling_back':
return 'badge-blue';
default:
return 'badge-gray';
}
}
function formatMigrationStatus(status: ApplicationMigrationJob['status']): string {
return status.replace(/_/g, ' ');
}
export default function AdminAppsPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 400);
const [migrateApp, setMigrateApp] = useState<Application | null>(null);
const [targetClusterId, setTargetClusterId] = useState('');
const { data: apps = [], isLoading } = useQuery<Application[]>({
queryKey: ['admin-applications', debouncedSearch],
queryFn: () =>
api
.get('/applications/all', { params: debouncedSearch ? { search: debouncedSearch } : {} })
.then((r) => r.data),
});
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
invalidateKeys: [['admin-applications']],
successMessage: 'Application deleted',
});
const { data: clusters = [] } = useQuery<Cluster[]>({
queryKey: ['admin-clusters'],
queryFn: () => api.get('/clusters').then((r) => r.data),
});
const { data: migrations = [] } = useQuery<ApplicationMigrationJob[]>({
queryKey: ['application-migrations'],
queryFn: () => api.get('/application-migrations').then((r) => r.data),
refetchInterval: 5000,
});
const selectedMigration = migrateApp
? migrations.find((migration) => migration.applicationId === migrateApp.id)
: undefined;
const clusterById = new Map(clusters.map((cluster) => [cluster.id, cluster]));
const currentCluster = migrateApp?.clusterId ? clusterById.get(migrateApp.clusterId) : undefined;
const targetClusters = migrateApp
? clusters.filter((cluster) => cluster.id !== migrateApp.clusterId)
: [];
const { data: selectedMigrationEvents = [] } = useQuery<ApplicationMigrationEvent[]>({
queryKey: ['application-migration-events', selectedMigration?.id],
queryFn: () => api.get(`/application-migrations/${selectedMigration!.id}/events`).then((r) => r.data),
enabled: !!selectedMigration?.id,
refetchInterval: selectedMigration && ['queued', 'running', 'rolling_back'].includes(selectedMigration.status) ? 3000 : false,
});
const migrateMutation = useMutation({
mutationFn: ({ appId, clusterId }: { appId: string; clusterId: string }) =>
api.post(`/application-migrations/applications/${appId}`, {
targetClusterId: clusterId,
migrateStorage: true,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
toast.success('Migration job queued');
},
onError: (err: any) => toast.error(err?.response?.data?.message || 'Failed to queue migration'),
});
const retryMigration = useMutation({
mutationFn: (id: string) => api.post(`/application-migrations/${id}/retry`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
toast.success('Migration retry queued');
},
onError: (err: any) => toast.error(err?.response?.data?.message || 'Failed to retry migration'),
});
// Compute status counts from apps
const statusCounts = apps.reduce(
(acc, app) => {
const status = app.deployments?.[0]?.status || 'pending';
if (status === 'running') acc.running++;
else if (status === 'stopped') acc.stopped++;
else if (status === 'failed' || status === 'build_failed') acc.failed++;
else if (status === 'building' || status === 'deploying') acc.deploying++;
else acc.pending++;
// Lifecycle counts
const lc = app.lifecycleStatus || 'active';
if (lc === 'suspended') acc.suspended++;
if (lc === 'pending_deletion') acc.pendingDeletion++;
return acc;
},
{ running: 0, stopped: 0, failed: 0, deploying: 0, pending: 0, suspended: 0, pendingDeletion: 0 },
);
if (isLoading) {
return (
<div className="space-y-6">
<div className="page-header">
<div className="skeleton h-8 w-56" />
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="card py-4">
<div className="skeleton h-8 w-12 mx-auto mb-2" />
<div className="skeleton h-3 w-16 mx-auto" />
</div>
))}
</div>
<div className="skeleton h-10 w-full rounded-xl" />
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="card flex items-center gap-4">
<div className="skeleton w-11 h-11 rounded-xl" />
<div className="flex-1 space-y-2">
<div className="skeleton h-4 w-36" />
<div className="skeleton h-3 w-56" />
</div>
<div className="skeleton h-6 w-20 rounded-full" />
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="page-header">
<div>
<h1 className="page-title">All Applications</h1>
<p className="page-subtitle">
{apps.length} application{apps.length !== 1 ? 's' : ''}
{debouncedSearch && ` matching "${debouncedSearch}"`}
</p>
</div>
</div>
{/* Status Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
<div className="card py-4 text-center border-l-4 border-l-green-500">
<p className="text-2xl font-bold text-green-600">{statusCounts.running}</p>
<p className="text-xs text-gray-500 font-medium mt-1">Running</p>
</div>
<div className="card py-4 text-center border-l-4 border-l-blue-500">
<p className="text-2xl font-bold text-blue-600">{statusCounts.deploying}</p>
<p className="text-xs text-gray-500 font-medium mt-1">Deploying</p>
</div>
<div className="card py-4 text-center border-l-4 border-l-yellow-500">
<p className="text-2xl font-bold text-yellow-600">{statusCounts.pending}</p>
<p className="text-xs text-gray-500 font-medium mt-1">Pending</p>
</div>
<div className="card py-4 text-center border-l-4 border-l-gray-400">
<p className="text-2xl font-bold text-gray-500">{statusCounts.stopped}</p>
<p className="text-xs text-gray-500 font-medium mt-1">Stopped</p>
</div>
<div className="card py-4 text-center border-l-4 border-l-red-500">
<p className="text-2xl font-bold text-red-600">{statusCounts.failed}</p>
<p className="text-xs text-gray-500 font-medium mt-1">Failed</p>
</div>
<div className="card py-4 text-center border-l-4 border-l-amber-500">
<p className="text-2xl font-bold text-amber-600">{statusCounts.suspended}</p>
<p className="text-xs text-gray-500 font-medium mt-1">Suspended</p>
</div>
<div className="card py-4 text-center border-l-4 border-l-red-700">
<p className="text-2xl font-bold text-red-700">{statusCounts.pendingDeletion}</p>
<p className="text-xs text-gray-500 font-medium mt-1">Pending Del.</p>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search by app name, user name, email, or user ID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{apps.length === 0 ? (
<div className="card text-center py-16">
<Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
{debouncedSearch ? (
<>
<p className="text-gray-600 text-lg font-medium">No applications found</p>
<p className="text-gray-400 mt-1 text-sm">
No results for &quot;{debouncedSearch}&quot;. Try a different search.
</p>
</>
) : (
<>
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
<p className="text-gray-400 mt-1 text-sm">No applications have been created by any user.</p>
</>
)}
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden lg:block table-wrapper overflow-x-auto">
<table className="min-w-[1180px] w-full table-fixed divide-y divide-gray-200">
<thead className="bg-gray-50/80">
<tr>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-[300px] min-w-[300px]">Application</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Owner</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Service</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Cluster</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Migration</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Plan / Expiry</th>
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider min-w-[200px]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{apps.map((app) => {
const latestStatus = app.deployments?.[0]?.status || 'pending';
const lifecycle = app.lifecycleStatus || 'active';
const expiry = formatExpiry(app.planExpiresAt);
const latestMigration = migrations.find((migration) => migration.applicationId === app.id);
const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined;
const rowDeleting = isDeleting(app.id);
return (
<tr
key={app.id}
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'relative bg-gray-50/80' : ''}`}
>
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-[300px] group">
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
</div>
<div className="min-w-0 flex-1">
<TruncatedText className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
{app.name}
</TruncatedText>
<TruncatedText className="text-xs text-gray-400 capitalize">{app.runtime}</TruncatedText>
</div>
</Link>
</td>
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{app.user ? (
<div className="min-w-0">
<TruncatedText className="text-sm font-medium text-gray-900">
{`${app.user.firstName} ${app.user.lastName}`}
</TruncatedText>
<TruncatedText className="text-xs text-gray-400">{app.user.email}</TruncatedText>
<TruncatedText className="text-xs text-gray-300 font-mono">{app.userId}</TruncatedText>
</div>
) : (
<TruncatedText className="text-xs text-gray-400 font-mono">{app.userId}</TruncatedText>
)}
</td>
<td className={`px-6 py-4 max-w-[120px] text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>
<span className={`badge max-w-full truncate ${statusColors[latestStatus] || 'badge-gray'}`} title={latestStatus}>
{latestStatus}
</span>
</td>
<td className={`px-6 py-4 max-w-[180px] ${rowDeleting ? deletingRowContentClass : ''}`}>
<span
className={`inline-flex max-w-full items-center gap-1 truncate px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}
title={lifecycleLabels[lifecycle] || lifecycle}
>
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3 shrink-0" />}
<span className="truncate">{lifecycleLabels[lifecycle] || lifecycle}</span>
</span>
{lifecycle === 'pending_deletion' && app.scheduledDeletionAt && (
<TruncatedText className="text-xs text-red-500 mt-1">
{`Delete: ${formatDeletionDate(app.scheduledDeletionAt)}`}
</TruncatedText>
)}
</td>
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{assignedCluster ? (
<div className="min-w-0">
<TruncatedText className="text-sm font-medium text-gray-900">{assignedCluster.name}</TruncatedText>
<TruncatedText className="text-xs text-gray-400">
{`${assignedCluster.region || 'N/A'} · ${assignedCluster.status}/${assignedCluster.healthStatus || 'unknown'}`}
</TruncatedText>
</div>
) : app.clusterId ? (
<div className="min-w-0">
<TruncatedText className="text-sm font-medium text-gray-700">Unknown cluster</TruncatedText>
<TruncatedText className="text-xs text-gray-400 font-mono">{app.clusterId}</TruncatedText>
</div>
) : (
<TruncatedText className="text-xs text-gray-400">Not assigned</TruncatedText>
)}
</td>
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{latestMigration ? (
<span
className={`badge text-xs capitalize max-w-full truncate ${migrationStatusBadgeClass(latestMigration.status)}`}
title={formatMigrationStatus(latestMigration.status)}
>
{formatMigrationStatus(latestMigration.status)}
</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{app.billingCycle && (
<span
className="badge badge-purple text-xs max-w-full truncate"
title={cycleLabels[app.billingCycle] || app.billingCycle}
>
{cycleLabels[app.billingCycle] || app.billingCycle}
</span>
)}
{app.planExpiresAt ? (
<TruncatedText className={`text-xs mt-1 ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
{expiry.text}
</TruncatedText>
) : (
<TruncatedText className="text-xs text-gray-400">No plan</TruncatedText>
)}
</td>
<td className={`px-6 py-4 text-right ${rowDeleting ? deletingRowContentClass : ''}`}>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
View
</Link>
<button
type="button"
onClick={() => {
setMigrateApp(app);
setTargetClusterId('');
}}
disabled={!app.clusterId}
className="btn-secondary text-xs px-3 py-1.5 inline-flex items-center gap-1 disabled:opacity-50"
title={!app.clusterId ? 'Application is not assigned to a cluster' : undefined}
>
<ArrowRightLeft className="w-3 h-3" /> Migrate
</button>
<button
type="button"
disabled={isAnyDeleting}
onClick={async () => {
const ok = await confirm({ title: 'Delete Application', message: `Are you sure you want to delete "${app.name}"?`, confirmText: 'Delete', variant: 'danger' });
if (ok) deleteApplication(app.id);
}}
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors font-medium disabled:opacity-50 disabled:pointer-events-none"
>
<DeleteButtonLabel loading={isDeleting(app.id)} />
</button>
</div>
</td>
{rowDeleting && (
<DeletingTableRowOverlay
colSpan={8}
message={deletingResourceMessage('application', app.name)}
/>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile / tablet cards */}
<div className="lg:hidden grid gap-3">
{apps.map((app) => {
const latestStatus = app.deployments?.[0]?.status || 'pending';
const lifecycle = app.lifecycleStatus || 'active';
const expiry = formatExpiry(app.planExpiresAt);
const latestMigration = migrations.find((migration) => migration.applicationId === app.id);
const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined;
const cardDeleting = isDeleting(app.id);
return (
<div
key={app.id}
className={`relative card space-y-3 ${lifecycle === 'suspended' ? 'border-amber-200/80' : lifecycle === 'pending_deletion' ? 'border-red-200/80' : ''} ${cardDeleting ? 'bg-gray-50' : ''}`}
>
{cardDeleting && (
<DeletingCardOverlay message={deletingResourceMessage('application', app.name)} />
)}
<div className={cardDeleting ? deletingRowContentClass : undefined}>
<div className="flex items-start justify-between gap-3">
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center shrink-0">
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
</div>
<div className="min-w-0">
<TruncatedText className="font-semibold text-gray-900">{app.name}</TruncatedText>
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
</div>
</Link>
<span className={`badge shrink-0 ${statusColors[latestStatus] || 'badge-gray'}`}>
{latestStatus}
</span>
</div>
{app.user && (
<div className="text-xs text-gray-500 space-y-0.5">
<p className="flex items-center gap-1 font-medium text-gray-700">
<User className="w-3 h-3 shrink-0" />
{app.user.firstName} {app.user.lastName}
</p>
<TruncatedText>{app.user.email}</TruncatedText>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
{lifecycleLabels[lifecycle] || lifecycle}
</span>
{app.billingCycle && (
<span className="badge badge-purple">{cycleLabels[app.billingCycle] || app.billingCycle}</span>
)}
{latestMigration && (
<span className={`badge capitalize ${migrationStatusBadgeClass(latestMigration.status)}`}>
{formatMigrationStatus(latestMigration.status)}
</span>
)}
</div>
{lifecycle === 'pending_deletion' && app.scheduledDeletionAt && (
<p className="text-xs text-red-600">Delete: {formatDeletionDate(app.scheduledDeletionAt)}</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-gray-500">
<div>
<span className="text-gray-400 block">Cluster</span>
<TruncatedText className="font-medium text-gray-800">
{assignedCluster?.name || (app.clusterId ? 'Unknown' : 'Not assigned')}
</TruncatedText>
{assignedCluster && (
<TruncatedText className="text-gray-400 mt-0.5">
{`${assignedCluster.region || 'N/A'} · ${assignedCluster.status}`}
</TruncatedText>
)}
</div>
<div>
<span className="text-gray-400 block">Plan</span>
{app.planExpiresAt ? (
<span className={expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-700'}>{expiry.text}</span>
) : (
<span className="text-gray-400">No plan</span>
)}
</div>
<div className="flex items-center gap-3 sm:col-span-2">
<span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
<span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-3 border-t border-gray-100">
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
View
</Link>
<button
type="button"
onClick={() => {
setMigrateApp(app);
setTargetClusterId('');
}}
disabled={!app.clusterId}
className="btn-secondary text-xs px-3 py-1.5 inline-flex items-center gap-1 disabled:opacity-50"
title={!app.clusterId ? 'Application is not assigned to a cluster' : undefined}
>
<ArrowRightLeft className="w-3 h-3" /> Migrate
</button>
<button
type="button"
disabled={isAnyDeleting}
onClick={async () => {
const ok = await confirm({
title: 'Delete Application',
message: `Are you sure you want to delete "${app.name}"?`,
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteApplication(app.id);
}}
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 font-medium disabled:opacity-50 disabled:pointer-events-none"
>
<DeleteButtonLabel loading={isDeleting(app.id)} />
</button>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{migrateApp && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-6 space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ArrowRightLeft className="w-5 h-5" /> Migrate Application
</h2>
<p className="text-sm text-gray-500 mt-1">
Move <span className="font-medium">{migrateApp.name}</span> to a healthy target cluster with logs, retry, and rollback.
</p>
</div>
<button onClick={() => setMigrateApp(null)} className="p-1 text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Target Cluster</label>
<div className="mb-3 rounded-xl bg-gray-50 border border-gray-100 p-3">
<p className="text-xs font-medium text-gray-500">Current cluster</p>
<p className="text-sm font-semibold text-gray-900 mt-1">
{currentCluster?.name || (migrateApp.clusterId ? `Unknown cluster (${migrateApp.clusterId.slice(0, 8)})` : 'Not assigned')}
</p>
{currentCluster && (
<p className="text-xs text-gray-500 mt-0.5">
{currentCluster.region || 'N/A'} · {currentCluster.status}/{currentCluster.healthStatus || 'unknown'}
</p>
)}
</div>
<select
className="input-field"
value={targetClusterId}
onChange={(e) => setTargetClusterId(e.target.value)}
>
<option value="">Select healthy cluster</option>
{targetClusters
.map((cluster) => (
<option
key={cluster.id}
value={cluster.id}
disabled={cluster.status !== 'active' || cluster.healthStatus !== 'healthy'}
>
{cluster.name} · {cluster.region || 'N/A'} · {cluster.status}/{cluster.healthStatus || 'unknown'}
</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">
The current cluster is excluded. Migration is blocked for unhealthy, inactive, or maintenance clusters.
</p>
{targetClusters.length === 0 && (
<p className="text-xs text-amber-600 mt-2">
No other cluster is available as a migration target.
</p>
)}
</div>
{selectedMigration && (
<div className="rounded-xl border border-gray-200 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-800">Latest migration</p>
<p className="text-xs text-gray-500">
{selectedMigration.currentStep || 'queued'} · attempts {selectedMigration.attempts}/{selectedMigration.maxAttempts}
</p>
</div>
<span className={`badge ${
selectedMigration.status === 'completed'
? 'badge-green'
: selectedMigration.status === 'failed' || selectedMigration.status === 'rolled_back'
? 'badge-red'
: 'badge-blue'
}`}>
{selectedMigration.status}
</span>
</div>
{selectedMigration.errorMessage && (
<p className="text-sm text-red-600">{selectedMigration.errorMessage}</p>
)}
<div className="max-h-40 overflow-auto rounded-lg bg-gray-50 p-3 space-y-2">
{selectedMigrationEvents.length === 0 ? (
<p className="text-xs text-gray-400">No events yet.</p>
) : selectedMigrationEvents.map((event) => (
<div key={event.id} className="text-xs">
<span className="font-mono text-gray-400">{new Date(event.createdAt).toLocaleTimeString()}</span>
<span className={`ml-2 font-medium ${
event.level === 'error' ? 'text-red-600' : event.level === 'warn' ? 'text-amber-600' : 'text-gray-700'
}`}>
{event.step}
</span>
<span className="ml-2 text-gray-600">{event.message}</span>
</div>
))}
</div>
{['failed', 'rolled_back'].includes(selectedMigration.status) && (
<button
type="button"
onClick={() => retryMigration.mutate(selectedMigration.id)}
disabled={retryMigration.isPending}
className="btn-secondary text-sm inline-flex items-center gap-1"
>
<RefreshCw className={`w-4 h-4 ${retryMigration.isPending ? 'animate-spin' : ''}`} />
Retry migration
</button>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button onClick={() => setMigrateApp(null)} className="btn-secondary">
Cancel
</button>
<button
onClick={() => migrateMutation.mutate({ appId: migrateApp.id, clusterId: targetClusterId })}
disabled={!targetClusterId || migrateMutation.isPending}
className="btn-primary disabled:opacity-50"
>
{migrateMutation.isPending ? 'Queueing...' : 'Start Migration'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,900 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type {
PricingCatalog,
PricingRateRow,
OptionalServiceProfileRow,
CustomDomainCatalogRow,
BillingCycle,
PricingResourceType,
LifecycleSettings,
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
const resourceLabels: Record<PricingResourceType, string> = {
base_fee: 'Base fee',
cpu_per_core: 'CPU (per core)',
memory_per_gb: 'Memory (per GB)',
storage_per_gb: 'Storage (per GB)',
database_addon: 'Database addon',
redis_addon: 'Redis',
rabbitmq_addon: 'RabbitMQ',
elasticsearch_addon: 'Elasticsearch',
custom_domain_addon: 'Custom domain + SSL',
};
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */
function toPricingCatalogPatch(catalog: PricingCatalog) {
const optionalServices: Record<
string,
{ service: string; profile: OptionalServiceProfileRow; rates: PricingRateRow[] }
> = {};
for (const [key, entry] of Object.entries(catalog.optionalServices)) {
optionalServices[key] = {
service: entry.service ?? key,
profile: entry.profile,
rates: entry.rates,
};
}
return {
runtimes: catalog.runtimes,
optionalServices,
customDomain: catalog.customDomain,
};
}
function formatApiError(err: unknown, fallback: string): string {
if (!err || typeof err !== 'object' || !('response' in err)) return fallback;
const message = (err as { response?: { data?: { message?: string | string[] } } }).response
?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
return fallback;
}
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
const runtimes: PricingCatalog['runtimes'] = {};
for (const key of Object.keys(catalog.runtimes)) {
runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r }));
}
const optionalServices: PricingCatalog['optionalServices'] = {};
for (const key of Object.keys(catalog.optionalServices)) {
const entry = catalog.optionalServices[key];
optionalServices[key] = {
...entry,
profile: { ...entry.profile },
rates: entry.rates.map((r) => ({ ...r })),
};
}
return {
...catalog,
runtimes,
optionalServices,
customDomain: { ...catalog.customDomain },
};
}
function PricingMatrixTable({
rows,
onChange,
readOnly,
}: {
rows: PricingRateRow[];
onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void;
readOnly: boolean;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium text-gray-600">Resource</th>
{cycles.map((cycle) => (
<th key={cycle} className="text-left p-3 font-medium text-gray-600 capitalize">
{cycle} (T)
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.resourceType} className="border-t border-gray-100">
<td className="p-3 font-medium text-gray-900">
{resourceLabels[row.resourceType]}
</td>
{cycles.map((cycle) => {
const field =
cycle === 'hourly'
? 'hourlyPrice'
: cycle === 'monthly'
? 'monthlyPrice'
: 'yearlyPrice';
const val = row[field];
return (
<td key={cycle} className="p-3">
{readOnly ? (
<span className="font-mono text-gray-700">
{Number(val).toLocaleString('en-US')}
</span>
) : (
<input
type="number"
min={0}
className="input-field w-full max-w-[120px]"
value={val}
onChange={(e) =>
onChange(
row.resourceType,
cycle,
e.target.value === '' ? 0 : Number(e.target.value),
)
}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
function DeployDefaultsFields({
service,
profile,
readOnly,
onUpdate,
}: {
service: string;
profile: OptionalServiceProfileRow;
readOnly: boolean;
onUpdate: (patch: Partial<OptionalServiceProfileRow>) => void;
}) {
const isLogging = service === 'elasticsearch';
if (isLogging) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4 space-y-3">
<p className="text-xs text-gray-600">
Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">Log shipper CPU limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
) : (
<input
className="input-field w-full text-sm mt-0.5"
placeholder="50m"
value={profile.logShipperCpuLimit ?? ''}
onChange={(e) => onUpdate({ logShipperCpuLimit: e.target.value })}
/>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Log shipper memory limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
) : (
<input
className="input-field w-full text-sm mt-0.5"
placeholder="64Mi"
value={profile.logShipperMemoryLimit ?? ''}
onChange={(e) => onUpdate({ logShipperMemoryLimit: e.target.value })}
/>
)}
</div>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 space-y-3">
<p className="text-xs text-gray-600">
Shown when a user enables this service in deploy. They can change CPU, memory, and storage in
Resources & Configuration; actual billing uses their choices × unit prices below.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">CPU request</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.cpuRequest || '50m'}
onChange={(e) => onUpdate({ cpuRequest: e.target.value })}
>
<option value="50m">50m</option>
<option value="100m">100m</option>
<option value="250m">250m</option>
<option value="500m">500m</option>
</select>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">CPU limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.cpuLimit}
onChange={(e) => onUpdate({ cpuLimit: e.target.value })}
>
<option value="200m">200m</option>
<option value="250m">250m</option>
<option value="500m">500m</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Memory request</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.memoryRequest || '64Mi'}
onChange={(e) => onUpdate({ memoryRequest: e.target.value })}
>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Memory limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.memoryLimit}
onChange={(e) => onUpdate({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
)}
</div>
<div className="sm:col-span-2">
<label className="text-xs font-medium text-gray-600">Storage (Gi)</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
) : (
<input
type="number"
min={0}
step={0.1}
className="input-field w-full max-w-[140px] text-sm mt-0.5"
value={profile.storageGi}
onChange={(e) =>
onUpdate({ storageGi: e.target.value === '' ? 0 : Number(e.target.value) })
}
/>
)}
</div>
</div>
</div>
);
}
function CustomDomainPricing({
customDomain,
readOnly,
onChange,
}: {
customDomain: CustomDomainCatalogRow;
readOnly: boolean;
onChange: (cycle: BillingCycle, value: number) => void;
}) {
return (
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-3">Custom domain + SSL</h3>
<p className="text-xs text-gray-500 mb-3">Flat fee per billing cycle (not resource-based)</p>
<div className="grid grid-cols-3 gap-2">
{cycles.map((cycle) => {
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
return (
<div key={cycle}>
<label className="text-xs text-gray-500 capitalize">{cycle} (T)</label>
{readOnly ? (
<p className="font-mono text-sm">{Number(customDomain[field]).toLocaleString('en-US')}</p>
) : (
<input
type="number"
min={0}
className="input-field w-full text-sm mt-0.5"
value={customDomain[field]}
onChange={(e) =>
onChange(cycle, e.target.value === '' ? 0 : Number(e.target.value))
}
/>
)}
</div>
);
})}
</div>
</div>
);
}
export default function AdminBillingPage() {
const queryClient = useQueryClient();
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
const [activeOptionalService, setActiveOptionalService] = useState<string>('redis');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PricingCatalog | null>(null);
const { data: catalog, isLoading } = useQuery<PricingCatalog>({
queryKey: ['pricing-catalog'],
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
});
useEffect(() => {
const options = catalog?.runtimeOptions ?? [];
if (options.length === 0) return;
if (!options.some((o) => o.value === activeRuntime)) {
setActiveRuntime(options[0].value);
}
}, [catalog, activeRuntime]);
useEffect(() => {
const options = catalog?.optionalServiceOptions ?? [];
if (options.length === 0) return;
if (!options.some((o) => o.value === activeOptionalService)) {
setActiveOptionalService(options[0].value);
}
}, [catalog, activeOptionalService]);
const saveMutation = useMutation({
mutationFn: (catalog: PricingCatalog) =>
api.patch('/billing/pricing-catalog', toPricingCatalogPatch(catalog)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
toast.success('Billing plans saved');
setEditing(false);
setDraft(null);
},
onError: (err: unknown) => {
toast.error(formatApiError(err, 'Failed to save billing plans'));
},
});
const display = editing && draft ? draft : catalog;
const runtimeTabs = display?.runtimeOptions ?? catalog?.runtimeOptions ?? [];
const startEdit = () => {
if (!catalog) return;
setDraft(cloneCatalog(catalog));
if (!activeRuntime && catalog.runtimeOptions[0]) {
setActiveRuntime(catalog.runtimeOptions[0].value);
}
setEditing(true);
};
const updateRuntimePrice = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
runtimes: {
...draft.runtimes,
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
},
});
};
const updateOptionalProfile = (patch: Partial<OptionalServiceProfileRow>) => {
if (!draft) return;
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
profile: { ...entry.profile, ...patch },
},
},
});
};
const updateOptionalRate = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
rates: entry.rates.map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
},
},
});
};
const updateCustomDomain = (cycle: BillingCycle, value: number) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
customDomain: { ...draft.customDomain, [field]: value },
});
};
const fillYearlyFromMonthly = (scope: 'runtime' | 'optional') => {
if (!draft) return;
if (scope === 'runtime') {
setDraft({
...draft,
runtimes: {
...draft.runtimes,
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
},
});
} else {
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
rates: entry.rates.map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
},
},
});
}
};
const handleSave = () => {
if (!draft) return;
saveMutation.mutate(draft);
};
const runtimeRows = display?.runtimes[activeRuntime] ?? [];
const optionalServiceTabs = display?.optionalServiceOptions ?? [];
const activeOptionalEntry = display?.optionalServices[activeOptionalService];
const optionalRateRows = activeOptionalEntry?.rates ?? [];
const customDomain = display?.customDomain;
return (
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="page-title flex items-center gap-2">
<DollarSign className="w-6 h-6" /> Billing Plans
</h1>
<p className="page-subtitle">
Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost =
their usage × these rates.
</p>
</div>
{!editing ? (
<button
onClick={startEdit}
disabled={!catalog}
className="btn-primary flex items-center gap-2 shrink-0"
>
<Edit2 className="w-4 h-4" /> Edit plans
</button>
) : (
<div className="flex gap-2 shrink-0">
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="btn-primary text-sm disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => {
setEditing(false);
setDraft(null);
}}
className="btn-secondary text-sm"
>
Cancel
</button>
</div>
)}
</div>
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 flex gap-3 text-sm text-blue-900">
<Info className="w-5 h-5 shrink-0 text-blue-600 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">How billing works</p>
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
<li>
<strong>Applications</strong> user picks runtime resources in deploy; you set price per
core, GB, base fee, and database addon. CPU and RAM in estimates bill proportionally (for
example half the per-GB rate at half a gigabyte of memory).
</li>
<li>
<strong>Optional services (Redis, RabbitMQ)</strong> same unit matrix per service as
runtimes; deploy wizard defaults are edited separately. CPU/RAM in the cost calculator bill in
proportion to actual limits (e.g. 500Mi counts as 0.5× the per-GB memory rate).
</li>
<li>
<strong>Deploy defaults</strong> optional prefill only; changing them does not change
what existing apps pay unless the user chose those values at deploy.
</li>
</ul>
</div>
</div>
{isLoading ? (
<div className="text-center py-12 text-gray-400">Loading...</div>
) : !display ? (
<div className="text-center py-12 text-gray-400">No pricing data</div>
) : (
<>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Box className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Application runtimes</h2>
<p className="text-sm text-gray-500 mt-0.5">Unit pricing per runtime (hourly / monthly / yearly)</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{runtimeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setActiveRuntime(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeRuntime === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} unit prices
</h3>
{editing && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('runtime')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
</div>
<PricingMatrixTable
rows={runtimeRows}
readOnly={!editing}
onChange={updateRuntimePrice}
/>
</div>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Layers className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
<p className="text-sm text-gray-500 mt-0.5">
Unit pricing per service (hourly / monthly / yearly)
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{optionalServiceTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setActiveOptionalService(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeOptionalService === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">
{optionalServiceTabs.find((t) => t.value === activeOptionalService)?.label} unit prices
</h3>
{editing && activeOptionalEntry && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('optional')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
</div>
{activeOptionalEntry && (
<PricingMatrixTable
rows={optionalRateRows}
readOnly={!editing}
onChange={updateOptionalRate}
/>
)}
</div>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Server className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Optional services deploy defaults</h2>
<p className="text-sm text-gray-500 mt-0.5">
Prefill CPU, memory, and storage when a user enables each service in the deploy wizard
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{optionalServiceTabs.map((tab) => (
<button
key={`defaults-${tab.value}`}
type="button"
onClick={() => setActiveOptionalService(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeOptionalService === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{activeOptionalEntry && (
<DeployDefaultsFields
service={activeOptionalService}
profile={activeOptionalEntry.profile}
readOnly={!editing}
onUpdate={updateOptionalProfile}
/>
)}
</div>
{customDomain && (
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Globe className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Add-ons</h2>
<p className="text-sm text-gray-500 mt-0.5">Flat fees not tied to a runtime</p>
</div>
</div>
<CustomDomainPricing
customDomain={customDomain}
readOnly={!editing}
onChange={updateCustomDomain}
/>
</div>
)}
</>
)}
<LifecycleSettingsSection />
</div>
);
}
function LifecycleSettingsSection() {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [hourlyHours, setHourlyHours] = useState('');
const [monthlyDays, setMonthlyDays] = useState('');
const [yearlyDays, setYearlyDays] = useState('');
const { data: settings, isLoading } = useQuery<LifecycleSettings>({
queryKey: ['lifecycle-settings'],
queryFn: () => api.get('/lifecycle/settings').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
toast.success('Lifecycle settings updated');
setEditing(false);
},
onError: (err: unknown) => {
toast.error(formatApiError(err, 'Failed to save'));
},
});
const startEditing = () => {
if (settings) {
setHourlyHours(String((settings.hourly.deleteAfterMs || 0) / 3600000));
setMonthlyDays(String((settings.monthly.deleteAfterMs || 0) / 86400000));
setYearlyDays(String((settings.yearly.deleteAfterMs || 0) / 86400000));
}
setEditing(true);
};
const handleSave = () => {
const body: Record<string, number> = {};
if (hourlyHours) body.hourlyDeleteAfterMs = Number(hourlyHours) * 3600000;
if (monthlyDays) body.monthlyDeleteAfterMs = Number(monthlyDays) * 86400000;
if (yearlyDays) body.yearlyDeleteAfterMs = Number(yearlyDays) * 86400000;
saveMutation.mutate(body);
};
return (
<div className="card mt-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Data Retention & Deletion Policy</h2>
</div>
{!editing && (
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
<Edit2 className="w-3 h-3" /> Edit
</button>
)}
</div>
<p className="text-sm text-gray-500 mb-4">
Configure how long user data is retained after plan expiration before permanent deletion.
</p>
{isLoading ? (
<div className="text-center py-6 text-gray-400">Loading...</div>
) : editing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-blue-50 border border-blue-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-blue-600" />
<h3 className="font-semibold text-blue-900">Hourly Plans</h3>
</div>
<label className="text-xs text-blue-700 font-medium">Delete after (hours):</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={hourlyHours}
onChange={(e) => setHourlyHours(e.target.value)}
min={1}
placeholder="24"
/>
</div>
<div className="p-4 rounded-xl bg-purple-50 border border-purple-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-purple-900">Monthly Plans</h3>
</div>
<label className="text-xs text-purple-700 font-medium">Delete after (days):</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={monthlyDays}
onChange={(e) => setMonthlyDays(e.target.value)}
min={1}
placeholder="3"
/>
</div>
<div className="p-4 rounded-xl bg-green-50 border border-green-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-green-600" />
<h3 className="font-semibold text-green-900">Yearly Plans</h3>
</div>
<label className="text-xs text-green-700 font-medium">Delete after (days):</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={yearlyDays}
onChange={(e) => setYearlyDays(e.target.value)}
min={1}
placeholder="7"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setEditing(false)} className="btn-ghost">
Cancel
</button>
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="btn-primary disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-blue-50/50 border border-blue-100">
<h3 className="font-semibold text-blue-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> Hourly Plans
</h3>
<p className="text-2xl font-bold text-blue-700 mt-2">
{settings?.hourly.deleteAfterHours ??
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
<span className="text-sm font-normal ml-1">hours</span>
</p>
</div>
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> Monthly Plans
</h3>
<p className="text-2xl font-bold text-purple-700 mt-2">
{settings?.monthly.deleteAfterDays ??
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1">days</span>
</p>
</div>
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> Yearly Plans
</h3>
<p className="text-2xl font-bold text-green-700 mt-2">
{settings?.yearly.deleteAfterDays ??
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1">days</span>
</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,674 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Cluster, ClusterResources } from '@/types';
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
function ResourcePanel({ clusterId }: { clusterId: string }) {
const { data, isLoading, error } = useQuery<ClusterResources>({
queryKey: ['cluster-resources', clusterId],
queryFn: () => api.get(`/clusters/${clusterId}/resources`).then((r) => r.data),
refetchInterval: 30000,
});
if (isLoading) return <div className="p-4 text-sm text-gray-500">Loading resources...</div>;
if (error) return <div className="p-4 text-sm text-red-500">Failed to load resources</div>;
if (!data) return null;
const cpuCap = parseFloat(data.totalCpuCapacity);
const cpuAlloc = parseFloat(data.totalCpuAllocatable);
const memCap = parseFloat(data.totalMemoryCapacity);
const memAlloc = parseFloat(data.totalMemoryAllocatable);
const cpuUsedPct = cpuCap > 0 ? ((cpuCap - cpuAlloc) / cpuCap * 100) : 0;
const memUsedPct = memCap > 0 ? ((memCap - memAlloc) / memCap * 100) : 0;
return (
<div className="mt-4 pt-4 border-t border-gray-200 space-y-4 animate-fade-in">
{/* Summary cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-blue-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-700">{data.nodeCount}</div>
<div className="text-xs text-blue-600">Nodes</div>
</div>
<div className="bg-purple-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-purple-700">{data.podCount}</div>
<div className="text-xs text-purple-600">Pods</div>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-700">{data.appCount}</div>
<div className="text-xs text-green-600">Apps</div>
</div>
<div className="bg-orange-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-orange-700">{data.totalCpuCapacity}</div>
<div className="text-xs text-orange-600">Total CPU</div>
</div>
</div>
{/* CPU & Memory bars */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">CPU Reserved</span>
<span className="font-medium">{cpuUsedPct.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${cpuUsedPct > 80 ? 'bg-red-500' : cpuUsedPct > 60 ? 'bg-yellow-500' : 'bg-blue-500'}`}
style={{ width: `${Math.min(cpuUsedPct, 100)}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{data.totalCpuCapacity} capacity · {data.totalCpuAllocatable} allocatable
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Memory Reserved</span>
<span className="font-medium">{memUsedPct.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${memUsedPct > 80 ? 'bg-red-500' : memUsedPct > 60 ? 'bg-yellow-500' : 'bg-purple-500'}`}
style={{ width: `${Math.min(memUsedPct, 100)}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{data.totalMemoryCapacity} capacity · {data.totalMemoryAllocatable} allocatable
</div>
</div>
</div>
{/* Nodes table */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Nodes</h4>
<div className="overflow-x-auto -mx-2 px-2 rounded-xl border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Roles</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">CPU (Cap / Alloc)</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Memory (Cap / Alloc)</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{data.nodes.map((node) => (
<tr key={node.name} className="hover:bg-gray-50">
<td className="px-4 py-2 font-mono text-xs">{node.name}</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
node.status === 'Ready' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{node.status}
</span>
</td>
<td className="px-4 py-2 text-gray-600">{node.roles}</td>
<td className="px-4 py-2 text-gray-600 font-mono text-xs">{node.cpuCapacity} / {node.cpuAllocatable}</td>
<td className="px-4 py-2 text-gray-600 font-mono text-xs">{node.memoryCapacity} / {node.memoryAllocatable}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function apiErrorMessage(err: unknown, fallback: string): string {
const e = err as { response?: { data?: { message?: string | string[] } } };
const msg = e?.response?.data?.message;
if (Array.isArray(msg)) return msg.join(', ');
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
interface ClusterToolField {
key: string;
label: string;
type: 'text' | 'email';
required: boolean;
placeholder?: string;
helpText?: string;
}
interface ClusterTool {
id: string;
name: string;
description: string;
category: string;
dependencies: string[];
installFields: ClusterToolField[];
status: 'not_installed' | 'installing' | 'installed' | 'failed' | 'unknown';
message?: string;
details?: Record<string, unknown>;
}
const TOOL_STATUS_BADGE: Record<ClusterTool['status'], { label: string; cls: string }> = {
installed: { label: 'Installed', cls: 'badge-green' },
installing: { label: 'Installing…', cls: 'badge-yellow' },
failed: { label: 'Failed', cls: 'badge-red' },
not_installed: { label: 'Not installed', cls: 'badge-gray' },
unknown: { label: 'Unknown', cls: 'badge-gray' },
};
function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterTool; tools: ClusterTool[] }) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showForm, setShowForm] = useState(false);
const [fields, setFields] = useState<Record<string, string>>({});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['cluster-tools', clusterId] });
const installMutation = useMutation({
mutationFn: (params: Record<string, string>) =>
api.post(`/clusters/${clusterId}/tools/${tool.id}/install`, params),
onSuccess: (res) => {
invalidate();
setShowForm(false);
setFields({});
toast.success(res.data?.message || `${tool.name} install started`);
},
onError: (err) => toast.error(apiErrorMessage(err, `Failed to install ${tool.name}`)),
});
const uninstallMutation = useMutation({
mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`),
onSuccess: (res) => {
invalidate();
toast.success(res.data?.message || `${tool.name} removed`);
},
onError: (err) => toast.error(apiErrorMessage(err, `Failed to remove ${tool.name}`)),
});
const unmetDeps = tool.dependencies.filter(
(depId) => tools.find((t) => t.id === depId)?.status !== 'installed',
);
const depsBlocked = unmetDeps.length > 0;
const isInstalled = tool.status === 'installed';
const isBusy = installMutation.isPending || uninstallMutation.isPending;
const badge = TOOL_STATUS_BADGE[tool.status];
const startInstall = () => {
if (tool.installFields.length > 0) {
setShowForm((s) => !s);
} else {
installMutation.mutate({});
}
};
const submitForm = () => {
for (const f of tool.installFields) {
if (f.required && !fields[f.key]?.trim()) {
toast.error(`${f.label} is required`);
return;
}
}
installMutation.mutate(fields);
};
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{tool.name}</h3>
<span className={`badge ${badge.cls}`}>{badge.label}</span>
<span className="badge badge-gray">{tool.category}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{tool.description}</p>
{tool.message && (
<p className="text-xs text-gray-400 mt-1">{tool.message}</p>
)}
{depsBlocked && !isInstalled && (
<p className="text-xs text-amber-600 mt-1">
Requires:{' '}
{unmetDeps
.map((d) => tools.find((t) => t.id === d)?.name || d)
.join(', ')}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{tool.status === 'installing' && (
<RotateCw className="w-4 h-4 text-yellow-600 animate-spin" />
)}
{isInstalled ? (
<button
type="button"
onClick={async () => {
const ok = await confirm({
title: `Remove ${tool.name}`,
message: `Uninstall "${tool.name}" from this cluster?`,
confirmText: 'Uninstall',
variant: 'danger',
});
if (ok) uninstallMutation.mutate();
}}
disabled={isBusy}
className="btn-secondary text-sm text-red-600 disabled:opacity-50"
>
{uninstallMutation.isPending ? 'Removing…' : 'Uninstall'}
</button>
) : (
<button
type="button"
onClick={startInstall}
disabled={isBusy || depsBlocked}
className="btn-primary text-sm disabled:opacity-50"
title={depsBlocked ? 'Install required tools first' : undefined}
>
{installMutation.isPending
? 'Installing…'
: tool.status === 'failed'
? 'Repair'
: 'Install'}
</button>
)}
</div>
</div>
{showForm && !isInstalled && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3 animate-fade-in">
{tool.installFields.map((f) => (
<div key={f.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{f.label}{f.required ? ' *' : ''}
</label>
<input
type={f.type === 'email' ? 'email' : 'text'}
className="input-field text-sm"
placeholder={f.placeholder}
value={fields[f.key] || ''}
onChange={(e) => setFields({ ...fields, [f.key]: e.target.value })}
/>
{f.helpText && <p className="text-xs text-gray-400 mt-1">{f.helpText}</p>}
</div>
))}
<div className="flex gap-2">
<button
type="button"
onClick={submitForm}
disabled={installMutation.isPending}
className="btn-primary text-sm"
>
{installMutation.isPending ? 'Installing…' : 'Confirm install'}
</button>
<button type="button" onClick={() => setShowForm(false)} className="btn-ghost text-sm">
Cancel
</button>
</div>
</div>
)}
</div>
);
}
function ClusterToolsPanel({
clusters,
selectedClusterId,
onSelectCluster,
}: {
clusters: Cluster[];
selectedClusterId: string | null;
onSelectCluster: (id: string) => void;
}) {
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
const { data: tools = [], isLoading, error } = useQuery<ClusterTool[]>({
queryKey: ['cluster-tools', clusterId],
queryFn: () => api.get(`/clusters/${clusterId}/tools`).then((r) => r.data),
enabled: !!clusterId,
refetchInterval: (query) =>
(query.state.data || []).some((t) => t.status === 'installing') ? 8000 : 30000,
});
return (
<div className="card p-4 border border-indigo-100 bg-indigo-50/30">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ScrollText className="w-5 h-5 text-indigo-600" /> Tools Management
</h2>
<p className="text-sm text-gray-600 mt-1">
Install and manage infrastructure tools per cluster. Nothing is installed automatically
add only what each cluster needs.
</p>
</div>
{clusters.length > 1 && (
<select
className="input-field text-sm py-1.5 max-w-[220px]"
value={clusterId || ''}
onChange={(e) => onSelectCluster(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}{c.isDefault ? ' (default)' : ''}
</option>
))}
</select>
)}
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Loading tools</p>
) : error ? (
<p className="text-sm text-red-500">Failed to load tools for this cluster.</p>
) : (
<div className="space-y-3">
{clusterId &&
tools.map((tool) => (
<ToolRow key={tool.id} clusterId={clusterId} tool={tool} tools={tools} />
))}
</div>
)}
</div>
);
}
export default function AdminClustersPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showForm, setShowForm] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
const [loggingClusterId, setLoggingClusterId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
description: '',
apiServer: '',
kubeconfig: '',
region: '',
provider: '',
weight: 1,
tags: '',
isDefault: false,
});
const { data: clusters = [], isLoading } = useQuery<Cluster[]>({
queryKey: ['admin-clusters'],
queryFn: () => api.get('/clusters').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: typeof form) => api.post('/clusters', {
...data,
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
weight: Number(data.weight) || 1,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster added & connection verified ✓');
setShowForm(false);
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
},
onError: (err: any) => {
const message = err?.response?.data?.message || 'Failed to add cluster';
toast.error(message);
},
});
const testMutation = useMutation({
mutationFn: (id: string) => {
setTestingId(id);
return api.post(`/clusters/${id}/test`);
},
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
const data = res.data;
if (data.connected) {
toast.success(`Connection OK — Kubernetes ${data.version}`);
} else {
toast.error(`Connection failed: ${data.error}`);
}
setTestingId(null);
},
onError: () => {
toast.error('Failed to test connection');
setTestingId(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster removed');
},
});
const toggleResources = (id: string) => {
setExpandedResources((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<div className="space-y-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Cluster Management</h1>
<p className="page-subtitle">{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered</p>
</div>
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add Cluster'}
</button>
</div>
{clusters.length > 0 && (
<ClusterToolsPanel
clusters={clusters}
selectedClusterId={loggingClusterId}
onSelectCluster={setLoggingClusterId}
/>
)}
{showForm && (
<div className="card space-y-4 animate-slide-up">
<h2 className="text-lg font-semibold text-gray-900">Register New Cluster</h2>
<p className="text-sm text-gray-500">
The system will verify the Kubernetes connection before registering.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Server URL</label>
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
<option value="">Select provider</option>
<option value="aws">AWS (EKS)</option>
<option value="gcp">GCP (GKE)</option>
<option value="azure">Azure (AKS)</option>
<option value="bare-metal">Bare Metal</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Weight</label>
<input
type="number"
min={1}
className="input-field"
value={form.weight}
onChange={(e) => setForm({ ...form, weight: Number(e.target.value) || 1 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tags</label>
<input
className="input-field"
placeholder="ssd, production, iran"
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kubeconfig (YAML)</label>
<textarea
className="input-field font-mono text-xs"
rows={8}
placeholder="Paste your kubeconfig here..."
value={form.kubeconfig}
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isDefault"
checked={form.isDefault}
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
/>
<label htmlFor="isDefault" className="text-sm text-gray-700">Set as default cluster</label>
</div>
<button
onClick={() => createMutation.mutate(form)}
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
className="btn-primary"
>
{createMutation.isPending ? <><RotateCw className="w-4 h-4 inline animate-spin" /> Verifying connection & adding...</> : 'Add Cluster'}
</button>
</div>
)}
{isLoading ? (
<div className="space-y-3">
{[1,2].map(i => (
<div key={i} className="card flex items-center gap-4">
<div className="skeleton w-11 h-11 rounded-xl" />
<div className="flex-1 space-y-2">
<div className="skeleton h-4 w-40" />
<div className="skeleton h-3 w-64" />
</div>
<div className="skeleton h-8 w-24 rounded-lg" />
</div>
))}
</div>
) : clusters.length === 0 ? (
<div className="card text-center py-16">
<Server className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">No clusters registered yet.</p>
<p className="text-gray-400 text-sm mt-1">Add a Kubernetes cluster to start deploying applications.</p>
</div>
) : (
<div className="grid gap-4">
{clusters.map((cluster) => (
<div key={cluster.id} className="card">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
cluster.status === 'active' ? 'bg-emerald-50' : 'bg-red-50'
}`}>
{cluster.status === 'active' ? <CheckCircle className="w-5 h-5 text-emerald-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
{cluster.isDefault && (
<span className="badge badge-blue">Default</span>
)}
<span className={`badge ${
cluster.status === 'active' ? 'badge-green'
: cluster.status === 'maintenance' ? 'badge-yellow'
: 'badge-red'
}`}>
{cluster.status}
</span>
<span className={`badge ${
cluster.healthStatus === 'healthy' ? 'badge-green'
: cluster.healthStatus === 'degraded' ? 'badge-yellow'
: cluster.healthStatus === 'unhealthy' ? 'badge-red'
: 'badge-gray'
}`}>
health: {cluster.healthStatus || 'unknown'}
</span>
<span className="badge badge-gray">weight {cluster.weight || 1}</span>
</div>
<p className="text-sm text-gray-500 truncate">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
</p>
{cluster.healthMessage && (
<p className="text-xs text-gray-400 mt-1">
{cluster.healthMessage}
{cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString()}` : ''}
</p>
)}
{cluster.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{cluster.tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">
{tag}
</span>
))}
</div>
)}
{cluster.availableResources && (
<p className="text-xs text-gray-500 mt-2">
CPU {cluster.availableResources.totalCpuAllocatable || 'n/a'} · Memory {cluster.availableResources.totalMemoryAllocatable || 'n/a'} · Pods {cluster.availableResources.podCount ?? 'n/a'} · Apps {cluster.availableResources.appCount ?? 'n/a'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<button
onClick={() => toggleResources(cluster.id)}
className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`}
>
<BarChart3 className="w-4 h-4 inline" /> Resources
</button>
<button
onClick={() => testMutation.mutate(cluster.id)}
disabled={testingId === cluster.id}
className="btn-ghost text-sm disabled:opacity-50"
>
{testingId === cluster.id ? <><Clock className="w-3 h-3 inline animate-spin" /> Testing...</> : <><Plug className="w-3 h-3 inline" /> Test</>}
</button>
<button
onClick={async () => {
const ok = await confirm({ title: 'Remove Cluster', message: `Are you sure you want to remove "${cluster.name}"?`, confirmText: 'Remove', variant: 'danger' });
if (ok) deleteMutation.mutate(cluster.id);
}}
className="text-sm text-red-600 hover:text-red-800 font-medium"
>
Remove
</button>
</div>
</div>
{/* Expandable resource panel */}
{expandedResources.has(cluster.id) && (
<ResourcePanel clusterId={cluster.id} />
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,295 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText, Search, User, Wallet, CreditCard, XCircle, Download } from 'lucide-react';
import { toast } from 'react-toastify';
import api from '@/lib/api';
import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types';
const statusLabels: Record<InvoiceStatus, string> = {
draft: 'Draft',
issued: 'Unpaid',
partially_paid: 'Partially paid',
paid: 'Paid',
void: 'Void',
failed: 'Failed',
};
const statusClasses: Record<InvoiceStatus, string> = {
draft: 'badge-gray',
issued: 'badge-yellow',
partially_paid: 'badge-blue',
paid: 'badge-green',
void: 'badge-gray',
failed: 'badge-red',
};
export default function AdminInvoicesPage() {
const queryClient = useQueryClient();
const [status, setStatus] = useState<'all' | InvoiceStatus>('all');
const [paymentMethod, setPaymentMethod] = useState<'all' | PaymentMethod>('all');
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [statusReason, setStatusReason] = useState('');
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['admin-invoices', status, paymentMethod, search],
queryFn: () => {
const params: Record<string, string> = { limit: '200' };
if (status !== 'all') params.status = status;
if (paymentMethod !== 'all') params.paymentMethod = paymentMethod;
if (search.trim()) params.search = search.trim();
return api.get('/billing/admin/invoices', { params }).then((r) => r.data);
},
});
const { data: selectedInvoice } = useQuery<Invoice>({
queryKey: ['admin-invoice', selectedId],
queryFn: () => api.get(`/billing/admin/invoices/${selectedId}`).then((r) => r.data),
enabled: !!selectedId,
});
const updateStatusMutation = useMutation({
mutationFn: ({ invoiceId, nextStatus, reason }: { invoiceId: string; nextStatus: InvoiceStatus; reason: string }) =>
api.patch(`/billing/admin/invoices/${invoiceId}/status`, { status: nextStatus, reason }).then((r) => r.data),
onSuccess: () => {
toast.success('Invoice status updated');
setStatusReason('');
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
queryClient.invalidateQueries({ queryKey: ['admin-invoice', selectedId] });
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update invoice status'),
});
const downloadPdfMutation = useMutation({
mutationFn: async (invoice: Invoice) => {
const { data } = await api.get(`/billing/admin/invoices/${invoice.id}/pdf`, { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.download = `${invoice.invoiceNumber}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => toast.error('Failed to download invoice PDF'),
});
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-';
const handleStatusUpdate = (nextStatus: InvoiceStatus) => {
if (!selectedInvoice) return;
if (!statusReason.trim()) {
toast.error('Reason is required for manual status changes');
return;
}
updateStatusMutation.mutate({
invoiceId: selectedInvoice.id,
nextStatus,
reason: statusReason.trim(),
});
};
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in">
<div>
<h1 className="page-title flex items-center gap-2">
<FileText className="w-6 h-6" /> Invoice Management
</h1>
<p className="page-subtitle">Track all user invoices, payments, gateway refs, and wallet transactions.</p>
</div>
<div className="card grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="relative md:col-span-2">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search invoice, email, or application"
className="input-field pl-9 w-full"
/>
</div>
<select value={status} onChange={(e) => setStatus(e.target.value as any)} className="input-field">
<option value="all">All statuses</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value as any)} className="input-field">
<option value="all">All methods</option>
<option value="wallet">Wallet</option>
<option value="gateway">Gateway</option>
<option value="mixed">Mixed</option>
</select>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 card p-0 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Invoice</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">User</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Method</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{isLoading ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">Loading...</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">No invoices found</td></tr>
) : invoices.map((invoice) => (
<tr
key={invoice.id}
onClick={() => setSelectedId(invoice.id)}
className={`cursor-pointer hover:bg-gray-50 ${selectedId === invoice.id ? 'bg-primary-50' : ''}`}
>
<td className="px-4 py-3">
<p className="text-sm font-semibold text-gray-900">{invoice.invoiceNumber}</p>
<p className="text-xs text-gray-500">{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-900">{invoice.user?.email || invoice.userId}</p>
</td>
<td className="px-4 py-3">
<span className={statusClasses[invoice.status]}>{statusLabels[invoice.status]}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{invoice.paymentMethod || '-'}</td>
<td className="px-4 py-3 text-right">
<p className="text-sm font-bold text-gray-900">{formatPrice(invoice.dueAmount)} T</p>
<p className="text-xs text-gray-400">Total {formatPrice(invoice.total)} T</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
{!selectedInvoice ? (
<div className="text-center py-12 text-gray-400">
<FileText className="w-10 h-10 mx-auto mb-3" />
Select an invoice
</div>
) : (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
</div>
<div className="flex flex-col items-end gap-2">
<span className={statusClasses[selectedInvoice.status]}>{statusLabels[selectedInvoice.status]}</span>
<button
type="button"
onClick={() => downloadPdfMutation.mutate(selectedInvoice)}
disabled={downloadPdfMutation.isPending}
className="btn-secondary text-xs inline-flex items-center gap-1 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5" />
Download PDF
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Total</p>
<p className="font-bold text-gray-900">{formatPrice(selectedInvoice.total)} T</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Due</p>
<p className="font-bold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} T</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-700">
<User className="w-4 h-4 text-gray-400" />
{selectedInvoice.user?.email || selectedInvoice.userId}
</div>
<div className="flex items-center gap-2 text-gray-700">
<CreditCard className="w-4 h-4 text-gray-400" />
Tracking: {selectedInvoice.gatewayTrackingCode || '-'}
</div>
<div className="flex items-center gap-2 text-gray-700">
<Wallet className="w-4 h-4 text-gray-400" />
Method: {selectedInvoice.paymentMethod || '-'}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Line items</h3>
<div className="space-y-2">
{(selectedInvoice.lines || []).map((line) => (
<div key={line.id} className="border border-gray-100 rounded-xl p-3 flex justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-900">{line.label}</p>
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
</div>
<span className="text-sm font-bold">{formatPrice(line.amount)} T</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Transactions</h3>
{(selectedInvoice.transactions || []).length === 0 ? (
<p className="text-sm text-gray-400">No linked transactions yet</p>
) : (
<div className="space-y-2">
{(selectedInvoice.transactions || []).map((tx) => (
<div key={tx.id} className="border border-gray-100 rounded-xl p-3">
<div className="flex justify-between gap-3">
<span className="text-sm text-gray-700">{tx.description || tx.type}</span>
<span className="text-sm font-bold">{formatPrice(tx.amount)} T</span>
</div>
<p className="text-xs text-gray-400 mt-1">{tx.type} · {formatDate(tx.createdAt)}</p>
</div>
))}
</div>
)}
</div>
<div className="border-t border-gray-100 pt-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-900">Manual status change</h3>
<textarea
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
className="input-field w-full min-h-[80px]"
placeholder="Reason is required for audit visibility"
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleStatusUpdate('failed')}
disabled={updateStatusMutation.isPending}
className="btn-secondary text-red-600 flex items-center justify-center gap-2 disabled:opacity-50"
>
<XCircle className="w-4 h-4" /> Failed
</button>
<button
type="button"
onClick={() => handleStatusUpdate('void')}
disabled={updateStatusMutation.isPending}
className="btn-secondary disabled:opacity-50"
>
Void
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,395 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Cluster, ClusterPool } from '@/types';
import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
export default function AdminPoolsPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showForm, setShowForm] = useState(false);
const [editingPool, setEditingPool] = useState<ClusterPool | null>(null);
const [form, setForm] = useState({
name: '',
description: '',
strategy: 'weighted-resource' as 'least-apps' | 'round-robin' | 'weighted-round-robin' | 'least-loaded' | 'weighted-resource' | 'region-based',
clusterIds: [] as string[],
isDefault: false,
priority: 100,
});
const { data: pools = [], isLoading } = useQuery<ClusterPool[]>({
queryKey: ['admin-pools'],
queryFn: () => api.get('/clusters/pools').then((r) => r.data),
});
const { data: clusters = [] } = useQuery<Cluster[]>({
queryKey: ['admin-clusters'],
queryFn: () => api.get('/clusters').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: typeof form) => api.post('/clusters/pools', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
toast.success('Cluster pool created!');
resetForm();
},
onError: (err: any) => {
toast.error(err?.response?.data?.message || 'Failed to create pool');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: typeof form }) =>
api.patch(`/clusters/pools/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
toast.success('Cluster pool updated!');
resetForm();
},
onError: (err: any) => {
toast.error(err?.response?.data?.message || 'Failed to update pool');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/clusters/pools/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
toast.success('Cluster pool deleted');
},
});
const resetForm = () => {
setShowForm(false);
setEditingPool(null);
setForm({ name: '', description: '', strategy: 'weighted-resource', clusterIds: [], isDefault: false, priority: 100 });
};
const startEdit = (pool: ClusterPool) => {
setEditingPool(pool);
setForm({
name: pool.name,
description: pool.description || '',
strategy: pool.strategy,
clusterIds: pool.clusterIds,
isDefault: pool.isDefault || false,
priority: pool.priority || 100,
});
setShowForm(true);
};
const handleSubmit = () => {
if (editingPool) {
updateMutation.mutate({ id: editingPool.id, data: form });
} else {
createMutation.mutate(form);
}
};
const toggleCluster = (clusterId: string) => {
setForm((prev) => ({
...prev,
clusterIds: prev.clusterIds.includes(clusterId)
? prev.clusterIds.filter((id) => id !== clusterId)
: [...prev.clusterIds, clusterId],
}));
};
return (
<div className="space-y-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Cluster Pools</h1>
<p className="page-subtitle">
Load-balanced groups of clusters for automatic app distribution
</p>
</div>
<button
onClick={() => { showForm ? resetForm() : setShowForm(true); }}
className={showForm ? 'btn-ghost' : 'btn-primary'}
>
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Create Pool'}
</button>
</div>
{/* Create/Edit Form */}
{showForm && (
<div className="card space-y-4 animate-slide-up">
<h2 className="text-lg font-semibold text-gray-900">
{editingPool ? `Edit "${editingPool.name}"` : 'Create New Cluster Pool'}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pool Name</label>
<input
className="input-field"
placeholder="production-pool"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Strategy</label>
<select
className="input-field"
value={form.strategy}
onChange={(e) => setForm({ ...form, strategy: e.target.value as any })}
>
<option value="weighted-resource">Weighted Resource prefer healthy capacity and higher weights</option>
<option value="least-loaded">Least Loaded prefer lowest CPU, memory, and pod pressure</option>
<option value="weighted-round-robin">Weighted Round Robin rotate proportionally by weight</option>
<option value="region-based">Region Based prefer matching region, then weight and load</option>
<option value="least-apps">Least Apps deploy to cluster with fewest apps</option>
<option value="round-robin">Round Robin rotate across clusters evenly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<input
type="number"
min={1}
className="input-field"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: Number(e.target.value) || 100 })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input
className="input-field"
placeholder="Load-balanced pool for production workloads"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={form.isDefault}
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
/>
Use as default allocator pool
</label>
{/* Cluster selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Clusters ({form.clusterIds.length} selected)
</label>
{clusters.length === 0 ? (
<p className="text-sm text-gray-400 py-4 text-center">
No clusters registered. Add clusters first.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{clusters.map((cluster) => {
const isSelected = form.clusterIds.includes(cluster.id);
return (
<button
key={cluster.id}
type="button"
onClick={() => toggleCluster(cluster.id)}
className={`p-3 rounded-xl border-2 text-left transition-all ${
isSelected
? 'border-primary-500 bg-primary-50 shadow-sm'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected ? 'border-primary-500 bg-primary-500' : 'border-gray-300'
}`}>
{isSelected && <span className="text-white text-xs"></span>}
</div>
<div>
<p className="font-semibold text-sm text-gray-900">
{cluster.name}
{cluster.isDefault && (
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
Default
</span>
)}
</p>
<p className="text-xs text-gray-500">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1}
</p>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
cluster.status === 'active'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{cluster.status}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
cluster.healthStatus === 'healthy'
? 'bg-green-100 text-green-700'
: cluster.healthStatus === 'degraded'
? 'bg-yellow-100 text-yellow-700'
: cluster.healthStatus === 'unhealthy'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-600'
}`}>
{cluster.healthStatus || 'unknown'}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
<div className="flex space-x-3">
<button
onClick={handleSubmit}
disabled={
!form.name ||
form.clusterIds.length === 0 ||
createMutation.isPending ||
updateMutation.isPending
}
className="btn-primary disabled:opacity-50"
>
{createMutation.isPending || updateMutation.isPending
? <><Clock className="w-4 h-4 inline animate-spin" /> Saving...</>
: editingPool
? 'Update Pool'
: 'Create Pool'}
</button>
<button onClick={resetForm} className="btn-secondary">
Cancel
</button>
</div>
</div>
)}
{/* Pool List */}
{isLoading ? (
<div className="space-y-3">
{[1,2].map(i => (
<div key={i} className="card space-y-3">
<div className="flex items-center gap-3">
<div className="skeleton h-5 w-32" />
<div className="skeleton h-5 w-16 rounded-full" />
</div>
<div className="skeleton h-3 w-64" />
<div className="flex gap-2">
<div className="skeleton h-8 w-28 rounded-lg" />
<div className="skeleton h-8 w-28 rounded-lg" />
</div>
</div>
))}
</div>
) : pools.length === 0 ? (
<div className="card text-center py-16">
<Scale className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">No cluster pools created yet</p>
<p className="text-sm text-gray-400 mt-1">
Create a pool to enable load-balanced deployment across multiple clusters
</p>
</div>
) : (
<div className="grid gap-4">
{pools.map((pool) => {
const poolClusters = clusters.filter((c) => pool.clusterIds.includes(c.id));
const activeClusters = poolClusters.filter((c) => c.status === 'active');
return (
<div key={pool.id} className="card">
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-2">
<h3 className="text-lg font-semibold text-gray-900">{pool.name}</h3>
<span className={`badge ${pool.isActive ? 'badge-green' : 'badge-gray'}`}>
{pool.isActive ? 'Active' : 'Inactive'}
</span>
<span className="badge badge-purple flex items-center gap-1">
{pool.strategy === 'weighted-resource'
? <><BarChart3 className="w-3 h-3" /> Weighted Resource</>
: pool.strategy === 'least-loaded'
? <><BarChart3 className="w-3 h-3" /> Least Loaded</>
: pool.strategy === 'weighted-round-robin'
? <><RotateCw className="w-3 h-3" /> Weighted RR</>
: pool.strategy === 'region-based'
? <><BarChart3 className="w-3 h-3" /> Region Based</>
: pool.strategy === 'least-apps'
? <><BarChart3 className="w-3 h-3" /> Least Apps</>
: <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span>
{pool.isDefault && <span className="badge badge-blue">Default Pool</span>}
<span className="badge badge-gray">Priority {pool.priority || 100}</span>
</div>
{pool.description && (
<p className="text-sm text-gray-500 mb-3">{pool.description}</p>
)}
{/* Cluster chips */}
<div className="flex flex-wrap gap-2">
{poolClusters.length > 0 ? poolClusters.map((cluster) => (
<div
key={cluster.id}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium ${
cluster.status === 'active'
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
<span>{cluster.status === 'active' ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}</span>
<span>{cluster.name}</span>
<span className="text-gray-400">
({cluster.provider || 'N/A'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1} · {cluster.healthStatus || 'unknown'})
</span>
</div>
)) : (
<p className="text-xs text-gray-400">No clusters in this pool (they may have been deleted)</p>
)}
</div>
<p className="text-xs text-gray-400 mt-2">
{activeClusters.length}/{poolClusters.length} clusters active · Created {new Date(pool.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => startEdit(pool)}
className="btn-ghost text-sm"
>
<Pencil className="w-3 h-3 inline" /> Edit
</button>
<button
onClick={async () => {
const ok = await confirm({
title: `Delete Pool "${pool.name}"`,
message: 'Apps already assigned to this pool will keep their current cluster.',
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteMutation.mutate(pool.id);
}}
className="text-sm text-red-600 hover:text-red-800"
>
Remove
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,181 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types';
import { ClipboardList, Wrench, Briefcase, User, Inbox } from 'lucide-react';
const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
waiting: 'bg-orange-100 text-orange-700',
answered: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
};
const priorityColors: Record<string, string> = {
low: 'bg-blue-100 text-blue-700',
medium: 'bg-yellow-100 text-yellow-700',
high: 'bg-red-100 text-red-700',
};
export default function AdminTicketsPage() {
const [deptFilter, setDeptFilter] = useState<TicketDepartment | ''>('');
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
const { data: stats } = useQuery<TicketStats>({
queryKey: ['ticket-stats'],
queryFn: () => api.get('/tickets/admin/stats').then((r) => r.data),
});
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
queryKey: ['admin-tickets', deptFilter, statusFilter],
queryFn: () => {
const params = new URLSearchParams();
if (deptFilter) params.append('department', deptFilter);
if (statusFilter) params.append('status', statusFilter);
const qs = params.toString();
return api.get(`/tickets/admin/all${qs ? `?${qs}` : ''}`).then((r) => r.data);
},
});
const formatResponseTime = (minutes: number) => {
if (minutes < 60) return `${minutes} min`;
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
return `${Math.round(minutes / 1440)} days`;
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><ClipboardList className="w-6 h-6" /> All Tickets</h1>
<p className="text-sm text-gray-500 mt-1">Overview of all support tickets across departments</p>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Total Tickets</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalTickets}</p>
</div>
<div className="card p-4 border-l-4 border-l-red-500">
<p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</p>
<p className={`text-2xl font-bold mt-1 ${stats.openTickets > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.openTickets}
</p>
<p className="text-xs text-gray-400 mt-0.5">Need staff response</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p>
<p className="text-2xl font-bold text-primary-600 mt-1">
{stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'}
</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase font-semibold">By Department</p>
<div className="mt-1 space-y-0.5">
{Object.entries(stats.byDepartment).map(([dept, data]) => (
<div key={dept} className="flex items-center justify-between text-xs">
<span className="text-gray-600 capitalize flex items-center gap-1">{dept === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {dept}</span>
<span className="font-medium">
<span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered</span>
<span className="text-gray-400"> / {data.total}</span>
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4">
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-500 self-center">Department:</span>
{(['' as const, 'technical' as TicketDepartment, 'sales' as TicketDepartment]).map((dept) => (
<button
key={dept}
onClick={() => setDeptFilter(dept)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
deptFilter === dept
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{dept === '' ? 'All' : dept === 'technical' ? 'Technical' : 'Sales'}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-500 self-center">Status:</span>
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === status
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
</div>
{/* Tickets Table */}
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
</div>
) : tickets.length === 0 ? (
<div className="card p-12 text-center">
<Inbox className="w-10 h-10 mx-auto text-gray-300" />
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets found</h3>
</div>
) : (
<div className="space-y-3">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/dashboard/tickets/${ticket.id}`}
className="card p-4 block hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
{ticket.status}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
{ticket.priority}
</span>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 flex items-center gap-1">
{ticket.department === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {ticket.department === 'technical' ? 'Technical' : 'Sales'}
</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
{ticket.user && (
<span className="flex items-center gap-1">
<User className="w-3 h-3" /> {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
</span>
)}
<span></span>
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
<span></span>
<span>{ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
</div>
</div>
<span className="text-gray-400 text-sm"></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,397 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { AdminUser } from '@/types';
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
export default function AdminUsersPage() {
const queryClient = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.role === 'admin';
const canStaffResetPassword =
currentUser?.role === 'admin' || currentUser?.role === 'technical';
const canResetPasswordFor = (user: AdminUser) => {
if (!canStaffResetPassword) return false;
if (currentUser?.role === 'admin') return true;
return user.role === 'user';
};
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null);
const [pwdModalPassword, setPwdModalPassword] = useState('');
const [form, setForm] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
role: 'user' as 'user' | 'admin' | 'technical' | 'sales',
});
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
queryKey: ['admin-users', search],
queryFn: () =>
api.get('/users', { params: search ? { search } : {} }).then((r) => r.data),
});
const createUser = useMutation({
mutationFn: (data: typeof form) => api.post('/users', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('User created successfully');
setShowForm(false);
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' });
},
onError: (err: any) => {
toast.error(err?.response?.data?.message || 'Failed to create user');
},
});
const toggleActive = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('User updated');
},
});
const changeRole = useMutation({
mutationFn: ({ id, role }: { id: string; role: string }) =>
api.patch(`/users/${id}/role`, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('Role updated');
},
});
const resetPassword = useMutation({
mutationFn: ({ id, password }: { id: string; password: string }) =>
api.patch(`/users/${id}/password`, { password }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('Password updated');
setPwdModalUser(null);
setPwdModalPassword('');
},
onError: (err: unknown) => {
const msg =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(typeof msg === 'string' ? msg : 'Failed to update password');
},
});
const openPwdModal = (user: AdminUser) => {
setPwdModalUser(user);
setPwdModalPassword('');
};
const closePwdModal = () => {
if (resetPassword.isPending) return;
setPwdModalUser(null);
setPwdModalPassword('');
};
const submitPwdModal = () => {
if (!pwdModalUser || pwdModalPassword.length < 8) return;
resetPassword.mutate({ id: pwdModalUser.id, password: pwdModalPassword });
};
return (
<div className="space-y-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">User Management</h1>
<p className="page-subtitle">{users.length} user{users.length !== 1 ? 's' : ''} registered</p>
</div>
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add User'}
</button>
</div>
{/* Create user form */}
{showForm && (
<div className="card space-y-4 animate-slide-up">
<h2 className="text-lg font-semibold text-gray-900">Create New User</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">First Name</label>
<input
className="input-field"
placeholder="John"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
<input
className="input-field"
placeholder="Doe"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
className="input-field"
type="email"
placeholder="john@example.com"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
className="input-field"
type="password"
placeholder="Min 8 characters"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
className="input-field w-full sm:w-48"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
</div>
<button
onClick={() => createUser.mutate(form)}
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending}
className="btn-primary disabled:opacity-50"
>
{createUser.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> Creating...</> : 'Create User'}
</button>
</div>
)}
{/* Search bar */}
<div className="relative">
<input
className="input-field pl-10 w-full"
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
</div>
{isLoading ? (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="card flex items-center gap-4">
<div className="skeleton w-10 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="skeleton h-4 w-32" />
<div className="skeleton h-3 w-48" />
</div>
<div className="skeleton h-6 w-16 rounded-full" />
</div>
))}
</div>
) : users.length === 0 ? (
<div className="card text-center py-16">
<Users className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden lg:block table-wrapper">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50/80">
<tr>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">User</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Apps</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Created</th>
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4">
{isAdmin ? (
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
) : (
<span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
)}
</td>
<td className="px-6 py-4">
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4">
<span className="badge badge-blue">{user.appCount ?? 0}</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
{canResetPasswordFor(user) && (
<button
type="button"
onClick={() => openPwdModal(user)}
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-800"
>
<KeyRound className="w-3.5 h-3.5" /> Password
</button>
)}
<button
type="button"
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="lg:hidden grid gap-3">
{users.map((user) => (
<div key={user.id} className="card space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="badge badge-blue">{user.appCount ?? 0} apps</span>
<span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
{isAdmin ? (
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
) : (
<span className="text-sm text-gray-500 capitalize">{user.role}</span>
)}
{canResetPasswordFor(user) && (
<button
type="button"
onClick={() => openPwdModal(user)}
className="btn-secondary text-xs inline-flex items-center gap-1"
>
<KeyRound className="w-3.5 h-3.5" /> Password
</button>
)}
<button
type="button"
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
))}
</div>
</>
)}
{pwdModalUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="presentation"
onClick={(e) => e.target === e.currentTarget && closePwdModal()}
>
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Set password</h3>
<p className="text-sm text-gray-600">
New password for{' '}
<strong>
{pwdModalUser.firstName} {pwdModalUser.lastName}
</strong>{' '}
({pwdModalUser.email})
</p>
<input
type="password"
className="input-field w-full"
placeholder="Min 8 characters"
value={pwdModalPassword}
onChange={(e) => setPwdModalPassword(e.target.value)}
autoComplete="new-password"
/>
<div className="flex justify-end gap-2 pt-2">
<button type="button" className="btn-ghost" onClick={closePwdModal}>
Cancel
</button>
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={pwdModalPassword.length < 8 || resetPassword.isPending}
onClick={submitPwdModal}
>
{resetPassword.isPending ? 'Saving...' : 'Save password'}
</button>
</div>
</div>
</div>
)}
</div>
);
}