adds sections as list tasks,log expenses. fees, notifications for sub contrcator
This commit is contained in:
@@ -31,6 +31,7 @@ import VendorDashboard from './pages/vendor/VendorDashboard';
|
||||
import VendorOrders from './pages/vendor/VendorOrders';
|
||||
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
||||
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
||||
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
|
||||
import CreateLeadPage from './pages/CreateLeadPage';
|
||||
import LeadsListPage from './pages/LeadsListPage';
|
||||
import LynkDispatchPage from './pages/LynkDispatchPage';
|
||||
@@ -357,6 +358,11 @@ function App() {
|
||||
<ProjectList />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/subcontractor/tasks/:taskId" element={
|
||||
<ProtectedRoute allowedRoles={['SUBCONTRACTOR']}>
|
||||
<SubcontractorTaskDetailPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SpotlightCard } from '../SpotlightCard';
|
||||
import {
|
||||
Bell, ClipboardList, MessageSquare, RefreshCcw, CheckCircle, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Allowed notification types for the subcontractor dashboard.
|
||||
// Keep keys in sync with mockStore notification.type values.
|
||||
const NOTIF_CONFIG = {
|
||||
task_assigned: {
|
||||
label: 'New Task Assigned',
|
||||
tone: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15',
|
||||
icon: ClipboardList,
|
||||
side: 'border-l-blue-500',
|
||||
},
|
||||
message_received: {
|
||||
label: 'Message Received',
|
||||
tone: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/15',
|
||||
icon: MessageSquare,
|
||||
side: 'border-l-emerald-500',
|
||||
},
|
||||
task_updated: {
|
||||
label: 'Task Updated',
|
||||
tone: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/15',
|
||||
icon: RefreshCcw,
|
||||
side: 'border-l-amber-500',
|
||||
},
|
||||
status_changed: {
|
||||
label: 'Status Changed',
|
||||
tone: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-500/15',
|
||||
icon: CheckCircle,
|
||||
side: 'border-l-purple-500',
|
||||
},
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = Object.keys(NOTIF_CONFIG);
|
||||
|
||||
const formatDateTime = (iso) => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const dateStr = d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
return `${dateStr} · ${timeStr}`;
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'task_assigned', label: 'New Task' },
|
||||
{ key: 'message_received', label: 'Message' },
|
||||
{ key: 'task_updated', label: 'Updates' },
|
||||
{ key: 'status_changed', label: 'Status' },
|
||||
];
|
||||
|
||||
/**
|
||||
* NotificationsPanel — global subcontractor notifications.
|
||||
*
|
||||
* Renders a SpotlightCard with tabbed filtering and a list of notification rows.
|
||||
* Each row navigates to its referenced task (if any) when clicked.
|
||||
*/
|
||||
export const NotificationsPanel = ({ notifications, onMarkAllRead, onNotificationClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState('all');
|
||||
|
||||
const allowed = useMemo(
|
||||
() => notifications.filter(n => ALLOWED_TYPES.includes(n.type)),
|
||||
[notifications],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (tab === 'all') return allowed;
|
||||
return allowed.filter(n => n.type === tab);
|
||||
}, [allowed, tab]);
|
||||
|
||||
const unreadCount = allowed.filter(n => !n.isRead).length;
|
||||
|
||||
const handleClick = (n) => {
|
||||
if (onNotificationClick) onNotificationClick(n);
|
||||
if (n.taskId) navigate(`/subcontractor/tasks/${n.taskId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SpotlightCard className="p-5 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
|
||||
<Bell size={14} />
|
||||
</div>
|
||||
<h3 className="text-sm sm:text-base font-bold text-zinc-900 dark:text-white tracking-wide">
|
||||
Notifications
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 font-mono shrink-0">
|
||||
{unreadCount} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={unreadCount === 0}
|
||||
className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 hover:underline disabled:text-zinc-400 disabled:no-underline transition-colors shrink-0"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab strip — scrollable on narrow screens */}
|
||||
<div className="mt-4 -mx-1 px-1 overflow-x-auto custom-scrollbar">
|
||||
<div className="inline-flex rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 p-1 min-w-full">
|
||||
{TABS.map(t => {
|
||||
const count = t.key === 'all' ? allowed.length : allowed.filter(n => n.type === t.key).length;
|
||||
return (
|
||||
<TabPill
|
||||
key={t.key}
|
||||
active={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
label={t.label}
|
||||
count={count}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 max-h-[36rem] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-500">
|
||||
<Bell size={20} className="mx-auto mb-2 opacity-50" />
|
||||
<p className="text-xs">You're all caught up.</p>
|
||||
</div>
|
||||
) : filtered.map(n => (
|
||||
<NotificationRow key={n.id} notification={n} onClick={() => handleClick(n)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-zinc-200 dark:border-white/10">
|
||||
<p className="text-[10px] text-zinc-500 flex items-center gap-1.5">
|
||||
<AlertCircle size={10} /> In-app only · No SMS or email unless specified.
|
||||
</p>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
};
|
||||
|
||||
const TabPill = ({ active, onClick, label, count }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold whitespace-nowrap transition-colors
|
||||
${active
|
||||
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
|
||||
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{typeof count === 'number' && (
|
||||
<span className={`text-[10px] font-mono px-1.5 rounded-full ${active ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' : 'bg-zinc-200 dark:bg-white/10 text-zinc-500'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const NotificationRow = ({ notification, onClick }) => {
|
||||
const cfg = NOTIF_CONFIG[notification.type];
|
||||
if (!cfg) return null;
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-start gap-3 p-3 rounded-xl text-left border-l-2 ${cfg.side}
|
||||
${notification.isRead
|
||||
? 'bg-zinc-50 dark:bg-white/[0.02] border-y border-r border-zinc-100 dark:border-white/5'
|
||||
: 'bg-white dark:bg-white/5 border-y border-r border-zinc-200 dark:border-white/10 shadow-sm'
|
||||
}
|
||||
hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors`}
|
||||
>
|
||||
<div className={`p-1.5 rounded-lg shrink-0 ${cfg.tone}`}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-zinc-900 dark:text-white">{cfg.label}</span>
|
||||
{!notification.isRead && <span className="w-1.5 h-1.5 rounded-full bg-blue-500" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-400 mt-0.5 line-clamp-2">{notification.message}</p>
|
||||
<p className="text-[10px] text-zinc-500 font-mono mt-1">{formatDateTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPanel;
|
||||
+634
-25
@@ -5014,10 +5014,33 @@ export const SUBCONTRACTOR_TASK_PRIORITIES = [
|
||||
export const SUBCONTRACTOR_TASK_STATUSES = [
|
||||
'Assigned',
|
||||
'In Progress',
|
||||
'On Hold',
|
||||
'Completed',
|
||||
'Cancelled',
|
||||
];
|
||||
|
||||
// Statuses the subcontractor themselves can set from the Task Detail page.
|
||||
// Owner / admin can still flip a task to Cancelled from the admin page.
|
||||
export const SUBCONTRACTOR_SELF_STATUSES = ['Assigned', 'In Progress', 'On Hold', 'Completed'];
|
||||
|
||||
export const SUBCONTRACTOR_EXPENSE_CATEGORIES = [
|
||||
'Materials',
|
||||
'Equipment Rental',
|
||||
'Travel / Fuel',
|
||||
'Permits',
|
||||
'Disposal / Dumpster',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const SUBCONTRACTOR_FEE_TYPES = [
|
||||
'Labour / Service Fee',
|
||||
'Inspection Fee',
|
||||
'Mobilization Fee',
|
||||
'Overtime',
|
||||
'Callback / Warranty',
|
||||
'Other',
|
||||
];
|
||||
|
||||
const MOCK_SUBCONTRACTORS = [
|
||||
{
|
||||
id: 'sub_001',
|
||||
@@ -5082,6 +5105,9 @@ const MOCK_SUBCONTRACTORS = [
|
||||
];
|
||||
|
||||
const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
// -------------------------------------------------------------------
|
||||
// sct_001 — Maya Patel (sub_002) — fully populated reference task
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
id: 'sct_001',
|
||||
companyId: 'lynkeduppro',
|
||||
@@ -5092,17 +5118,47 @@ const MOCK_SUBCONTRACTOR_TASKS = [
|
||||
assignedByName: 'Justin Johnson',
|
||||
title: 'Replace damaged ridge cap',
|
||||
location: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
description: 'Two ridge cap shingles missing on the south slope after wind storm. Replace with matching tile and seal.',
|
||||
description: 'Two ridge cap shingles missing on the south slope after wind storm. Replace with matching tile and seal. Inspect adjacent tiles for hairline cracks while you are up there.',
|
||||
dueDate: '2026-05-25',
|
||||
priority: 'high',
|
||||
status: 'Assigned',
|
||||
status: 'In Progress',
|
||||
photos: [
|
||||
{ id: 'sct_001_p1', name: 'Roof_Damage.jpg', url: '/assets/images/properties/Hail_Damaged_Shingles.jpg', annotation: 'Repair this corner' },
|
||||
{ id: 'sct_001_p2', name: 'Roof_Wide.jpg', url: '/assets/images/properties/Storm_Worn_Roof.jpg', annotation: 'Inspect adjacent tiles too' },
|
||||
{ id: 'sct_001_p3', name: 'Ridge_Closeup.jpg', url: '/assets/images/properties/Cracked_Storm_Shingles.jpg', annotation: 'Closeup of missing cap' },
|
||||
{ id: 'sct_001_p4', name: 'Roof_Reference.jpg', url: '/assets/images/properties/Overlapping_Roof_Shingles.jpg', annotation: '' },
|
||||
],
|
||||
statusHistory: [
|
||||
{ id: 'sct_001_h1', status: 'Assigned', actorId: 'owner_001', actorName: 'Justin Johnson', comment: 'Task created and assigned.', at: '2026-05-15T09:12:00Z' },
|
||||
{ id: 'sct_001_h2', status: 'In Progress', actorId: 'sub_002', actorName: 'Maya Patel', comment: 'On site, sourced materials.', at: '2026-05-16T08:30:00Z' },
|
||||
],
|
||||
thread: [
|
||||
{ id: 'sct_001_t1', channel: 'external', senderId: 'owner_001', senderName: 'Justin Johnson', senderRole: 'Owner', body: 'Please source them — match Owens Corning Estate Gray as best you can.', at: '2026-05-15T09:14:00Z' },
|
||||
{ id: 'sct_001_t2', channel: 'external', senderId: 'sub_002', senderName: 'Maya Patel', senderRole: 'Subcontractor', body: 'Got it. 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,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { StatCard } from '../../components/StatCard';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight } from 'lucide-react';
|
||||
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
|
||||
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
||||
import { NotificationsPanel } from '../../components/subcontractor/NotificationsPanel';
|
||||
|
||||
// --- Reusable list modal for filtered tasks / clock-in / payment history ---
|
||||
const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIns, onTaskClick }) => {
|
||||
@@ -194,11 +196,18 @@ const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIn
|
||||
|
||||
const SubContractorDashboard = () => {
|
||||
const { user } = useAuth();
|
||||
const { projects } = useMockStore();
|
||||
const { projects, subcontractorTasks, notifications, markNotificationRead } = useMockStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const subId = user?.id || 'sub_001';
|
||||
|
||||
// Aggregate Tasks
|
||||
// Notifications for the current subcontractor (across all tasks).
|
||||
const myNotifications = useMemo(
|
||||
() => notifications.filter(n => n.recipientUserId === subId),
|
||||
[notifications, subId],
|
||||
);
|
||||
|
||||
// Aggregate Tasks (legacy milestone-based for stat parity below)
|
||||
const myTasks = projects.flatMap(p =>
|
||||
p.milestones.filter(m => m.assignedTo === subId)
|
||||
.map(m => ({ ...m, projectAddress: p.address, projectId: p.id, projectStatus: p.status }))
|
||||
@@ -208,6 +217,12 @@ const SubContractorDashboard = () => {
|
||||
const pendingTasks = myTasks.filter(t => t.status === 'pending');
|
||||
const completedTasks = myTasks.filter(t => t.status === 'completed');
|
||||
|
||||
// CRM-assigned tasks (the real "My Assignments")
|
||||
const myAssignments = useMemo(
|
||||
() => subcontractorTasks.filter(t => t.subcontractorId === subId),
|
||||
[subcontractorTasks, subId],
|
||||
);
|
||||
|
||||
// Financials
|
||||
const myInvoices = projects.flatMap(p =>
|
||||
(p.invoices || []).filter(i => i.submittedBy === subId)
|
||||
@@ -308,58 +323,70 @@ const SubContractorDashboard = () => {
|
||||
</div>
|
||||
|
||||
{/* Main Task List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
<div className="lg:col-span-2">
|
||||
<SpotlightCard className="p-6 h-full">
|
||||
<SpotlightCard className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">My Assignments</h2>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">{myAssignments.length} task{myAssignments.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{myTasks.length > 0 ? myTasks.map((task, idx) => (
|
||||
<div key={idx} className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer" onClick={() => handleTaskClick(task)}>
|
||||
{myAssignments.length > 0 ? myAssignments.map(task => {
|
||||
const overdue = task.dueDate && task.dueDate < new Date().toISOString().slice(0, 10)
|
||||
&& task.status !== 'Completed' && task.status !== 'Cancelled';
|
||||
const statusTone = {
|
||||
Completed: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400',
|
||||
'In Progress': 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400',
|
||||
'On Hold': 'bg-amber-100 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400',
|
||||
Assigned: 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400',
|
||||
Cancelled: 'bg-zinc-100 text-zinc-500 dark:bg-white/10 dark:text-zinc-500',
|
||||
}[task.status] || 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400';
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/subcontractor/tasks/${task.id}`)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/subcontractor/tasks/${task.id}`); }}
|
||||
className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-lg ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400' :
|
||||
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' :
|
||||
'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4 min-w-0">
|
||||
<div className={`p-3 rounded-lg shrink-0 ${statusTone}`}>
|
||||
<CheckSquare size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-zinc-900 dark:text-white">{task.name}</h4>
|
||||
<div className="flex items-center text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
<MapPin size={12} className="mr-1" />
|
||||
{task.projectAddress}
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">{task.title}</h4>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
<span className="flex items-center gap-1"><MapPin size={12} />{task.location}</span>
|
||||
<span className="font-mono">{task.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 md:gap-4 shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-mono font-medium text-zinc-500 mb-1">Due: {task.dueDate}</p>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600' :
|
||||
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600' : 'bg-amber-100 text-amber-600'
|
||||
}`}>
|
||||
{task.status.replace('_', ' ')}
|
||||
<p className={`text-xs font-mono font-medium mb-1 flex items-center justify-end gap-1 ${overdue ? 'text-red-500 font-bold' : 'text-zinc-500'}`}>
|
||||
{overdue && <AlertCircle size={11} />}
|
||||
Due: {task.dueDate}
|
||||
</p>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${statusTone}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
{task.status !== 'completed' && (
|
||||
<button className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight size={16} className="text-zinc-400 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-10 text-zinc-500"><p>No tasks assigned.</p></div>
|
||||
);
|
||||
}) : (
|
||||
<div className="text-center py-10 text-zinc-500"><p>No tasks assigned yet.</p></div>
|
||||
)}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
|
||||
{/* Earnings Widget */}
|
||||
<div>
|
||||
{/* Right column: Earnings + Notifications */}
|
||||
<div className="space-y-6">
|
||||
<SpotlightCard className="p-6">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Tracker</h2>
|
||||
<div className="relative pt-4 pb-8">
|
||||
@@ -385,6 +412,12 @@ const SubContractorDashboard = () => {
|
||||
</button>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
<NotificationsPanel
|
||||
notifications={myNotifications}
|
||||
onMarkAllRead={() => myNotifications.forEach(n => !n.isRead && markNotificationRead(n.id))}
|
||||
onNotificationClick={(n) => { if (!n.isRead) markNotificationRead(n.id); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user