feat: inisialisasi project kit v2

This commit is contained in:
2026-05-21 15:57:29 +07:00
commit d4fd478e1f
271 changed files with 35300 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
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>
);
}