450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
FaHistory,
|
|
FaSync,
|
|
FaChevronDown,
|
|
FaChevronUp,
|
|
FaUser,
|
|
FaClock,
|
|
FaCheckCircle,
|
|
FaSpinner,
|
|
} from 'react-icons/fa';
|
|
import { useAuditLogs } from '../hooks/useAuditLogs';
|
|
import type { AuditLogEntry, VersionChange } from '../hooks/useAuditLogs';
|
|
|
|
// ============== PROPS ==============
|
|
|
|
interface ActivityLogProps {
|
|
/** Frappe DocType name (e.g. 'Asset', 'Inspection', 'Work_Order') */
|
|
doctype: string;
|
|
/** Document name / ID */
|
|
docname: string | null;
|
|
/** Document creation date (for "Created" entry at bottom) */
|
|
creationDate?: string;
|
|
/** Document owner/creator email */
|
|
createdBy?: string;
|
|
/** Title shown in header */
|
|
title?: string;
|
|
/** Max entries to fetch */
|
|
limit?: number;
|
|
/** Number of entries visible before "Show All" */
|
|
initialVisible?: number;
|
|
/** Allow collapse/expand */
|
|
collapsible?: boolean;
|
|
/** Start collapsed */
|
|
startCollapsed?: boolean;
|
|
/** Compact mode for sidebar placement */
|
|
compact?: boolean;
|
|
/** Additional CSS class */
|
|
className?: string;
|
|
/** Callback after refresh */
|
|
onRefresh?: () => void;
|
|
}
|
|
|
|
// ============== HELPER FUNCTIONS ==============
|
|
|
|
const formatFieldName = (fieldName: string): string => {
|
|
if (!fieldName) return '';
|
|
return fieldName
|
|
.replace(/^custom_/, '')
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
};
|
|
|
|
const formatValue = (value: any): string => {
|
|
if (value === null || value === undefined) return '(empty)';
|
|
if (value === '') return '(empty)';
|
|
if (value === 0) return '0';
|
|
if (value === 1) return '1';
|
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
if (typeof value === 'object') return JSON.stringify(value);
|
|
return String(value);
|
|
};
|
|
|
|
const formatAuditDate = (dateStr: string): string => {
|
|
if (!dateStr) return '';
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
|
|
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
|
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
|
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
const formatUsername = (email: string): string => {
|
|
if (!email) return 'Unknown';
|
|
const atIndex = email.indexOf('@');
|
|
if (atIndex === -1) return email;
|
|
return email.substring(0, atIndex);
|
|
};
|
|
|
|
const getChangeColor = (fieldName: string): string => {
|
|
const lower = fieldName.toLowerCase();
|
|
if (lower.includes('status') || lower.includes('state') || lower.includes('workflow')) {
|
|
return 'text-purple-600 dark:text-purple-400';
|
|
}
|
|
if (lower.includes('date')) {
|
|
return 'text-blue-600 dark:text-blue-400';
|
|
}
|
|
if (
|
|
lower.includes('technician') ||
|
|
lower.includes('supervisor') ||
|
|
lower.includes('assigned') ||
|
|
lower.includes('location') ||
|
|
lower.includes('department') ||
|
|
lower.includes('building') ||
|
|
lower.includes('room')
|
|
) {
|
|
return 'text-green-600 dark:text-green-400';
|
|
}
|
|
return 'text-gray-600 dark:text-gray-400';
|
|
};
|
|
|
|
// ============== SUB-COMPONENTS ==============
|
|
|
|
/** Single timeline entry */
|
|
const TimelineEntry: React.FC<{
|
|
log: AuditLogEntry;
|
|
isLatest: boolean;
|
|
compact: boolean;
|
|
}> = ({ log, isLatest, compact }) => {
|
|
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
|
|
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
|
|
const iconSize = compact ? 8 : 10;
|
|
const textSize = compact ? 'text-[10px]' : 'text-xs';
|
|
const valueSize = compact ? 'text-[9px]' : 'text-[10px]';
|
|
|
|
return (
|
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
|
|
{/* Timeline dot */}
|
|
<div
|
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 ${
|
|
isLatest ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
/>
|
|
|
|
{/* Entry content */}
|
|
<div
|
|
className={`${compact ? 'p-2' : 'p-3'} rounded-lg ${
|
|
isLatest
|
|
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800/50'
|
|
: 'bg-gray-50 dark:bg-gray-700/50'
|
|
}`}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<div
|
|
className={`${avatarSize} rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center`}
|
|
>
|
|
<FaUser className="text-gray-500 dark:text-gray-400" size={iconSize} />
|
|
</div>
|
|
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
|
|
{formatUsername(log.owner)}
|
|
</span>
|
|
</div>
|
|
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
|
|
<FaClock size={iconSize} />
|
|
<span title={new Date(log.creation).toLocaleString()}>
|
|
{formatAuditDate(log.creation)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Changes */}
|
|
<div className="space-y-1">
|
|
{log.changes.length > 0 ? (
|
|
log.changes.map((change, i) => (
|
|
<div key={i} className={textSize}>
|
|
<span className={`font-medium ${getChangeColor(change.field)}`}>
|
|
{formatFieldName(change.field)}
|
|
</span>
|
|
<span className="text-gray-500 dark:text-gray-400"> changed from </span>
|
|
<span
|
|
className={`px-1 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded ${valueSize} font-mono`}
|
|
>
|
|
{formatValue(change.oldValue)}
|
|
</span>
|
|
<span className="text-gray-500 dark:text-gray-400"> → </span>
|
|
<span
|
|
className={`px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded ${valueSize} font-mono`}
|
|
>
|
|
{formatValue(change.newValue)}
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className={`${textSize} text-gray-500 dark:text-gray-400 italic`}>Document updated</p>
|
|
)}
|
|
|
|
{log.added && log.added.length > 0 && (
|
|
<div className={`${textSize} text-green-600 dark:text-green-400`}>
|
|
<span className="font-medium">Added:</span> {log.added.length} item(s)
|
|
</div>
|
|
)}
|
|
{log.removed && log.removed.length > 0 && (
|
|
<div className={`${textSize} text-red-600 dark:text-red-400`}>
|
|
<span className="font-medium">Removed:</span> {log.removed.length} item(s)
|
|
</div>
|
|
)}
|
|
{log.rowChanged && log.rowChanged.length > 0 && (
|
|
<div className={`${textSize} text-orange-600 dark:text-orange-400`}>
|
|
<span className="font-medium">Modified:</span> {log.rowChanged.length} row(s)
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** "Created this document" entry */
|
|
const CreatedEntry: React.FC<{
|
|
creationDate: string;
|
|
createdBy: string;
|
|
doctype: string;
|
|
compact: boolean;
|
|
}> = ({ creationDate, createdBy, doctype, compact }) => {
|
|
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
|
|
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
|
|
const iconSize = compact ? 8 : 10;
|
|
const textSize = compact ? 'text-[10px]' : 'text-xs';
|
|
|
|
// Clean doctype for display (e.g. "Work_Order" → "Work Order")
|
|
const displayDoctype = doctype.replace(/_/g, ' ');
|
|
|
|
return (
|
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
|
|
<div
|
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 bg-green-500`}
|
|
/>
|
|
<div
|
|
className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800/50`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<div
|
|
className={`${avatarSize} rounded-full bg-green-200 dark:bg-green-800 flex items-center justify-center`}
|
|
>
|
|
<FaUser className="text-green-600 dark:text-green-400" size={iconSize} />
|
|
</div>
|
|
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
|
|
{formatUsername(createdBy)}
|
|
</span>
|
|
</div>
|
|
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
|
|
<FaClock size={iconSize} />
|
|
<span title={new Date(creationDate).toLocaleString()}>
|
|
{formatAuditDate(creationDate)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 bg-green-100 dark:bg-green-800/50 text-green-700 dark:text-green-300 rounded ${textSize} font-medium`}
|
|
>
|
|
<FaCheckCircle size={iconSize} />
|
|
Created this {displayDoctype}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============== MAIN COMPONENT ==============
|
|
|
|
const ActivityLog: React.FC<ActivityLogProps> = ({
|
|
doctype,
|
|
docname,
|
|
creationDate,
|
|
createdBy,
|
|
title = 'Activity Log',
|
|
limit = 50,
|
|
initialVisible = 5,
|
|
collapsible = true,
|
|
startCollapsed = false,
|
|
compact = false,
|
|
className = '',
|
|
onRefresh,
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = useState(!startCollapsed);
|
|
const [showAll, setShowAll] = useState(false);
|
|
|
|
const { auditLogs, loading, refetch } = useAuditLogs({
|
|
doctype,
|
|
docname,
|
|
limit,
|
|
enabled: !!docname,
|
|
});
|
|
|
|
const handleRefresh = () => {
|
|
refetch();
|
|
onRefresh?.();
|
|
};
|
|
|
|
if (!docname) return null;
|
|
|
|
const headerIconSize = compact ? 14 : 16;
|
|
const headerTextClass = compact ? 'text-sm' : 'text-base';
|
|
const timelineLineLeft = compact ? 'left-2' : 'left-3';
|
|
const showMoreTextSize = compact ? 'text-[10px]' : 'text-xs';
|
|
const showMoreIconSize = compact ? 8 : 10;
|
|
|
|
const visibleLogs = showAll ? auditLogs : auditLogs.slice(0, initialVisible);
|
|
|
|
return (
|
|
<div
|
|
className={`bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden ${className}`}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
|
|
<div
|
|
className={`flex items-center gap-2 flex-1 ${collapsible ? 'cursor-pointer' : ''}`}
|
|
onClick={() => collapsible && setIsExpanded(!isExpanded)}
|
|
>
|
|
<FaHistory className="text-blue-500" size={headerIconSize} />
|
|
<h2 className={`${headerTextClass} font-semibold text-gray-800 dark:text-white`}>
|
|
{title}
|
|
</h2>
|
|
{auditLogs.length > 0 && (
|
|
<span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-[10px] font-medium">
|
|
{auditLogs.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRefresh();
|
|
}}
|
|
disabled={loading}
|
|
className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors disabled:opacity-50"
|
|
title="Refresh activity log"
|
|
>
|
|
<FaSync className={loading ? 'animate-spin' : ''} size={compact ? 10 : 12} />
|
|
</button>
|
|
{collapsible && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1"
|
|
>
|
|
{isExpanded ? (
|
|
<FaChevronUp size={compact ? 12 : 14} />
|
|
) : (
|
|
<FaChevronDown size={compact ? 12 : 14} />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{isExpanded && (
|
|
<div className="p-3">
|
|
{/* Loading */}
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-6">
|
|
<FaSpinner className="animate-spin text-blue-500 mr-2" size={14} />
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">Loading...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!loading && auditLogs.length === 0 && (
|
|
<div className="relative">
|
|
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
|
|
|
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'} mb-3`}>
|
|
<div
|
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1 ${compact ? 'w-2.5 h-2.5' : 'w-3 h-3'} rounded-full border-2 border-white dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
|
|
/>
|
|
<div className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-gray-50 dark:bg-gray-700/50`}>
|
|
<p className={`${compact ? 'text-[10px]' : 'text-xs'} text-gray-500 dark:text-gray-400 italic`}>
|
|
No changes recorded yet
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{creationDate && createdBy && (
|
|
<CreatedEntry
|
|
creationDate={creationDate}
|
|
createdBy={createdBy}
|
|
doctype={doctype}
|
|
compact={compact}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Timeline */}
|
|
{!loading && auditLogs.length > 0 && (
|
|
<div className="relative">
|
|
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
|
|
|
|
<div className="space-y-3">
|
|
{visibleLogs.map((log, index) => (
|
|
<TimelineEntry
|
|
key={log.name}
|
|
log={log}
|
|
isLatest={index === 0}
|
|
compact={compact}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Show More/Less */}
|
|
{auditLogs.length > initialVisible && (
|
|
<div className="mt-3 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAll(!showAll)}
|
|
className={`inline-flex items-center gap-1 px-2 py-1 ${showMoreTextSize} font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors`}
|
|
>
|
|
{showAll ? (
|
|
<>
|
|
<FaChevronUp size={showMoreIconSize} /> Show Less
|
|
</>
|
|
) : (
|
|
<>
|
|
<FaChevronDown size={showMoreIconSize} /> Show All ({auditLogs.length})
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Created entry at bottom */}
|
|
{creationDate && createdBy && (
|
|
<div className="mt-3">
|
|
<CreatedEntry
|
|
creationDate={creationDate}
|
|
createdBy={createdBy}
|
|
doctype={doctype}
|
|
compact={compact}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ActivityLog; |