This commit is contained in:
2026-03-16 09:09:54 -04:00
parent e289d1a29b
commit ecf77fd105
37 changed files with 3505 additions and 119 deletions

View File

@@ -1,43 +1,53 @@
import { Link } from 'react-router-dom';
import { useGetActivity } from '../generated/anthoLumeAPIV1';
import { Table } from '../components/Table';
export default function ActivityPage() {
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
const activities = data?.data?.activities;
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
const columns = [
{
key: 'document_id' as const,
header: 'Document',
render: (_: any, row: any) => (
<Link
to={`/documents/${row.document_id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
),
},
{
key: 'start_time' as const,
header: 'Time',
render: (value: any) => value || 'N/A',
},
{
key: 'duration' as const,
header: 'Duration',
render: (value: any) => {
if (!value) return 'N/A';
// Format duration (in seconds) to readable format
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const seconds = value % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
},
},
{
key: 'end_percentage' as const,
header: 'Percent',
render: (value: any) => (value != null ? `${value}%` : '0%'),
},
];
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-white dark:bg-gray-700">
<thead>
<tr className="border-b dark:border-gray-600">
<th className="text-left p-3 text-gray-500 dark:text-white">Activity Type</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Timestamp</th>
</tr>
</thead>
<tbody>
{activities?.map((activity: any) => (
<tr key={activity.id} className="border-b dark:border-gray-600">
<td className="p-3 text-gray-700 dark:text-gray-300">
{activity.activity_type}
</td>
<td className="p-3">
<a href={`/documents/${activity.document_id}`} className="text-blue-600 dark:text-blue-400">
{activity.document_id}
</a>
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{new Date(activity.timestamp).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
return <Table columns={columns} data={activities || []} loading={isLoading} />;
}

View File

@@ -1,8 +1,181 @@
import { useState } from 'react';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
import { FolderOpen } from 'lucide-react';
export default function AdminImportPage() {
const [currentPath, setCurrentPath] = useState<string>('');
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
const { data: directoryData, isLoading } = useGetImportDirectory(
currentPath ? { directory: currentPath } : {}
);
const postImport = usePostImport();
const directories = directoryData?.data?.items || [];
const currentPathDisplay = directoryData?.data?.current_path ?? currentPath ?? '/data';
const handleSelectDirectory = (directory: string) => {
setSelectedDirectory(`${currentPath}/${directory}`);
};
const handleNavigateUp = () => {
if (currentPathDisplay !== '/') {
const parts = currentPathDisplay.split('/');
parts.pop();
setCurrentPath(parts.join('/') || '');
}
};
const handleImport = () => {
if (!selectedDirectory) return;
postImport.mutate(
{
data: {
directory: selectedDirectory,
type: importType,
},
},
{
onSuccess: (response) => {
console.log('Import completed:', response.data);
// Redirect to import results page
window.location.href = '/admin/import-results';
},
onError: (error) => {
console.error('Import failed:', error);
alert('Import failed: ' + (error as any).message);
},
}
);
};
const handleCancel = () => {
setSelectedDirectory('');
};
if (isLoading && !currentPath) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
if (selectedDirectory) {
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<div
className="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p className="text-lg font-semibold text-gray-500">
Selected Import Directory
</p>
<form className="flex gap-4 flex-col" onSubmit={handleImport}>
<div className="flex justify-between gap-4 w-full">
<div className="flex gap-4 items-center">
<FolderOpen size={20} />
<p className="font-medium text-lg break-all">
{selectedDirectory}
</p>
</div>
<div className="flex flex-col justify-around gap-2 mr-4">
<div className="inline-flex gap-2 items-center">
<input
type="radio"
id="direct"
checked={importType === 'DIRECT'}
onChange={() => setImportType('DIRECT')}
/>
<label htmlFor="direct">Direct</label>
</div>
<div className="inline-flex gap-2 items-center">
<input
type="radio"
id="copy"
checked={importType === 'COPY'}
onChange={() => setImportType('COPY')}
/>
<label htmlFor="copy">Copy</label>
</div>
</div>
</div>
<div className="flex gap-4">
<Button type="submit" className="px-10 py-2 text-base">
Import Directory
</Button>
<Button
type="button"
variant="secondary"
onClick={handleCancel}
className="px-10 py-2 text-base"
>
Cancel
</Button>
</div>
</form>
</div>
</div>
</div>
);
}
return (
<div>
<h1 className="text-xl font-bold dark:text-white">Admin - Import</h1>
<p className="text-gray-500 dark:text-gray-400">Document import page</p>
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table
className="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"
>
<thead className="text-gray-800 dark:text-gray-400">
<tr>
<th
className="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 w-12"
></th>
<th
className="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 break-all"
>
{currentPath}
</th>
</tr>
</thead>
<tbody className="text-black dark:text-white">
{currentPath !== '/' && (
<tr>
<td
className="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400"
></td>
<td className="p-3 border-b border-gray-200">
<button onClick={handleNavigateUp}>
<p>../</p>
</button>
</td>
</tr>
)}
{directories.length === 0 ? (
<tr>
<td className="text-center p-3" colSpan={2}>No Folders</td>
</tr>
) : (
directories.map((item) => (
<tr key={item.name}>
<td
className="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400"
>
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
<FolderOpen size={20} />
</button>
</td>
<td className="p-3 border-b border-gray-200">
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
<p>{item.name ?? ''}</p>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
import type { ImportResult } from '../generated/model/importResult';
import { Link } from 'react-router-dom';
export default function AdminImportResultsPage() {
const { data: resultsData, isLoading } = useGetImportResults();
const results = resultsData?.data?.results || [];
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table
className="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"
>
<thead className="text-gray-800 dark:text-gray-400">
<tr>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Document
</th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Status
</th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Error
</th>
</tr>
</thead>
<tbody className="text-black dark:text-white">
{results.length === 0 ? (
<tr>
<td className="text-center p-3" colSpan={3}>No Results</td>
</tr>
) : (
results.map((result: ImportResult, index: number) => (
<tr key={index}>
<td
className="p-3 border-b border-gray-200 grid"
style={{ gridTemplateColumns: '4rem auto' }}
>
<span className="text-gray-800 dark:text-gray-400">Name:</span>
{result.id ? (
<Link to={`/documents/${result.id}`}>{result.name}</Link>
) : (
<span>N/A</span>
)}
<span className="text-gray-800 dark:text-gray-400">File:</span>
<span>{result.path}</span>
</td>
<td className="p-3 border-b border-gray-200">
<p>{result.status}</p>
</td>
<td className="p-3 border-b border-gray-200">
<p>{result.error || ''}</p>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,8 +1,66 @@
import { useState, FormEvent } from 'react';
import { useGetLogs } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
import { Search } from 'lucide-react';
export default function AdminLogsPage() {
const [filter, setFilter] = useState('');
const { data: logsData, isLoading, refetch } = useGetLogs(
filter ? { filter } : {}
);
const logs = logsData?.data?.logs || [];
const handleFilterSubmit = (e: FormEvent) => {
e.preventDefault();
refetch();
};
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div>
<h1 className="text-xl font-bold dark:text-white">Admin - Logs</h1>
<p className="text-gray-500 dark:text-gray-400">System logs page</p>
{/* Filter Form */}
<div
className="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<form className="flex gap-4 flex-col lg:flex-row" onSubmit={handleFilterSubmit}>
<div className="flex flex-col w-full grow">
<div className="flex relative">
<span
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<Search size={15} />
</span>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="JQ Filter"
/>
</div>
</div>
<div className="lg:w-60">
<Button variant="secondary" type="submit">Filter</Button>
</div>
</form>
</div>
{/* Log Display */}
<div
className="flex flex-col-reverse text-black dark:text-white w-full overflow-scroll"
style={{ fontFamily: 'monospace' }}
>
{logs.map((log: string, index: number) => (
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
{log}
</span>
))}
</div>
</div>
);
}

View File

@@ -1,8 +1,218 @@
import { useState, FormEvent } from 'react';
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
interface BackupTypes {
covers: boolean;
documents: boolean;
}
export default function AdminPage() {
const { isLoading } = useGetAdmin();
const postAdminAction = usePostAdminAction();
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
covers: false,
documents: false,
});
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleBackupSubmit = (e: FormEvent) => {
e.preventDefault();
const backupTypesList: string[] = [];
if (backupTypes.covers) backupTypesList.push('COVERS');
if (backupTypes.documents) backupTypesList.push('DOCUMENTS');
postAdminAction.mutate(
{
data: {
action: 'BACKUP',
backup_types: backupTypesList as any,
},
},
{
onSuccess: (response) => {
// Handle file download
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
setMessage('Backup completed successfully');
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage('Backup failed: ' + (error as any).message);
setMessage(null);
},
}
);
};
const handleRestoreSubmit = (e: FormEvent) => {
e.preventDefault();
if (!restoreFile) return;
const formData = new FormData();
formData.append('restore_file', restoreFile);
formData.append('action', 'RESTORE');
postAdminAction.mutate(
{
data: formData as any,
},
{
onSuccess: () => {
setMessage('Restore completed successfully');
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage('Restore failed: ' + (error as any).message);
setMessage(null);
},
}
);
};
const handleMetadataMatch = () => {
postAdminAction.mutate(
{
data: {
action: 'METADATA_MATCH',
},
},
{
onSuccess: () => {
setMessage('Metadata matching started');
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage('Metadata matching failed: ' + (error as any).message);
setMessage(null);
},
}
);
};
const handleCacheTables = () => {
postAdminAction.mutate(
{
data: {
action: 'CACHE_TABLES',
},
},
{
onSuccess: () => {
setMessage('Cache tables started');
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage('Cache tables failed: ' + (error as any).message);
setMessage(null);
},
}
);
};
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div>
<h1 className="text-xl font-bold dark:text-white">Admin - General</h1>
<p className="text-gray-500 dark:text-gray-400">Admin general settings page</p>
<div className="w-full flex flex-col gap-4 grow">
{/* Backup & Restore Card */}
<div
className="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p className="text-lg font-semibold mb-2">Backup & Restore</p>
<div className="flex flex-col gap-4">
{/* Backup Form */}
<form className="flex justify-between" onSubmit={handleBackupSubmit}>
<div className="flex gap-8 items-center">
<div>
<input
type="checkbox"
id="backup_covers"
checked={backupTypes.covers}
onChange={(e) => setBackupTypes({ ...backupTypes, covers: e.target.checked })}
/>
<label htmlFor="backup_covers">Covers</label>
</div>
<div>
<input
type="checkbox"
id="backup_documents"
checked={backupTypes.documents}
onChange={(e) => setBackupTypes({ ...backupTypes, documents: e.target.checked })}
/>
<label htmlFor="backup_documents">Documents</label>
</div>
</div>
<div className="w-40 h-10">
<Button variant="secondary" type="submit">Backup</Button>
</div>
</form>
{/* Restore Form */}
<form
onSubmit={handleRestoreSubmit}
className="flex justify-between grow"
>
<div className="flex items-center w-1/2">
<input
type="file"
accept=".zip"
onChange={(e) => setRestoreFile(e.target.files?.[0] || null)}
className="w-full"
/>
</div>
<div className="w-40 h-10">
<Button variant="secondary" type="submit">Restore</Button>
</div>
</form>
</div>
{errorMessage && (
<span className="text-red-400 text-xs">{errorMessage}</span>
)}
{message && (
<span className="text-green-400 text-xs">{message}</span>
)}
</div>
{/* Tasks Card */}
<div
className="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p className="text-lg font-semibold">Tasks</p>
<table className="min-w-full bg-white dark:bg-gray-700 text-sm">
<tbody className="text-black dark:text-white">
<tr>
<td className="pl-0">
<p>Metadata Matching</p>
</td>
<td className="py-2 float-right">
<div className="w-40 h-10 text-base">
<Button variant="secondary" onClick={handleMetadataMatch}>Run</Button>
</div>
</td>
</tr>
<tr>
<td>
<p>Cache Tables</p>
</td>
<td className="py-2 float-right">
<div className="w-40 h-10 text-base">
<Button variant="secondary" onClick={handleCacheTables}>Run</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,8 +1,234 @@
import { useState, FormEvent } from 'react';
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
import { Plus, Trash2 } from 'lucide-react';
export default function AdminUsersPage() {
const { data: usersData, isLoading, refetch } = useGetUsers({});
const updateUser = useUpdateUser();
const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false);
const users = usersData?.data?.users || [];
const handleCreateUser = (e: FormEvent) => {
e.preventDefault();
if (!newUsername || !newPassword) return;
updateUser.mutate(
{
data: {
operation: 'CREATE',
user: newUsername,
password: newPassword,
is_admin: newIsAdmin,
},
},
{
onSuccess: () => {
setShowAddForm(false);
setNewUsername('');
setNewPassword('');
setNewIsAdmin(false);
refetch();
},
onError: (error: any) => {
alert('Failed to create user: ' + error.message);
},
}
);
};
const handleDeleteUser = (userId: string) => {
updateUser.mutate(
{
data: {
operation: 'DELETE',
user: userId,
},
},
{
onSuccess: () => {
refetch();
},
onError: (error: any) => {
alert('Failed to delete user: ' + error.message);
},
}
);
};
const handleUpdatePassword = (userId: string, password: string) => {
if (!password) return;
updateUser.mutate(
{
data: {
operation: 'UPDATE',
user: userId,
password: password,
},
},
{
onSuccess: () => {
refetch();
},
onError: (error: any) => {
alert('Failed to update password: ' + error.message);
},
}
);
};
const handleToggleAdmin = (userId: string, isAdmin: boolean) => {
updateUser.mutate(
{
data: {
operation: 'UPDATE',
user: userId,
is_admin: isAdmin,
},
},
{
onSuccess: () => {
refetch();
},
onError: (error: any) => {
alert('Failed to update admin status: ' + error.message);
},
}
);
};
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div>
<h1 className="text-xl font-bold dark:text-white">Admin - Users</h1>
<p className="text-gray-500 dark:text-gray-400">User management page</p>
<div className="relative h-full overflow-x-auto">
{/* Add User Form */}
{showAddForm && (
<div className="absolute top-10 left-10 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form onSubmit={handleCreateUser}
className="flex flex-col gap-2 text-black dark:text-white text-sm">
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="Username"
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Password"
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="new_is_admin"
checked={newIsAdmin}
onChange={(e) => setNewIsAdmin(e.target.checked)}
/>
<label htmlFor="new_is_admin">Admin</label>
</div>
<button
className="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit"
>
Create
</button>
</form>
</div>
)}
{/* Users Table */}
<div className="min-w-full overflow-scroll rounded shadow">
<table className="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead className="text-gray-800 dark:text-gray-400">
<tr>
<th className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 w-12">
<button onClick={() => setShowAddForm(!showAddForm)}>
<Plus size={20} />
</button>
</th>
<th className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th>
<th className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Password</th>
<th className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center">
Permissions
</th>
<th className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 w-48">Created</th>
</tr>
</thead>
<tbody className="text-black dark:text-white">
{users.length === 0 ? (
<tr>
<td className="text-center p-3" colSpan={5}>No Results</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id}>
{/* Delete Button */}
<td className="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400 cursor-pointer relative">
<button onClick={() => handleDeleteUser(user.id)}>
<Trash2 size={20} />
</button>
</td>
{/* User ID */}
<td className="p-3 border-b border-gray-200">
<p>{user.id}</p>
</td>
{/* Password Reset */}
<td className="border-b border-gray-200 px-3">
<button
onClick={() => {
const password = prompt(`Enter new password for ${user.id}`);
if (password) handleUpdatePassword(user.id, password);
}}
className="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
>
Reset
</button>
</td>
{/* Admin Toggle */}
<td className="flex gap-2 justify-center p-3 border-b border-gray-200 text-center min-w-40">
<button
onClick={() => handleToggleAdmin(user.id, true)}
disabled={user.admin}
className={`px-2 py-1 rounded-md text-white dark:text-black ${
user.admin
? 'bg-gray-800 dark:bg-gray-100 cursor-default'
: 'bg-gray-400 dark:bg-gray-600 cursor-pointer'
}`}
>
admin
</button>
<button
onClick={() => handleToggleAdmin(user.id, false)}
disabled={!user.admin}
className={`px-2 py-1 rounded-md text-white dark:text-black ${
!user.admin
? 'bg-gray-800 dark:bg-gray-100 cursor-default'
: 'bg-gray-400 dark:bg-gray-600 cursor-pointer'
}`}
>
user
</button>
</td>
{/* Created Date */}
<td className="p-3 border-b border-gray-200">
<p>{user.created_at}</p>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,51 +1,40 @@
import { Link } from 'react-router-dom';
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
import { Table } from '../components/Table';
export default function ProgressPage() {
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
const progress = data?.data?.progress;
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
const columns = [
{
key: 'document_id' as const,
header: 'Document',
render: (_: any, row: any) => (
<Link
to={`/documents/${row.document_id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
),
},
{
key: 'device_name' as const,
header: 'Device Name',
render: (value: any) => value || 'Unknown',
},
{
key: 'percentage' as const,
header: 'Percentage',
render: (value: any) => (value ? `${Math.round(value)}%` : '0%'),
},
{
key: 'created_at' as const,
header: 'Created At',
render: (value: any) => (value ? new Date(value).toLocaleDateString() : 'N/A'),
},
];
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-white dark:bg-gray-700">
<thead>
<tr className="border-b dark:border-gray-600">
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Device Name</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Percentage</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Created At</th>
</tr>
</thead>
<tbody>
{progress?.map((row: any) => (
<tr key={row.document_id} className="border-b dark:border-gray-600">
<td className="p-3">
<Link
to={`/documents/${row.document_id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{row.device_name || 'Unknown'}
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{row.percentage ? Math.round(row.percentage) : 0}%
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
return <Table columns={columns} data={progress || []} loading={isLoading} />;
}