admin page added
This commit is contained in:
parent
742646268c
commit
d36f16d5bb
@ -11,6 +11,8 @@ import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||
import { MyDailyLogsPage } from './screens/MyDailyLogsPage'
|
||||
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
||||
import { ReportPage } from './screens/ReportPage'
|
||||
import { DatasetsPage } from './screens/admin/DatasetsPage'
|
||||
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -42,6 +44,10 @@ function App() {
|
||||
|
||||
<Route path="reports/:reportType" element={<ReportPage />} />
|
||||
<Route path="sales-report" element={<DailySalesReportPage />} />
|
||||
|
||||
<Route path="admin/datasets" element={<DatasetsPage />} />
|
||||
<Route path="admin/datasets/:id" element={<DatasetItemsPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@ -3,6 +3,7 @@ import type {
|
||||
AiDecision,
|
||||
ApiError,
|
||||
AuditEntry,
|
||||
Dataset,
|
||||
FormScreenResponse,
|
||||
LoginResponse,
|
||||
RecordViewParams,
|
||||
@ -301,6 +302,24 @@ export class ZinoClient {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Datasets ---
|
||||
|
||||
getDatasets(): Promise<Dataset[]> {
|
||||
return this.request<Dataset[]>('GET', `/app/${APP_ID}/datasets`);
|
||||
}
|
||||
|
||||
getDataset(id: number | string): Promise<Dataset> {
|
||||
return this.request<Dataset>('GET', `/app/${APP_ID}/datasets/${id}`);
|
||||
}
|
||||
|
||||
createDataset(payload: { name: string; data_type: 'string' | 'object'; keys: string[]; description?: string }): Promise<Dataset> {
|
||||
return this.request<Dataset>('POST', `/app/${APP_ID}/datasets`, payload);
|
||||
}
|
||||
|
||||
updateDataset(id: number | string, payload: Partial<{ name: string; data_type: 'string' | 'object'; keys: string[]; description: string; items: any[] }>): Promise<Dataset> {
|
||||
return this.request<Dataset>('PUT', `/app/${APP_ID}/datasets/${id}`, payload);
|
||||
}
|
||||
|
||||
// --- File upload + OCR (multipart, field-scoped) ---
|
||||
|
||||
private fieldForm(file: File, ctx: FieldContext): FormData {
|
||||
|
||||
@ -19,6 +19,19 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// --- Datasets ---
|
||||
|
||||
export interface Dataset {
|
||||
id: number;
|
||||
uuid?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
data_type: 'string' | 'object';
|
||||
keys: string[];
|
||||
item_count?: number;
|
||||
items?: any[];
|
||||
}
|
||||
|
||||
// --- Record view (POST /app/{appId}/view/recordview) ---
|
||||
|
||||
export interface RecordViewField {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { LogOut, ChevronDown } from 'lucide-react';
|
||||
import { LogOut, ChevronDown, Database } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||
@ -78,6 +78,7 @@ export function ConsoleLayout() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
|
||||
<button
|
||||
@ -91,6 +92,15 @@ export function ConsoleLayout() {
|
||||
<p className="text-sm font-bold text-gray-900 truncate">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
|
||||
</div>
|
||||
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
|
||||
<NavLink
|
||||
to="/admin/datasets"
|
||||
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
|
||||
>
|
||||
<Database size={16} />
|
||||
Manage Datasets
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
421
src/screens/admin/DatasetItemsPage.tsx
Normal file
421
src/screens/admin/DatasetItemsPage.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import type { Dataset } from '../../api/types';
|
||||
import { Button } from '../../components/buttons/Button';
|
||||
import { Spinner } from '../../components/reusable/Spinner';
|
||||
import { ArrowLeft, Save, Trash2, Plus, Database, Upload } from 'lucide-react';
|
||||
|
||||
export function DatasetItemsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [dataset, setDataset] = useState<Dataset | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [invalidCells, setInvalidCells] = useState<Set<string>>(new Set());
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
|
||||
// Local state for items editing
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) fetchDataset(id);
|
||||
}, [id]);
|
||||
|
||||
const fetchDataset = async (datasetId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await orderBookingClient.getDataset(datasetId);
|
||||
setDataset(res);
|
||||
setItems(res.items || []);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch dataset details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!dataset || !id) return;
|
||||
setInvalidCells(new Set());
|
||||
|
||||
let hasError = false;
|
||||
const newInvalidCells = new Set<string>();
|
||||
|
||||
if (dataset.data_type === 'object') {
|
||||
items.forEach((obj, idx) => {
|
||||
const hasSomeValue = dataset.keys.some(k => (obj[k] || '').trim() !== '');
|
||||
const hasMissingValue = dataset.keys.some(k => !(obj[k] || '').trim());
|
||||
|
||||
if (hasSomeValue && hasMissingValue) {
|
||||
hasError = true;
|
||||
dataset.keys.forEach(k => {
|
||||
if (!(obj[k] || '').trim()) {
|
||||
newInvalidCells.add(`${idx}-${k}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
setInvalidCells(newInvalidCells);
|
||||
return;
|
||||
}
|
||||
|
||||
let cleanedItems: any[] = [];
|
||||
if (dataset.data_type === 'string') {
|
||||
cleanedItems = items.filter(val => typeof val === 'string' && val.trim() !== '');
|
||||
} else {
|
||||
cleanedItems = items.filter(obj => dataset.keys.some(k => (obj[k] || '').trim() !== ''));
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
items: cleanedItems
|
||||
};
|
||||
const res = await orderBookingClient.updateDataset(id, payload);
|
||||
setDataset(res);
|
||||
setItems(res.items || []);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to save dataset items');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// String Array handlers
|
||||
const handleStringChange = (index: number, val: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = val;
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleAddString = () => {
|
||||
if (items.length > 0 && typeof items[items.length - 1] === 'string' && items[items.length - 1].trim() === '') {
|
||||
return;
|
||||
}
|
||||
setItems([...items, '']);
|
||||
};
|
||||
|
||||
// Object Array handlers
|
||||
const handleObjectChange = (index: number, key: string, val: string) => {
|
||||
const newItems = [...items];
|
||||
if (!newItems[index]) newItems[index] = {};
|
||||
newItems[index][key] = val;
|
||||
setItems(newItems);
|
||||
|
||||
if (invalidCells.has(`${index}-${key}`)) {
|
||||
const newInvalid = new Set(invalidCells);
|
||||
newInvalid.delete(`${index}-${key}`);
|
||||
setInvalidCells(newInvalid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddObject = () => {
|
||||
if (items.length > 0) {
|
||||
const lastObj = items[items.length - 1];
|
||||
const hasAnyValue = dataset?.keys?.some(k => (lastObj[k] || '').trim() !== '');
|
||||
if (!hasAnyValue && dataset?.keys?.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newObj: any = {};
|
||||
if (dataset?.keys) {
|
||||
dataset.keys.forEach(k => { newObj[k] = ''; });
|
||||
}
|
||||
setItems([...items, newObj]);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
setItems(newItems);
|
||||
setSelectedRows(new Set());
|
||||
setInvalidCells(new Set());
|
||||
};
|
||||
|
||||
const toggleRowSelection = (index: number) => {
|
||||
const newSet = new Set(selectedRows);
|
||||
if (newSet.has(index)) newSet.delete(index);
|
||||
else newSet.add(index);
|
||||
setSelectedRows(newSet);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedRows.size === items.length && items.length > 0) {
|
||||
setSelectedRows(new Set());
|
||||
} else {
|
||||
setSelectedRows(new Set(items.map((_, i) => i)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMultiDelete = () => {
|
||||
if (selectedRows.size === 0) return;
|
||||
const newItems = items.filter((_, idx) => !selectedRows.has(idx));
|
||||
setItems(newItems);
|
||||
setSelectedRows(new Set());
|
||||
setInvalidCells(new Set());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (dataset) {
|
||||
setItems(dataset.items || []);
|
||||
setSelectedRows(new Set());
|
||||
setInvalidCells(new Set());
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
if (!text) return;
|
||||
|
||||
const rows = text.split(/\r?\n/).map(row => row.trim()).filter(row => row);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
if (dataset?.data_type === 'string') {
|
||||
const parsed = rows.map(r => r.split(',')[0].replace(/^"|"$/g, '').trim());
|
||||
setItems([...items, ...parsed]);
|
||||
} else {
|
||||
const headers = rows[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
|
||||
const newItems = rows.slice(1).map(row => {
|
||||
const values = row.split(',').map(v => v.replace(/^"|"$/g, '').trim());
|
||||
const obj: any = {};
|
||||
headers.forEach((h, i) => {
|
||||
if (dataset?.keys?.includes(h)) {
|
||||
obj[h] = values[i] || '';
|
||||
}
|
||||
});
|
||||
dataset?.keys?.forEach(k => {
|
||||
if (!(k in obj)) obj[k] = '';
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
setItems([...items, ...newItems]);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-10 flex justify-center"><Spinner /></div>;
|
||||
}
|
||||
|
||||
if (!dataset) {
|
||||
return <div className="p-10 text-center text-red-600 font-medium">{error || 'Dataset not found.'}</div>;
|
||||
}
|
||||
|
||||
const isString = dataset.data_type === 'string';
|
||||
const hasChanges = JSON.stringify(items) !== JSON.stringify(dataset.items || []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin/datasets')}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-slate-200 text-slate-500 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 m-0 flex items-center gap-2">
|
||||
<Database className="text-brand-base" /> {dataset.name}
|
||||
<span className="text-sm font-normal text-slate-500 bg-slate-100 px-2.5 py-0.5 rounded-full border border-slate-200 ml-1">
|
||||
{items.length} {isString ? 'Items' : 'Rows'}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Manage items for this dataset</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<input type="file" accept=".csv" onChange={handleFileUpload} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" title="Import CSV" />
|
||||
<Button variant="outline" size="sm" className="rounded-full"><Upload size={14} className="mr-1.5" /> Import CSV</Button>
|
||||
</div>
|
||||
{selectedRows.size > 0 && (
|
||||
<Button variant="danger" size="sm" onClick={handleMultiDelete} className="rounded-full font-semibold px-3 flex items-center">
|
||||
<Trash2 size={14} className="mr-1.5" /> Delete Selected ({selectedRows.size})
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Button variant="secondary" size="sm" onClick={handleReset} disabled={saving} className="rounded-full font-semibold">
|
||||
Reset Changes
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges} className="flex items-center gap-2 rounded-full">
|
||||
<Save size={16} /> {saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="p-4 bg-red-50 text-red-600 rounded-md border border-red-100 font-medium">{error}</div>}
|
||||
|
||||
<div className="p-0">
|
||||
{isString ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border border-b-0 border-slate-300 bg-[#FFFBF4]">
|
||||
<h3 className="text-sm font-semibold text-slate-700 m-0">
|
||||
{dataset.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto border border-slate-300">
|
||||
<table className="min-w-full table-auto text-left text-sm whitespace-nowrap border-collapse border-2 border-slate-300 bg-[#FFFBF4]">
|
||||
<thead className="bg-emerald-900 border-b-2 border-emerald-950">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold text-white w-10 text-center border-r border-emerald-800">
|
||||
<input type="checkbox" checked={selectedRows.size === items.length && items.length > 0} onChange={toggleSelectAll} className="rounded border-emerald-800 text-emerald-600 focus:ring-emerald-500 cursor-pointer" />
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-white w-10 text-center border-r border-emerald-800">#</th>
|
||||
<th className="px-4 py-3 font-semibold text-white border-r border-emerald-800">Value</th>
|
||||
<th className="px-4 py-3 font-semibold text-white text-center w-16">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y-2 divide-slate-300">
|
||||
{items.map((val, idx) => (
|
||||
<tr key={idx} className={`bg-[#FFFBF4] hover:bg-emerald-50 transition-colors ${selectedRows.has(idx) ? '!bg-emerald-100' : ''}`}>
|
||||
<td className="px-4 py-2 text-center border-r border-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(idx)}
|
||||
onChange={() => toggleRowSelection(idx)}
|
||||
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500 font-mono text-center text-xs border-r border-slate-300">{idx + 1}</td>
|
||||
<td className="px-0 py-0 relative border-r border-slate-300 min-w-[150px]">
|
||||
<div className="invisible px-4 py-2 text-sm whitespace-pre" aria-hidden="true">
|
||||
{val || 'Enter value'}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={(e) => handleStringChange(idx, e.target.value)}
|
||||
className="absolute inset-0 w-full h-full px-4 py-2 border-none bg-transparent text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-emerald-500 transition-colors"
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(idx)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 transition-all cursor-pointer"
|
||||
title="Remove Row"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-5 py-10 text-center text-slate-500 italic font-medium">
|
||||
No string items exist.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button variant="primary" size="sm" onClick={handleAddString} className="rounded-full">
|
||||
<Plus size={16} className="mr-1.5" /> Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-3 border border-b-0 border-slate-300 bg-[#FFFBF4]">
|
||||
<h3 className="text-sm font-semibold text-slate-700 m-0">
|
||||
{dataset.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto border border-slate-300">
|
||||
<table className="min-w-full table-auto text-left text-sm whitespace-nowrap border-collapse border-2 border-slate-300 bg-[#FFFBF4]">
|
||||
<thead className="bg-emerald-900 border-b-2 border-emerald-950">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold text-white w-10 text-center border-r border-emerald-800">
|
||||
<input type="checkbox" checked={selectedRows.size === items.length && items.length > 0} onChange={toggleSelectAll} className="rounded border-emerald-800 text-emerald-600 focus:ring-emerald-500 cursor-pointer" />
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-white w-10 text-center border-r border-emerald-800">#</th>
|
||||
{dataset.keys?.map(k => (
|
||||
<th key={k} className="px-4 py-3 font-semibold text-white border-r border-emerald-800">{k}</th>
|
||||
))}
|
||||
<th className="px-4 py-3 font-semibold text-white text-center w-16">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y-2 divide-slate-300">
|
||||
{items.map((rowObj, idx) => (
|
||||
<tr key={idx} className={`bg-[#FFFBF4] hover:bg-emerald-50 transition-colors ${selectedRows.has(idx) ? '!bg-emerald-100' : ''}`}>
|
||||
<td className="px-4 py-2 text-center border-r border-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(idx)}
|
||||
onChange={() => toggleRowSelection(idx)}
|
||||
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500 font-mono text-center text-xs border-r border-slate-300">{idx + 1}</td>
|
||||
{dataset.keys?.map(k => {
|
||||
const isInvalid = invalidCells.has(`${idx}-${k}`);
|
||||
return (
|
||||
<td key={k} className="px-0 py-0 relative border-r border-slate-300 min-w-[150px]">
|
||||
<div className="invisible px-4 py-2 text-sm whitespace-pre" aria-hidden="true">
|
||||
{rowObj[k] || `Enter ${k}`}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={rowObj[k] || ''}
|
||||
onChange={(e) => handleObjectChange(idx, k, e.target.value)}
|
||||
className={`absolute inset-0 w-full h-full px-4 py-2 border-none bg-transparent text-sm focus:outline-none transition-colors ${isInvalid
|
||||
? 'ring-2 ring-inset ring-red-400 bg-red-50/50 focus:ring-red-500'
|
||||
: 'focus:ring-2 focus:ring-inset focus:ring-emerald-500'
|
||||
}`}
|
||||
placeholder={`Enter ${k}`}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(idx)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 transition-all cursor-pointer"
|
||||
title="Remove Row"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={(dataset.keys?.length || 0) + 3} className="px-5 py-10 text-center text-slate-500 italic font-medium">
|
||||
No data rows available.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button variant="primary" size="sm" onClick={handleAddObject} className="rounded-full">
|
||||
<Plus size={16} className="mr-1.5" /> Add Row
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/screens/admin/DatasetsPage.tsx
Normal file
85
src/screens/admin/DatasetsPage.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import type { Dataset } from '../../api/types';
|
||||
import { Card } from '../../components/reusable/Card';
|
||||
import { Button } from '../../components/buttons/Button';
|
||||
import { Spinner } from '../../components/reusable/Spinner';
|
||||
import { Database, List } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function DatasetsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, []);
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await orderBookingClient.getDatasets();
|
||||
setDatasets(res);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch datasets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-800 m-0 flex items-center gap-2">
|
||||
<Database className="text-brand-base" /> Datasets
|
||||
</h1>
|
||||
|
||||
</header>
|
||||
|
||||
{error && <div className="p-4 bg-red-50 text-red-600 rounded-md border border-red-100 font-medium">{error}</div>}
|
||||
|
||||
<Card className="p-0 overflow-hidden shadow-sm border border-slate-200">
|
||||
{loading ? (
|
||||
<div className="p-10 flex justify-center"><Spinner /></div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-semibold text-slate-600">Name</th>
|
||||
<th className="px-5 py-3 font-semibold text-slate-600">Items</th>
|
||||
<th className="px-5 py-3 font-semibold text-slate-600 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{datasets.map((ds) => (
|
||||
<tr key={ds.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-5 py-4 text-slate-900 font-bold">{ds.name}</td>
|
||||
<td className="px-5 py-4 text-slate-600 font-medium">{ds.item_count || 0}</td>
|
||||
<td className="px-5 py-4 text-right flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => navigate(`/admin/datasets/${ds.id}`)} className="px-3 py-1.5 text-xs">
|
||||
<List size={14} className="mr-1.5" /> Items
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{datasets.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-5 py-10 text-center text-slate-500 italic font-medium">No datasets found.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user