refactor(ts): convert CleanupPanel + UsersTable admin cards to TSX (B17)

CleanupPanel (CleanupForm/CleanupStatus/CleanupResult; keyed toggle array typed
[keyof CleanupForm,string][]). UsersTable (AdminUser type exported + reused by
AdminPage's users state; ResetForm; typed row handlers). typecheck 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 21:17:16 -05:00
parent bb3e9f551c
commit a9a58d8aa2
3 changed files with 89 additions and 36 deletions

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Play, Wrench } from 'lucide-react'; import { Play, Wrench } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -9,9 +10,42 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { FieldRow, Toggle, formatDateTime } from './adminShared'; import { FieldRow, Toggle, formatDateTime } from './adminShared';
interface CleanupForm {
import_sessions_enabled: boolean;
temp_exports_enabled: boolean;
temp_export_max_age_hours: number;
backup_partials_enabled: boolean;
import_history_enabled: boolean;
import_history_max_age_days: number;
}
interface CleanupTask {
pruned?: number;
removed?: number;
}
interface CleanupResult {
import_sessions?: CleanupTask;
temp_export_files?: CleanupTask;
backup_partials?: CleanupTask;
import_history?: CleanupTask;
}
interface CleanupStatus {
last_run_at?: string | null;
last_result?: CleanupResult | null;
settings?: CleanupForm;
}
const TASK_TOGGLES: [keyof CleanupForm, string][] = [
['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'],
['temp_exports_enabled', 'Remove stale SQLite export temp files'],
['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'],
];
export default function CleanupPanel() { export default function CleanupPanel() {
const [status, setStatus] = useState(null); const [status, setStatus] = useState<CleanupStatus | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState<CleanupForm>({
import_sessions_enabled: true, import_sessions_enabled: true,
temp_exports_enabled: true, temp_exports_enabled: true,
temp_export_max_age_hours: 2, temp_export_max_age_hours: 2,
@ -25,11 +59,11 @@ export default function CleanupPanel() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const data = await api.adminCleanup(); const data = await api.adminCleanup() as CleanupStatus;
setStatus(data); setStatus(data);
if (data.settings) setForm(data.settings); if (data.settings) setForm(data.settings);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load cleanup settings.'); toast.error(errMessage(err, 'Failed to load cleanup settings.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -37,16 +71,16 @@ export default function CleanupPanel() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); const set = (k: keyof CleanupForm, v: boolean | number) => setForm(prev => ({ ...prev, [k]: v }) as CleanupForm);
async function handleSave() { async function handleSave() {
setSaving(true); setSaving(true);
try { try {
const next = await api.saveAdminCleanup(form); const next = await api.saveAdminCleanup(form) as CleanupForm | null;
if (next) setForm(next); if (next) setForm(next);
toast.success('Cleanup settings saved.'); toast.success('Cleanup settings saved.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save cleanup settings.'); toast.error(errMessage(err, 'Failed to save cleanup settings.'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -55,7 +89,7 @@ export default function CleanupPanel() {
async function handleRunNow() { async function handleRunNow() {
setRunning(true); setRunning(true);
try { try {
const result = await api.runAdminCleanup(); const result = await api.runAdminCleanup() as { ran_at?: string; tasks?: CleanupResult };
setStatus(prev => ({ setStatus(prev => ({
...prev, ...prev,
last_run_at: result.ran_at, last_run_at: result.ran_at,
@ -63,13 +97,13 @@ export default function CleanupPanel() {
})); }));
toast.success('Cleanup tasks completed.'); toast.success('Cleanup tasks completed.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Cleanup run failed.'); toast.error(errMessage(err, 'Cleanup run failed.'));
} finally { } finally {
setRunning(false); setRunning(false);
} }
} }
function resultLine(label, task, countKey) { function resultLine(label: string, task: CleanupTask | undefined, countKey: keyof CleanupTask): string | null {
if (!task || task[countKey] == null) return null; if (!task || task[countKey] == null) return null;
return `${label}: ${task[countKey]}`; return `${label}: ${task[countKey]}`;
} }
@ -137,11 +171,7 @@ export default function CleanupPanel() {
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium">Task Settings</p> <p className="text-sm font-medium">Task Settings</p>
{[ {TASK_TOGGLES.map(([key, label]) => (
['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'],
['temp_exports_enabled', 'Remove stale SQLite export temp files'],
['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'],
].map(([key, label]) => (
<FieldRow key={key} label={label}> <FieldRow key={key} label={label}>
<Toggle checked={!!form[key]} onChange={v => set(key, v)} label={label} /> <Toggle checked={!!form[key]} onChange={v => set(key, v)} label={label} />
</FieldRow> </FieldRow>

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button'; import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -11,19 +11,41 @@ import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import type { User } from '@/hooks/useAuth';
export default function UsersTable({ users, onRefresh, currentUser }) { export interface AdminUser {
const [resetForms, setResetForms] = useState({}); id: number;
const [deleting, setDeleting] = useState(null); username: string;
const [resetting, setResetting] = useState(null); role?: string;
const [roleUpdating, setRoleUpdating] = useState(null); is_default_admin?: boolean;
const [activeUpdating, setActiveUpdating] = useState(null); active?: boolean | number;
const [deleteTarget, setDeleteTarget] = useState(null); must_change_password?: boolean;
[key: string]: unknown;
}
const setReset = (id, v) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } })); interface ResetForm {
const getForm = (id) => resetForms[id] || { pw: '', open: false }; pw?: string;
open?: boolean;
}
const handleReset = async (user) => { interface UsersTableProps {
users?: AdminUser[];
onRefresh: () => void;
currentUser?: User | null;
}
export default function UsersTable({ users, onRefresh, currentUser }: UsersTableProps) {
const [resetForms, setResetForms] = useState<Record<number, ResetForm>>({});
const [deleting, setDeleting] = useState<number | null>(null);
const [resetting, setResetting] = useState<number | null>(null);
const [roleUpdating, setRoleUpdating] = useState<number | null>(null);
const [activeUpdating, setActiveUpdating] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
const setReset = (id: number, v: ResetForm) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } }));
const getForm = (id: number): ResetForm => resetForms[id] || { pw: '', open: false };
const handleReset = async (user: AdminUser) => {
const form = getForm(user.id); const form = getForm(user.id);
if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; } if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; }
setResetting(user.id); setResetting(user.id);
@ -32,7 +54,7 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
toast.success(`Password reset for ${user.username}.`); toast.success(`Password reset for ${user.username}.`);
setReset(user.id, { pw: '', open: false }); setReset(user.id, { pw: '', open: false });
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to reset password.'); toast.error(errMessage(err, 'Failed to reset password.'));
} finally { } finally {
setResetting(null); setResetting(null);
} }
@ -47,13 +69,13 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
setDeleteTarget(null); setDeleteTarget(null);
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to delete user.'); toast.error(errMessage(err, 'Failed to delete user.'));
} finally { } finally {
setDeleting(null); setDeleting(null);
} }
}; };
const handleRoleChange = async (user, role) => { const handleRoleChange = async (user: AdminUser, role: string) => {
if (user.role === role) return; if (user.role === role) return;
setRoleUpdating(user.id); setRoleUpdating(user.id);
try { try {
@ -61,20 +83,20 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`); toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`);
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update user role.'); toast.error(errMessage(err, 'Failed to update user role.'));
} finally { } finally {
setRoleUpdating(null); setRoleUpdating(null);
} }
}; };
const handleActiveChange = async (user, active) => { const handleActiveChange = async (user: AdminUser, active: boolean) => {
setActiveUpdating(user.id); setActiveUpdating(user.id);
try { try {
await api.updateUserActive(user.id, { active }); await api.updateUserActive(user.id, { active });
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`); toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update user status.'); toast.error(errMessage(err, 'Failed to update user status.'));
} finally { } finally {
setActiveUpdating(null); setActiveUpdating(null);
} }

View File

@ -12,6 +12,7 @@ import UsersTable from '@/components/admin/UsersTable';
import AddUserCard from '@/components/admin/AddUserCard'; import AddUserCard from '@/components/admin/AddUserCard';
import BackupManagementCard from '@/components/admin/BackupManagementCard'; import BackupManagementCard from '@/components/admin/BackupManagementCard';
import CleanupPanel from '@/components/admin/CleanupPanel'; import CleanupPanel from '@/components/admin/CleanupPanel';
import type { AdminUser } from '@/components/admin/UsersTable';
import type { User } from '@/hooks/useAuth'; import type { User } from '@/hooks/useAuth';
export default function AdminPage() { export default function AdminPage() {
@ -20,7 +21,7 @@ export default function AdminPage() {
const [me, setMe] = useState<User | null>(null); const [me, setMe] = useState<User | null>(null);
const [hasUsers, setHasUsers] = useState<boolean | null>(null); const [hasUsers, setHasUsers] = useState<boolean | null>(null);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [users, setUsers] = useState<unknown[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [authMode, setAuthMode] = useState('multi'); const [authMode, setAuthMode] = useState('multi');
const loadMe = useCallback(async () => { const loadMe = useCallback(async () => {
@ -34,7 +35,7 @@ export default function AdminPage() {
const loadUsers = useCallback(async () => { const loadUsers = useCallback(async () => {
try { try {
const d = await api.adminUsers() as { users?: unknown[] } | unknown[]; const d = await api.adminUsers() as { users?: AdminUser[] } | AdminUser[];
setUsers(Array.isArray(d) ? d : (d.users || [])); setUsers(Array.isArray(d) ? d : (d.users || []));
} catch { /* ignore */ } } catch { /* ignore */ }
}, []); }, []);