244 lines
15 KiB
TypeScript
244 lines
15 KiB
TypeScript
import { Link } from '@inertiajs/react';
|
|
import { Skeleton } from './Skeleton';
|
|
|
|
interface Column<T> {
|
|
header: string;
|
|
accessorKey: keyof T | string;
|
|
sortable?: boolean;
|
|
cell?: (item: T) => React.ReactNode;
|
|
}
|
|
|
|
interface PaginationMeta {
|
|
current_page: number;
|
|
last_page: number;
|
|
total: number;
|
|
per_page: number;
|
|
}
|
|
|
|
interface DataTableProps<T> {
|
|
data: T[];
|
|
columns: Column<T>[];
|
|
meta?: PaginationMeta;
|
|
links?: any[];
|
|
filters?: any;
|
|
onFilterChange?: (filters: any) => void;
|
|
onSort?: (field: string, direction: 'asc' | 'desc') => void;
|
|
onEdit?: (item: T) => void;
|
|
onDelete?: (item: T) => void;
|
|
onRestore?: (item: T) => void;
|
|
onPermanentDelete?: (item: T) => void;
|
|
canEdit?: boolean;
|
|
canDelete?: boolean;
|
|
isLoading?: boolean;
|
|
emptyAction?: React.ReactNode;
|
|
}
|
|
|
|
export function DataTable<T extends { id: number | string }>({
|
|
data,
|
|
columns,
|
|
meta,
|
|
links,
|
|
filters = {},
|
|
onSort,
|
|
onEdit,
|
|
onDelete,
|
|
onRestore,
|
|
onPermanentDelete,
|
|
canEdit = false,
|
|
canDelete = false,
|
|
isLoading = false,
|
|
emptyAction,
|
|
selectedIds = [],
|
|
onSelectionChange,
|
|
}: DataTableProps<T> & {
|
|
selectedIds?: (number | string)[],
|
|
onSelectionChange?: (ids: (number | string)[]) => void
|
|
}) {
|
|
|
|
const toggleAll = () => {
|
|
if (!onSelectionChange) return;
|
|
if (selectedIds.length === data.length) {
|
|
onSelectionChange([]);
|
|
} else {
|
|
onSelectionChange(data.map(item => item.id));
|
|
}
|
|
};
|
|
|
|
const toggleOne = (id: number | string) => {
|
|
if (!onSelectionChange) return;
|
|
if (selectedIds.includes(id)) {
|
|
onSelectionChange(selectedIds.filter(i => i !== id));
|
|
} else {
|
|
onSelectionChange([...selectedIds, id]);
|
|
}
|
|
};
|
|
|
|
const handleSort = (field: string) => {
|
|
if (!onSort) return;
|
|
const direction = filters.sort_field === field && filters.sort_direction === 'asc' ? 'desc' : 'asc';
|
|
onSort(field, direction);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="relative bg-white rounded-2xl border border-gray-100 shadow-sm transition-all overflow-hidden">
|
|
|
|
|
|
<div className="overflow-x-auto custom-scrollbar">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="bg-gray-50/50 border-b border-gray-50">
|
|
{onSelectionChange && (
|
|
<th className="pl-8 py-5 w-10">
|
|
<div className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={data.length > 0 && selectedIds.length === data.length}
|
|
onChange={toggleAll}
|
|
className="w-4 h-4 rounded-lg border-gray-200 text-[#D4A017] focus:ring-[#D4A017]/20 transition-all cursor-pointer"
|
|
/>
|
|
</div>
|
|
</th>
|
|
)}
|
|
{columns.map((col, idx) => (
|
|
<th
|
|
key={idx}
|
|
className={`px-8 py-5 text-sm font-bold text-[#3D4E4B]/40 tracking-tight whitespace-nowrap ${idx === 0 && !onSelectionChange ? 'pl-8' : ''} ${col.sortable ? 'cursor-pointer hover:text-[#D4A017] transition-colors group' : ''}`}
|
|
onClick={() => col.sortable && handleSort(col.accessorKey as string)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{col.header}
|
|
{col.sortable && (
|
|
<div className="flex flex-col opacity-20 group-hover:opacity-100 transition-opacity scale-75">
|
|
<svg className={`w-3 h-3 ${filters.sort_field === col.accessorKey && filters.sort_direction === 'asc' ? 'text-[#D4A017]' : ''}`} fill="currentColor" viewBox="0 0 24 24"><path d="M12 4l-8 8h16z"/></svg>
|
|
<svg className={`w-3 h-3 -mt-1.5 ${filters.sort_field === col.accessorKey && filters.sort_direction === 'desc' ? 'text-[#D4A017]' : ''}`} fill="currentColor" viewBox="0 0 24 24"><path d="M12 20l8-8H4z"/></svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</th>
|
|
))}
|
|
{(canEdit || onDelete || onRestore || onPermanentDelete) && (
|
|
<th className="px-8 py-5 text-right text-sm font-bold text-[#3D4E4B]/40 tracking-tight">Actions</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{isLoading ? (
|
|
Array.from({ length: 5 }).map((_, i) => (
|
|
<tr key={i} className="anim-fade">
|
|
{onSelectionChange && <td className="pl-8 py-5"><Skeleton width={16} height={16} /></td>}
|
|
{columns.map((_, idx) => (
|
|
<td key={idx} className={`px-8 py-5 ${idx === 0 && !onSelectionChange ? 'pl-8' : ''}`}>
|
|
<Skeleton variant="text" width={idx === 0 ? '60%' : '80%'} />
|
|
</td>
|
|
))}
|
|
{(canEdit || onDelete || onRestore || onPermanentDelete) && (
|
|
<td className="px-8 py-5 text-right"><Skeleton className="ml-auto" width={80} height={32} /></td>
|
|
)}
|
|
</tr>
|
|
))
|
|
) : data.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length + (onSelectionChange ? 1 : 0) + 1} className="px-8 py-12 text-center">
|
|
<div className="flex flex-col items-center gap-3 anim-up">
|
|
<div className="w-12 h-12 rounded-2xl bg-gray-50 flex items-center justify-center text-gray-300">
|
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0a2 2 0 01-2 2H6a2 2 0 01-2-2m16 0l-8 4-8-4" /></svg>
|
|
</div>
|
|
<p className="text-sm font-bold text-gray-400">No data found</p>
|
|
{emptyAction && <div className="mt-1">{emptyAction}</div>}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : data.map((item) => (
|
|
<tr key={item.id} className={`hover:bg-gray-50/30 transition-colors group ${selectedIds.includes(item.id) ? 'bg-[#D4A017]/5' : ''}`}>
|
|
{onSelectionChange && (
|
|
<td className="pl-8 py-5">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.includes(item.id)}
|
|
onChange={() => toggleOne(item.id)}
|
|
className="w-4 h-4 rounded-lg border-gray-200 text-[#D4A017] focus:ring-[#D4A017]/20 transition-all cursor-pointer"
|
|
/>
|
|
</td>
|
|
)}
|
|
{columns.map((col, colIndex) => (
|
|
<td key={colIndex} className={`px-8 py-5 whitespace-nowrap ${colIndex === 0 && !onSelectionChange ? 'pl-8' : ''}`}>
|
|
{col.cell ? col.cell(item) : (
|
|
<span className="text-xs font-bold text-[#3D4E4B]">{String(item[col.accessorKey as keyof T] || '-')}</span>
|
|
)}
|
|
</td>
|
|
))}
|
|
{(canEdit || onDelete || onRestore || onPermanentDelete) && (
|
|
<td className="px-8 py-5 whitespace-nowrap text-right">
|
|
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all translate-x-2 group-hover:translate-x-0">
|
|
{onRestore && (
|
|
<button onClick={() => onRestore(item)} className="p-2 rounded-xl text-gray-400 hover:text-[#21A59F] hover:bg-[#21A59F]/5 transition-all" title="Restore">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
|
</button>
|
|
)}
|
|
{canEdit && onEdit && (
|
|
<button onClick={() => onEdit(item)} className="p-2 rounded-xl text-gray-400 hover:text-[#D4A017] hover:bg-[#D4A017]/5 transition-all" title="Edit">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
|
</button>
|
|
)}
|
|
{onDelete && (
|
|
<button onClick={() => onDelete(item)} className="p-2 rounded-xl text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all" title="Archive">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
|
</button>
|
|
)}
|
|
{onPermanentDelete && (
|
|
<button onClick={() => onPermanentDelete(item)} className="p-2 rounded-xl text-red-300 hover:text-red-600 hover:bg-red-50 transition-all" title="Delete permanently">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pagination - Professional Flat Style */}
|
|
{meta && links && (
|
|
<div className="flex items-center justify-between px-2">
|
|
<div className="text-sm font-bold text-[#3D4E4B]/40 tracking-tight">
|
|
Showing <span className="text-[#3D4E4B]">{data.length}</span> of <span className="text-[#3D4E4B]">{meta.total}</span> entries
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{links.map((link, idx) => {
|
|
if (link.label.includes('Previous')) {
|
|
return (
|
|
<Link key={idx} href={link.url || '#'} preserveState className={`px-4 py-2 rounded-xl text-sm font-bold tracking-tight transition-all ${!link.url ? 'text-gray-300 pointer-events-none' : 'text-[#3D4E4B] hover:bg-white hover:shadow-sm'}`}>Prev</Link>
|
|
);
|
|
}
|
|
if (link.label.includes('Next')) {
|
|
return (
|
|
<Link key={idx} href={link.url || '#'} preserveState className={`px-4 py-2 rounded-xl text-sm font-bold tracking-tight transition-all ${!link.url ? 'text-gray-300 pointer-events-none' : 'text-[#3D4E4B] hover:bg-white hover:shadow-sm'}`}>Next</Link>
|
|
);
|
|
}
|
|
if (link.label.includes('...') || /^\d+$/.test(link.label)) {
|
|
return (
|
|
<Link
|
|
key={idx} href={link.url || '#'} preserveState
|
|
className={`w-9 h-9 flex items-center justify-center rounded-xl text-sm font-bold transition-all
|
|
${link.active
|
|
? 'bg-[#3D4E4B] text-white'
|
|
: 'text-[#3D4E4B] hover:bg-white hover:shadow-sm'
|
|
}`}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|