diff --git a/src/App.jsx b/src/App.jsx index ad5198d..239ed42 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'; @@ -219,6 +220,16 @@ function App() { } /> + + + + } /> + + + + } /> {/* Protected Admin Routes — Owner has full admin access */} { { 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, ]; @@ -167,6 +169,7 @@ const Layout = () => { label: ProCanvas }, { to: "/admin/estimates", icon: Calculator, label: "Estimates" }, + { to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" }, { to: "/owner/settings", icon: Settings, label: "Org Settings" }, ...commonItems, ]; 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 */} +
+ +