296 lines
17 KiB
TypeScript
296 lines
17 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
|
import { Head, router } from '@inertiajs/react';
|
|
import { PageProps } from '@/types';
|
|
import { DataTable } from '@/Components/DataTable';
|
|
import { Portal } from '@/Components/Portal';
|
|
import _ from 'lodash';
|
|
|
|
interface Activity {
|
|
id: number;
|
|
log_name: string;
|
|
description: string;
|
|
subject_type: string;
|
|
subject_id: number;
|
|
causer_id: number;
|
|
causer?: { first_name: string; last_name: string; email: string };
|
|
properties: any;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ActivityLogsPageProps extends PageProps {
|
|
activities: { data: Activity[]; meta: any; links: any[]; };
|
|
filters: any;
|
|
availableLogNames: string[];
|
|
availableEvents: string[];
|
|
}
|
|
|
|
/* ─── Log Detail Modal (Modern & Clean) ───────────────────────────── */
|
|
/* ─── Log Detail Modal (Modern & Clean) ───────────────────────────── */
|
|
function LogModal({ activity, onClose }: { activity: Activity; onClose: () => void }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const jsonString = JSON.stringify(activity.properties, null, 4);
|
|
|
|
const handleCopy = () => {
|
|
navigator.clipboard.writeText(jsonString);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<Portal>
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#3D4E4B]/60 backdrop-blur-md anim-fade">
|
|
<div className="bg-white w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden anim-zoom border border-gray-100 flex flex-col max-h-[90vh]">
|
|
<div className="p-8 border-b border-gray-50 shrink-0">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-xl font-bold text-[#3D4E4B] tracking-tight">Activity Details</h2>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-50 rounded-xl transition-colors">
|
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-gray-400 font-medium">{activity.description}</p>
|
|
</div>
|
|
|
|
<div className="p-8 overflow-y-auto custom-scrollbar space-y-8">
|
|
{/* Meta Info */}
|
|
<div className="grid grid-cols-2 gap-8">
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Performed By</label>
|
|
<p className="text-sm font-bold text-[#3D4E4B]">{activity.causer ? `${activity.causer.first_name} ${activity.causer.last_name}` : 'System'}</p>
|
|
</div>
|
|
<div className="space-y-1 text-right">
|
|
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Date & Time</label>
|
|
<p className="text-sm font-bold text-[#3D4E4B]">{new Date(activity.created_at).toLocaleString()}</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Log Name</label>
|
|
<p className="text-sm font-bold text-[#3D4E4B]">{activity.log_name}</p>
|
|
</div>
|
|
<div className="space-y-1 text-right">
|
|
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Subject</label>
|
|
<p className="text-sm font-bold text-[#3D4E4B]">{activity.subject_type.split('\\').pop()} #{activity.subject_id}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payload */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Data Changes / Properties</label>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleCopy}
|
|
className={`text-[10px] font-bold px-2 py-0.5 rounded-lg border transition-all uppercase tracking-tight flex items-center gap-1 ${copied ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-[#D4A017]/5 text-[#D4A017] border-[#D4A017]/10 hover:bg-[#D4A017]/10'}`}
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path d="M5 13l4 4L19 7" /></svg>
|
|
Copied
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" /></svg>
|
|
Copy JSON
|
|
</>
|
|
)}
|
|
</button>
|
|
<span className="text-[10px] font-bold text-gray-400 bg-gray-50 px-2 py-0.5 rounded-lg border border-gray-100 uppercase tracking-tight">JSON Format</span>
|
|
</div>
|
|
</div>
|
|
<div className="bg-[#1E1E1E] p-6 overflow-hidden shadow-inner border border-white/5 relative group">
|
|
<pre className="text-[11px] font-mono text-gray-300 whitespace-pre-wrap break-all leading-relaxed custom-scrollbar max-h-[400px] overflow-y-auto">
|
|
{jsonString}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-8 border-t border-gray-50 bg-gray-50/30 shrink-0 flex justify-end">
|
|
<button onClick={onClose} className="h-11 px-8 bg-white border border-gray-200 rounded-xl text-sm font-bold text-[#3D4E4B] hover:bg-gray-50 transition-all shadow-sm">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
export default function ActivityLogsIndex({ activities, filters, availableLogNames, availableEvents }: ActivityLogsPageProps) {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [selectedLog, setSelectedLog] = useState<Activity | null>(null);
|
|
const [selectedIds, setSelectedIds] = useState<(number | string)[]>([]);
|
|
const [localFilters, setLocalFilters] = useState({
|
|
search: filters.search || '',
|
|
log_name: filters.log_name || '',
|
|
event: filters.event || '',
|
|
per_page: filters.per_page || 15,
|
|
});
|
|
|
|
const debouncedFilter = useCallback(_.debounce((params) => {
|
|
setIsLoading(true);
|
|
router.get(route('activity-logs.index'), params, {
|
|
preserveState: true,
|
|
preserveScroll: true,
|
|
replace: true,
|
|
only: ['activities', 'filters'],
|
|
onFinish: () => setIsLoading(false)
|
|
});
|
|
}, 400), []);
|
|
|
|
const updateFilter = (key: string, value: any) => {
|
|
const newFilters = { ...localFilters, [key]: value };
|
|
setLocalFilters(newFilters);
|
|
const params = { ...newFilters, page: 1 };
|
|
setSelectedIds([]); // Clear selection on filter change
|
|
debouncedFilter(params);
|
|
};
|
|
|
|
const handleBulkDelete = () => {
|
|
const count = selectedIds.length;
|
|
swal.confirm('Purge Logs?', `Are you sure you want to permanently delete ${count} activity logs?`, 'Purge')
|
|
.then(result => {
|
|
if (result.isConfirmed) {
|
|
router.post(route('activity-logs.bulk-delete'), { ids: selectedIds }, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
setSelectedIds([]);
|
|
swal.success('Purged', `${count} logs deleted successfully.`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
header: 'Activity',
|
|
accessorKey: 'description',
|
|
cell: (a: Activity) => (
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-bold text-[#3D4E4B] tracking-tight">{a.description}</span>
|
|
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-0.5">{a.log_name}</span>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
header: 'Causer',
|
|
accessorKey: 'causer',
|
|
cell: (a: Activity) => (
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-500">
|
|
{a.causer ? `${a.causer.first_name[0]}${a.causer.last_name[0]}` : 'SYS'}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs font-bold text-[#3D4E4B]">{a.causer ? `${a.causer.first_name} ${a.causer.last_name}` : 'System'}</span>
|
|
<span className="text-[10px] text-gray-400 font-semibold">{a.causer?.email || 'automated@system'}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
header: 'Properties',
|
|
accessorKey: 'properties',
|
|
cell: (a: Activity) => (
|
|
<div className="max-w-[300px] truncate">
|
|
<code className="text-[10px] bg-gray-50 px-1.5 py-0.5 rounded text-gray-500 font-semibold">
|
|
{JSON.stringify(a.properties)}
|
|
</code>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
header: 'Date & Time',
|
|
accessorKey: 'created_at',
|
|
cell: (a: Activity) => (
|
|
<span className="text-xs font-semibold text-gray-400 tracking-tight">
|
|
{new Date(a.created_at).toLocaleString('en-US', {
|
|
day: '2-digit', month: 'short', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
})}
|
|
</span>
|
|
)
|
|
},
|
|
{
|
|
header: 'Action',
|
|
accessorKey: 'actions',
|
|
cell: (a: Activity) => (
|
|
<div className="flex justify-end pr-4">
|
|
<button
|
|
onClick={() => setSelectedLog(a)}
|
|
className="p-2 rounded-xl text-gray-400 hover:text-[#D4A017] hover:bg-[#D4A017]/5 transition-all opacity-0 group-hover:opacity-100 translate-x-2 group-hover:translate-x-0"
|
|
title="View Details"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
];
|
|
|
|
return (
|
|
<AuthenticatedLayout>
|
|
<Head title="Activity Logs" />
|
|
|
|
<div className="flex items-center justify-between mb-8 anim-down">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">Activity Logs</h1>
|
|
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Audit trail of system events and user actions</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative w-[240px]">
|
|
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
|
<input type="text" placeholder="Search logs…" value={localFilters.search} onChange={e => updateFilter('search', e.target.value)}
|
|
className="w-full h-11 pl-10 pr-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 placeholder-gray-400 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm" />
|
|
</div>
|
|
|
|
<select value={localFilters.log_name} onChange={e => updateFilter('log_name', e.target.value)}
|
|
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
|
<option value="">All Logs</option>
|
|
{availableLogNames.map(n => <option key={n} value={n}>{n}</option>)}
|
|
</select>
|
|
|
|
<select value={localFilters.event} onChange={e => updateFilter('event', e.target.value)}
|
|
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
|
<option value="">All Events</option>
|
|
{availableEvents.map(e => <option key={e} value={e}>{e}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="anim-up">
|
|
<DataTable
|
|
data={activities.data} columns={columns as any} meta={activities.meta} links={activities.links} filters={localFilters}
|
|
isLoading={isLoading}
|
|
selectedIds={selectedIds}
|
|
onSelectionChange={setSelectedIds}
|
|
/>
|
|
</div>
|
|
|
|
{selectedLog && <LogModal activity={selectedLog} onClose={() => setSelectedLog(null)} />}
|
|
|
|
{/* Floating Bulk Actions Bar */}
|
|
<Portal>
|
|
<div className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-40 transition-all duration-500 ${selectedIds.length > 0 ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'}`}>
|
|
<div className="bg-[#3D4E4B] rounded-2xl shadow-2xl px-6 py-4 flex items-center gap-6 border border-white/10 backdrop-blur-xl">
|
|
<div className="flex items-center gap-3 pr-6 border-r border-white/10">
|
|
<span className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center text-white text-xs font-bold">{selectedIds.length}</span>
|
|
<span className="text-white text-sm font-bold tracking-tight">Logs selected</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={handleBulkDelete} className="h-10 px-5 rounded-xl bg-red-500 text-white text-xs font-bold hover:bg-red-600 transition-all flex items-center gap-2">
|
|
<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>
|
|
Bulk Purge
|
|
</button>
|
|
<button onClick={() => setSelectedIds([])} className="h-10 px-4 text-white/40 text-xs font-bold hover:text-white transition-colors">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
</AuthenticatedLayout>
|
|
);
|
|
}
|