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' && (
-
- )}
+
- )) : (
-
+ );
+ }) : (
+
)}
- {/* 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.
+
+
+ )}
+
+ {/* Status history */}
+
+
+ Status History
+
+ {(task.statusHistory || []).length === 0 ? (
+
No status changes recorded yet.
+ ) : (
+
+ {[...task.statusHistory].slice().reverse().map((h) => {
+ const cfg = STATUS_CONFIG[h.status] || STATUS_CONFIG.Assigned;
+ return (
+ -
+
+
+
+
+ by {h.actorName}
+ {formatDateTime(h.at)}
+
+ {h.comment && (
+
{h.comment}
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
+
+// ===========================================================================
+// Section 3 — Task Thread / Communication
+// ===========================================================================
+const TaskThreadCard = ({ task, disabled, onSend }) => {
+ const [channel, setChannel] = useState('external'); // external | internal
+ const [draft, setDraft] = useState('');
+ const scrollerRef = useRef(null);
+
+ const messages = useMemo(
+ () => (task.thread || []).filter(m => m.channel === channel),
+ [task.thread, channel],
+ );
+
+ useEffect(() => {
+ if (scrollerRef.current) {
+ scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
+ }
+ }, [messages.length]);
+
+ const submit = (e) => {
+ e?.preventDefault();
+ if (!draft.trim() || disabled) return;
+ onSend(draft, channel);
+ setDraft('');
+ };
+
+ return (
+
+
+
+
+ setChannel('external')} icon={MessageSquare} label="External" />
+ setChannel('internal')} icon={Lock} label="Internal" />
+
+
+
+
+ {messages.length === 0 ? (
+
+
+
No {channel === 'internal' ? 'internal notes' : 'messages'} yet.
+
+ ) : messages.map(m => (
+
+ ))}
+
+
+
+
+ );
+};
+
+const ChannelTab = ({ active, onClick, icon: Icon, label }) => (
+
+);
+
+const MessageBubble = ({ message, channel }) => {
+ const mine = !!message.mine;
+ const bubbleBase = channel === 'internal'
+ ? 'bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20'
+ : (mine ? 'bg-blue-600 text-white' : 'bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10');
+ const meta = mine
+ ? 'text-blue-100/80'
+ : channel === 'internal'
+ ? 'text-amber-700 dark:text-amber-400'
+ : 'text-zinc-500';
+
+ return (
+
+
+ {!mine && (
+
+ {message.senderName?.charAt(0)?.toUpperCase() || '?'}
+
+ )}
+
+ {!mine && (
+
+ {message.senderName} · {message.senderRole}
+
+ )}
+
+ {message.body}
+
+
{formatDateTime(message.at)}
+
+
+
+ );
+};
+
+// ===========================================================================
+// Section 1.b + Section 2 — Description + Photos
+// ===========================================================================
+const DescriptionPhotosCard = ({ task, onPreview }) => {
+ const photos = task.photos || [];
+ return (
+
+
+
+
+
+
Description
+
+ {task.description || No description provided.}
+
+
+
+
+
+ Photos ({photos.length})
+
+ {photos.length === 0 ? (
+
No photos attached.
+ ) : (
+
+ {photos.map((p, idx) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+// ===========================================================================
+// Section 5 — Expenses card (right rail)
+// ===========================================================================
+const ExpensesCard = ({ task, onLogExpense, disabled }) => {
+ const expenses = task.expenses || [];
+ const total = expenses.reduce((sum, e) => sum + (Number(e.amount) || 0), 0);
+
+ return (
+
+
+
+
+ {expenses.length === 0 ? (
+
+ ) : (
+
+ {expenses.map(e => (
+ -
+
+
{e.description || e.category}
+
{e.category} · {formatDate(e.createdAt)}
+ {e.notes &&
{e.notes}
}
+
+ {fmtMoney(e.amount)}
+
+ ))}
+
+ )}
+
+
+
+ Total Expenses
+ {fmtMoney(total)}
+
+
+ );
+};
+
+// ===========================================================================
+// Section 6 — Fees card (right rail)
+// ===========================================================================
+const FeesCard = ({ task, onLogFee, disabled }) => {
+ const fees = task.fees || [];
+ const total = fees.reduce((sum, f) => sum + (Number(f.amount) || 0), 0);
+
+ return (
+
+
+
+
+ {fees.length === 0 ? (
+
+ ) : (
+
+ {fees.map(f => (
+ -
+
+
{f.description || f.type}
+
{f.type} · {formatDate(f.createdAt)}
+
+ {fmtMoney(f.amount)}
+
+ ))}
+
+ )}
+
+
+
+ Total Fee
+ {fmtMoney(total)}
+
+
+ );
+};
+
+const EmptyLine = ({ icon: Icon, text }) => (
+
+);
+
+// ===========================================================================
+// Modals — Log Expense, Log Fee, Photo Lightbox
+// ===========================================================================
+const ModalShell = ({ isOpen, onClose, children, label }) => {
+ useEffect(() => {
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
+ if (isOpen) window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+ return createPortal(
+
,
+ document.body,
+ );
+};
+
+const LogExpenseModal = ({ isOpen, task, onClose, onSubmit }) => {
+ const { expenseCategories } = useMockStore();
+ // Re-mount the form when the modal opens so each open starts at defaults.
+ if (!isOpen) return null;
+ return
;
+};
+
+const LogExpenseModalBody = ({ task, onClose, onSubmit, expenseCategories }) => {
+ const [description, setDescription] = useState('');
+ const [category, setCategory] = useState(expenseCategories[0] || 'Materials');
+ const [amount, setAmount] = useState('');
+ const [notes, setNotes] = useState('');
+ const [receiptName, setReceiptName] = useState('');
+
+ const submit = (e) => {
+ e.preventDefault();
+ if (!amount || Number(amount) <= 0) return;
+ onSubmit({
+ description,
+ category,
+ amount: Number(amount),
+ notes,
+ receiptUrl: receiptName ? `mock://receipts/${receiptName}` : '',
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
Log Expense
+
Task: {task.title}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const LogFeeModal = ({ isOpen, task, onClose, onSubmit }) => {
+ const { feeTypes } = useMockStore();
+ if (!isOpen) return null;
+ return
;
+};
+
+const LogFeeModalBody = ({ task, onClose, onSubmit, feeTypes }) => {
+ const [description, setDescription] = useState('');
+ const [type, setType] = useState(feeTypes[0] || 'Labour / Service Fee');
+ const [amount, setAmount] = useState('');
+
+ const submit = (e) => {
+ e.preventDefault();
+ if (!amount || Number(amount) <= 0) return;
+ onSubmit({ description, type, amount: Number(amount) });
+ };
+
+ return (
+
+
+
+
+
+
+
+
Log My Fee
+
Task: {task.title}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const Field = ({ label, children }) => (
+
+
+ {children}
+
+);
+
+const inputCls = 'w-full rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/10 px-3 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/40 transition-colors';
+
+const PhotoLightbox = ({ photos, index, onClose, onIndexChange }) => {
+ if (index === null || !photos[index]) return null;
+ const current = photos[index];
+ const hasPrev = index > 0;
+ const hasNext = index < photos.length - 1;
+
+ return createPortal(
+
+
+
+
+ {hasPrev && (
+
+ )}
+ {hasNext && (
+
+ )}
+
+
+

+ {(current.annotation || current.name) && (
+
+ {current.annotation &&
{current.annotation}
}
+ {current.name &&
{current.name} · Photo {index + 1} of {photos.length}
}
+
+ )}
+
+
,
+ document.body,
+ );
+};
+
+// ===========================================================================
+// Section heading shared primitive
+// ===========================================================================
+const SectionHeading = ({icon: Icon, title, hint, inline }) => (
+
+);
+
+export default SubcontractorTaskDetailPage;