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