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_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' }, { 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', projectId: 'proj_004',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-16T17:30:00Z', paymentRequestedAt: '2026-05-16T17:30:00Z',
@@ -5197,6 +5234,53 @@ const MOCK_SUBCONTRACTOR_TASKS = [
fees: [ fees: [
{ id: 'sct_002_f1', description: 'Labour — Day 1 disconnect / prep', type: 'Labour / Service Fee', amount: 425, createdAt: '2026-05-12T17:00:00Z' }, { 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', projectId: 'proj_003',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-12T17:00:00Z', paymentRequestedAt: '2026-05-12T17:00:00Z',
@@ -5319,6 +5403,33 @@ const MOCK_SUBCONTRACTOR_TASKS = [
fees: [ fees: [
{ id: 'sct_005_f1', description: 'Inspection — initial visit', type: 'Inspection Fee', amount: 125, createdAt: '2026-05-14T13:00:00Z' }, { 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', projectId: 'proj_012',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-14T13:00:00Z', 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_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' }, { 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', projectId: 'proj_011',
paymentStatus: 'Unpaid', paymentStatus: 'Unpaid',
paymentRequestedAt: '2026-05-19T12:35:00Z', paymentRequestedAt: '2026-05-19T12:35:00Z',
@@ -5456,6 +5594,43 @@ const MOCK_SUBCONTRACTOR_TASKS = [
updatedAt: '2026-05-19T18:00:00Z', 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 // sct_009 Jennifer Wu (sub_004) On Hold plumbing
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -5952,13 +6127,19 @@ export const MockStoreProvider = ({ children }) => {
return task; return task;
}; };
const setSubcontractorTaskStatus = (taskId, nextStatus, comment, actor) => { const setSubcontractorTaskStatus = (taskId, nextStatus, comment, actor, photos = []) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
let didChange = false; let didChange = false;
let companyId = null; let companyId = null;
let companyName = null; let companyName = null;
let assignedByUserId = 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 => { setSubcontractorTasks(prev => prev.map(t => {
if (t.id !== taskId) return t; if (t.id !== taskId) return t;
if (t.status === nextStatus) return t; if (t.status === nextStatus) return t;
@@ -5974,10 +6155,22 @@ export const MockStoreProvider = ({ children }) => {
comment: comment?.trim() || '', comment: comment?.trim() || '',
at: now, 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 { return {
...t, ...t,
status: nextStatus, status: nextStatus,
statusHistory: [...(t.statusHistory || []), entry], statusHistory: [...(t.statusHistory || []), entry],
activities: [...(t.activities || []), activityEntry],
updatedAt: now, updatedAt: now,
}; };
})); }));
@@ -6085,6 +6278,59 @@ export const MockStoreProvider = ({ children }) => {
toast.success('Fee submitted'); 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) => { const updateSubcontractorTask = (taskId, data) => {
let updatedSubId = null; let updatedSubId = null;
let prevSubId = null; let prevSubId = null;
@@ -6421,6 +6667,7 @@ export const MockStoreProvider = ({ children }) => {
addSubcontractorTaskMessage, addSubcontractorTaskMessage,
addSubcontractorTaskExpense, addSubcontractorTaskExpense,
addSubcontractorTaskFee, addSubcontractorTaskFee,
addSubcontractorTaskActivity,
notifications, notifications,
addNotification, addNotification,
markNotificationRead, markNotificationRead,
@@ -8,6 +8,7 @@ import {
ArrowLeft, ChevronRight, ClipboardList, MapPin, Calendar, Building2, User, Hash, ArrowLeft, ChevronRight, ClipboardList, MapPin, Calendar, Building2, User, Hash,
DollarSign, Receipt, Camera, Image as ImageIcon, MessageSquare, Lock, Send, DollarSign, Receipt, Camera, Image as ImageIcon, MessageSquare, Lock, Send,
Clock, PauseCircle, CheckCircle, RefreshCcw, AlertCircle, X, Plus, Upload, Clock, PauseCircle, CheckCircle, RefreshCcw, AlertCircle, X, Plus, Upload,
Activity, ImagePlus, Loader2, Play, Trash2, AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -102,6 +103,7 @@ const SubcontractorTaskDetailPage = () => {
subcontractorTasks, subSelfStatuses, subcontractorTasks, subSelfStatuses,
setSubcontractorTaskStatus, addSubcontractorTaskMessage, setSubcontractorTaskStatus, addSubcontractorTaskMessage,
addSubcontractorTaskExpense, addSubcontractorTaskFee, addSubcontractorTaskExpense, addSubcontractorTaskFee,
addSubcontractorTaskActivity,
} = useMockStore(); } = useMockStore();
const task = useMemo(() => subcontractorTasks.find(t => t.id === taskId), [subcontractorTasks, taskId]); const task = useMemo(() => subcontractorTasks.find(t => t.id === taskId), [subcontractorTasks, taskId]);
@@ -109,6 +111,7 @@ const SubcontractorTaskDetailPage = () => {
const [logExpenseOpen, setLogExpenseOpen] = useState(false); const [logExpenseOpen, setLogExpenseOpen] = useState(false);
const [logFeeOpen, setLogFeeOpen] = useState(false); const [logFeeOpen, setLogFeeOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(null); 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 // ESC closes any open modal — handled inside each modal too, but having a guard
// at page level keeps focus tidy. // at page level keeps focus tidy.
@@ -116,10 +119,11 @@ const SubcontractorTaskDetailPage = () => {
const handler = (e) => { const handler = (e) => {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (lightboxIndex !== null) setLightboxIndex(null); if (lightboxIndex !== null) setLightboxIndex(null);
if (activityLightbox) setActivityLightbox(null);
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [lightboxIndex]); }, [lightboxIndex, activityLightbox]);
if (!task) { if (!task) {
return ( return (
@@ -144,11 +148,19 @@ const SubcontractorTaskDetailPage = () => {
const overdue = isOverdue(task); const overdue = isOverdue(task);
const closed = task.status === 'Completed' || task.status === 'Cancelled'; const closed = task.status === 'Completed' || task.status === 'Cancelled';
const handleStatusChange = (nextStatus, comment) => { const handleStatusChange = (nextStatus, comment, photos = []) => {
setSubcontractorTaskStatus(task.id, nextStatus, comment, { setSubcontractorTaskStatus(task.id, nextStatus, comment, {
id: user?.id || task.subcontractorId, id: user?.id || task.subcontractorId,
name: user?.name || task.subcontractorName, name: user?.name || task.subcontractorName,
role: 'Subcontractor', 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} disabled={closed}
statuses={subSelfStatuses} statuses={subSelfStatuses}
onChange={handleStatusChange} onChange={handleStatusChange}
onPostProgress={handlePostProgress}
onActivityPreview={(photos, index) => setActivityLightbox({ photos, index })}
/> />
<TaskThreadCard <TaskThreadCard
@@ -258,6 +272,12 @@ const SubcontractorTaskDetailPage = () => {
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex} onIndexChange={setLightboxIndex}
/> />
<PhotoLightbox
photos={activityLightbox?.photos || []}
index={activityLightbox?.index ?? null}
onClose={() => setActivityLightbox(null)}
onIndexChange={(idx) => setActivityLightbox(prev => prev ? { ...prev, index: idx } : prev)}
/>
</div> </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 STATUS_COPY = {
const [comment, setComment] = useState(''); 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 [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) => { const handlePick = (s) => {
if (disabled) return; if (disabled) return;
if (s === task.status) return; if (s === task.status) return;
if (isSubmittingStatus) return;
setPendingStatus(s); setPendingStatus(s);
setStatusNote('');
setStatusPhotos([]);
}; };
const confirm = () => { const confirmStatus = () => {
if (!pendingStatus) return; if (!pendingStatus || !canConfirm) return;
onChange(pendingStatus, comment); setIsSubmittingStatus(true);
setComment(''); // Brief simulated commit so users see the loading state on slow devices.
setTimeout(() => {
onChange(pendingStatus, statusNote.trim(), statusPhotos);
setIsSubmittingStatus(false);
setStatusNote('');
setStatusPhotos([]);
setPendingStatus(null); setPendingStatus(null);
}, 350);
}; };
const cancel = () => { const cancelStatus = () => {
if (isSubmittingStatus) return;
setPendingStatus(null); 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 ( return (
<SpotlightCard className="p-5 sm:p-6"> <SpotlightCard className="p-5 sm:p-6">
<SectionHeading <SectionHeading
icon={RefreshCcw} icon={RefreshCcw}
title="Update Task Status" 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"> <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} key={s}
type="button" type="button"
onClick={() => handlePick(s)} 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 className={`group relative flex items-center justify-center gap-2 px-3 py-3 rounded-xl text-sm font-bold border transition-all
${isCurrent ${isCurrent
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400 shadow-lg shadow-blue-500/10' ? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400 shadow-lg shadow-blue-500/10'
@@ -384,47 +494,158 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
})} })}
</div> </div>
{pendingStatus && ( {pendingStatus && copy && (
<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"> <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">
<p className="text-xs text-zinc-700 dark:text-zinc-300"> <div className="flex flex-wrap items-center gap-2">
Changing status to <span className="font-bold text-blue-700 dark:text-blue-400">{pendingStatus}</span>. Add an optional comment for the admin. <span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Pending:</span>
</p> <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 <textarea
value={comment} value={statusNote}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setStatusNote(e.target.value)}
rows={2} rows={3}
placeholder="e.g. Materials sourced, starting tomorrow." disabled={isSubmittingStatus}
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" 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 className="flex justify-end gap-2"> </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 <button
type="button" type="button"
onClick={cancel} onClick={cancelStatus}
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={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 Cancel
</button> </button>
<button <button
type="button" type="button"
onClick={confirm} onClick={confirmStatus}
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" 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> </button>
</div> </div>
</div> </div>
)} )}
{/* Status history */} {/* Continuous progress updates while In Progress or On Hold */}
<div className="mt-5 pt-5 border-t border-zinc-200 dark:border-white/10"> {!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"> <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 <Clock size={11} /> Status History
</h4> </h4>
{(task.statusHistory || []).length === 0 ? ( {history.length === 0 ? (
<p className="text-xs text-zinc-500 italic">No status changes recorded yet.</p> <p className="text-xs text-zinc-500 italic">No status changes recorded yet.</p>
) : ( ) : (
<ol className="space-y-3"> <ol className="space-y-3 max-h-[28rem] overflow-y-auto pr-1 custom-scrollbar">
{[...task.statusHistory].slice().reverse().map((h) => { {[...history].slice().reverse().map((h) => {
const cfg = STATUS_CONFIG[h.status] || STATUS_CONFIG.Assigned; const cfg = STATUS_CONFIG[h.status] || STATUS_CONFIG.Assigned;
return ( return (
<li key={h.id} className="flex items-start gap-3"> <li key={h.id} className="flex items-start gap-3">
@@ -433,8 +654,8 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge status={h.status} /> <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-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> </div>
<div className="text-[11px] text-zinc-500 font-mono mt-0.5">{formatDateTime(h.at)}</div>
{h.comment && ( {h.comment && (
<p className="text-sm text-zinc-700 dark:text-zinc-300 mt-1">{h.comment}</p> <p className="text-sm text-zinc-700 dark:text-zinc-300 mt-1">{h.comment}</p>
)} )}
@@ -445,7 +666,183 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
</ol> </ol>
)} )}
</div> </div>
</SpotlightCard> );
};
// ===========================================================================
// 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>
); );
}; };