profit-planet-frontend/src/app/admin/dev-management/page.tsx
2026-06-29 23:15:19 +02:00

735 lines
36 KiB
TypeScript

'use client'
import { useTranslation } from '../../i18n/useTranslation';
import React from 'react'
import Header from '../../components/nav/Header'
import Footer from '../../components/Footer'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon, WrenchScrewdriverIcon, FolderOpenIcon, ArrowPathIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline'
import useAuthStore from '../../store/authStore'
import { useRouter } from 'next/navigation'
import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql'
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, downloadExoscaleArchive, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
export default function DevManagementPage() {
const { t } = useTranslation();
const router = useRouter()
const user = useAuthStore(s => s.user)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin')) ||
(user as any)?.role === 'super_admin' ||
(user as any)?.userType === 'super_admin' ||
(user as any)?.isSuperAdmin === true ||
((user as any)?.roles?.includes?.('super_admin'))
)
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/admin')
return
}
setAuthChecked(true)
}, [user, isAdmin, router])
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
const [result, setResult] = React.useState<SqlExecutionData | null>(null)
const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null)
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
const [activeTab, setActiveTab] = React.useState<'sql' | 'structure' | 'loose' | 'ghost'>('sql')
const [structureUsers, setStructureUsers] = React.useState<FolderStructureIssueUser[]>([])
const [structureMeta, setStructureMeta] = React.useState<{ scannedUsers?: number; invalidCount?: number } | null>(null)
const [looseUsers, setLooseUsers] = React.useState<LooseFileUser[]>([])
const [looseMeta, setLooseMeta] = React.useState<{ scannedUsers?: number; looseCount?: number } | null>(null)
const [ghostDirs, setGhostDirs] = React.useState<GhostDirectory[]>([])
const [ghostMeta, setGhostMeta] = React.useState<{ ghostCount?: number } | null>(null)
const [exoscaleLoading, setExoscaleLoading] = React.useState(false)
const [exoscaleError, setExoscaleError] = React.useState('')
const [fixingUserId, setFixingUserId] = React.useState<number | null>(null)
const [fixingAll, setFixingAll] = React.useState(false)
const [fixResults, setFixResults] = React.useState<Record<number, FixResult>>({})
const [structureActionMeta, setStructureActionMeta] = React.useState<{ processedUsers?: number; createdTotal?: number; errorCount?: number } | null>(null)
const [structureActionResults, setStructureActionResults] = React.useState<FixResult[]>([])
const [looseActionMeta, setLooseActionMeta] = React.useState<{ processedUsers?: number; movedTotal?: number; errorCount?: number } | null>(null)
const [looseActionResults, setLooseActionResults] = React.useState<FixResult[]>([])
const [structureStatus, setStructureStatus] = React.useState('')
const [looseStatus, setLooseStatus] = React.useState('')
const [ghostStatus, setGhostStatus] = React.useState('')
const [archiveLoading, setArchiveLoading] = React.useState(false)
const [archiveStatus, setArchiveStatus] = React.useState('')
const formatNow = () => new Date().toLocaleString()
const runImport = async () => {
setError('')
if (!selectedFile) {
setError('Please select a SQL dump file.')
return
}
setLoading(true)
try {
const res = await importSqlDump(selectedFile)
if (!res.ok) {
setError(res.message || 'Failed to import SQL dump.')
return
}
setResult(res.data || null)
setMeta(res.meta || null)
} finally {
setLoading(false)
}
}
const clearResults = () => {
setResult(null)
setMeta(null)
setError('')
}
const loadStructureIssues = async () => {
setExoscaleError('')
setExoscaleLoading(true)
setStructureStatus('Refreshing list...')
try {
const res = await listFolderStructureIssues()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load folder structure issues.')
return
}
setStructureUsers(res.data || [])
setStructureMeta(res.meta || null)
setStructureStatus(`Last refresh: ${formatNow()}`)
} finally {
setExoscaleLoading(false)
}
}
const loadLooseFiles = async () => {
setExoscaleError('')
setExoscaleLoading(true)
setLooseStatus('Refreshing list...')
try {
const res = await listLooseFiles()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load loose files.')
return
}
setLooseUsers(res.data || [])
setLooseMeta(res.meta || null)
setLooseStatus(`Last refresh: ${formatNow()}`)
} finally {
setExoscaleLoading(false)
}
}
const loadGhostDirectories = async () => {
setExoscaleError('')
setExoscaleLoading(true)
setGhostStatus('Refreshing list...')
try {
const res = await listGhostDirectories()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load ghost directories.')
return
}
setGhostDirs(res.data || [])
setGhostMeta(res.meta || null)
setGhostStatus(`Last refresh: ${formatNow()}`)
} finally {
setExoscaleLoading(false)
}
}
const runArchiveDownload = async () => {
setExoscaleError('')
setArchiveLoading(true)
setArchiveStatus('Preparing ZIP export...')
try {
const res = await downloadExoscaleArchive()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to download Exoscale archive.')
setArchiveStatus('')
return
}
setArchiveStatus(`Download started: ${res.fileName || formatNow()}`)
} finally {
setArchiveLoading(false)
}
}
const runCreateStructure = async (userId?: number) => {
setExoscaleError('')
if (userId) {
setFixingUserId(userId)
setStructureStatus(`Creating folders for #${userId}...`)
} else {
setFixingAll(true)
setStructureStatus('Creating folders for each user...')
setStructureActionResults([])
setStructureActionMeta({ processedUsers: 0, createdTotal: 0, errorCount: 0 })
}
try {
const targets = userId ? [{ userId }] : structureUsers.map(u => ({ userId: u.userId }))
for (const target of targets) {
const res = await createFolderStructure(target.userId)
if (!res.ok) {
setExoscaleError(res.message || 'Failed to create folder structure.')
break
}
const batch = res.data || []
setStructureActionResults(prev => [...prev, ...batch])
setStructureActionMeta(prev => ({
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
createdTotal: (prev?.createdTotal || 0) + (res.meta?.createdTotal || 0),
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
}))
setFixResults(prev => {
const next = { ...prev }
for (const item of batch) {
next[item.userId] = item
}
return next
})
setStructureStatus(`Last create: ${formatNow()} (processed #${target.userId})`)
}
await loadStructureIssues()
} finally {
setFixingUserId(null)
setFixingAll(false)
}
}
const runMoveLooseFiles = async (userId?: number) => {
setExoscaleError('')
if (userId) {
setFixingUserId(userId)
setLooseStatus(`Moving loose files for #${userId}...`)
} else {
setFixingAll(true)
setLooseStatus('Moving loose files for each user...')
setLooseActionResults([])
setLooseActionMeta({ processedUsers: 0, movedTotal: 0, errorCount: 0 })
}
try {
const targets = userId ? [{ userId }] : looseUsers.map(u => ({ userId: u.userId }))
for (const target of targets) {
const res = await moveLooseFilesToContract(target.userId)
if (!res.ok) {
setExoscaleError(res.message || 'Failed to move loose files.')
break
}
const batch = res.data || []
setLooseActionResults(prev => [...prev, ...batch])
setLooseActionMeta(prev => ({
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
movedTotal: (prev?.movedTotal || 0) + (res.meta?.movedTotal || 0),
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
}))
setFixResults(prev => {
const next = { ...prev }
for (const item of batch) {
next[item.userId] = item
}
return next
})
setLooseStatus(`Last move: ${formatNow()} (processed #${target.userId})`)
}
await loadLooseFiles()
} finally {
setFixingUserId(null)
setFixingAll(false)
}
}
React.useEffect(() => {
if (activeTab === 'structure') loadStructureIssues()
if (activeTab === 'loose') loadLooseFiles()
if (activeTab === 'ghost') loadGhostDirectories()
}, [activeTab])
const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSelectedFile(file)
e.target.value = ''
}
if (!authChecked) return null
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 via-white to-blue-50">
<Header />
{/* tighter padding on mobile */}
<main className="flex-1 py-6 md:py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<header className="bg-white/90 backdrop-blur border border-blue-100 py-6 md:py-8 px-4 sm:px-6 rounded-2xl shadow-lg flex flex-col gap-4">
{/* stack on mobile */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="h-11 w-11 sm:h-12 sm:w-12 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
<CommandLineIcon className="h-6 w-6 text-blue-700" />
</div>
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">{t('autofix.k664072a1')}</h1>
<p className="text-sm sm:text-base text-blue-700">{t('autofix.k6e4a6069')}</p>
</div>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div>
<div className="font-semibold">{t('autofix.k6c6e5c0f')}</div>
<div>{t('autofix.k8a35cc53')}</div>
</div>
</div>
</header>
<div className="mt-6">
<section className="mb-6 rounded-2xl border border-blue-100 bg-white/90 p-4 shadow-lg">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-blue-900">Exoscale Backup Export</h2>
<p className="text-sm text-gray-600">Ladet alle Dateien aus dem Exoscale Storage und liefert sie als ZIP-Download aus.</p>
{archiveStatus && (
<div className="mt-1 text-xs text-slate-500">{archiveStatus}</div>
)}
</div>
<button
onClick={runArchiveDownload}
disabled={archiveLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<ArrowDownTrayIcon className="h-4 w-4" /> {archiveLoading ? 'Creating ZIP...' : 'Download Exoscale ZIP'}
</button>
</div>
</section>
<div className="inline-flex w-full sm:w-auto rounded-xl border border-blue-100 bg-white/90 p-1 shadow-sm">
<button
onClick={() => setActiveTab('sql')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<CommandLineIcon className="h-4 w-4" />{t('autofix.k4db68c96')}</button>
<button
onClick={() => setActiveTab('structure')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<WrenchScrewdriverIcon className="h-4 w-4" />{t('autofix.kcb491706')}</button>
<button
onClick={() => setActiveTab('loose')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k04b5cbca')}</button>
<button
onClick={() => setActiveTab('ghost')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k6838438d')}</button>
</div>
</div>
{activeTab === 'sql' && (
<>
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k981b1f1a')}</h2>
{/* actions: stack on mobile, full width */}
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
<button
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<ArrowUpTrayIcon className="h-4 w-4" />{t('autofix.k8a59b156')}</button>
<button
onClick={clearResults}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<TrashIcon className="h-4 w-4" /> Clear
</button>
<button
onClick={runImport}
disabled={loading}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<PlayIcon className="h-4 w-4" /> {loading ? 'Importing...' : 'Import'}
</button>
</div>
</div>
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
<div className="text-sm text-gray-600">{t('autofix.kb6eacc9d')}</div>
<div className="mt-2 text-xs text-gray-500">{t('autofix.k3ac8ca10')}</div>
{selectedFile && (
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
Selected: {selectedFile.name}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".sql,text/plain"
className="hidden"
onChange={onImportFile}
/>
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
</div>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<h3 className="text-lg font-semibold text-blue-900 mb-3">{t('autofix.kde2b4fa0')}</h3>
<div className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<span>{t('autofix.k7938d4fd')}</span>
<span className="font-semibold text-blue-900">
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
</span>
</div>
<div className="flex justify-between">
<span>Multi-statement</span>
<span className="font-semibold text-blue-900">{result?.isMulti ? 'Yes' : 'No'}</span>
</div>
<div className="flex justify-between">
<span>Duration</span>
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
</div>
</div>
<div className="mt-6 text-xs text-gray-500">{t('autofix.k0f0395ca')}</div>
</div>
</section>
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kb4675362')}</h2>
</div>
{!result && (
<div className="text-sm text-gray-500">{t('autofix.k23c9f0ff')}</div>
)}
{result?.result && (
<pre className="mt-2 rounded-lg bg-slate-50 border border-gray-200 p-3 md:p-4 text-xs text-gray-800 overflow-auto">
{JSON.stringify(result.result, null, 2)}
</pre>
)}
</section>
</>
)}
{activeTab === 'structure' && (
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kd51f320c')}</h2>
<p className="text-sm text-gray-600">{t('autofix.kb1341138')}</p>
{structureStatus && (
<div className="text-xs text-slate-500">{structureStatus}</div>
)}
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button
onClick={loadStructureIssues}
disabled={exoscaleLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => runCreateStructure()}
disabled={fixingAll || exoscaleLoading || structureUsers.length === 0}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Creating...' : t('autofix.k1db77fc0')}</button>
</div>
</div>
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
Scanned: {structureMeta?.scannedUsers ?? '-'}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
Missing: {structureMeta?.invalidCount ?? structureUsers.length}
</span>
</div>
{exoscaleError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{exoscaleError}
</div>
)}
{exoscaleLoading ? (
<div className="text-sm text-gray-500">{t('autofix.k8358f1d1')}</div>
) : structureUsers.length === 0 ? (
<div className="text-sm text-gray-500">{t('autofix.k9e609523')}</div>
) : (
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
{structureUsers.map(user => {
const fix = fixResults[user.userId]
const displayName = user.name || user.email
const missing = [!user.hasContractFolder ? 'contract' : null, !user.hasGdprFolder ? 'gdpr' : null].filter(Boolean).join(', ')
return (
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
<div className="text-xs text-gray-500 truncate">
#{user.userId} {user.email} {user.userType} {user.contractCategory}
</div>
<div className="mt-1 text-xs text-gray-600">{t('autofix.kd058bb7b')}<span className="font-semibold text-blue-900">{missing || 'none'}</span></div>
{fix && (
<div className="mt-2 text-xs text-emerald-700">
Created {fix.created || 0}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => runCreateStructure(user.userId)}
disabled={fixingUserId === user.userId || fixingAll}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Creating...' : 'Create'}
</button>
</div>
</div>
)
})}
</div>
)}
{(structureActionMeta || structureActionResults.length > 0) && (
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">{t('autofix.k941fd092')}</div>
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
<span>Processed: {structureActionMeta?.processedUsers ?? '-'}</span>
<span>Created: {structureActionMeta?.createdTotal ?? '-'}</span>
<span>Errors: {structureActionMeta?.errorCount ?? '-'}</span>
</div>
{structureActionResults.length > 0 && (
<div className="mt-3 space-y-2 text-xs text-gray-700">
{structureActionResults.map(item => (
<div key={item.userId} className="flex flex-wrap gap-2">
<span className="font-semibold text-blue-900">#{item.userId}</span>
{item.contractCategory && (
<span className="text-gray-500">{item.contractCategory}</span>
)}
<span>Created: {item.created ?? 0}</span>
{item.errors && item.errors.length > 0 && (
<span className="text-red-700">Errors: {item.errors.length}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</section>
)}
{activeTab === 'loose' && (
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k04b5cbca')}</h2>
<p className="text-sm text-gray-600">{t('autofix.kbff01823')}</p>
{looseStatus && (
<div className="text-xs text-slate-500">{looseStatus}</div>
)}
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button
onClick={loadLooseFiles}
disabled={exoscaleLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => runMoveLooseFiles()}
disabled={fixingAll || exoscaleLoading || looseUsers.length === 0}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Moving...' : t('autofix.k0188c7bc')}</button>
</div>
</div>
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
Scanned: {looseMeta?.scannedUsers ?? '-'}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
Loose: {looseMeta?.looseCount ?? looseUsers.length}
</span>
</div>
{exoscaleError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{exoscaleError}
</div>
)}
{exoscaleLoading ? (
<div className="text-sm text-gray-500">{t('autofix.k8193b7a2')}</div>
) : looseUsers.length === 0 ? (
<div className="text-sm text-gray-500">{t('autofix.k1db0c7cd')}</div>
) : (
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
{looseUsers.map(user => {
const fix = fixResults[user.userId]
const displayName = user.name || user.email
return (
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
<div className="text-xs text-gray-500 truncate">
#{user.userId} {user.email} {user.userType} {user.contractCategory}
</div>
<div className="mt-1 text-xs text-gray-600">{t('autofix.kf340aa10')}<span className="font-semibold text-blue-900">{user.looseObjects}</span>
</div>
{user.sampleKeys && user.sampleKeys.length > 0 && (
<div className="mt-2 text-[11px] text-gray-400 break-all">
Sample: {user.sampleKeys.join(', ')}
</div>
)}
{fix && (
<div className="mt-2 text-xs text-emerald-700">
Moved {fix.moved}, skipped {fix.skipped}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => runMoveLooseFiles(user.userId)}
disabled={fixingUserId === user.userId || fixingAll}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Moving...' : 'Move'}
</button>
</div>
</div>
)
})}
</div>
)}
{(looseActionMeta || looseActionResults.length > 0) && (
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">{t('autofix.kcf61fc9e')}</div>
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
<span>Processed: {looseActionMeta?.processedUsers ?? '-'}</span>
<span>Moved: {looseActionMeta?.movedTotal ?? '-'}</span>
<span>Errors: {looseActionMeta?.errorCount ?? '-'}</span>
</div>
{looseActionResults.length > 0 && (
<div className="mt-3 space-y-2 text-xs text-gray-700">
{looseActionResults.map(item => (
<div key={item.userId} className="flex flex-wrap gap-2">
<span className="font-semibold text-blue-900">#{item.userId}</span>
{item.contractCategory && (
<span className="text-gray-500">{item.contractCategory}</span>
)}
<span>Moved: {item.moved}</span>
<span>Skipped: {item.skipped}</span>
{item.errors && item.errors.length > 0 && (
<span className="text-red-700">Errors: {item.errors.length}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</section>
)}
{activeTab === 'ghost' && (
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k6838438d')}</h2>
<p className="text-sm text-gray-600">{t('autofix.k77444d5b')}</p>
{ghostStatus && (
<div className="text-xs text-slate-500">{ghostStatus}</div>
)}
</div>
<button
onClick={loadGhostDirectories}
disabled={exoscaleLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
<div className="mb-4 text-xs text-gray-600">
Ghost directories: {ghostMeta?.ghostCount ?? ghostDirs.length}
</div>
{exoscaleError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{exoscaleError}
</div>
)}
{exoscaleLoading ? (
<div className="text-sm text-gray-500">{t('autofix.k883ea8c5')}</div>
) : ghostDirs.length === 0 ? (
<div className="text-sm text-gray-500">{t('autofix.k12a7170a')}</div>
) : (
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
{ghostDirs.map(dir => (
<div key={`${dir.contractCategory}-${dir.userId}`} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-blue-900 truncate">#{dir.userId}</div>
<div className="text-xs text-gray-500 truncate">
{dir.contractCategory} {dir.basePrefix}
</div>
</div>
</div>
))}
</div>
)}
</section>
)}
</div>
</main>
<Footer />
</div>
</PageTransitionEffect>
)
}