From 258997507473cf7ab2ffc9cf9c03060204627d50 Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Tue, 19 May 2026 18:17:36 +0530 Subject: [PATCH] adds sections as list tasks,log expenses. fees, notifications for sub contrcator --- src/App.jsx | 6 + .../subcontractor/NotificationsPanel.jsx | 197 +++ src/data/mockStore.jsx | 663 +++++++++- .../subcontractor/SubContractorDashboard.jsx | 99 +- .../SubcontractorTaskDetailPage.jsx | 1098 +++++++++++++++++ 5 files changed, 2003 insertions(+), 60 deletions(-) create mode 100644 src/components/subcontractor/NotificationsPanel.jsx create mode 100644 src/pages/subcontractor/SubcontractorTaskDetailPage.jsx diff --git a/src/App.jsx b/src/App.jsx index 239ed42..3f14643 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> + + + + } /> {/* Catch all */} diff --git a/src/components/subcontractor/NotificationsPanel.jsx b/src/components/subcontractor/NotificationsPanel.jsx new file mode 100644 index 0000000..9467bd9 --- /dev/null +++ b/src/components/subcontractor/NotificationsPanel.jsx @@ -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 ( + +
+
+
+ +
+

+ Notifications +

+ {unreadCount > 0 && ( + + {unreadCount} new + + )} +
+ +
+ + {/* Tab strip — scrollable on narrow screens */} +
+
+ {TABS.map(t => { + const count = t.key === 'all' ? allowed.length : allowed.filter(n => n.type === t.key).length; + return ( + setTab(t.key)} + label={t.label} + count={count} + /> + ); + })} +
+
+ +
+ {filtered.length === 0 ? ( +
+ +

You're all caught up.

+
+ ) : filtered.map(n => ( + handleClick(n)} /> + ))} +
+ +
+

+ In-app only · No SMS or email unless specified. +

+
+
+ ); +}; + +const TabPill = ({ active, onClick, label, count }) => ( + +); + +const NotificationRow = ({ notification, onClick }) => { + const cfg = NOTIF_CONFIG[notification.type]; + if (!cfg) return null; + const Icon = cfg.icon; + return ( + + ); +}; + +export default NotificationsPanel; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 58a76b2..0f9a7e8 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -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_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. I’ll 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; 3–4 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, diff --git a/src/pages/subcontractor/SubContractorDashboard.jsx b/src/pages/subcontractor/SubContractorDashboard.jsx index d25d5d4..a681de1 100644 --- a/src/pages/subcontractor/SubContractorDashboard.jsx +++ b/src/pages/subcontractor/SubContractorDashboard.jsx @@ -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 = () => { {/* Main Task List */} -
+
- +

My Assignments

+ {myAssignments.length} task{myAssignments.length === 1 ? '' : 's'}
- {myTasks.length > 0 ? myTasks.map((task, idx) => ( -
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 ( +
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" + >
-
-
+
+
-
-

{task.name}

-
- - {task.projectAddress} +
+

{task.title}

+
+ {task.location} + {task.id}
-
+
-

Due: {task.dueDate}

- - {task.status.replace('_', ' ')} +

+ {overdue && } + Due: {task.dueDate} +

+ + {task.status}
- {task.status !== 'completed' && ( - - )} +
- )) : ( -

No tasks assigned.

+ ); + }) : ( +

No tasks assigned yet.

)}
- {/* Earnings Widget */} -
+ {/* Right column: Earnings + Notifications */} +

Earnings Tracker

@@ -385,6 +412,12 @@ const SubContractorDashboard = () => {
+ + myNotifications.forEach(n => !n.isRead && markNotificationRead(n.id))} + onNotificationClick={(n) => { if (!n.isRead) markNotificationRead(n.id); }} + />
diff --git a/src/pages/subcontractor/SubcontractorTaskDetailPage.jsx b/src/pages/subcontractor/SubcontractorTaskDetailPage.jsx new file mode 100644 index 0000000..7bad063 --- /dev/null +++ b/src/pages/subcontractor/SubcontractorTaskDetailPage.jsx @@ -0,0 +1,1098 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { useMockStore } from '../../data/mockStore'; +import { SpotlightCard } from '../../components/SpotlightCard'; +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, +} from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Shared style maps (kept in sync with SubcontractorTasksPage.jsx) +// --------------------------------------------------------------------------- +const PRIORITY_STYLES = { + low: { label: 'Low', cls: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300', dot: 'bg-zinc-400' }, + medium: { label: 'Medium', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400', dot: 'bg-blue-500' }, + high: { label: 'High', cls: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400', dot: 'bg-red-500' }, +}; + +const STATUS_CONFIG = { + Assigned: { cls: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400', icon: Clock, dot: 'bg-amber-500' }, + 'In Progress': { cls: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400', icon: PauseCircle, dot: 'bg-blue-500' }, + 'On Hold': { cls: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400', icon: PauseCircle, dot: 'bg-orange-500' }, + Completed: { cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400', icon: CheckCircle, dot: 'bg-emerald-500' }, + Cancelled: { cls: 'bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300', icon: X, dot: 'bg-zinc-500' }, +}; + +const formatDate = (iso) => { + if (!iso) return '—'; + try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } + catch { return iso; } +}; + +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 fmtMoney = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(Number(n) || 0); + +const isOverdue = (task) => task?.dueDate + && task.dueDate < new Date().toISOString().slice(0, 10) + && task.status !== 'Completed' && task.status !== 'Cancelled'; + +// --------------------------------------------------------------------------- +// Reused badge primitives +// --------------------------------------------------------------------------- +const StatusBadge = ({ status, size = 'sm' }) => { + const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.Assigned; + const Icon = cfg.icon; + const sizeCls = size === 'md' + ? 'text-[11px] px-3 py-1' + : 'text-[10px] px-2.5 py-1'; + return ( + + + {status} + + ); +}; + +const PriorityBadge = ({ priority }) => { + const cfg = PRIORITY_STYLES[priority] || PRIORITY_STYLES.medium; + return ( + + + {cfg.label} priority + + ); +}; + +const MetaItem = ({ icon: Icon, label, value, secondary, mono, highlight }) => ( +
+
+ +
+
+
{label}
+
{value || '—'}
+ {secondary && ( +
{secondary}
+ )} +
+
+); + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- +const SubcontractorTaskDetailPage = () => { + const { taskId } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const { + subcontractorTasks, subSelfStatuses, + setSubcontractorTaskStatus, addSubcontractorTaskMessage, + addSubcontractorTaskExpense, addSubcontractorTaskFee, + } = useMockStore(); + + const task = useMemo(() => subcontractorTasks.find(t => t.id === taskId), [subcontractorTasks, taskId]); + + const [logExpenseOpen, setLogExpenseOpen] = useState(false); + const [logFeeOpen, setLogFeeOpen] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); + + // ESC closes any open modal — handled inside each modal too, but having a guard + // at page level keeps focus tidy. + useEffect(() => { + const handler = (e) => { + if (e.key !== 'Escape') return; + if (lightboxIndex !== null) setLightboxIndex(null); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [lightboxIndex]); + + if (!task) { + return ( +
+
+
+ +
+

Task not found

+

It may have been removed or reassigned. Head back to your assignments to see the latest.

+ +
+
+ ); + } + + const overdue = isOverdue(task); + const closed = task.status === 'Completed' || task.status === 'Cancelled'; + + const handleStatusChange = (nextStatus, comment) => { + setSubcontractorTaskStatus(task.id, nextStatus, comment, { + id: user?.id || task.subcontractorId, + name: user?.name || task.subcontractorName, + role: 'Subcontractor', + }); + }; + + const handleSendMessage = (body, channel) => { + addSubcontractorTaskMessage(task.id, { + body, + channel, + sender: { + id: user?.id || task.subcontractorId, + name: user?.name || task.subcontractorName, + role: 'Subcontractor', + }, + }); + }; + + return ( +
+ {/* Ambient bg — matches SubcontractorTasksPage */} +
+
+
+
+ +
+ {/* Breadcrumb / back */} +
+ + + + {task.title} + +
+ + {/* Header spans full width */} + setLogExpenseOpen(true)} + onLogFee={() => setLogFeeOpen(true)} + /> + + {/* Two-column shell below: main content + financials/notifications rail */} +
+
+ + + + + setLightboxIndex(idx)} + /> +
+ +
+ setLogExpenseOpen(true)} + disabled={closed} + /> + setLogFeeOpen(true)} + disabled={closed} + /> +
+
+
+ + {/* Modals */} + setLogExpenseOpen(false)} + onSubmit={(fields) => { + addSubcontractorTaskExpense(task.id, fields); + setLogExpenseOpen(false); + }} + /> + setLogFeeOpen(false)} + onSubmit={(fields) => { + addSubcontractorTaskFee(task.id, fields); + setLogFeeOpen(false); + }} + /> + setLightboxIndex(null)} + onIndexChange={setLightboxIndex} + /> +
+ ); +}; + +// =========================================================================== +// Section 1 — Header / Task Details +// =========================================================================== +const TaskHeaderCard = ({ task, overdue, onLogExpense, onLogFee }) => { + const closed = task.status === 'Completed' || task.status === 'Cancelled'; + return ( + + {/* Top row: ID + badges */} +
+ {task.id} + + + + {overdue && ( + + Overdue + + )} +
+ + {/* Title + actions */} +
+

+ {task.title} +

+ +
+ + +
+
+ + {/* Meta strip — horizontal inline row matching Figma */} +
+ + + + + +
+
+ ); +}; + +// =========================================================================== +// Section 4 — Status Management +// =========================================================================== +const StatusManagementCard = ({ task, statuses, disabled, onChange }) => { + const [comment, setComment] = useState(''); + const [pendingStatus, setPendingStatus] = useState(null); + + const handlePick = (s) => { + if (disabled) return; + if (s === task.status) return; + setPendingStatus(s); + }; + + const confirm = () => { + if (!pendingStatus) return; + onChange(pendingStatus, comment); + setComment(''); + setPendingStatus(null); + }; + + const cancel = () => { + setPendingStatus(null); + setComment(''); + }; + + return ( + + + +
+ {statuses.map(s => { + const cfg = STATUS_CONFIG[s] || STATUS_CONFIG.Assigned; + const Icon = cfg.icon; + const isCurrent = task.status === s; + const isPending = pendingStatus === s; + return ( + + ); + })} +
+ + {pendingStatus && ( +
+

+ Changing status to {pendingStatus}. Add an optional comment for the admin. +

+