2026-03-23 17:43:17 +05:30

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;