subcontractor: add progress updates and task activity timeline
This commit is contained in:
+248
-1
@@ -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('');
|
||||
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,47 +494,158 @@ 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>
|
||||
{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={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"
|
||||
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 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
|
||||
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">
|
||||
{/* 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>
|
||||
{(task.statusHistory || []).length === 0 ? (
|
||||
{history.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) => {
|
||||
<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">
|
||||
@@ -433,8 +654,8 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
@@ -445,7 +666,183 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange }) => {
|
||||
</ol>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user