116 lines
9.3 KiB
TypeScript
116 lines
9.3 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { api } from '@/lib/api';
|
|
import { PageHeader } from '@/components/ui/page-header';
|
|
import { DataTable } from '@/components/ui/data-table';
|
|
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ActionIcon } from '@/components/ui/action-icon';
|
|
import { Modal } from '@/components/ui/modal';
|
|
import { FormModal } from '@/components/ui/form-modal';
|
|
import { useToast } from '@/components/ui/toast';
|
|
|
|
const CATEGORIES = ['router', 'olt', 'cable', 'tool', 'vehicle', 'computer', 'other'];
|
|
|
|
export default function AssetsPage() {
|
|
const { toast } = useToast();
|
|
const [assets, setAssets] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [detailTarget, setDetailTarget] = useState<any>(null);
|
|
const [catFilter, setCatFilter] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (catFilter) params.set('category', catFilter);
|
|
const r = await api.get(`/assets?${params}`);
|
|
setAssets(r.data.data);
|
|
} catch { toast('Failed to load assets', 'error'); }
|
|
finally { setLoading(false); }
|
|
}, [catFilter, toast]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const filtered = search ? assets.filter((a: any) => a.name.toLowerCase().includes(search.toLowerCase()) || a.serialNumber?.toLowerCase().includes(search.toLowerCase())) : assets;
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title="Assets" description="Track company equipment and inventory"
|
|
action={<div className="flex gap-2">
|
|
<select value={catFilter} onChange={(e) => setCatFilter(e.target.value)} aria-label="Filter category"
|
|
className="rounded-lg border border-surface-200 dark:border-surface-700 px-3 py-2 text-sm bg-white dark:bg-surface-800 text-surface-700 dark:text-surface-300 cursor-pointer focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
|
|
<option value="">All categories</option>
|
|
{CATEGORIES.map((c) => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
|
|
</select>
|
|
<Button onClick={() => setShowCreate(true)}>Add Asset</Button>
|
|
</div>} />
|
|
|
|
<div className="mt-5">
|
|
<DataTable data={filtered} loading={loading} keyExtractor={(a: any) => a.id}
|
|
emptyTitle="No assets" emptyDescription="Add your first equipment or tool."
|
|
searchPlaceholder="Search by name or serial number..." searchValue={search} onSearchChange={setSearch}
|
|
onRowClick={(a: any) => setDetailTarget(a)}
|
|
columns={[
|
|
{ key: 'name', label: 'Name', sortable: true, render: (a: any) => <span className="font-medium text-surface-800 dark:text-surface-200">{a.name}</span> },
|
|
{ key: 'category', label: 'Category', sortable: true, render: (a: any) => <Badge label={a.category} /> },
|
|
{ key: 'serialNumber', label: 'Serial #', render: (a: any) => <span className="font-mono text-surface-500 dark:text-surface-400 text-xs">{a.serialNumber || '—'}</span> },
|
|
{ key: 'status', label: 'Status', sortable: true, render: (a: any) => <Badge label={a.status} variant={statusBadgeVariant(a.status)} /> },
|
|
{ key: 'assignedTo', label: 'Assigned To', render: (a: any) => a.assignedTo ? <span className="text-surface-600 dark:text-surface-300">{a.assignedTo.firstName} {a.assignedTo.lastName}</span> : <span className="text-surface-300">Unassigned</span> },
|
|
{ key: 'purchasePrice', label: 'Value', align: 'right' as const, render: (a: any) => a.purchasePrice ? <span className="text-surface-700 dark:text-surface-300">PHP {Number(a.purchasePrice).toLocaleString()}</span> : <span className="text-surface-300">—</span> },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<CreateAssetModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
|
|
|
|
{/* Asset Detail Modal */}
|
|
<Modal open={!!detailTarget} onClose={() => setDetailTarget(null)}
|
|
title={detailTarget?.name || 'Asset Details'} description={detailTarget?.serialNumber ? `S/N: ${detailTarget.serialNumber}` : ''}>
|
|
{detailTarget && (
|
|
<div className="space-y-3 text-sm">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><span className="text-surface-500 dark:text-surface-400">Category:</span> <Badge label={detailTarget.category} /></div>
|
|
<div><span className="text-surface-500 dark:text-surface-400">Status:</span> <Badge label={detailTarget.status} variant={statusBadgeVariant(detailTarget.status)} /></div>
|
|
<div><span className="text-surface-500 dark:text-surface-400">Serial #:</span> <span className="font-mono text-surface-700 dark:text-surface-300">{detailTarget.serialNumber || '—'}</span></div>
|
|
<div><span className="text-surface-500 dark:text-surface-400">Value:</span> <span className="text-surface-900 dark:text-surface-200 font-medium">{detailTarget.purchasePrice ? `PHP ${Number(detailTarget.purchasePrice).toLocaleString()}` : '—'}</span></div>
|
|
<div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Assigned To:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.assignedTo ? `${detailTarget.assignedTo.firstName} ${detailTarget.assignedTo.lastName}` : 'Unassigned'}</span></div>
|
|
{detailTarget.location && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Location:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.location}</span></div>}
|
|
{detailTarget.notes && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Notes:</span> <span className="text-surface-600 dark:text-surface-300">{detailTarget.notes}</span></div>}
|
|
</div>
|
|
<div className="flex justify-end pt-2 border-t border-surface-200 dark:border-surface-700">
|
|
<Button size="sm" variant="ghost" onClick={() => setDetailTarget(null)}>Close</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateAssetModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void }) {
|
|
const { toast } = useToast();
|
|
const [form, setForm] = useState({ name: '', category: 'router', serialNumber: '', purchasePrice: 0, location: '', notes: '' });
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm bg-white dark:bg-surface-800 text-surface-900 dark:text-surface-200 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
|
|
useEffect(() => { if (open) setForm({ name: '', category: 'router', serialNumber: '', purchasePrice: 0, location: '', notes: '' }); }, [open]);
|
|
async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSubmitting(true);
|
|
try { await api.post('/assets', { ...form, serialNumber: form.serialNumber || undefined, purchasePrice: form.purchasePrice || undefined, location: form.location || undefined, notes: form.notes || undefined }); toast('Asset added', 'success'); onSuccess(); onClose(); }
|
|
catch (err: any) { toast(err.response?.data?.error || 'Failed', 'error'); } finally { setSubmitting(false); } }
|
|
return (
|
|
<FormModal open={open} onClose={onClose} title="Add Asset"><form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Name</label><input type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={ic} placeholder="e.g. Mikrotik hEX S" /></div>
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Category</label><select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className={ic}>{CATEGORIES.map((c) => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}</select></div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Serial Number</label><input type="text" value={form.serialNumber} onChange={(e) => setForm({ ...form, serialNumber: e.target.value })} className={ic} /></div>
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Purchase Price (PHP)</label><input type="number" min={0} value={form.purchasePrice || ''} onChange={(e) => setForm({ ...form, purchasePrice: parseFloat(e.target.value) || 0 })} className={ic} /></div>
|
|
</div>
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Location</label><input type="text" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} className={ic} placeholder="e.g. Warehouse, Field" /></div>
|
|
<div className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button><Button type="submit" loading={submitting}>Add Asset</Button></div>
|
|
</form></FormModal>);
|
|
}
|