adds sections as list tasks,log expenses. fees, notifications for sub contrcator

This commit is contained in:
Satyam Rastogi
2026-05-19 18:17:36 +05:30
parent c7a9849d1f
commit 2589975074
5 changed files with 2003 additions and 60 deletions
+6
View File
@@ -31,6 +31,7 @@ import VendorDashboard from './pages/vendor/VendorDashboard';
import VendorOrders from './pages/vendor/VendorOrders';
import ContractorDashboard from './pages/contractor/ContractorDashboard';
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
import CreateLeadPage from './pages/CreateLeadPage';
import LeadsListPage from './pages/LeadsListPage';
import LynkDispatchPage from './pages/LynkDispatchPage';
@@ -357,6 +358,11 @@ function App() {
<ProjectList />
</ProtectedRoute>
} />
<Route path="/subcontractor/tasks/:taskId" element={
<ProtectedRoute allowedRoles={['SUBCONTRACTOR']}>
<SubcontractorTaskDetailPage />
</ProtectedRoute>
} />
</Route>
{/* Catch all */}
@@ -0,0 +1,197 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SpotlightCard } from '../SpotlightCard';
import {
Bell, ClipboardList, MessageSquare, RefreshCcw, CheckCircle, AlertCircle,
} from 'lucide-react';
// Allowed notification types for the subcontractor dashboard.
// Keep keys in sync with mockStore notification.type values.
const NOTIF_CONFIG = {
task_assigned: {
label: 'New Task Assigned',
tone: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15',
icon: ClipboardList,
side: 'border-l-blue-500',
},
message_received: {
label: 'Message Received',
tone: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/15',
icon: MessageSquare,
side: 'border-l-emerald-500',
},
task_updated: {
label: 'Task Updated',
tone: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/15',
icon: RefreshCcw,
side: 'border-l-amber-500',
},
status_changed: {
label: 'Status Changed',
tone: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-500/15',
icon: CheckCircle,
side: 'border-l-purple-500',
},
};
const ALLOWED_TYPES = Object.keys(NOTIF_CONFIG);
const formatDateTime = (iso) => {
if (!iso) return '—';
try {
const d = new Date(iso);
const dateStr = d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
return `${dateStr} · ${timeStr}`;
} catch { return iso; }
};
const TABS = [
{ key: 'all', label: 'All' },
{ key: 'task_assigned', label: 'New Task' },
{ key: 'message_received', label: 'Message' },
{ key: 'task_updated', label: 'Updates' },
{ key: 'status_changed', label: 'Status' },
];
/**
* NotificationsPanel — global subcontractor notifications.
*
* Renders a SpotlightCard with tabbed filtering and a list of notification rows.
* Each row navigates to its referenced task (if any) when clicked.
*/
export const NotificationsPanel = ({ notifications, onMarkAllRead, onNotificationClick }) => {
const navigate = useNavigate();
const [tab, setTab] = useState('all');
const allowed = useMemo(
() => notifications.filter(n => ALLOWED_TYPES.includes(n.type)),
[notifications],
);
const filtered = useMemo(() => {
if (tab === 'all') return allowed;
return allowed.filter(n => n.type === tab);
}, [allowed, tab]);
const unreadCount = allowed.filter(n => !n.isRead).length;
const handleClick = (n) => {
if (onNotificationClick) onNotificationClick(n);
if (n.taskId) navigate(`/subcontractor/tasks/${n.taskId}`);
};
return (
<SpotlightCard className="p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
<Bell size={14} />
</div>
<h3 className="text-sm sm:text-base font-bold text-zinc-900 dark:text-white tracking-wide">
Notifications
</h3>
{unreadCount > 0 && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 font-mono shrink-0">
{unreadCount} new
</span>
)}
</div>
<button
type="button"
onClick={onMarkAllRead}
disabled={unreadCount === 0}
className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 hover:underline disabled:text-zinc-400 disabled:no-underline transition-colors shrink-0"
>
Mark all read
</button>
</div>
{/* Tab strip — scrollable on narrow screens */}
<div className="mt-4 -mx-1 px-1 overflow-x-auto custom-scrollbar">
<div className="inline-flex rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 p-1 min-w-full">
{TABS.map(t => {
const count = t.key === 'all' ? allowed.length : allowed.filter(n => n.type === t.key).length;
return (
<TabPill
key={t.key}
active={tab === t.key}
onClick={() => setTab(t.key)}
label={t.label}
count={count}
/>
);
})}
</div>
</div>
<div className="mt-4 space-y-2 max-h-[36rem] overflow-y-auto pr-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="text-center py-10 text-zinc-500">
<Bell size={20} className="mx-auto mb-2 opacity-50" />
<p className="text-xs">You&apos;re all caught up.</p>
</div>
) : filtered.map(n => (
<NotificationRow key={n.id} notification={n} onClick={() => handleClick(n)} />
))}
</div>
<div className="mt-4 pt-3 border-t border-zinc-200 dark:border-white/10">
<p className="text-[10px] text-zinc-500 flex items-center gap-1.5">
<AlertCircle size={10} /> In-app only · No SMS or email unless specified.
</p>
</div>
</SpotlightCard>
);
};
const TabPill = ({ active, onClick, label, count }) => (
<button
type="button"
onClick={onClick}
className={`flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold whitespace-nowrap transition-colors
${active
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
}`}
>
{label}
{typeof count === 'number' && (
<span className={`text-[10px] font-mono px-1.5 rounded-full ${active ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' : 'bg-zinc-200 dark:bg-white/10 text-zinc-500'}`}>
{count}
</span>
)}
</button>
);
const NotificationRow = ({ notification, onClick }) => {
const cfg = NOTIF_CONFIG[notification.type];
if (!cfg) return null;
const Icon = cfg.icon;
return (
<button
type="button"
onClick={onClick}
className={`w-full flex items-start gap-3 p-3 rounded-xl text-left border-l-2 ${cfg.side}
${notification.isRead
? 'bg-zinc-50 dark:bg-white/[0.02] border-y border-r border-zinc-100 dark:border-white/5'
: 'bg-white dark:bg-white/5 border-y border-r border-zinc-200 dark:border-white/10 shadow-sm'
}
hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors`}
>
<div className={`p-1.5 rounded-lg shrink-0 ${cfg.tone}`}>
<Icon size={14} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-zinc-900 dark:text-white">{cfg.label}</span>
{!notification.isRead && <span className="w-1.5 h-1.5 rounded-full bg-blue-500" />}
</div>
<p className="text-[11px] text-zinc-600 dark:text-zinc-400 mt-0.5 line-clamp-2">{notification.message}</p>
<p className="text-[10px] text-zinc-500 font-mono mt-1">{formatDateTime(notification.createdAt)}</p>
</div>
</button>
);
};
export default NotificationsPanel;
+634 -25
View File
@@ -5014,10 +5014,33 @@ export const SUBCONTRACTOR_TASK_PRIORITIES = [
export const SUBCONTRACTOR_TASK_STATUSES = [
'Assigned',
'In Progress',
'On Hold',
'Completed',
'Cancelled',
];
// Statuses the subcontractor themselves can set from the Task Detail page.
// Owner / admin can still flip a task to Cancelled from the admin page.
export const SUBCONTRACTOR_SELF_STATUSES = ['Assigned', 'In Progress', 'On Hold', 'Completed'];
export const SUBCONTRACTOR_EXPENSE_CATEGORIES = [
'Materials',
'Equipment Rental',
'Travel / Fuel',
'Permits',
'Disposal / Dumpster',
'Other',
];
export const SUBCONTRACTOR_FEE_TYPES = [
'Labour / Service Fee',
'Inspection Fee',
'Mobilization Fee',
'Overtime',
'Callback / Warranty',
'Other',
];
const MOCK_SUBCONTRACTORS = [
{
id: 'sub_001',
@@ -5082,6 +5105,9 @@ const MOCK_SUBCONTRACTORS = [
];
const MOCK_SUBCONTRACTOR_TASKS = [
// -------------------------------------------------------------------
// sct_001 Maya Patel (sub_002) fully populated reference task
// -------------------------------------------------------------------
{
id: 'sct_001',
companyId: 'lynkeduppro',
@@ -5092,17 +5118,47 @@ const MOCK_SUBCONTRACTOR_TASKS = [
assignedByName: 'Justin Johnson',
title: 'Replace damaged ridge cap',
location: '2612 Dunwick Dr, Plano, TX 75023',
description: 'Two ridge cap shingles missing on the south slope after wind storm. Replace with matching tile and seal.',
description: 'Two ridge cap shingles missing on the south slope after wind storm. Replace with matching tile and seal. Inspect adjacent tiles for hairline cracks while you are up there.',
dueDate: '2026-05-25',
priority: 'high',
status: 'Assigned',
status: 'In Progress',
photos: [
{ id: 'sct_001_p1', name: 'Roof_Damage.jpg', url: '/assets/images/properties/Hail_Damaged_Shingles.jpg', annotation: 'Repair this corner' },
{ id: 'sct_001_p2', name: 'Roof_Wide.jpg', url: '/assets/images/properties/Storm_Worn_Roof.jpg', annotation: 'Inspect adjacent tiles too' },
{ id: 'sct_001_p3', name: 'Ridge_Closeup.jpg', url: '/assets/images/properties/Cracked_Storm_Shingles.jpg', annotation: 'Closeup of missing cap' },
{ id: 'sct_001_p4', name: 'Roof_Reference.jpg', url: '/assets/images/properties/Overlapping_Roof_Shingles.jpg', annotation: '' },
],
statusHistory: [
{ id: 'sct_001_h1', status: 'Assigned', actorId: 'owner_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-15T09:12:00Z' },
{ id: 'sct_001_h2', status: 'In Progress', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'On site, sourced materials.', at: '2026-05-16T08:30:00Z' },
],
thread: [
{ id: 'sct_001_t1', channel: 'external', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Please source them — match Owens Corning Estate Gray as best you can.', at: '2026-05-15T09:14:00Z' },
{ id: 'sct_001_t2', channel: 'external', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Got it. Ill be there on May 20. Do you have shingles in storage or should I source them?', at: '2026-05-15T10:30:00Z', mine: true },
{ id: 'sct_001_t3', channel: 'external', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Source them and put it on the task expenses. Homeowner is OK to reimburse.', at: '2026-05-15T11:00:00Z' },
{ id: 'sct_001_t4', channel: 'external', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Sounds good — picking up at Home Depot tomorrow morning.', at: '2026-05-15T11:08:00Z', mine: true },
{ id: 'sct_001_t5', channel: 'internal', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Heads up team: homeowner is sensitive about noise — keep work to daylight hours only. No early-morning starts.', at: '2026-05-15T11:05:00Z' },
{ id: 'sct_001_t6', channel: 'internal', senderId: 'ADM01', senderName: 'Admin One', senderRole: 'Admin', body: 'Permits are not required for this scope. Skip the city portal step.', at: '2026-05-15T12:10:00Z' },
{ id: 'sct_001_t7', channel: 'internal', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Noted. Will text homeowner when crew is 30 min out.', at: '2026-05-15T12:25:00Z', mine: true },
],
expenses: [
{ id: 'sct_001_e1', description: 'Owens Corning Estate Gray (single pkg)', category: 'Materials', amount: 64.5, notes: 'From Home Depot, kept receipt.', receiptUrl: 'mock://receipts/oc-estate-gray.pdf', createdAt: '2026-05-16T13:10:00Z' },
{ id: 'sct_001_e2', description: 'Roofing nails 1.25"', category: 'Materials', amount: 14.99, notes: '', receiptUrl: '', createdAt: '2026-05-16T13:12:00Z' },
{ id: 'sct_001_e3', description: 'Sealant tube', category: 'Materials', amount: 21.0, notes: 'NP1 polyurethane.', receiptUrl: '', createdAt: '2026-05-16T13:14:00Z' },
{ id: 'sct_001_e4', description: 'Dumpster rental (half day)', category: 'Equipment Rental', amount: 85.0, notes: 'Shared with adjacent job.', receiptUrl: '', createdAt: '2026-05-16T15:00:00Z' },
{ id: 'sct_001_e5', description: 'Fuel — round trip to supply yard', category: 'Travel / Fuel', amount: 18.4, notes: '', receiptUrl: '', createdAt: '2026-05-16T17:30:00Z' },
],
fees: [
{ 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' },
],
createdAt: '2026-05-15T09:12:00Z',
updatedAt: '2026-05-15T09:12:00Z',
updatedAt: '2026-05-16T17:30:00Z',
},
// -------------------------------------------------------------------
// sct_002 Carlos (sub_001) In Progress electrical
// -------------------------------------------------------------------
{
id: 'sct_002',
companyId: 'lynkeduppro',
@@ -5113,16 +5169,38 @@ const MOCK_SUBCONTRACTOR_TASKS = [
assignedByName: 'Justin Johnson',
title: 'Replace breaker panel',
location: '6613 Phoenix Pl, Plano, TX 75023',
description: 'Outdated 100A panel — upgrade to 200A. Permit pulled, materials staged on site.',
description: 'Outdated 100A panel — upgrade to 200A. Permit pulled, materials staged on site. Coordinate Oncor disconnect for the morning of installation.',
dueDate: '2026-05-22',
priority: 'medium',
status: 'In Progress',
photos: [
{ id: 'sct_002_p1', name: 'Old_Panel.jpg', url: '/assets/images/properties/Beige_Two_Story_House.jpg', annotation: 'Existing 100A panel — disconnect and remove' },
{ id: 'sct_002_p2', name: 'Service_Mast.jpg', url: '/assets/images/properties/Modern_White_Suburban_Home.jpg', annotation: 'Service mast — verify clearance' },
],
statusHistory: [
{ id: 'sct_002_h1', status: 'Assigned', actorId: 'owner_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-10T15:40:00Z' },
{ id: 'sct_002_h2', status: 'In Progress', actorId: 'sub_001', actorName: 'Carlos Subcontractor', comment: 'On site, started disconnect.', at: '2026-05-12T08:20:00Z' },
],
thread: [
{ id: 'sct_002_t1', channel: 'external', senderId: 'sub_001', senderName: 'Carlos Subcontractor', senderRole: 'Subcontractor', body: 'Oncor disconnect is confirmed for May 12, 8 AM.', at: '2026-05-11T16:00:00Z', mine: true },
{ id: 'sct_002_t2', channel: 'external', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Perfect. Homeowner will be on site.', at: '2026-05-11T16:30:00Z' },
{ id: 'sct_002_t3', channel: 'internal', senderId: 'ADM01', senderName: 'Admin One', senderRole: 'Admin', body: 'Inspection slot booked with the city for May 15.', at: '2026-05-12T09:30:00Z' },
],
expenses: [
{ id: 'sct_002_e1', description: '200A panel — Square D', category: 'Materials', amount: 312.0, notes: '', receiptUrl: '', createdAt: '2026-05-11T09:00:00Z' },
{ id: 'sct_002_e2', description: 'Conduit + fittings', category: 'Materials', amount: 47.25, notes: '3/4" EMT + connectors.', receiptUrl: '', createdAt: '2026-05-11T09:10:00Z' },
{ id: 'sct_002_e3', description: 'Permit fee — City of Plano', category: 'Permits', amount: 95.0, notes: 'Reimbursable.', receiptUrl: '', createdAt: '2026-05-11T14:00:00Z' },
],
fees: [
{ id: 'sct_002_f1', description: 'Labour — Day 1 disconnect / prep', type: 'Labour / Service Fee', amount: 425, createdAt: '2026-05-12T17:00:00Z' },
],
createdAt: '2026-05-10T15:40:00Z',
updatedAt: '2026-05-12T08:20:00Z',
updatedAt: '2026-05-12T17:00:00Z',
},
// -------------------------------------------------------------------
// sct_003 Dwayne (sub_003) Completed paint job
// -------------------------------------------------------------------
{
id: 'sct_003',
companyId: 'lynkeduppro',
@@ -5133,40 +5211,420 @@ const MOCK_SUBCONTRACTOR_TASKS = [
assignedByName: 'Admin One',
title: 'Touch-up exterior paint',
location: '3913 Arizona Pl, Plano, TX 75023',
description: 'Front fascia and side trim need fresh coat after siding repair.',
description: 'Front fascia and side trim need fresh coat after siding repair. Match existing Sherwin-Williams color.',
dueDate: '2026-05-18',
priority: 'low',
status: 'Completed',
photos: [],
photos: [
{ id: 'sct_003_p1', name: 'Before.jpg', url: '/assets/images/properties/Brick_Front_Porch_Home.jpg', annotation: 'Before' },
],
statusHistory: [
{ id: 'sct_003_h1', status: 'Assigned', actorId: 'ADM01', actorName: 'Admin One', comment: 'Task created.', at: '2026-05-05T11:00:00Z' },
{ id: 'sct_003_h2', status: 'In Progress', actorId: 'sub_003', actorName: 'Dwayne Holland', comment: 'Started prep work.', at: '2026-05-13T08:00:00Z' },
{ id: 'sct_003_h3', status: 'Completed', actorId: 'sub_003', actorName: 'Dwayne Holland', comment: 'Walked through with homeowner.', at: '2026-05-14T17:30:00Z' },
],
thread: [
{ id: 'sct_003_t1', channel: 'external', senderId: 'sub_003', senderName: 'Dwayne Holland', senderRole: 'Subcontractor', body: 'Wrapped up — homeowner signed off. Photos uploaded.', at: '2026-05-14T17:25:00Z', mine: true },
],
expenses: [
{ id: 'sct_003_e1', description: 'Paint — SW Repose Gray (1 gal)', category: 'Materials', amount: 52.5, notes: '', receiptUrl: '', createdAt: '2026-05-13T07:30:00Z' },
],
fees: [
{ id: 'sct_003_f1', description: 'Touch-up paint labour', type: 'Labour / Service Fee', amount: 180, createdAt: '2026-05-14T17:35:00Z' },
],
createdAt: '2026-05-05T11:00:00Z',
updatedAt: '2026-05-14T17:30:00Z',
},
// -------------------------------------------------------------------
// sct_004 Maya (sub_002) emergency tarping, Assigned
// -------------------------------------------------------------------
{
id: 'sct_004',
companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_002',
subcontractorName: 'Maya Patel',
assignedBy: 'owner_001',
assignedByName: 'Justin Johnson',
title: 'Emergency tarp — storm damage',
location: '4221 Magnolia Ln, Plano, TX 75023',
description: 'Hailstorm overnight; 34 ft section of decking exposed near chimney. Tarp ASAP to prevent further water intrusion. Permanent repair will be scheduled after insurance scope.',
dueDate: '2026-05-19',
priority: 'high',
status: 'Assigned',
photos: [
{ id: 'sct_004_p1', name: 'Storm_Damage.jpg', url: '/assets/images/properties/Broken_Roof_Surface.jpg', annotation: 'Exposed decking near chimney' },
{ id: 'sct_004_p2', name: 'Roof_Texture.jpg', url: '/assets/images/properties/Residential_Roof_Texture.jpg', annotation: '' },
],
statusHistory: [
{ id: 'sct_004_h1', status: 'Assigned', actorId: 'owner_001', actorName: 'Justin Johnson', comment: 'Storm-response priority. Please confirm ETA.', at: '2026-05-18T22:40:00Z' },
],
thread: [
{ id: 'sct_004_t1', channel: 'external', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Can you make it first thing tomorrow morning?', at: '2026-05-18T22:42:00Z' },
{ id: 'sct_004_t2', channel: 'external', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Yes — on site by 7:30 AM. Bringing tarps and battens.', at: '2026-05-18T23:05:00Z', mine: true },
{ id: 'sct_004_t3', channel: 'internal', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Customer is filing an insurance claim — make sure to document everything with photos.', at: '2026-05-18T23:10:00Z' },
],
expenses: [],
fees: [],
createdAt: '2026-05-18T22:40:00Z',
updatedAt: '2026-05-18T23:10:00Z',
},
// -------------------------------------------------------------------
// sct_005 Maya (sub_002) Attic ventilation inspection, On Hold
// -------------------------------------------------------------------
{
id: 'sct_005',
companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_002',
subcontractorName: 'Maya Patel',
assignedBy: 'ADM01',
assignedByName: 'Admin One',
title: 'Inspect attic ventilation',
location: '5510 Custer Rd, Plano, TX 75023',
description: 'Customer reports unusually high attic temps. Check ridge vent, soffit airflow, and intake/exhaust balance. Recommend remediation.',
dueDate: '2026-05-30',
priority: 'medium',
status: 'On Hold',
photos: [
{ id: 'sct_005_p1', name: 'Attic_View.jpg', url: '/assets/images/properties/Roof_Inspection_Angle.jpg', annotation: 'Access from south gable' },
],
statusHistory: [
{ id: 'sct_005_h1', status: 'Assigned', actorId: 'ADM01', actorName: 'Admin One', comment: 'Task created.', at: '2026-05-12T10:00:00Z' },
{ id: 'sct_005_h2', status: 'In Progress', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Initial pass done; baffles look blocked.', at: '2026-05-14T11:00:00Z' },
{ id: 'sct_005_h3', status: 'On Hold', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Waiting on homeowner to clear stored boxes from access path.', at: '2026-05-15T16:00:00Z' },
],
thread: [
{ id: 'sct_005_t1', channel: 'external', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Access is blocked — homeowner needs to move some storage before I can complete the inspection.', at: '2026-05-15T15:55:00Z', mine: true },
{ id: 'sct_005_t2', channel: 'internal', senderId: 'ADM01', senderName: 'Admin One', senderRole: 'Admin', body: 'I will reach out to the homeowner and reschedule.', at: '2026-05-15T16:20:00Z' },
],
expenses: [
{ id: 'sct_005_e1', description: 'Infrared thermometer (rental)', category: 'Equipment Rental', amount: 35.0, notes: '', receiptUrl: '', createdAt: '2026-05-14T10:30:00Z' },
],
fees: [
{ id: 'sct_005_f1', description: 'Inspection — initial visit', type: 'Inspection Fee', amount: 125, createdAt: '2026-05-14T13:00:00Z' },
],
createdAt: '2026-05-12T10:00:00Z',
updatedAt: '2026-05-15T16:20:00Z',
},
// -------------------------------------------------------------------
// sct_006 Maya (sub_002) Gutter replacement, Assigned
// -------------------------------------------------------------------
{
id: 'sct_006',
companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_002',
subcontractorName: 'Maya Patel',
assignedBy: 'owner_001',
assignedByName: 'Justin Johnson',
title: 'Replace gutter on south elevation',
location: '8917 Elmwood Dr, Plano, TX 75025',
description: '30 ft section of K-style gutter — pulling away from fascia after storm. Replace with new 6" oversized gutter, match existing bronze color.',
dueDate: '2026-06-02',
priority: 'low',
status: 'Assigned',
photos: [],
statusHistory: [
{ id: 'sct_006_h1', status: 'Assigned', actorId: 'owner_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-17T14:00:00Z' },
],
thread: [],
expenses: [],
fees: [],
createdAt: '2026-05-17T14:00:00Z',
updatedAt: '2026-05-17T14:00:00Z',
},
// -------------------------------------------------------------------
// sct_007 Maya (sub_002) completed earlier this month
// -------------------------------------------------------------------
{
id: 'sct_007',
companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_002',
subcontractorName: 'Maya Patel',
assignedBy: 'ADM01',
assignedByName: 'Admin One',
title: 'Roof deck repair — north slope',
location: '1408 Cedar Ridge, Allen, TX 75002',
description: 'Sheathing soft in two ~2x2 ft areas. Cut out, replace with matching OSB, re-felt and re-shingle.',
dueDate: '2026-05-08',
priority: 'high',
status: 'Completed',
photos: [
{ id: 'sct_007_p1', name: 'Deck_Repair.jpg', url: '/assets/images/properties/Cracked_Asphalt_Shingles.jpg', annotation: 'Soft section, top right' },
],
statusHistory: [
{ id: 'sct_007_h1', status: 'Assigned', actorId: 'ADM01', actorName: 'Admin One', comment: 'Task created.', at: '2026-05-01T09:00:00Z' },
{ id: 'sct_007_h2', status: 'In Progress', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Tear-off underway.', at: '2026-05-05T07:30:00Z' },
{ id: 'sct_007_h3', status: 'Completed', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'Closed out, photos in.', at: '2026-05-07T16:45:00Z' },
],
thread: [
{ id: 'sct_007_t1', channel: 'external', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'All done — closed out. Final photos uploaded.', at: '2026-05-07T16:40:00Z', mine: true },
],
expenses: [
{ id: 'sct_007_e1', description: 'OSB sheathing 4x8 (2 sheets)', category: 'Materials', amount: 76.0, notes: '', receiptUrl: '', createdAt: '2026-05-05T07:00:00Z' },
{ id: 'sct_007_e2', description: 'Underlayment roll', category: 'Materials', amount: 38.0, notes: '', receiptUrl: '', createdAt: '2026-05-05T07:05:00Z' },
{ id: 'sct_007_e3', description: 'Disposal — dump fee', category: 'Disposal / Dumpster', amount: 55.0, notes: '', receiptUrl: '', createdAt: '2026-05-07T17:00:00Z' },
],
fees: [
{ id: 'sct_007_f1', description: 'Labour — deck repair (2 days)', type: 'Labour / Service Fee', amount: 920, createdAt: '2026-05-07T16:50:00Z' },
],
createdAt: '2026-05-01T09:00:00Z',
updatedAt: '2026-05-07T17:00:00Z',
},
// -------------------------------------------------------------------
// sct_008 Carlos (sub_001) GFCI install, fully populated
// -------------------------------------------------------------------
{
id: 'sct_008',
companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_001',
subcontractorName: 'Carlos Subcontractor',
assignedBy: 'ADM01',
assignedByName: 'Admin One',
title: 'Install GFCI outlets — kitchen + bath',
location: '224 Greenfield Way, Frisco, TX 75033',
description: 'Replace 4 standard receptacles with GFCI: 2 kitchen counter outlets (left of sink + island), 1 master bath vanity, 1 powder room vanity. Code update required as part of insurance work. Use Leviton SmartlockPro 20A devices. Test all branches with plug-in tester before signing off.',
dueDate: '2026-05-26',
priority: 'medium',
status: 'In Progress',
photos: [
{ id: 'sct_008_p1', name: 'Kitchen_Outlet.jpg', url: '/assets/images/properties/Modern_White_Suburban_Home.jpg', annotation: 'Kitchen — left of sink, current standard receptacle' },
{ id: 'sct_008_p2', name: 'Island_Outlet.jpg', url: '/assets/images/properties/Sunny_Suburban_House.jpg', annotation: 'Island outlet — replace with GFCI' },
{ id: 'sct_008_p3', name: 'Master_Bath.jpg', url: '/assets/images/properties/Brick_Front_Porch_Home.jpg', annotation: 'Master bath vanity outlet' },
{ id: 'sct_008_p4', name: 'Powder_Room.jpg', url: '/assets/images/properties/Luxury_Driveway_Residence.jpg', annotation: 'Powder room — confirm wire gauge' },
],
statusHistory: [
{ id: 'sct_008_h1', status: 'Assigned', actorId: 'ADM01', actorName: 'Admin One', comment: 'Task created and assigned.', at: '2026-05-16T13:00:00Z' },
{ id: 'sct_008_h2', status: 'In Progress', actorId: 'sub_001', actorName: 'Carlos Subcontractor', comment: 'On site, kitchen done; bath next.', at: '2026-05-19T10:15:00Z' },
],
thread: [
{ id: 'sct_008_t1', channel: 'external', senderId: 'ADM01', senderName: 'Admin One', senderRole: 'Admin', body: 'Insurance is reimbursing the code upgrade — keep receipts for the GFCI devices.', at: '2026-05-16T13:05:00Z' },
{ id: 'sct_008_t2', channel: 'external', senderId: 'sub_001', senderName: 'Carlos Subcontractor', senderRole: 'Subcontractor', body: 'Confirmed — Leviton 20A devices, will keep receipts. Targeting May 19 for the visit.', at: '2026-05-16T14:20:00Z', mine: true },
{ id: 'sct_008_t3', channel: 'external', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Homeowner has 2 small kids — please bring outlet covers as a courtesy.', at: '2026-05-17T09:00:00Z' },
{ id: 'sct_008_t4', channel: 'external', senderId: 'sub_001', senderName: 'Carlos Subcontractor', senderRole: 'Subcontractor', body: 'Will do. Already in my truck.', at: '2026-05-17T09:05:00Z', mine: true },
{ id: 'sct_008_t5', channel: 'external', senderId: 'sub_001', senderName: 'Carlos Subcontractor', senderRole: 'Subcontractor', body: 'Kitchen done. Both counter outlets and the island are on GFCI now. Starting bath after lunch.', at: '2026-05-19T12:30:00Z', mine: true },
{ id: 'sct_008_t6', channel: 'internal', senderId: 'ADM01', senderName: 'Admin One', senderRole: 'Admin', body: 'No permit needed for like-for-like swap. Skip the city portal.', at: '2026-05-16T13:10:00Z' },
{ id: 'sct_008_t7', channel: 'internal', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Carlos — if you find any aluminum branch wiring, stop and flag it. We will scope as a change order.', at: '2026-05-17T10:00:00Z' },
{ id: 'sct_008_t8', channel: 'internal', senderId: 'sub_001', senderName: 'Carlos Subcontractor', senderRole: 'Subcontractor', body: 'All copper, no aluminum. Proceeding as scoped.', at: '2026-05-19T10:30:00Z', mine: true },
],
expenses: [
{ id: 'sct_008_e1', description: 'Leviton 20A GFCI receptacles (qty 4)', category: 'Materials', amount: 92.0, notes: 'SmartlockPro tamper-resistant.', receiptUrl: 'mock://receipts/leviton-gfci.pdf', createdAt: '2026-05-18T16:00:00Z' },
{ id: 'sct_008_e2', description: 'Wire nuts + electrical tape', category: 'Materials', amount: 11.5, notes: '', receiptUrl: '', createdAt: '2026-05-18T16:02:00Z' },
{ id: 'sct_008_e3', description: 'Childproof outlet covers (10-pack)', category: 'Materials', amount: 8.99, notes: 'Courtesy for homeowner.', receiptUrl: '', createdAt: '2026-05-18T16:05:00Z' },
{ id: 'sct_008_e4', description: 'Plug-in circuit tester', category: 'Equipment Rental', amount: 24.0, notes: 'Sperry Instruments GFCI tester.', receiptUrl: '', createdAt: '2026-05-18T16:10:00Z' },
{ id: 'sct_008_e5', description: 'Fuel — Frisco round trip', category: 'Travel / Fuel', amount: 22.4, notes: '', receiptUrl: '', createdAt: '2026-05-19T18:00:00Z' },
],
fees: [
{ 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' },
],
createdAt: '2026-05-16T13:00:00Z',
updatedAt: '2026-05-19T18:00:00Z',
},
// -------------------------------------------------------------------
// sct_009 Jennifer Wu (sub_004) On Hold plumbing
// -------------------------------------------------------------------
{
id: 'sct_009',
companyId: 'lynkeduppro',
companyName: 'LynkedUp Pro Roofing',
subcontractorId: 'sub_004',
subcontractorName: 'Jennifer Wu',
assignedBy: 'owner_001',
assignedByName: 'Justin Johnson',
title: 'Rough-in plumbing for ADU bathroom',
location: '6112 Roundrock Dr, Plano, TX 75025',
description: 'New ADU build — bathroom rough-in. Coordinate with framing inspection.',
dueDate: '2026-06-15',
priority: 'medium',
status: 'On Hold',
photos: [],
statusHistory: [
{ id: 'sct_009_h1', status: 'Assigned', actorId: 'owner_001', actorName: 'Justin Johnson', comment: 'Task created.', at: '2026-05-09T10:00:00Z' },
{ id: 'sct_009_h2', status: 'On Hold', actorId: 'sub_004', actorName: 'Jennifer Wu', comment: 'Waiting on framing to finish.', at: '2026-05-13T12:00:00Z' },
],
thread: [],
expenses: [],
fees: [],
createdAt: '2026-05-09T10:00:00Z',
updatedAt: '2026-05-13T12:00:00Z',
},
];
const MOCK_NOTIFICATIONS = [
// ============================================
// sub_002 Maya Patel (default demo user)
// ============================================
// -- Task Assigned -----------------------------
{
id: 'notif_001',
recipientUserId: 'sub_001',
recipientRole: 'SUBCONTRACTOR',
id: 'notif_001', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'task_assigned',
message: 'You have been assigned a task by LynkedUp Pro Roofing',
taskId: 'sct_002',
fromCompanyId: 'lynkeduppro',
fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false,
createdAt: '2026-05-10T15:40:00Z',
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Replace damaged ridge cap"',
taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-15T09:12:00Z',
},
{
id: 'notif_002',
recipientUserId: 'sub_002',
recipientRole: 'SUBCONTRACTOR',
id: 'notif_002', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'task_assigned',
message: 'You have been assigned a task by LynkedUp Pro Roofing',
taskId: 'sct_001',
fromCompanyId: 'lynkeduppro',
fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false,
createdAt: '2026-05-15T09:12:00Z',
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Emergency tarp — storm damage"',
taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-18T22:40:00Z',
},
{
id: 'notif_003', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'task_assigned',
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Replace gutter on south elevation"',
taskId: 'sct_006', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-17T14:00:00Z',
},
{
id: 'notif_004', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'task_assigned',
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Inspect attic ventilation"',
taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-12T10:00:00Z',
},
// -- Message Received --------------------------
{
id: 'notif_005', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'message_received',
message: 'Justin Johnson replied on sct_001 — "Please source them — match Owens Corning…"',
taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-15T09:14:00Z',
},
{
id: 'notif_006', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'message_received',
message: 'Justin Johnson sent a new message on sct_004 — "Can you make it first thing tomorrow morning?"',
taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-18T22:42:00Z',
},
{
id: 'notif_007', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'message_received',
message: 'Admin One added an internal note on sct_005 — "I will reach out to the homeowner…"',
taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-15T16:20:00Z',
},
// -- Task Updated ------------------------------
{
id: 'notif_008', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'task_updated',
message: 'Due date for sct_001 was updated to May 25, 2026 by Justin Johnson.',
taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-15T11:00:00Z',
},
{
id: 'notif_009', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'task_updated',
message: 'Priority for sct_004 raised to High by Justin Johnson.',
taskId: 'sct_004', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-18T22:50:00Z',
},
{
id: 'notif_010', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'task_updated',
message: 'New photo attached to sct_001 by Justin Johnson.',
taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-15T12:00:00Z',
},
// -- Status Changed ----------------------------
{
id: 'notif_011', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'status_changed',
message: 'Status for sct_001 changed to In Progress by Maya Patel.',
taskId: 'sct_001', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-16T08:30:00Z',
},
{
id: 'notif_012', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'status_changed',
message: 'Status for sct_005 changed to On Hold by Maya Patel.',
taskId: 'sct_005', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-15T16:00:00Z',
},
{
id: 'notif_013', recipientUserId: 'sub_002', recipientRole: 'SUBCONTRACTOR',
type: 'status_changed',
message: 'Status for sct_007 changed to Completed by Maya Patel.',
taskId: 'sct_007', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-07T16:45:00Z',
},
// ============================================
// sub_001 Carlos
// ============================================
{
id: 'notif_021', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'task_assigned',
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Replace breaker panel"',
taskId: 'sct_002', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-10T15:40:00Z',
},
{
id: 'notif_022', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'task_assigned',
message: 'You have been assigned a task by LynkedUp Pro Roofing — "Install GFCI outlets — kitchen + bath"',
taskId: 'sct_008', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-16T13:00:00Z',
},
{
id: 'notif_026', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'message_received',
message: 'Admin One sent a new message on sct_008 — "Insurance is reimbursing the code upgrade…"',
taskId: 'sct_008', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-16T13:05:00Z',
},
{
id: 'notif_027', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'message_received',
message: 'Justin Johnson sent a new message on sct_008 — "Homeowner has 2 small kids…"',
taskId: 'sct_008', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-17T09:00:00Z',
},
{
id: 'notif_028', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'task_updated',
message: 'Photos updated on sct_008 by Admin One.',
taskId: 'sct_008', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-16T13:30:00Z',
},
{
id: 'notif_029', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'status_changed',
message: 'Status for sct_008 changed to In Progress by Carlos Subcontractor.',
taskId: 'sct_008', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-19T10:15:00Z',
},
{
id: 'notif_023', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'message_received',
message: 'Justin Johnson confirmed Oncor disconnect on sct_002.',
taskId: 'sct_002', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-11T16:30:00Z',
},
{
id: 'notif_024', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'task_updated',
message: 'Permit fee on sct_002 was approved by Admin One.',
taskId: 'sct_002', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: false, createdAt: '2026-05-11T14:30:00Z',
},
{
id: 'notif_025', recipientUserId: 'sub_001', recipientRole: 'SUBCONTRACTOR',
type: 'status_changed',
message: 'Status for sct_002 changed to In Progress by Carlos Subcontractor.',
taskId: 'sct_002', fromCompanyId: 'lynkeduppro', fromCompanyName: 'LynkedUp Pro Roofing',
isRead: true, createdAt: '2026-05-12T08:20:00Z',
},
];
@@ -5434,6 +5892,17 @@ export const MockStoreProvider = ({ children }) => {
url: p.url,
annotation: p.annotation || '',
})),
statusHistory: [{
id: `h_${Date.now()}`,
status: 'Assigned',
actorId: input.assignedBy,
actorName: input.assignedByName,
comment: 'Task created and assigned.',
at: now,
}],
thread: [],
expenses: [],
fees: [],
createdAt: now,
updatedAt: now,
};
@@ -5454,6 +5923,139 @@ export const MockStoreProvider = ({ children }) => {
return task;
};
const setSubcontractorTaskStatus = (taskId, nextStatus, comment, actor) => {
const now = new Date().toISOString();
let didChange = false;
let companyId = null;
let companyName = null;
let assignedByUserId = null;
setSubcontractorTasks(prev => prev.map(t => {
if (t.id !== taskId) return t;
if (t.status === nextStatus) return t;
didChange = true;
companyId = t.companyId;
companyName = t.companyName;
assignedByUserId = t.assignedBy;
const entry = {
id: `h_${Date.now()}`,
status: nextStatus,
actorId: actor?.id || t.subcontractorId,
actorName: actor?.name || t.subcontractorName,
comment: comment?.trim() || '',
at: now,
};
return {
...t,
status: nextStatus,
statusHistory: [...(t.statusHistory || []), entry],
updatedAt: now,
};
}));
if (didChange) {
// Notify owner / admin who assigned the task
if (assignedByUserId) {
addNotification({
recipientUserId: assignedByUserId,
recipientRole: 'OWNER',
type: 'status_changed',
message: `Status changed to ${nextStatus} by ${actor?.name || 'subcontractor'}`,
taskId,
fromCompanyId: companyId,
fromCompanyName: companyName,
});
}
toast.success(`Status updated to ${nextStatus}`);
}
};
const addSubcontractorTaskMessage = (taskId, { body, channel = 'external', sender }) => {
if (!body?.trim()) return;
const now = new Date().toISOString();
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 msg = {
id: `m_${Date.now()}`,
channel,
senderId: sender?.id,
senderName: sender?.name || 'You',
senderRole: sender?.role || 'Subcontractor',
body: body.trim(),
at: now,
mine: true,
};
return {
...t,
thread: [...(t.thread || []), msg],
updatedAt: now,
};
}));
// Notify the other side
const recipientUserId = sender?.id === assignedByUserId ? subId : assignedByUserId;
if (recipientUserId) {
addNotification({
recipientUserId,
recipientRole: sender?.id === assignedByUserId ? 'SUBCONTRACTOR' : 'OWNER',
type: 'message_received',
message: `New message from ${sender?.name || 'a teammate'}`,
taskId,
fromCompanyId: companyId,
fromCompanyName: companyName,
});
}
};
const addSubcontractorTaskExpense = (taskId, fields) => {
const now = new Date().toISOString();
setSubcontractorTasks(prev => prev.map(t => t.id === taskId ? {
...t,
expenses: [
...(t.expenses || []),
{
id: `e_${Date.now()}`,
description: fields.description?.trim() || '',
category: fields.category || 'Materials',
amount: Number(fields.amount) || 0,
notes: fields.notes?.trim() || '',
receiptUrl: fields.receiptUrl || '',
createdAt: now,
},
],
updatedAt: now,
} : t));
toast.success('Expense logged');
};
const addSubcontractorTaskFee = (taskId, fields) => {
const now = new Date().toISOString();
setSubcontractorTasks(prev => prev.map(t => t.id === taskId ? {
...t,
fees: [
...(t.fees || []),
{
id: `f_${Date.now()}`,
description: fields.description?.trim() || '',
type: fields.type || 'Labour / Service Fee',
amount: Number(fields.amount) || 0,
createdAt: now,
},
],
updatedAt: now,
} : t));
toast.success('Fee submitted');
};
const updateSubcontractorTask = (taskId, data) => {
let updatedSubId = null;
let prevSubId = null;
@@ -5779,10 +6381,17 @@ export const MockStoreProvider = ({ children }) => {
subcontractorTasks,
taskPriorities: SUBCONTRACTOR_TASK_PRIORITIES,
taskStatuses: SUBCONTRACTOR_TASK_STATUSES,
subSelfStatuses: SUBCONTRACTOR_SELF_STATUSES,
expenseCategories: SUBCONTRACTOR_EXPENSE_CATEGORIES,
feeTypes: SUBCONTRACTOR_FEE_TYPES,
addSubcontractorTask,
updateSubcontractorTask,
cancelSubcontractorTask,
reassignSubcontractorTask,
setSubcontractorTaskStatus,
addSubcontractorTaskMessage,
addSubcontractorTaskExpense,
addSubcontractorTaskFee,
notifications,
addNotification,
markNotificationRead,
@@ -1,12 +1,14 @@
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore';
import { StatCard } from '../../components/StatCard';
import { SpotlightCard } from '../../components/SpotlightCard';
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight } from 'lucide-react';
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight, AlertCircle } from 'lucide-react';
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
import { NotificationsPanel } from '../../components/subcontractor/NotificationsPanel';
// --- Reusable list modal for filtered tasks / clock-in / payment history ---
const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIns, onTaskClick }) => {
@@ -194,11 +196,18 @@ const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIn
const SubContractorDashboard = () => {
const { user } = useAuth();
const { projects } = useMockStore();
const { projects, subcontractorTasks, notifications, markNotificationRead } = useMockStore();
const navigate = useNavigate();
const subId = user?.id || 'sub_001';
// Aggregate Tasks
// Notifications for the current subcontractor (across all tasks).
const myNotifications = useMemo(
() => notifications.filter(n => n.recipientUserId === subId),
[notifications, subId],
);
// Aggregate Tasks (legacy milestone-based for stat parity below)
const myTasks = projects.flatMap(p =>
p.milestones.filter(m => m.assignedTo === subId)
.map(m => ({ ...m, projectAddress: p.address, projectId: p.id, projectStatus: p.status }))
@@ -208,6 +217,12 @@ const SubContractorDashboard = () => {
const pendingTasks = myTasks.filter(t => t.status === 'pending');
const completedTasks = myTasks.filter(t => t.status === 'completed');
// CRM-assigned tasks (the real "My Assignments")
const myAssignments = useMemo(
() => subcontractorTasks.filter(t => t.subcontractorId === subId),
[subcontractorTasks, subId],
);
// Financials
const myInvoices = projects.flatMap(p =>
(p.invoices || []).filter(i => i.submittedBy === subId)
@@ -308,58 +323,70 @@ const SubContractorDashboard = () => {
</div>
{/* Main Task List */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
<div className="lg:col-span-2">
<SpotlightCard className="p-6 h-full">
<SpotlightCard className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">My Assignments</h2>
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">{myAssignments.length} task{myAssignments.length === 1 ? '' : 's'}</span>
</div>
<div className="space-y-4">
{myTasks.length > 0 ? myTasks.map((task, idx) => (
<div key={idx} className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer" onClick={() => handleTaskClick(task)}>
{myAssignments.length > 0 ? myAssignments.map(task => {
const overdue = task.dueDate && task.dueDate < new Date().toISOString().slice(0, 10)
&& task.status !== 'Completed' && task.status !== 'Cancelled';
const statusTone = {
Completed: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400',
'In Progress': 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400',
'On Hold': 'bg-amber-100 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400',
Assigned: 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400',
Cancelled: 'bg-zinc-100 text-zinc-500 dark:bg-white/10 dark:text-zinc-500',
}[task.status] || 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400';
return (
<div
key={task.id}
role="button"
tabIndex={0}
onClick={() => navigate(`/subcontractor/tasks/${task.id}`)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/subcontractor/tasks/${task.id}`); }}
className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer"
>
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-lg ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400' :
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' :
'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400'
}`}>
<div className="flex items-start gap-4 min-w-0">
<div className={`p-3 rounded-lg shrink-0 ${statusTone}`}>
<CheckSquare size={20} />
</div>
<div>
<h4 className="font-bold text-zinc-900 dark:text-white">{task.name}</h4>
<div className="flex items-center text-xs text-zinc-500 dark:text-zinc-400 mt-1">
<MapPin size={12} className="mr-1" />
{task.projectAddress}
<div className="min-w-0">
<h4 className="font-bold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">{task.title}</h4>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500 dark:text-zinc-400 mt-1">
<span className="flex items-center gap-1"><MapPin size={12} />{task.location}</span>
<span className="font-mono">{task.id}</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 md:gap-4 shrink-0">
<div className="text-right">
<p className="text-xs font-mono font-medium text-zinc-500 mb-1">Due: {task.dueDate}</p>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600' :
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600' : 'bg-amber-100 text-amber-600'
}`}>
{task.status.replace('_', ' ')}
<p className={`text-xs font-mono font-medium mb-1 flex items-center justify-end gap-1 ${overdue ? 'text-red-500 font-bold' : 'text-zinc-500'}`}>
{overdue && <AlertCircle size={11} />}
Due: {task.dueDate}
</p>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${statusTone}`}>
{task.status}
</span>
</div>
{task.status !== 'completed' && (
<button className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition-colors"
onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
Update
</button>
)}
<ChevronRight size={16} className="text-zinc-400 group-hover:text-blue-500 transition-colors" />
</div>
</div>
</div>
)) : (
<div className="text-center py-10 text-zinc-500"><p>No tasks assigned.</p></div>
);
}) : (
<div className="text-center py-10 text-zinc-500"><p>No tasks assigned yet.</p></div>
)}
</div>
</SpotlightCard>
</div>
{/* Earnings Widget */}
<div>
{/* Right column: Earnings + Notifications */}
<div className="space-y-6">
<SpotlightCard className="p-6">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Tracker</h2>
<div className="relative pt-4 pb-8">
@@ -385,6 +412,12 @@ const SubContractorDashboard = () => {
</button>
</div>
</SpotlightCard>
<NotificationsPanel
notifications={myNotifications}
onMarkAllRead={() => myNotifications.forEach(n => !n.isRead && markNotificationRead(n.id))}
onNotificationClick={(n) => { if (!n.isRead) markNotificationRead(n.id); }}
/>
</div>
</div>
File diff suppressed because it is too large Load Diff