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
+248 -1
View File
@@ -5152,6 +5152,43 @@ const MOCK_SUBCONTRACTOR_TASKS = [
{ id: 'sct_001_f1', description: 'Labour — Ridge Cap Replacement', type: 'Labour / Service Fee', amount: 350, createdAt: '2026-05-16T13:20:00Z' },
{ id: 'sct_001_f2', description: 'Mobilization to site', type: 'Mobilization Fee', amount: 75, createdAt: '2026-05-16T13:22:00Z' },
],
activities: [
{
id: 'sct_001_a1', type: 'status_change', status: 'Assigned',
note: 'Task created and assigned. Materials list shared with crew.',
photos: [],
actorId: 'owner_001', actorName: 'Justin Johnson', actorRole: 'Owner',
at: '2026-05-15T09:12:00Z',
},
{
id: 'sct_001_a2', type: 'status_change', status: 'In Progress',
note: 'On site, sourced replacement Owens Corning Estate Gray. Starting on the south slope.',
photos: [
{ id: 'sct_001_a2_p1', url: '/assets/images/properties/Hail_Damaged_Shingles.jpg', name: 'arrival.jpg' },
],
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
at: '2026-05-16T08:30:00Z',
},
{
id: 'sct_001_a3', type: 'progress_update', status: 'In Progress',
note: 'First two damaged ridge tiles removed. Decking underneath is clean — no rot.',
photos: [
{ id: 'sct_001_a3_p1', url: '/assets/images/properties/Storm_Worn_Roof.jpg', name: 'decking-clean.jpg' },
],
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
at: '2026-05-16T10:45:00Z',
},
{
id: 'sct_001_a4', type: 'progress_update', status: 'In Progress',
note: 'Replacement ridge caps installed on lower row. Sealing next.',
photos: [
{ id: 'sct_001_a4_p1', url: '/assets/images/properties/Cracked_Storm_Shingles.jpg', name: 'lower-row.jpg' },
{ id: 'sct_001_a4_p2', url: '/assets/images/properties/Overlapping_Roof_Shingles.jpg', name: 'wide-shot.jpg' },
],
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
at: '2026-05-16T14:20:00Z',
},
],
projectId: 'proj_004',
paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-16T17:30:00Z',
@@ -5197,6 +5234,53 @@ const MOCK_SUBCONTRACTOR_TASKS = [
fees: [
{ id: 'sct_002_f1', description: 'Labour — Day 1 disconnect / prep', type: 'Labour / Service Fee', amount: 425, createdAt: '2026-05-12T17:00:00Z' },
],
activities: [
{
id: 'sct_002_a1', type: 'status_change', status: 'Assigned',
note: 'Task created and assigned. Permit pulled, materials staged.',
photos: [],
actorId: 'owner_001', actorName: 'Justin Johnson', actorRole: 'Owner',
at: '2026-05-10T15:40:00Z',
},
{
id: 'sct_002_a2', type: 'status_change', status: 'In Progress',
note: 'Oncor on site for disconnect. Cutting power to existing 100A panel and starting tear-out.',
photos: [
{ id: 'sct_002_a2_p1', url: '/assets/images/properties/Beige_Two_Story_House.jpg', name: 'oncor-disconnect.jpg' },
],
actorId: 'sub_001', actorName: 'Carlos Subcontractor', actorRole: 'Subcontractor',
at: '2026-05-12T08:20:00Z',
},
{
id: 'sct_002_a3', type: 'progress_update', status: 'In Progress',
note: 'Old 100A panel removed. Service entrance wires capped and labelled. Mast looks good — no signs of corrosion.',
photos: [
{ id: 'sct_002_a3_p1', url: '/assets/images/properties/Modern_White_Suburban_Home.jpg', name: 'mast-check.jpg' },
{ id: 'sct_002_a3_p2', url: '/assets/images/properties/Brick_Front_Porch_Home.jpg', name: 'old-panel-out.jpg' },
],
actorId: 'sub_001', actorName: 'Carlos Subcontractor', actorRole: 'Subcontractor',
at: '2026-05-12T10:30:00Z',
},
{
id: 'sct_002_a4', type: 'progress_update', status: 'In Progress',
note: 'New 200A Square D panel mounted and bonded. Two new ground rods driven and connected.',
photos: [
{ id: 'sct_002_a4_p1', url: '/assets/images/properties/Sunny_Suburban_House.jpg', name: 'new-panel-mount.jpg' },
],
actorId: 'sub_001', actorName: 'Carlos Subcontractor', actorRole: 'Subcontractor',
at: '2026-05-12T13:15:00Z',
},
{
id: 'sct_002_a5', type: 'progress_update', status: 'In Progress',
note: 'Migrated 20 of 24 home circuits to the new panel. Tested with multimeter — all live and balanced across legs. Remaining 4 circuits (HVAC + dryer) to be wired tomorrow before re-energizing.',
photos: [
{ id: 'sct_002_a5_p1', url: '/assets/images/properties/Luxury_Driveway_Residence.jpg', name: 'circuits-migrated.jpg' },
{ id: 'sct_002_a5_p2', url: '/assets/images/properties/Overlapping_Roof_Shingles.jpg', name: 'panel-labels.jpg' },
],
actorId: 'sub_001', actorName: 'Carlos Subcontractor', actorRole: 'Subcontractor',
at: '2026-05-12T16:00:00Z',
},
],
projectId: 'proj_003',
paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-12T17:00:00Z',
@@ -5319,6 +5403,33 @@ const MOCK_SUBCONTRACTOR_TASKS = [
fees: [
{ id: 'sct_005_f1', description: 'Inspection — initial visit', type: 'Inspection Fee', amount: 125, createdAt: '2026-05-14T13:00:00Z' },
],
activities: [
{
id: 'sct_005_a1', type: 'status_change', status: 'Assigned',
note: 'Task created. Customer reports unusually high attic temps.',
photos: [],
actorId: 'ADM01', actorName: 'Admin One', actorRole: 'Admin',
at: '2026-05-12T10:00:00Z',
},
{
id: 'sct_005_a2', type: 'status_change', status: 'In Progress',
note: 'Initial inspection pass — soffit baffles appear blocked. Need attic access for confirmation.',
photos: [
{ id: 'sct_005_a2_p1', url: '/assets/images/properties/Roof_Inspection_Angle.jpg', name: 'south-gable.jpg' },
],
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
at: '2026-05-14T11:00:00Z',
},
{
id: 'sct_005_a3', type: 'status_change', status: 'On Hold',
note: 'Work stopped — homeowner needs to clear stored boxes blocking attic access before I can finish.',
photos: [
{ id: 'sct_005_a3_p1', url: '/assets/images/properties/Beige_Two_Story_House.jpg', name: 'access-blocked.jpg' },
],
actorId: 'sub_002', actorName: 'Maya Patel', actorRole: 'Subcontractor',
at: '2026-05-15T16:00:00Z',
},
],
projectId: 'proj_012',
paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-14T13:00:00Z',
@@ -5449,6 +5560,33 @@ const MOCK_SUBCONTRACTOR_TASKS = [
{ id: 'sct_008_f1', description: 'Labour — Kitchen GFCI install (2 outlets + island)', type: 'Labour / Service Fee', amount: 220, createdAt: '2026-05-19T12:35:00Z' },
{ id: 'sct_008_f2', description: 'Mobilization to site', type: 'Mobilization Fee', amount: 75, createdAt: '2026-05-19T08:00:00Z' },
],
activities: [
{
id: 'sct_008_a1', type: 'status_change', status: 'Assigned',
note: 'Task created and assigned to Carlos. Insurance covering code upgrade.',
photos: [],
actorId: 'ADM01', actorName: 'Admin One', actorRole: 'Admin',
at: '2026-05-16T13:00:00Z',
},
{
id: 'sct_008_a2', type: 'status_change', status: 'In Progress',
note: 'Arrived on site. Verified wiring is copper (no aluminum). Starting kitchen counter outlets first.',
photos: [
{ id: 'sct_008_a2_p1', url: '/assets/images/properties/Modern_White_Suburban_Home.jpg', name: 'kitchen-before.jpg' },
],
actorId: 'sub_001', actorName: 'Carlos Subcontractor', actorRole: 'Subcontractor',
at: '2026-05-19T10:15:00Z',
},
{
id: 'sct_008_a3', type: 'progress_update', status: 'In Progress',
note: 'Both kitchen counter outlets swapped to Leviton 20A GFCI. Tested with plug-in tester — passed.',
photos: [
{ id: 'sct_008_a3_p1', url: '/assets/images/properties/Sunny_Suburban_House.jpg', name: 'gfci-installed.jpg' },
],
actorId: 'sub_001', actorName: 'Carlos Subcontractor', actorRole: 'Subcontractor',
at: '2026-05-19T12:30:00Z',
},
],
projectId: 'proj_011',
paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-19T12:35:00Z',
@@ -5456,6 +5594,43 @@ const MOCK_SUBCONTRACTOR_TASKS = [
updatedAt: '2026-05-19T18:00:00Z',
},
// -------------------------------------------------------------------
// sct_010 Carlos (sub_001) newly assigned, awaiting acknowledgement
// -------------------------------------------------------------------
{
id: 'sct_010',
companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_001',
subcontractorName: 'Carlos Subcontractor',
assignedBy: 'owner_001',
assignedByName: 'Justin Johnson',
title: 'Install EV charger circuit — garage',
location: '3917 Arizona Pl, Plano, TX 75023',
description: 'Homeowner purchased a Tesla Wall Connector. Run a dedicated 60A 240V circuit from the main panel to the garage wall. Coordinate with homeowner for outage window. Permit required.',
dueDate: '2026-05-29',
priority: 'medium',
status: 'Assigned',
photos: [
{ id: 'sct_010_p1', name: 'Garage_Wall.jpg', url: '/assets/images/properties/Modern_White_Suburban_Home.jpg', annotation: 'Wall Connector mounting location' },
{ id: 'sct_010_p2', name: 'Panel_Location.jpg', url: '/assets/images/properties/Brick_Front_Porch_Home.jpg', annotation: 'Main panel — check available breaker slots' },
],
statusHistory: [
{ id: 'sct_010_h1', status: 'Assigned', actorId: 'owner_001', actorName: 'Justin Johnson', comment: 'Task created and assigned. Please confirm ETA.', at: '2026-05-20T09:00:00Z' },
],
thread: [
{ id: 'sct_010_t1', channel: 'external', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Carlos — homeowner is hoping to have this in before next weekend. Can you take a look at the panel and confirm slot availability?', at: '2026-05-20T09:02:00Z' },
],
expenses: [],
fees: [],
activities: [],
projectId: 'proj_011',
paymentStatus: 'Unpaid',
paymentRequestedAt: null,
createdAt: '2026-05-20T09:00:00Z',
updatedAt: '2026-05-20T09:02:00Z',
},
// -------------------------------------------------------------------
// sct_009 Jennifer Wu (sub_004) On Hold plumbing
// -------------------------------------------------------------------
@@ -5952,13 +6127,19 @@ export const MockStoreProvider = ({ children }) => {
return task;
};
const setSubcontractorTaskStatus = (taskId, nextStatus, comment, actor) => {
const setSubcontractorTaskStatus = (taskId, nextStatus, comment, actor, photos = []) => {
const now = new Date().toISOString();
let didChange = false;
let companyId = null;
let companyName = null;
let assignedByUserId = null;
const normalizedPhotos = (photos || []).map((p, i) => ({
id: p.id || `${Date.now()}_${i}_${Math.random().toString(36).slice(2, 7)}`,
url: p.url,
name: p.name || '',
}));
setSubcontractorTasks(prev => prev.map(t => {
if (t.id !== taskId) return t;
if (t.status === nextStatus) return t;
@@ -5974,10 +6155,22 @@ export const MockStoreProvider = ({ children }) => {
comment: comment?.trim() || '',
at: now,
};
const activityEntry = {
id: `a_${Date.now()}`,
type: 'status_change',
status: nextStatus,
note: comment?.trim() || '',
photos: normalizedPhotos,
actorId: actor?.id || t.subcontractorId,
actorName: actor?.name || t.subcontractorName,
actorRole: actor?.role || 'Subcontractor',
at: now,
};
return {
...t,
status: nextStatus,
statusHistory: [...(t.statusHistory || []), entry],
activities: [...(t.activities || []), activityEntry],
updatedAt: now,
};
}));
@@ -6085,6 +6278,59 @@ export const MockStoreProvider = ({ children }) => {
toast.success('Fee submitted');
};
const addSubcontractorTaskActivity = (taskId, { note, photos = [] }, actor) => {
const trimmed = note?.trim() || '';
if (!trimmed || !photos.length) return;
const now = new Date().toISOString();
const normalizedPhotos = photos.map((p, i) => ({
id: p.id || `${Date.now()}_${i}_${Math.random().toString(36).slice(2, 7)}`,
url: p.url,
name: p.name || '',
}));
let companyId = null;
let companyName = null;
let assignedByUserId = null;
let subId = null;
setSubcontractorTasks(prev => prev.map(t => {
if (t.id !== taskId) return t;
companyId = t.companyId;
companyName = t.companyName;
assignedByUserId = t.assignedBy;
subId = t.subcontractorId;
const entry = {
id: `a_${Date.now()}`,
type: 'progress_update',
status: t.status,
note: trimmed,
photos: normalizedPhotos,
actorId: actor?.id || t.subcontractorId,
actorName: actor?.name || t.subcontractorName,
actorRole: actor?.role || 'Subcontractor',
at: now,
};
return {
...t,
activities: [...(t.activities || []), entry],
updatedAt: now,
};
}));
const recipientUserId = actor?.id === assignedByUserId ? subId : assignedByUserId;
if (recipientUserId) {
addNotification({
recipientUserId,
recipientRole: actor?.id === assignedByUserId ? 'SUBCONTRACTOR' : 'OWNER',
type: 'progress_update',
message: `New progress update from ${actor?.name || 'subcontractor'}`,
taskId,
fromCompanyId: companyId,
fromCompanyName: companyName,
});
}
toast.success('Progress update posted');
};
const updateSubcontractorTask = (taskId, data) => {
let updatedSubId = null;
let prevSubId = null;
@@ -6421,6 +6667,7 @@ export const MockStoreProvider = ({ children }) => {
addSubcontractorTaskMessage,
addSubcontractorTaskExpense,
addSubcontractorTaskFee,
addSubcontractorTaskActivity,
notifications,
addNotification,
markNotificationRead,
@@ -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
// ===========================================================================