subcontractor: add progress updates and task activity timeline
This commit is contained in:
@@ -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
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user