From 97084db4de778aff21ed89e482132535d4f82d2e Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Thu, 2 Jul 2026 20:25:12 -0400 Subject: [PATCH] Add HistoryDetailModal component for detailed activity view --- src/views/History/HistoryCard.jsx | 392 ++++++++---------------- src/views/Modals/HistoryDetailModal.jsx | 197 ++++++++++++ 2 files changed, 321 insertions(+), 268 deletions(-) create mode 100644 src/views/Modals/HistoryDetailModal.jsx diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index cd81f81..6004071 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -1,94 +1,48 @@ import { AccessTime, - CalendarMonth, Check, - EventNote, HourglassEmpty, MoreVert, - Person, Redo, RunningWithErrors, Schedule, ThumbDown, Timelapse, - Toll, } from '@mui/icons-material' -import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy' +import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy' import moment from 'moment' import { useLocalization } from '../../contexts/LocalizationContext' import { TASK_COLOR } from '../../utils/Colors.jsx' -const getCompletedChip = historyEntry => { - if (historyEntry.status === 0 || historyEntry.status === 5 || historyEntry.status === 6) { - return null - } - - if (!historyEntry.dueDate) { - return null - // } - // > - // No Due Date - // - } - - const performedAt = moment(historyEntry.performedAt) - const dueDate = moment(historyEntry.dueDate) - // TODO: make this a config at some point - const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds - - if (Math.abs(performedAt - dueDate) <= gracePeriod) { - return ( - } - > - On Time - - ) - } else if (performedAt.isBefore(dueDate)) { - return ( - } - > - Early - - ) - } else { - return ( - } - > - Late - - ) - } -} const formatTime = seconds => { - if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) { - return null - } - const hours = Math.floor(seconds / 3600) - const minutes = Math.floor((seconds % 3600) / 60) - const secs = seconds % 60 - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` +} + +const stripHtmlTags = html => { + if (!html) return '' + if (typeof document === 'undefined') { + return String(html).replace(/<[^>]*>/g, '') + } + const div = document.createElement('div') + div.innerHTML = html + return div.textContent || div.innerText || '' +} + +const statusConfig = { + 0: { label: 'In Progress', color: 'primary', icon: }, + 1: { label: 'Completed', color: 'success', icon: }, + 2: { label: 'Skipped', color: 'warning', icon: }, + 3: { label: 'Pending Approval', color: 'neutral', icon: }, + 4: { label: 'Rejected', color: 'danger', icon: }, + 5: { label: 'Missed', color: 'danger', icon: }, + 6: { label: 'Rescheduled', color: 'warning', icon: }, } -/** - * Compact HistoryCard component - content only - */ const HistoryCard = ({ allHistory, performers, @@ -96,235 +50,137 @@ const HistoryCard = ({ index, onToggleActions, onViewNote, + onViewDetails, }) => { const { fmt } = useLocalization() const performer = performers.find(p => p.userId === historyEntry.completedBy) const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo) + const config = statusConfig[historyEntry.status] ?? statusConfig[1] + const displayLabel = + historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label + const actionDate = historyEntry.performedAt || historyEntry.updatedAt - const formatTimeDifference = (startDate, endDate) => { - const diffInMinutes = moment(startDate).diff(endDate, 'minutes') - let timeValue = diffInMinutes - let unit = 'minute' + const getTimingLine = () => { + const { status, performedAt, dueDate } = historyEntry + if (!dueDate) return null - if (diffInMinutes >= 60) { - const diffInHours = moment(startDate).diff(endDate, 'hours') - timeValue = diffInHours - unit = 'hour' - - if (diffInHours >= 24) { - const diffInDays = moment(startDate).diff(endDate, 'days') - timeValue = diffInDays - unit = 'day' - } + if (status === 6) { + return `Was due ${moment(dueDate).format('MMM D')}` } - - return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}` + if (status === 5) { + return `Was due ${moment(dueDate).format('MMM D')}` + } + if ((status === 1 || status === 2 || status === 0) && performedAt) { + const diffHours = moment(performedAt).diff(dueDate, 'hours') + const abs = Math.abs(diffHours) + if (abs <= 6) return null // chip already says "On Time" + if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date` + return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date` + } + return null } - const getStatusAvatar = () => { - const statusMap = { - 0: { icon: , color: 'primary' }, // Started - 1: { icon: , color: 'success' }, // Completed - 2: { icon: , color: 'warning' }, // Skipped - 3: { icon: , color: 'neutral' }, // Pending Approval - 4: { icon: , color: 'danger' }, // Rejected - 5: { icon: , color: 'danger' }, // Missed - 6: { icon: , color: 'warning' }, // Rescheduled - } + const timingLine = getTimingLine() + const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note' + const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : '' - const config = statusMap[historyEntry.status] || statusMap[1] - return ( - - {config.icon} - - ) - } + const metaTextParts = [ + fmt.dateTime(actionDate), + historyEntry.completedBy !== historyEntry.assignedTo && assignedTo + ? `Assigned to ${assignedTo.displayName}` + : null, + historyEntry?.duration > 0 ? `⏱ ${formatTime(historyEntry.duration)}` : null, + historyEntry?.points > 0 ? `★ ${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null, + ].filter(Boolean) return ( onViewDetails?.()} sx={{ display: 'flex', - alignItems: 'center', - minHeight: 64, minWidth: '100%', - px: 2, - py: 1.5, bgcolor: 'background.body', borderBottom: '1px solid', borderColor: 'divider', + borderLeft: '3px solid', + borderLeftColor: `${config.color}.400`, + cursor: onViewDetails ? 'pointer' : 'default', + '&:hover': onViewDetails ? { bgcolor: 'background.level1' } : {}, }} > - - - {/* First Row/Column: Status and Time Info */} - - + {/* Status + timing chip */} + + + - {getStatusAvatar()} + {config.icon} + + + {displayLabel} + + + - - {historyEntry.status === 0 - ? 'In Progress' - : historyEntry.status === 1 - ? 'Completed' - : historyEntry.status === 2 - ? 'Skipped' - : historyEntry.status === 3 - ? 'Pending Approval' - : historyEntry.status === 4 - ? 'Rejected' - : historyEntry.status === 5 - ? 'Missed' - : historyEntry.status === 6 - ? 'Rescheduled' - : 'Completed'} - + {/* Timing relationship line */} + {timingLine && ( + + {timingLine} + + )} - }> - {fmt.dateTime( - historyEntry.performedAt || historyEntry.updatedAt, - )} - + {/* Notes inline */} - - {getCompletedChip(historyEntry)} - - - + {plainTextNotes && ( + + { e.stopPropagation(); onViewNote?.(historyEntry.notes) }} + > + {plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}…` : plainTextNotes} + + + )} - {/* Second Row/Column: Completion Status (right side on desktop) */} - - + {performer && ( + + } > - {historyEntry.dueDate && ( - }> - {fmt.dateTime(historyEntry.dueDate)} - - )} - - - - {/* Third Row: Performer and Assignment Info */} - - - {performer && ( - - } - > - {performer?.displayName || 'Unknown'} - - )} - - {historyEntry.completedBy !== historyEntry.assignedTo && - assignedTo && ( - } - > - Assigned to {assignedTo.displayName} - - )} - - {historyEntry.notes && ( - } - sx={{ - maxWidth: '120px', - overflow: 'hidden', - cursor: 'pointer', - }} - onClick={e => { - e.stopPropagation() - onViewNote?.(historyEntry.notes) - }} - > - Note - - )} - {/* add a duration chip if we have duration */} - {historyEntry?.duration > 0 && ( - } - > - {formatTime(historyEntry.duration)} - - )} - {historyEntry?.points > 0 && ( - } - > - {historyEntry.points} pt - {historyEntry.points > 1 ? 's' : ''} - - )} - - - + {performer.displayName} + + )} + {metaTextParts.length > 0 && ( + + {metaTextParts.join(' · ')} + + )} + - + + e.stopPropagation()}> {onToggleActions && ( { - e.stopPropagation() - onToggleActions() - }} + onClick={e => { e.stopPropagation(); onToggleActions() }} > diff --git a/src/views/Modals/HistoryDetailModal.jsx b/src/views/Modals/HistoryDetailModal.jsx new file mode 100644 index 0000000..2173484 --- /dev/null +++ b/src/views/Modals/HistoryDetailModal.jsx @@ -0,0 +1,197 @@ +import { + AccessTime, + CalendarMonth, + Check, + HourglassEmpty, + Person, + Redo, + RunningWithErrors, + Schedule, + ThumbDown, + Update, +} from '@mui/icons-material' +import { Avatar, Box, Chip, Divider, Stack, Typography } from '@mui/joy' +import moment from 'moment' +import { useLocalization } from '../../contexts/LocalizationContext' +import { useResponsiveModal } from '../../hooks/useResponsiveModal' +import { TASK_COLOR } from '../../utils/Colors.jsx' +import RichTextEditor from '../components/RichTextEditor.jsx' + +const STATUS_CONFIG = { + 0: { label: 'In Progress', color: 'primary', icon: }, + 1: { label: 'Completed', color: 'success', icon: }, + 2: { label: 'Skipped', color: 'warning', icon: }, + 3: { label: 'Pending Approval', color: 'neutral', icon: }, + 4: { label: 'Rejected', color: 'danger', icon: }, + 5: { label: 'Missed', color: 'danger', icon: }, + 6: { label: 'Rescheduled', color: 'warning', icon: }, +} + +const DetailRow = ({ icon, label, value, children }) => ( + + {icon} + + {label} + {children ?? ( + {value} + )} + + +) + +const TimingBadge = ({ historyEntry }) => { + if (!historyEntry.dueDate || !historyEntry.performedAt) return null + if ([0, 5, 6].includes(historyEntry.status)) return null + + const performedAt = moment(historyEntry.performedAt) + const dueDate = moment(historyEntry.dueDate) + const diffHours = performedAt.diff(dueDate, 'hours') + const gracePeriod = 6 * 60 * 60 * 1000 + + if (Math.abs(performedAt - dueDate) <= gracePeriod) { + return }>On Time + } else if (performedAt.isBefore(dueDate)) { + const abs = Math.abs(diffHours) + const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early` + return }>{label} + } else { + const abs = Math.abs(diffHours) + const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late` + return {label} + } +} + +function HistoryDetailModal({ config }) { + const { ResponsiveModal } = useResponsiveModal() + const { fmt } = useLocalization() + + const entry = config?.entry + const performers = config?.performers ?? [] + + if (!entry) return null + + const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1] + const isFirstSchedule = entry.status === 6 && !entry.dueDate + const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label + const performer = performers.find(p => p.userId === entry.completedBy) + const assignedTo = performers.find(p => p.userId === entry.assignedTo) + const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo + + // updatedAt is only meaningful if it differs from performedAt by more than a minute + const showUpdatedAt = + entry.updatedAt && + entry.performedAt && + Math.abs(moment(entry.updatedAt).diff(entry.performedAt, 'minutes')) > 1 + + const formatDuration = seconds => { + if (!seconds || seconds <= 0) return null + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` + } + + return ( + + {/* Status header */} + + + + {statusCfg.icon} + + + {statusLabel} + + + + + + + + + {/* Who performed it */} + {performer && ( + } label='Performed by'> + + + {performer.displayName} + + + )} + + {/* Assigned to (only if different) */} + {isDifferentAssignee && assignedTo && ( + } label='Assigned to' value={assignedTo.displayName} /> + )} + + + + {/* Performed at */} + {entry.performedAt && ( + } + label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'} + value={fmt.dateTime(entry.performedAt)} + /> + )} + + {/* Due date */} + {entry.dueDate && ( + } + label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'} + value={fmt.dateTime(entry.dueDate)} + /> + )} + + {/* Last updated (only if meaningfully different from performedAt) */} + {showUpdatedAt && ( + } + label='Last updated' + value={fmt.dateTime(entry.updatedAt)} + /> + )} + + {/* Duration */} + {entry.duration > 0 && ( + } + label='Duration' + value={formatDuration(entry.duration)} + /> + )} + + {/* Points */} + {entry.points > 0 && ( + ★} + label='Points earned' + value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`} + /> + )} + + {/* Notes */} + {entry.notes && ( + <> + + + + {entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'} + + + + + + + )} + + + ) +} + +export default HistoryDetailModal