subcontractor: add progress updates and task activity timeline

This commit is contained in:
Satyam Rastogi
2026-05-21 15:30:19 +05:30
parent ad39848b0f
commit 3a038ffc6f
2 changed files with 706 additions and 62 deletions
@@ -8,6 +8,7 @@ import {
ArrowLeft, ChevronRight, ClipboardList, MapPin, Calendar, Building2, User, Hash,
DollarSign, Receipt, Camera, Image as ImageIcon, MessageSquare, Lock, Send,
Clock, PauseCircle, CheckCircle, RefreshCcw, AlertCircle, X, Plus, Upload,
Activity, ImagePlus, Loader2, Play, Trash2, AlertTriangle,
} from 'lucide-react';
// ---------------------------------------------------------------------------
@@ -102,6 +103,7 @@ const SubcontractorTaskDetailPage = () => {
subcontractorTasks, subSelfStatuses,
setSubcontractorTaskStatus, addSubcontractorTaskMessage,
addSubcontractorTaskExpense, addSubcontractorTaskFee,
addSubcontractorTaskActivity,
} = useMockStore();
const task = useMemo(() => subcontractorTasks.find(t => t.id === taskId), [subcontractorTasks, taskId]);
@@ -109,6 +111,7 @@ const SubcontractorTaskDetailPage = () => {
const [logExpenseOpen, setLogExpenseOpen] = useState(false);
const [logFeeOpen, setLogFeeOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(null);
const [activityLightbox, setActivityLightbox] = useState(null); // { photos, index }
// ESC closes any open modal — handled inside each modal too, but having a guard
// at page level keeps focus tidy.
@@ -116,10 +119,11 @@ const SubcontractorTaskDetailPage = () => {
const handler = (e) => {
if (e.key !== 'Escape') return;
if (lightboxIndex !== null) setLightboxIndex(null);
if (activityLightbox) setActivityLightbox(null);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [lightboxIndex]);
}, [lightboxIndex, activityLightbox]);
if (!task) {
return (
@@ -144,11 +148,19 @@ const SubcontractorTaskDetailPage = () => {
const overdue = isOverdue(task);
const closed = task.status === 'Completed' || task.status === 'Cancelled';
const handleStatusChange = (nextStatus, comment) => {
const handleStatusChange = (nextStatus, comment, photos = []) => {
setSubcontractorTaskStatus(task.id, nextStatus, comment, {
id: user?.id || task.subcontractorId,
name: user?.name || task.subcontractorName,
role: 'Subcontractor',
}, photos);
};
const handlePostProgress = ({ note, photos }) => {
addSubcontractorTaskActivity(task.id, { note, photos }, {
id: user?.id || task.subcontractorId,
name: user?.name || task.subcontractorName,
role: 'Subcontractor',
});
};
@@ -204,6 +216,8 @@ const SubcontractorTaskDetailPage = () => {
disabled={closed}
statuses={subSelfStatuses}
onChange={handleStatusChange}
onPostProgress={handlePostProgress}
onActivityPreview={(photos, index) => setActivityLightbox({ photos, index })}
/>
<TaskThreadCard
@@ -258,6 +272,12 @@ const SubcontractorTaskDetailPage = () => {
onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex}
/>
<PhotoLightbox
photos={activityLightbox?.photos || []}
index={activityLightbox?.index ?? null}
onClose={() => setActivityLightbox(null)}
onIndexChange={(idx) => setActivityLightbox(prev => prev ? { ...prev, index: idx } : prev)}
/>
</div>
);
};
@@ -323,36 +343,126 @@ const TaskHeaderCard = ({ task, overdue, onLogExpense, onLogFee }) => {
};
// ===========================================================================
// Section 4 — Status Management
// Section 4 — Status Management (with note + photo evidence + progress updates)
// ===========================================================================
const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
const [comment, setComment] = useState('');
const STATUS_COPY = {
Assigned: {
title: 'Acknowledge assignment',
hint: 'Confirm you have received this task and attach a photo of the site or scope of work.',
notePlaceholder: 'e.g. Acknowledged — heading to site tomorrow morning.',
photoHint: 'Site or arrival photo',
accent: 'amber',
},
'In Progress': {
title: 'Start work',
hint: 'Add a brief note about what you are starting and one or more "before" photos.',
notePlaceholder: 'e.g. On site. Materials staged. Starting south slope ridge cap removal.',
photoHint: 'Before / starting photo',
accent: 'blue',
},
'On Hold': {
title: 'Put task on hold',
hint: 'Tell us why work has stopped, and attach a supporting photo (weather, materials, access, etc.).',
notePlaceholder: 'e.g. Work stopped due to rain. Will resume tomorrow.',
photoHint: 'Supporting photo (e.g. rain, blocked access)',
accent: 'orange',
},
Completed: {
title: 'Mark as completed',
hint: 'Add a final completion note and at least one photo showing the finished work.',
notePlaceholder: 'e.g. House painting completed successfully. Walked through with homeowner.',
photoHint: 'Completion / final-look photo(s)',
accent: 'emerald',
},
};
const ACCENT_BTN = {
amber: 'bg-amber-600 hover:bg-amber-500 shadow-amber-500/20',
blue: 'bg-blue-600 hover:bg-blue-500 shadow-blue-500/20',
orange: 'bg-orange-600 hover:bg-orange-500 shadow-orange-500/20',
emerald: 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-500/20',
};
const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgress, onActivityPreview }) => {
const [pendingStatus, setPendingStatus] = useState(null);
const [statusNote, setStatusNote] = useState('');
const [statusPhotos, setStatusPhotos] = useState([]);
const [isSubmittingStatus, setIsSubmittingStatus] = useState(false);
const [progressNote, setProgressNote] = useState('');
const [progressPhotos, setProgressPhotos] = useState([]);
const [isSubmittingProgress, setIsSubmittingProgress] = useState(false);
const copy = pendingStatus ? STATUS_COPY[pendingStatus] || STATUS_COPY.Assigned : null;
const accentBtnCls = copy ? ACCENT_BTN[copy.accent] : ACCENT_BTN.blue;
const noteOk = statusNote.trim().length > 0;
const photoOk = statusPhotos.length > 0;
const canConfirm = noteOk && photoOk && !isSubmittingStatus;
const progressNoteOk = progressNote.trim().length > 0;
const progressPhotoOk = progressPhotos.length > 0;
const canPostProgress = progressNoteOk && progressPhotoOk && !isSubmittingProgress;
const handlePick = (s) => {
if (disabled) return;
if (s === task.status) return;
if (isSubmittingStatus) return;
setPendingStatus(s);
setStatusNote('');
setStatusPhotos([]);
};
const confirm = () => {
if (!pendingStatus) return;
onChange(pendingStatus, comment);
setComment('');
setPendingStatus(null);
const confirmStatus = () => {
if (!pendingStatus || !canConfirm) return;
setIsSubmittingStatus(true);
// Brief simulated commit so users see the loading state on slow devices.
setTimeout(() => {
onChange(pendingStatus, statusNote.trim(), statusPhotos);
setIsSubmittingStatus(false);
setStatusNote('');
setStatusPhotos([]);
setPendingStatus(null);
}, 350);
};
const cancel = () => {
const cancelStatus = () => {
if (isSubmittingStatus) return;
setPendingStatus(null);
setComment('');
setStatusNote('');
setStatusPhotos([]);
};
const submitProgress = () => {
if (!canPostProgress) return;
setIsSubmittingProgress(true);
setTimeout(() => {
onPostProgress({ note: progressNote.trim(), photos: progressPhotos });
setIsSubmittingProgress(false);
setProgressNote('');
setProgressPhotos([]);
}, 350);
};
const canPostUpdates = task.status === 'In Progress' || task.status === 'On Hold';
const updateAccent = task.status === 'On Hold' ? 'orange' : 'blue';
const updateBadgeCls = task.status === 'On Hold'
? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400';
const UpdateIcon = task.status === 'On Hold' ? PauseCircle : Play;
const postBtnCls = task.status === 'On Hold'
? 'bg-orange-600 hover:bg-orange-500 shadow-orange-500/20'
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-500/20';
const sortedActivities = useMemo(() => {
return [...(task.activities || [])].sort((a, b) => new Date(b.at) - new Date(a.at));
}, [task.activities]);
return (
<SpotlightCard className="p-5 sm:p-6">
<SectionHeading
icon={RefreshCcw}
title="Update Task Status"
hint="Select your current work status for this task. The admin will be notified of any change."
hint="Move this task through the workflow. A note and at least one photo are required for every status change."
/>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 sm:gap-3 mt-4">
@@ -366,7 +476,7 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
key={s}
type="button"
onClick={() => handlePick(s)}
disabled={disabled}
disabled={disabled || isSubmittingStatus}
className={`group relative flex items-center justify-center gap-2 px-3 py-3 rounded-xl text-sm font-bold border transition-all
${isCurrent
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400 shadow-lg shadow-blue-500/10'
@@ -384,71 +494,358 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
})}
</div>
{pendingStatus && (
<div className="mt-4 p-3 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-200 dark:border-blue-500/20 space-y-3">
<p className="text-xs text-zinc-700 dark:text-zinc-300">
Changing status to <span className="font-bold text-blue-700 dark:text-blue-400">{pendingStatus}</span>. Add an optional comment for the admin.
</p>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
placeholder="e.g. Materials sourced, starting tomorrow."
className="w-full text-sm rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/40 transition-colors"
/>
<div className="flex justify-end gap-2">
{pendingStatus && copy && (
<div className="mt-4 p-4 rounded-xl bg-blue-50/60 dark:bg-blue-500/5 border border-blue-200 dark:border-blue-500/20 space-y-4 transition-all">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Pending:</span>
<StatusBadge status={pendingStatus} />
<span className="text-xs text-zinc-600 dark:text-zinc-300 font-semibold">{copy.title}</span>
</div>
<p className="text-[12px] text-zinc-600 dark:text-zinc-400">{copy.hint}</p>
<div>
<RequiredLabel ok={noteOk}>Note / comment</RequiredLabel>
<textarea
value={statusNote}
onChange={(e) => setStatusNote(e.target.value)}
rows={3}
disabled={isSubmittingStatus}
placeholder={copy.notePlaceholder}
className={`w-full text-sm rounded-lg bg-white dark:bg-zinc-900 border px-3 py-2 outline-none transition-colors disabled:opacity-60
${statusNote.length > 0 || noteOk
? 'border-zinc-200 dark:border-white/10 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/40'
: 'border-zinc-200 dark:border-white/10 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/40'
}`}
/>
</div>
<div>
<RequiredLabel ok={photoOk}>Photo upload <span className="text-zinc-400 font-normal normal-case"> {copy.photoHint}</span></RequiredLabel>
<PhotoPicker
photos={statusPhotos}
onChange={setStatusPhotos}
disabled={isSubmittingStatus}
ctaLabel="Tap to upload photo(s)"
/>
</div>
{!canConfirm && !isSubmittingStatus && (
<div className="flex items-start gap-2 text-[11px] text-amber-700 dark:text-amber-400">
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
<span>
{(!noteOk && !photoOk) && 'Add a note and at least one photo to enable the status update.'}
{(!noteOk && photoOk) && 'Add a note to enable the status update.'}
{(noteOk && !photoOk) && 'Upload at least one photo to enable the status update.'}
</span>
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={cancel}
className="px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
onClick={cancelStatus}
disabled={isSubmittingStatus}
className="px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="button"
onClick={confirm}
className="px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-white bg-blue-600 hover:bg-blue-500 transition-colors"
onClick={confirmStatus}
disabled={!canConfirm}
className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-white shadow-lg transition-all active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none ${accentBtnCls}`}
>
Confirm change
{isSubmittingStatus
? <><Loader2 size={12} className="animate-spin" /> Updating</>
: <><CheckCircle size={12} /> Confirm change</>
}
</button>
</div>
</div>
)}
{/* Status history */}
<div className="mt-5 pt-5 border-t border-zinc-200 dark:border-white/10">
<h4 className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3 flex items-center gap-1.5">
<Clock size={11} /> Status History
</h4>
{(task.statusHistory || []).length === 0 ? (
<p className="text-xs text-zinc-500 italic">No status changes recorded yet.</p>
) : (
<ol className="space-y-3">
{[...task.statusHistory].slice().reverse().map((h) => {
const cfg = STATUS_CONFIG[h.status] || STATUS_CONFIG.Assigned;
return (
<li key={h.id} className="flex items-start gap-3">
<span className={`mt-1 w-2.5 h-2.5 rounded-full ${cfg.dot} shrink-0`} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge status={h.status} />
<span className="text-xs text-zinc-600 dark:text-zinc-400">by <span className="font-semibold text-zinc-900 dark:text-white">{h.actorName}</span></span>
<span className="text-[11px] text-zinc-500 font-mono">{formatDateTime(h.at)}</span>
</div>
{h.comment && (
<p className="text-sm text-zinc-700 dark:text-zinc-300 mt-1">{h.comment}</p>
)}
</div>
</li>
);
})}
</ol>
)}
{/* Continuous progress updates while In Progress or On Hold */}
{!pendingStatus && canPostUpdates && !disabled && (
<div className="mt-4 p-4 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/10 space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${updateBadgeCls}`}>
<UpdateIcon size={10} /> {task.status}
</span>
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">Post a progress update</h4>
</div>
<p className="text-[12px] text-zinc-500">
{task.status === 'On Hold'
? 'Keep the admin posted while this task is paused — share why it is still on hold or what changed. Each update needs a note and at least one photo.'
: 'Keep the admin in the loop with ongoing updates while you work. Each update needs a note and at least one photo.'}
</p>
<div>
<RequiredLabel ok={progressNoteOk}>Progress note</RequiredLabel>
<textarea
value={progressNote}
onChange={(e) => setProgressNote(e.target.value)}
rows={2}
disabled={isSubmittingProgress}
placeholder={task.status === 'On Hold'
? 'e.g. "Still waiting on materials — supplier ETA Friday."'
: 'e.g. "First wall painting completed."'}
className="w-full text-sm rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/40 transition-colors disabled:opacity-60"
/>
</div>
<div>
<RequiredLabel ok={progressPhotoOk}>Photos</RequiredLabel>
<PhotoPicker
photos={progressPhotos}
onChange={setProgressPhotos}
disabled={isSubmittingProgress}
ctaLabel={task.status === 'On Hold' ? 'Tap to upload supporting photo(s)' : 'Tap to upload progress photo(s)'}
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={submitProgress}
disabled={!canPostProgress}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-white shadow-lg transition-all active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none ${postBtnCls}`}
>
{isSubmittingProgress
? <><Loader2 size={12} className="animate-spin" /> Posting</>
: <><Send size={12} /> Post update</>
}
</button>
</div>
</div>
)}
{/* Status History + Activity Timeline — side by side */}
<div className="mt-5 pt-5 border-t border-zinc-200 dark:border-white/10 grid grid-cols-1 lg:grid-cols-2 gap-5">
<StatusHistoryPanel statusHistory={task.statusHistory} />
<ActivityTimelinePanel
activities={sortedActivities}
onPreview={(photos, index) => onActivityPreview?.(photos, index)}
/>
</div>
</SpotlightCard>
);
};
// ===========================================================================
// Status History panel — left side of the workflow rail
// ===========================================================================
const StatusHistoryPanel = ({ statusHistory }) => {
const history = statusHistory || [];
return (
<div className="rounded-xl bg-zinc-50/60 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/10 p-4 flex flex-col min-w-0">
<h4 className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3 flex items-center gap-1.5">
<Clock size={11} /> Status History
</h4>
{history.length === 0 ? (
<p className="text-xs text-zinc-500 italic">No status changes recorded yet.</p>
) : (
<ol className="space-y-3 max-h-[28rem] overflow-y-auto pr-1 custom-scrollbar">
{[...history].slice().reverse().map((h) => {
const cfg = STATUS_CONFIG[h.status] || STATUS_CONFIG.Assigned;
return (
<li key={h.id} className="flex items-start gap-3">
<span className={`mt-1 w-2.5 h-2.5 rounded-full ${cfg.dot} shrink-0`} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge status={h.status} />
<span className="text-xs text-zinc-600 dark:text-zinc-400">by <span className="font-semibold text-zinc-900 dark:text-white">{h.actorName}</span></span>
</div>
<div className="text-[11px] text-zinc-500 font-mono mt-0.5">{formatDateTime(h.at)}</div>
{h.comment && (
<p className="text-sm text-zinc-700 dark:text-zinc-300 mt-1">{h.comment}</p>
)}
</div>
</li>
);
})}
</ol>
)}
</div>
);
};
// ===========================================================================
// Activity Timeline panel — right side of the workflow rail
// ===========================================================================
const ActivityTimelinePanel = ({ activities, onPreview }) => {
return (
<div className="rounded-xl bg-zinc-50/60 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/10 p-4 flex flex-col min-w-0">
<h4 className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3 flex items-center gap-1.5">
<Activity size={11} /> Task Activity Timeline
</h4>
{(!activities || activities.length === 0) ? (
<div className="py-6 text-center text-zinc-500">
<Activity size={18} className="mx-auto mb-1.5 opacity-50" />
<p className="text-xs">No activity yet.</p>
<p className="text-[11px] mt-1">Status changes and progress updates will appear here.</p>
</div>
) : (
<ol className="relative max-h-[28rem] overflow-y-auto pr-1 custom-scrollbar">
{activities.map((a, idx) => (
<ActivityTimelineRow
key={a.id}
activity={a}
isLast={idx === activities.length - 1}
onPreview={(index) => onPreview?.(a.photos || [], index)}
/>
))}
</ol>
)}
</div>
);
};
// ===========================================================================
// Required-field label
// ===========================================================================
const RequiredLabel = ({ children, ok }) => (
<label className="text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full transition-colors ${ok ? 'bg-emerald-500' : 'bg-red-500'}`} />
<span>{children}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider transition-colors ${ok ? 'text-emerald-600' : 'text-red-500'}`}>
{ok ? 'Ready' : 'Required'}
</span>
</label>
);
// ===========================================================================
// Photo Picker — mock upload with preview + remove
// ===========================================================================
const PhotoPicker = ({ photos, onChange, disabled, ctaLabel = 'Tap to upload photo(s)' }) => {
const handleFiles = (files) => {
const arr = Array.from(files || []);
if (!arr.length) return;
Promise.all(arr.map(file => new Promise((res) => {
const reader = new FileReader();
reader.onload = () => res({
id: `local_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
url: reader.result,
name: file.name,
});
reader.onerror = () => res(null);
reader.readAsDataURL(file);
}))).then(results => {
const next = results.filter(Boolean);
if (next.length) onChange([...(photos || []), ...next]);
});
};
return (
<div className="space-y-2">
<label className={`flex items-center justify-center gap-2 rounded-xl border border-dashed py-4 transition-colors
${disabled
? 'border-zinc-200 dark:border-white/10 bg-zinc-100 dark:bg-white/[0.02] cursor-not-allowed opacity-60'
: 'border-zinc-300 dark:border-white/15 bg-zinc-50 dark:bg-white/[0.03] cursor-pointer hover:border-blue-400 dark:hover:border-blue-500/40'
}`}>
<ImagePlus size={14} className="text-zinc-500" />
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-400">{ctaLabel}</span>
<input
type="file"
accept="image/*"
multiple
disabled={disabled}
className="sr-only"
onChange={(e) => { handleFiles(e.target.files); e.target.value = ''; }}
/>
</label>
{photos && photos.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{photos.map((p) => (
<div key={p.id} className="relative group rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10 aspect-square bg-zinc-100 dark:bg-zinc-800">
<img src={p.url} alt={p.name || 'preview'} className="w-full h-full object-cover" />
{!disabled && (
<button
type="button"
onClick={() => onChange(photos.filter(x => x.id !== p.id))}
className="absolute top-1 right-1 p-1 rounded-full bg-black/60 text-white opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
aria-label="Remove photo"
>
<Trash2 size={11} />
</button>
)}
{p.name && (
<div className="absolute bottom-0 inset-x-0 px-1.5 py-0.5 text-[9px] font-mono text-white bg-gradient-to-t from-black/70 to-transparent truncate">
{p.name}
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
// ===========================================================================
// Activity Timeline row (Jira-style) — rendered inside StatusManagementCard
// ===========================================================================
const ActivityTimelineRow = ({ activity, isLast, onPreview }) => {
const isStatusChange = activity.type === 'status_change';
const cfg = STATUS_CONFIG[activity.status] || STATUS_CONFIG.Assigned;
const Icon = isStatusChange ? cfg.icon : MessageSquare;
return (
<li className="relative pl-10 pb-5 last:pb-0">
{!isLast && <span className="absolute left-[14px] top-8 bottom-0 w-px bg-zinc-200 dark:bg-white/10" aria-hidden />}
<span className={`absolute left-0 top-0 w-7 h-7 rounded-full flex items-center justify-center ring-4 ring-white dark:ring-[#09090b] ${isStatusChange ? cfg.cls : 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-300'}`}>
<Icon size={12} />
</span>
<div className="rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/10 p-3 transition-colors hover:border-blue-500/30">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{isStatusChange ? (
<>
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Status </span>
<StatusBadge status={activity.status} />
</>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400">
<MessageSquare size={11} /> Progress update
</span>
)}
<span className="text-xs text-zinc-600 dark:text-zinc-400">
by <span className="font-semibold text-zinc-900 dark:text-white">{activity.actorName}</span>
</span>
{activity.actorRole && (
<span className="text-[10px] uppercase tracking-wider text-zinc-500 font-bold">· {activity.actorRole}</span>
)}
<span className="text-[11px] text-zinc-500 font-mono sm:ml-auto">{formatDateTime(activity.at)}</span>
</div>
{activity.note && (
<p className="text-sm text-zinc-700 dark:text-zinc-300 mt-2 whitespace-pre-line">
{activity.note}
</p>
)}
{activity.photos && activity.photos.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-3">
{activity.photos.map((p, idx) => (
<button
key={p.id}
type="button"
onClick={() => onPreview?.(idx)}
className="group aspect-square rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10 bg-zinc-100 dark:bg-zinc-800"
>
<img
src={p.url}
alt={p.name || 'Activity photo'}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</button>
))}
</div>
)}
</div>
</li>
);
};
// ===========================================================================
// Section 3 — Task Thread / Communication
// ===========================================================================