diff --git a/src/App.jsx b/src/App.jsx index 5a2f3a1..f186611 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -20,6 +20,7 @@ import DocumentManagement from './pages/owner/DocumentManagement'; import OwnerProjectList from './pages/owner/OwnerProjectList'; import OwnerProjectDetail from './pages/owner/OwnerProjectDetail'; import OrgSettings from './pages/owner/OrgSettings'; +import SubcontractorTasksPage from './pages/owner/SubcontractorTasksPage'; import PlaceholderDashboard from './pages/PlaceholderDashboard'; import ProCanvas from './pages/ProCanvas'; import ProjectList from './pages/contractor/ProjectList'; @@ -30,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'; @@ -221,6 +223,21 @@ function App() { } /> + + + + } /> + + + + } /> + + + + } /> {/* Protected Admin Routes — Owner has full admin access */} } /> + + + + } /> {/* Catch all */} diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 399ea61..0878ec3 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -5,7 +5,8 @@ import { useTheme } from '../context/ThemeContext'; import { LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare, ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase, - FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning + FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning, + HardHat } from 'lucide-react'; import PageTransition from './PageTransition'; @@ -150,6 +151,7 @@ const Layout = () => { { to: "/owner/estimates", icon: Calculator, label: "Estimates" }, { to: "/admin/schedule", icon: Calendar, label: "Team Schedule" }, { to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" }, + { to: "/owner/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" }, { to: "/owner/settings", icon: Settings, label: "Org Settings" }, ...commonItems, ]; @@ -169,7 +171,8 @@ const Layout = () => { label: ProCanvas }, { to: "/admin/estimates", icon: Calculator, label: "Estimates" }, - { to: "/owner/settings", icon: Settings, label: "Org Settings" }, + { to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" }, + { to: "/admin/settings", icon: Settings, label: "Org Settings" }, ...commonItems, ]; case 'CONTRACTOR': diff --git a/src/components/owner/SubcontractorPaymentsPanel.jsx b/src/components/owner/SubcontractorPaymentsPanel.jsx new file mode 100644 index 0000000..546b71c --- /dev/null +++ b/src/components/owner/SubcontractorPaymentsPanel.jsx @@ -0,0 +1,483 @@ +import React, { useMemo, useState, useRef, useEffect } from 'react'; +import { Wallet, Users, Search, ChevronDown, X, Check, FilterX } from 'lucide-react'; + +const CATEGORY_COLORS = { + 'Electrical': 'text-cyan-500 dark:text-cyan-400 bg-cyan-500/10 border-cyan-500/20', + 'Roofing': 'text-orange-500 dark:text-orange-400 bg-orange-500/10 border-orange-500/20', + 'Painting': 'text-purple-500 dark:text-purple-400 bg-purple-500/10 border-purple-500/20', + 'Plumbing': 'text-blue-500 dark:text-blue-400 bg-blue-500/10 border-blue-500/20', + 'Windows & Glazing': 'text-teal-500 dark:text-teal-400 bg-teal-500/10 border-teal-500/20', +}; + +const PAYMENT_STATUS_COLORS = { + 'Paid': 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20', + 'Unpaid': 'text-amber-600 dark:text-amber-400 bg-amber-500/10 border-amber-500/20', +}; + +function taskTotal(task) { + const expenses = (task.expenses || []).reduce((s, e) => s + (Number(e.amount) || 0), 0); + const fees = (task.fees || []).reduce((s, f) => s + (Number(f.amount) || 0), 0); + return expenses + fees; +} + +const fmt = (amt) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0); + +const fmtDate = (iso) => { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + } catch { + return iso; + } +}; + +// Dark-mode-aware dropdown — replaces native setNameQuery(e.target.value)} + className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-9 pr-8 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500/40" + /> + {nameQuery && ( + + )} + + + {/* Project searchable dropdown */} +
+ + {projectOpen && ( +
+
+ + setProjectQuery(e.target.value)} + className="w-full bg-zinc-50 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-lg py-1.5 pl-8 pr-2 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none" + /> +
+
+ + {filteredProjectOptions.length === 0 ? ( +

No projects match "{projectQuery}"

+ ) : ( + filteredProjectOptions.map(p => { + const isSelected = p.name === projectFilter; + return ( + + ); + }) + )} +
+
+ )} +
+ + {/* Work Category filter */} + + + {/* Payment Status filter */} + + + {hasActiveFilters && ( +
+ +
+ )} + + )} + + {rows.length === 0 ? ( +
+ +

No subcontractor payments yet.

+

Tasks with logged fees or expenses will appear here.

+
+ ) : ( +
+ + + + + + + + + + + + + + {filteredRows.length === 0 && ( + + + + )} + {filteredRows.map((row) => ( + + + + + + + + + + ))} + + + + + + + + + + + +
SubcontractorProjectWork CategoryAmount to PayRequestedPaid DatePayment Status
+ +

No payments match the current filters.

+ +
+
+
+ {row.name?.[0] || '?'} +
+
+
{row.name}
+ {row.companyName && ( +
{row.companyName}
+ )} +
+
+
+
+
+ {row.projectName} +
+ {row.projectAddress && ( +
+ {row.projectAddress} +
+ )} +
+
+ + {row.workCategory} + + + + {fmt(row.amount)} + + + + {fmtDate(row.requestedAt)} + + + + {fmtDate(row.paidAt)} + + + + {row.paymentStatus} + +
+ {hasActiveFilters ? 'Filtered Total' : 'Total Amount'} + + + {fmt(total)} + + +
+ Outstanding (Unpaid) + + + {fmt(outstanding)} + + +
+
+ )} + + ); +}; + +export default SubcontractorPaymentsPanel; diff --git a/src/components/subcontractor/AssignTaskModal.jsx b/src/components/subcontractor/AssignTaskModal.jsx new file mode 100644 index 0000000..d868e07 --- /dev/null +++ b/src/components/subcontractor/AssignTaskModal.jsx @@ -0,0 +1,476 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { toast } from 'sonner'; +import { + X, ClipboardList, MapPin, Calendar, Camera, AlertCircle, + Trash2, User, Star, ImagePlus, Loader2, +} from 'lucide-react'; +import { useMockStore } from '../../data/mockStore'; +import { useAuth } from '../../context/AuthContext'; + +const inputBase = "w-full bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"; + +const PRIORITY_STYLES = { + low: { activeBg: 'bg-zinc-900 dark:bg-white', activeText: 'text-white dark:text-black', dot: 'bg-zinc-400' }, + medium: { activeBg: 'bg-blue-600', activeText: 'text-white', dot: 'bg-blue-500' }, + high: { activeBg: 'bg-red-600', activeText: 'text-white', dot: 'bg-red-500' }, +}; + +const todayIso = () => new Date().toISOString().slice(0, 10); + +const emptyForm = { + subcontractorId: '', + title: '', + location: '', + description: '', + dueDate: '', + priority: 'medium', + photos: [], +}; + +const AssignTaskModal = ({ isOpen, onClose, task = null, defaultSubcontractorId = null }) => { + const { user } = useAuth(); + const { subcontractors, orgSettings, addSubcontractorTask, updateSubcontractorTask, taskPriorities } = useMockStore(); + const fileInputRef = useRef(null); + + const isEdit = Boolean(task); + const activeSubs = useMemo(() => subcontractors.filter(s => s.status === 'active'), [subcontractors]); + + const [form, setForm] = useState(emptyForm); + const [errors, setErrors] = useState({}); + const [submitting, setSubmitting] = useState(false); + + // Reset / hydrate when modal opens or task switches + useEffect(() => { + if (!isOpen) return; + if (task) { + setForm({ + subcontractorId: task.subcontractorId, + title: task.title, + location: task.location, + description: task.description || '', + dueDate: task.dueDate, + priority: task.priority || 'medium', + photos: (task.photos || []).map(p => ({ ...p, isExisting: true })), + }); + } else { + setForm({ + ...emptyForm, + subcontractorId: defaultSubcontractorId || '', + }); + } + setErrors({}); + setSubmitting(false); + }, [isOpen, task, defaultSubcontractorId]); + + // Escape key + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onClose(); }; + if (isOpen) window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + + // Body scroll lock + useEffect(() => { + if (!isOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, [isOpen]); + + // Clean up object URLs we created for newly-picked photos + useEffect(() => () => { + form.photos.forEach(p => { if (p.objectUrl) URL.revokeObjectURL(p.objectUrl); }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!isOpen) return null; + + const setField = (k, v) => { + setForm(prev => ({ ...prev, [k]: v })); + setErrors(prev => ({ ...prev, [k]: undefined })); + }; + + const handleFiles = (e) => { + const incoming = Array.from(e.target.files || []); + if (!incoming.length) return; + const next = incoming.map((file, i) => { + const objectUrl = URL.createObjectURL(file); + return { + id: `new_${Date.now()}_${i}`, + name: file.name, + url: objectUrl, + objectUrl, + annotation: '', + }; + }); + setForm(prev => ({ ...prev, photos: [...prev.photos, ...next] })); + // reset input so picking the same file again still triggers change + e.target.value = ''; + }; + + const removePhoto = (id) => { + setForm(prev => { + const photo = prev.photos.find(p => p.id === id); + if (photo?.objectUrl) URL.revokeObjectURL(photo.objectUrl); + return { ...prev, photos: prev.photos.filter(p => p.id !== id) }; + }); + }; + + const updatePhotoAnnotation = (id, annotation) => { + setForm(prev => ({ + ...prev, + photos: prev.photos.map(p => p.id === id ? { ...p, annotation } : p), + })); + }; + + const validate = () => { + const err = {}; + if (!form.subcontractorId) err.subcontractorId = 'Select a subcontractor.'; + if (!form.title.trim()) err.title = 'Task title is required.'; + if (!form.location.trim()) err.location = 'Location is required.'; + if (!form.dueDate) err.dueDate = 'Due date is required.'; + else if (!isEdit && form.dueDate < todayIso()) err.dueDate = 'Due date must be today or later.'; + setErrors(err); + return Object.keys(err).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!validate()) return; + + setSubmitting(true); + // Tiny delay to surface the loading state — replace with real API later. + await new Promise(r => setTimeout(r, 400)); + + const payload = { + companyId: 'lynkeduppro', + companyName: orgSettings?.orgName || 'Your Company', + assignedBy: user?.id || 'unknown', + assignedByName: user?.name || 'Unknown', + subcontractorId: form.subcontractorId, + title: form.title.trim(), + location: form.location.trim(), + description: form.description.trim(), + dueDate: form.dueDate, + priority: form.priority, + photos: form.photos.map(p => ({ + id: p.isExisting ? p.id : undefined, + name: p.name, + url: p.url, + annotation: p.annotation || '', + })), + }; + + try { + if (isEdit) { + updateSubcontractorTask(task.id, { + subcontractorId: payload.subcontractorId, + title: payload.title, + location: payload.location, + description: payload.description, + dueDate: payload.dueDate, + priority: payload.priority, + photos: payload.photos, + }); + } else { + addSubcontractorTask(payload); + } + onClose(); + } catch { + toast.error('Something went wrong. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return createPortal( +
+
+
+ {/* Header */} +
+
+
+ +
+
+

+ {isEdit ? 'Edit Task' : 'Assign Task'} +

+

+ {isEdit ? task.id : 'New subcontractor task'} +

+
+
+ +
+ + {/* Body */} +
+
+ {/* Subcontractor select */} +
+ + + {/* Preview of selected sub */} + {form.subcontractorId && (() => { + const sub = activeSubs.find(s => s.id === form.subcontractorId); + if (!sub) return null; + return ( +
+
+
+ {sub.name.charAt(0)} +
+
+
{sub.name}
+
{sub.email} · {sub.phone}
+
+
+ + + {sub.rating?.toFixed?.(1) ?? '—'} + +
+ ); + })()} + {errors.subcontractorId && } +
+ + {/* Title */} +
+ + setField('title', e.target.value)} + className={inputBase} + placeholder="e.g. Replace damaged ridge cap" + maxLength={120} + aria-invalid={Boolean(errors.title)} + /> + {errors.title && } +
+ + {/* Location */} +
+ + setField('location', e.target.value)} + className={inputBase} + placeholder="2612 Dunwick Dr, Plano, TX 75023" + aria-invalid={Boolean(errors.location)} + /> + {errors.location && } +
+ + {/* Description */} +
+ +