feat(subtasks): owner table category/assigned-date columns, owner modal stage timeline + review gate, subcontractor picker stages
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, ClipboardList, MapPin, Calendar, User, Building2, Camera } from 'lucide-react';
|
import { X, ClipboardList, MapPin, Calendar, User, Building2, Camera, Clock, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
const PRIORITY_STYLES = {
|
const PRIORITY_STYLES = {
|
||||||
low: { label: 'Low', cls: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300' },
|
low: { label: 'Low', cls: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300' },
|
||||||
@@ -10,33 +12,111 @@ const PRIORITY_STYLES = {
|
|||||||
|
|
||||||
const STATUS_STYLES = {
|
const STATUS_STYLES = {
|
||||||
Assigned: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
|
Assigned: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
|
||||||
|
'Pre-Work Inspection': 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400',
|
||||||
'In Progress': 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
|
'In Progress': 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
|
||||||
'On Hold': 'bg-slate-100 text-slate-600 dark:bg-slate-500/20 dark:text-slate-400',
|
'On Hold': 'bg-slate-100 text-slate-600 dark:bg-slate-500/20 dark:text-slate-400',
|
||||||
|
'Post-Work Review': 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400',
|
||||||
Completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400',
|
Completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400',
|
||||||
|
'Rework Needed': 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400',
|
||||||
Cancelled: 'bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300',
|
Cancelled: 'bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_DOT = {
|
||||||
|
Assigned: 'bg-amber-500',
|
||||||
|
'Pre-Work Inspection': 'bg-purple-500',
|
||||||
|
'In Progress': 'bg-blue-500',
|
||||||
|
'On Hold': 'bg-slate-500',
|
||||||
|
'Post-Work Review': 'bg-orange-500',
|
||||||
|
Completed: 'bg-emerald-500',
|
||||||
|
'Rework Needed': 'bg-red-500',
|
||||||
|
Cancelled: 'bg-zinc-500',
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
const formatDate = (iso) => {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }
|
try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }
|
||||||
catch { return iso; }
|
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 TaskViewModal = ({ isOpen, onClose, task }) => {
|
const TaskViewModal = ({ isOpen, onClose, task }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { setSubcontractorTaskStatus } = useMockStore();
|
||||||
|
|
||||||
|
const [reviewNote, setReviewNote] = useState('');
|
||||||
|
const [reworkNote, setReworkNote] = useState('');
|
||||||
|
const [showReworkInput, setShowReworkInput] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
if (isOpen) window.addEventListener('keydown', handler);
|
if (isOpen) window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Reset review state when task changes
|
||||||
|
useEffect(() => {
|
||||||
|
setReviewNote('');
|
||||||
|
setReworkNote('');
|
||||||
|
setShowReworkInput(false);
|
||||||
|
setSubmitting(false);
|
||||||
|
}, [task?.id]);
|
||||||
|
|
||||||
if (!isOpen || !task) return null;
|
if (!isOpen || !task) return null;
|
||||||
|
|
||||||
const priority = PRIORITY_STYLES[task.priority] || PRIORITY_STYLES.medium;
|
const priority = PRIORITY_STYLES[task.priority] || PRIORITY_STYLES.medium;
|
||||||
|
const isOwnerOrAdmin = ['OWNER', 'ADMIN'].includes(user?.role);
|
||||||
|
const isPostWorkReview = task.status === 'Post-Work Review';
|
||||||
|
|
||||||
|
// Find the latest "Rework Needed" history entry for the callout
|
||||||
|
const statusHistory = task.statusHistory || [];
|
||||||
|
const reworkEntry = [...statusHistory]
|
||||||
|
.reverse()
|
||||||
|
.find(h => h.status === 'Rework Needed');
|
||||||
|
const showReworkCallout = task.status === 'Rework Needed' || !!reworkEntry;
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
if (submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
const note = reviewNote.trim() || 'Approved — passed review';
|
||||||
|
setSubcontractorTaskStatus(
|
||||||
|
task.id,
|
||||||
|
'Completed',
|
||||||
|
note,
|
||||||
|
{ id: user.id, name: user.name, role: user.role },
|
||||||
|
);
|
||||||
|
setSubmitting(false);
|
||||||
|
setReviewNote('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRework = () => {
|
||||||
|
if (!reworkNote.trim() || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubcontractorTaskStatus(
|
||||||
|
task.id,
|
||||||
|
'Rework Needed',
|
||||||
|
reworkNote.trim(),
|
||||||
|
{ id: user.id, name: user.name, role: user.role },
|
||||||
|
);
|
||||||
|
setSubmitting(false);
|
||||||
|
setReworkNote('');
|
||||||
|
setShowReworkInput(false);
|
||||||
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true">
|
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true">
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
<div className="relative w-full sm:max-w-2xl h-[90dvh] sm:h-auto sm:max-h-[88vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden flex flex-col animate-in slide-in-from-bottom-10 duration-200">
|
<div className="relative w-full sm:max-w-2xl h-[90dvh] sm:h-auto sm:max-h-[88vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden flex flex-col animate-in slide-in-from-bottom-10 duration-200">
|
||||||
|
{/* Header */}
|
||||||
<div className="px-5 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex items-center justify-between bg-zinc-50/50 dark:bg-white/5">
|
<div className="px-5 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex items-center justify-between bg-zinc-50/50 dark:bg-white/5">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-500 shrink-0">
|
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-500 shrink-0">
|
||||||
@@ -84,6 +164,176 @@ const TaskViewModal = ({ isOpen, onClose, task }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rework Needed callout — prominent amber banner */}
|
||||||
|
{showReworkCallout && reworkEntry && (
|
||||||
|
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle size={14} className="text-amber-600 dark:text-amber-400 shrink-0" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||||||
|
Rework Requested
|
||||||
|
</span>
|
||||||
|
{reworkEntry.actorName && (
|
||||||
|
<span className="text-[11px] text-amber-600 dark:text-amber-400 ml-auto font-mono">
|
||||||
|
by {reworkEntry.actorName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reworkEntry.comment && (
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200 font-medium whitespace-pre-line">
|
||||||
|
{reworkEntry.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reworkEntry.photos && reworkEntry.photos.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-3">
|
||||||
|
{reworkEntry.photos.map((p) => (
|
||||||
|
<div key={p.id} className="aspect-square rounded-lg overflow-hidden border border-amber-200 dark:border-amber-500/30 bg-amber-100 dark:bg-amber-500/10">
|
||||||
|
<img src={p.url} alt={p.name || 'Rework photo'} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[11px] text-amber-500 font-mono mt-2">{formatDateTime(reworkEntry.at)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Owner review gate — shown only when status is Post-Work Review AND user is OWNER/ADMIN */}
|
||||||
|
{isPostWorkReview && isOwnerOrAdmin && (
|
||||||
|
<div className="p-4 rounded-xl bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={14} className="text-orange-600 dark:text-orange-400 shrink-0" />
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-orange-700 dark:text-orange-400">
|
||||||
|
Owner Review Required
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-orange-700 dark:text-orange-300">
|
||||||
|
This task is awaiting your review. Approve it to mark as Completed, or send it back for rework with a note explaining what needs to be fixed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Optional approval note */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-300 mb-1.5 block">
|
||||||
|
Approval note <span className="text-zinc-400 font-normal normal-case">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reviewNote}
|
||||||
|
onChange={(e) => setReviewNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="e.g. Work looks good — passed inspection."
|
||||||
|
className="w-full text-sm rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40 transition-colors disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rework note — shown after clicking Send to Rework */}
|
||||||
|
{showReworkInput && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-red-600 dark:text-red-400 mb-1.5 flex items-center gap-1.5">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full transition-colors ${reworkNote.trim() ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||||
|
Rework note <span className={`text-[10px] font-bold uppercase tracking-wider transition-colors ${reworkNote.trim() ? 'text-emerald-600' : 'text-red-500'}`}>{reworkNote.trim() ? 'Ready' : 'Required'}</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reworkNote}
|
||||||
|
onChange={(e) => setReworkNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="Describe exactly what needs to be fixed or redone…"
|
||||||
|
className="w-full text-sm rounded-lg bg-white dark:bg-zinc-900 border border-red-200 dark:border-red-500/30 px-3 py-2 outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500/40 transition-colors disabled:opacity-60"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||||
|
{showReworkInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowReworkInput(false); setReworkNote(''); }}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showReworkInput ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReworkInput(true)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-white bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20 transition-all active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none"
|
||||||
|
>
|
||||||
|
<AlertTriangle size={12} /> Send to Rework
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRework}
|
||||||
|
disabled={!reworkNote.trim() || submitting}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-white bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20 transition-all active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none"
|
||||||
|
>
|
||||||
|
<AlertTriangle size={12} /> Confirm Rework
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={submitting || showReworkInput}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider text-white bg-emerald-600 hover:bg-emerald-500 shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none"
|
||||||
|
>
|
||||||
|
<CheckCircle size={12} /> Approve — Completed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status History Timeline */}
|
||||||
|
<div className="rounded-xl bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/10 p-4">
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3 flex items-center gap-1.5">
|
||||||
|
<Clock size={11} /> Status History
|
||||||
|
</h4>
|
||||||
|
{statusHistory.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-500 italic">No status changes recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-3 max-h-72 overflow-y-auto pr-1 custom-scrollbar">
|
||||||
|
{[...statusHistory].reverse().map((h) => {
|
||||||
|
const dotCls = STATUS_DOT[h.status] || 'bg-zinc-400';
|
||||||
|
return (
|
||||||
|
<li key={h.id} className="flex items-start gap-3">
|
||||||
|
<span className={`mt-1 w-2.5 h-2.5 rounded-full ${dotCls} shrink-0`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${STATUS_STYLES[h.status] || STATUS_STYLES.Assigned}`}>
|
||||||
|
{h.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
by <span className="font-semibold text-zinc-900 dark:text-white">{h.actorName}</span>
|
||||||
|
</span>
|
||||||
|
{h.actorRole && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-zinc-500 font-bold">· {h.actorRole}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-zinc-500 font-mono mt-0.5">{formatDateTime(h.at)}</div>
|
||||||
|
{h.comment && (
|
||||||
|
<p className="text-sm text-zinc-700 dark:text-zinc-300 mt-1 whitespace-pre-line">{h.comment}</p>
|
||||||
|
)}
|
||||||
|
{h.photos && h.photos.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-2">
|
||||||
|
{h.photos.map((p) => (
|
||||||
|
<div key={p.id} className="aspect-square rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10 bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<img src={p.url} alt={p.name || 'Status photo'} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Photos */}
|
{/* Photos */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-bold uppercase tracking-wider text-zinc-500 mb-2 flex items-center gap-1.5">
|
<h4 className="text-xs font-bold uppercase tracking-wider text-zinc-500 mb-2 flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -332,11 +332,13 @@ const SubcontractorTasksPage = () => {
|
|||||||
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
<tr>
|
<tr>
|
||||||
<Th>Task</Th>
|
<Th>Task</Th>
|
||||||
|
<Th>Task Category</Th>
|
||||||
<Th>Subcontractor</Th>
|
<Th>Subcontractor</Th>
|
||||||
<Th>Location</Th>
|
<Th>Location</Th>
|
||||||
<Th>Due</Th>
|
<Th>Due</Th>
|
||||||
<Th>Priority</Th>
|
<Th>Priority</Th>
|
||||||
<Th>Status</Th>
|
<Th>Status</Th>
|
||||||
|
<Th>Assigned Date</Th>
|
||||||
<Th>Created</Th>
|
<Th>Created</Th>
|
||||||
<Th align="right">Actions</Th>
|
<Th align="right">Actions</Th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -344,7 +346,7 @@ const SubcontractorTasksPage = () => {
|
|||||||
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
{paginated.length === 0 ? (
|
{paginated.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8}>
|
<td colSpan={10}>
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -482,6 +484,15 @@ const TaskRow = ({ task, onView, onEdit, onCancel, onReassign, openMenu, setOpen
|
|||||||
<div className="text-[11px] text-zinc-500 font-mono mt-0.5">{task.id}</div>
|
<div className="text-[11px] text-zinc-500 font-mono mt-0.5">{task.id}</div>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
|
{task.category ? (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300">
|
||||||
|
{task.category}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-400 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-7 h-7 rounded-full bg-blue-500/10 text-blue-500 flex items-center justify-center text-xs font-bold shrink-0">
|
<div className="w-7 h-7 rounded-full bg-blue-500/10 text-blue-500 flex items-center justify-center text-xs font-bold shrink-0">
|
||||||
@@ -508,6 +519,7 @@ const TaskRow = ({ task, onView, onEdit, onCancel, onReassign, openMenu, setOpen
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4"><PriorityBadge priority={task.priority} /></td>
|
<td className="px-5 py-4"><PriorityBadge priority={task.priority} /></td>
|
||||||
<td className="px-5 py-4"><StatusBadge status={task.status} /></td>
|
<td className="px-5 py-4"><StatusBadge status={task.status} /></td>
|
||||||
|
<td className="px-5 py-4 whitespace-nowrap text-sm text-zinc-500">{formatDate(task.assignedDate)}</td>
|
||||||
<td className="px-5 py-4 whitespace-nowrap text-sm text-zinc-500">{formatDate(task.createdAt)}</td>
|
<td className="px-5 py-4 whitespace-nowrap text-sm text-zinc-500">{formatDate(task.createdAt)}</td>
|
||||||
<td className="px-5 py-4 text-right relative">
|
<td className="px-5 py-4 text-right relative">
|
||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1">
|
||||||
@@ -584,12 +596,24 @@ const TaskCard = ({ task, onView, onEdit, onCancel, onReassign, canAssign }) =>
|
|||||||
<span className="flex items-center gap-1"><Users size={11} />{task.subcontractorName}</span>
|
<span className="flex items-center gap-1"><Users size={11} />{task.subcontractorName}</span>
|
||||||
<span className="flex items-center gap-1"><MapPin size={11} /><span className="truncate max-w-[160px]">{task.location}</span></span>
|
<span className="flex items-center gap-1"><MapPin size={11} /><span className="truncate max-w-[160px]">{task.location}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
<PriorityBadge priority={task.priority} />
|
<PriorityBadge priority={task.priority} />
|
||||||
|
{task.category && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300">
|
||||||
|
{task.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={`flex items-center gap-1 text-[11px] font-mono ${overdue ? 'text-red-500 font-bold' : 'text-zinc-500'}`}>
|
<span className={`flex items-center gap-1 text-[11px] font-mono ${overdue ? 'text-red-500 font-bold' : 'text-zinc-500'}`}>
|
||||||
<Calendar size={11} /> Due {formatDate(task.dueDate)}{overdue ? ' · OVERDUE' : ''}
|
<Calendar size={11} /> Due {formatDate(task.dueDate)}{overdue ? ' · OVERDUE' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-zinc-500">
|
||||||
|
{task.assignedDate && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={11} /> Assigned {formatDate(task.assignedDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-3 flex flex-wrap gap-2 pt-2 border-t border-zinc-100 dark:border-white/5">
|
<div className="mt-3 flex flex-wrap gap-2 pt-2 border-t border-zinc-100 dark:border-white/5">
|
||||||
<CardActionButton onClick={onView} icon={Eye}>View</CardActionButton>
|
<CardActionButton onClick={onView} icon={Eye}>View</CardActionButton>
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ const PRIORITY_STYLES = {
|
|||||||
|
|
||||||
const STATUS_CONFIG = {
|
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' },
|
Assigned: { cls: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400', icon: Clock, dot: 'bg-amber-500' },
|
||||||
|
'Pre-Work Inspection': { cls: 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400', icon: AlertCircle, dot: 'bg-violet-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' },
|
'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' },
|
'On Hold': { cls: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400', icon: PauseCircle, dot: 'bg-orange-500' },
|
||||||
|
'Post-Work Review': { cls: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-400', icon: Send, dot: 'bg-indigo-500' },
|
||||||
|
'Rework Needed': { cls: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300', icon: AlertTriangle, dot: 'bg-amber-600' },
|
||||||
Completed: { cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400', icon: CheckCircle, dot: 'bg-emerald-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' },
|
Cancelled: { cls: 'bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300', icon: X, dot: 'bg-zinc-500' },
|
||||||
};
|
};
|
||||||
@@ -353,6 +356,13 @@ const STATUS_COPY = {
|
|||||||
photoHint: 'Site or arrival photo',
|
photoHint: 'Site or arrival photo',
|
||||||
accent: 'amber',
|
accent: 'amber',
|
||||||
},
|
},
|
||||||
|
'Pre-Work Inspection': {
|
||||||
|
title: 'Log pre-work site inspection',
|
||||||
|
hint: 'Record your site-walk before starting. Note existing conditions, scope clarifications, and attach inspection photos.',
|
||||||
|
notePlaceholder: 'e.g. Site walk done. Existing shingles in poor shape on north side — photographed. Ready to start.',
|
||||||
|
photoHint: 'Inspection / site-condition photos',
|
||||||
|
accent: 'violet',
|
||||||
|
},
|
||||||
'In Progress': {
|
'In Progress': {
|
||||||
title: 'Start work',
|
title: 'Start work',
|
||||||
hint: 'Add a brief note about what you are starting and attach any "before" photos if you have them.',
|
hint: 'Add a brief note about what you are starting and attach any "before" photos if you have them.',
|
||||||
@@ -367,6 +377,13 @@ const STATUS_COPY = {
|
|||||||
photoHint: 'Supporting photo (e.g. rain, blocked access)',
|
photoHint: 'Supporting photo (e.g. rain, blocked access)',
|
||||||
accent: 'orange',
|
accent: 'orange',
|
||||||
},
|
},
|
||||||
|
'Post-Work Review': {
|
||||||
|
title: 'Submit for owner review',
|
||||||
|
hint: 'Work is complete on your end — submit it to the owner for final review and sign-off. Attach "after" photos and a completion note.',
|
||||||
|
notePlaceholder: 'e.g. All work completed. Cleaned up site. Final photos attached — ready for owner inspection.',
|
||||||
|
photoHint: 'Completion / after photos (required for review)',
|
||||||
|
accent: 'indigo',
|
||||||
|
},
|
||||||
Completed: {
|
Completed: {
|
||||||
title: 'Mark as completed',
|
title: 'Mark as completed',
|
||||||
hint: 'Add a final completion note and attach any photos of the finished work.',
|
hint: 'Add a final completion note and attach any photos of the finished work.',
|
||||||
@@ -378,8 +395,10 @@ const STATUS_COPY = {
|
|||||||
|
|
||||||
const ACCENT_BTN = {
|
const ACCENT_BTN = {
|
||||||
amber: 'bg-amber-600 hover:bg-amber-500 shadow-amber-500/20',
|
amber: 'bg-amber-600 hover:bg-amber-500 shadow-amber-500/20',
|
||||||
|
violet: 'bg-violet-600 hover:bg-violet-500 shadow-violet-500/20',
|
||||||
blue: 'bg-blue-600 hover:bg-blue-500 shadow-blue-500/20',
|
blue: 'bg-blue-600 hover:bg-blue-500 shadow-blue-500/20',
|
||||||
orange: 'bg-orange-600 hover:bg-orange-500 shadow-orange-500/20',
|
orange: 'bg-orange-600 hover:bg-orange-500 shadow-orange-500/20',
|
||||||
|
indigo: 'bg-indigo-600 hover:bg-indigo-500 shadow-indigo-500/20',
|
||||||
emerald: 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-500/20',
|
emerald: 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-500/20',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -441,14 +460,17 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgre
|
|||||||
}, 350);
|
}, 350);
|
||||||
};
|
};
|
||||||
|
|
||||||
const canPostUpdates = ['Assigned', 'In Progress', 'On Hold', 'Completed'].includes(task.status);
|
const canPostUpdates = ['Assigned', 'Pre-Work Inspection', 'In Progress', 'On Hold', 'Post-Work Review', 'Rework Needed'].includes(task.status);
|
||||||
const updateStatusCfg = STATUS_CONFIG[task.status] || STATUS_CONFIG.Assigned;
|
const updateStatusCfg = STATUS_CONFIG[task.status] || STATUS_CONFIG.Assigned;
|
||||||
const updateBadgeCls = updateStatusCfg.cls;
|
const updateBadgeCls = updateStatusCfg.cls;
|
||||||
const UpdateIcon = updateStatusCfg.icon || Play;
|
const UpdateIcon = updateStatusCfg.icon || Play;
|
||||||
const postBtnCls = (() => {
|
const postBtnCls = (() => {
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'Assigned': return 'bg-amber-600 hover:bg-amber-500 shadow-amber-500/20';
|
case 'Assigned': return 'bg-amber-600 hover:bg-amber-500 shadow-amber-500/20';
|
||||||
|
case 'Pre-Work Inspection': return 'bg-violet-600 hover:bg-violet-500 shadow-violet-500/20';
|
||||||
case 'On Hold': return 'bg-orange-600 hover:bg-orange-500 shadow-orange-500/20';
|
case 'On Hold': return 'bg-orange-600 hover:bg-orange-500 shadow-orange-500/20';
|
||||||
|
case 'Post-Work Review': return 'bg-indigo-600 hover:bg-indigo-500 shadow-indigo-500/20';
|
||||||
|
case 'Rework Needed': return 'bg-amber-700 hover:bg-amber-600 shadow-amber-500/20';
|
||||||
case 'Completed': return 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-500/20';
|
case 'Completed': return 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-500/20';
|
||||||
default: return 'bg-blue-600 hover:bg-blue-500 shadow-blue-500/20';
|
default: return 'bg-blue-600 hover:bg-blue-500 shadow-blue-500/20';
|
||||||
}
|
}
|
||||||
@@ -456,7 +478,10 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgre
|
|||||||
const progressPlaceholder = (() => {
|
const progressPlaceholder = (() => {
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'Assigned': return 'e.g. "Scheduled for Friday, reviewed site plan."';
|
case 'Assigned': return 'e.g. "Scheduled for Friday, reviewed site plan."';
|
||||||
|
case 'Pre-Work Inspection': return 'e.g. "Noted existing damage on east wall — photographed. Materials ordered."';
|
||||||
case 'On Hold': return 'e.g. "Still waiting on materials — supplier ETA Friday."';
|
case 'On Hold': return 'e.g. "Still waiting on materials — supplier ETA Friday."';
|
||||||
|
case 'Post-Work Review': return 'e.g. "All punch items cleared. Awaiting owner walk-through confirmation."';
|
||||||
|
case 'Rework Needed': return 'e.g. "Addressing the rework items — additional materials sourced."';
|
||||||
case 'Completed': return 'e.g. "Follow-up walkthrough done, no issues noted."';
|
case 'Completed': return 'e.g. "Follow-up walkthrough done, no issues noted."';
|
||||||
default: return 'e.g. "First wall painting completed."';
|
default: return 'e.g. "First wall painting completed."';
|
||||||
}
|
}
|
||||||
@@ -464,7 +489,10 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgre
|
|||||||
const progressCopy = (() => {
|
const progressCopy = (() => {
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'Assigned': return 'Share early notes about the upcoming work — site prep, schedule changes, anything the admin should know before you start.';
|
case 'Assigned': return 'Share early notes about the upcoming work — site prep, schedule changes, anything the admin should know before you start.';
|
||||||
|
case 'Pre-Work Inspection': return 'Log any additional observations from your site inspection — existing conditions, scope questions, or equipment notes.';
|
||||||
case 'On Hold': return 'Keep the admin posted while this task is paused — share why it is still on hold or what changed.';
|
case 'On Hold': return 'Keep the admin posted while this task is paused — share why it is still on hold or what changed.';
|
||||||
|
case 'Post-Work Review': return 'Post any follow-up notes while awaiting owner review — clarifications, additional photos, or punch-list responses.';
|
||||||
|
case 'Rework Needed': return 'Share your progress addressing the rework — the owner can track your updates while you make corrections.';
|
||||||
case 'Completed': return 'Add a follow-up note after completion — punch-list items, homeowner feedback, or anything that came up after sign-off.';
|
case 'Completed': return 'Add a follow-up note after completion — punch-list items, homeowner feedback, or anything that came up after sign-off.';
|
||||||
default: return 'Keep the admin in the loop with ongoing updates while you work.';
|
default: return 'Keep the admin in the loop with ongoing updates while you work.';
|
||||||
}
|
}
|
||||||
@@ -474,6 +502,14 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgre
|
|||||||
return [...(task.activities || [])].sort((a, b) => new Date(b.at) - new Date(a.at));
|
return [...(task.activities || [])].sort((a, b) => new Date(b.at) - new Date(a.at));
|
||||||
}, [task.activities]);
|
}, [task.activities]);
|
||||||
|
|
||||||
|
// Latest rework feedback from owner — shown when status is 'Rework Needed'
|
||||||
|
const reworkFeedback = useMemo(() => {
|
||||||
|
if (task.status !== 'Rework Needed') return null;
|
||||||
|
const history = task.statusHistory || [];
|
||||||
|
// Find the most recent 'Rework Needed' entry (reverse iterate for latest-first)
|
||||||
|
return [...history].reverse().find(h => h.status === 'Rework Needed') || null;
|
||||||
|
}, [task.status, task.statusHistory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpotlightCard className="p-5 sm:p-6">
|
<SpotlightCard className="p-5 sm:p-6">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
@@ -482,7 +518,45 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgre
|
|||||||
hint="Move this task through the workflow. Add a note describing the change, and attach photos if you have them."
|
hint="Move this task through the workflow. Add a note describing the change, and attach photos if you have them."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 sm:gap-3 mt-4">
|
{/* Rework callout — only shown when status is 'Rework Needed' */}
|
||||||
|
{reworkFeedback && (
|
||||||
|
<div className="mt-4 p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/40 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle size={14} className="text-amber-600 dark:text-amber-400 shrink-0" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||||||
|
Rework Requested
|
||||||
|
</span>
|
||||||
|
{reworkFeedback.actorName && (
|
||||||
|
<span className="text-[11px] text-amber-700/80 dark:text-amber-400/80 ml-auto font-semibold">
|
||||||
|
by {reworkFeedback.actorName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reworkFeedback.comment ? (
|
||||||
|
<p className="text-sm text-amber-900 dark:text-amber-200 whitespace-pre-line">
|
||||||
|
{reworkFeedback.comment}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm italic text-amber-700/70 dark:text-amber-400/70">
|
||||||
|
No specific feedback provided — contact the owner for details.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reworkFeedback.photos && reworkFeedback.photos.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-2">
|
||||||
|
{reworkFeedback.photos.map((p) => (
|
||||||
|
<div key={p.id} className="aspect-square rounded-lg overflow-hidden border border-amber-200 dark:border-amber-500/30 bg-amber-100 dark:bg-amber-500/10">
|
||||||
|
<img src={p.url} alt={p.name || 'Rework photo'} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-amber-700/70 dark:text-amber-400/60 pt-1">
|
||||||
|
Address the feedback above, then set status to <strong>In Progress</strong> and later <strong>Post-Work Review</strong> to resubmit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 sm:gap-3 mt-4">
|
||||||
{statuses.map(s => {
|
{statuses.map(s => {
|
||||||
const cfg = STATUS_CONFIG[s] || STATUS_CONFIG.Assigned;
|
const cfg = STATUS_CONFIG[s] || STATUS_CONFIG.Assigned;
|
||||||
const Icon = cfg.icon;
|
const Icon = cfg.icon;
|
||||||
@@ -494,7 +568,7 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgre
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePick(s)}
|
onClick={() => handlePick(s)}
|
||||||
disabled={disabled || isSubmittingStatus}
|
disabled={disabled || isSubmittingStatus}
|
||||||
className={`group relative flex items-center justify-center gap-2 px-3 py-3 rounded-xl text-sm font-bold border transition-all
|
className={`group relative flex items-center justify-center gap-2 px-3 py-3 rounded-xl text-sm font-bold border transition-all flex-1 min-w-[calc(50%-0.375rem)] sm:min-w-[calc(33%-0.375rem)] md:min-w-0
|
||||||
${isCurrent
|
${isCurrent
|
||||||
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400 shadow-lg shadow-blue-500/10'
|
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400 shadow-lg shadow-blue-500/10'
|
||||||
: isPending
|
: isPending
|
||||||
@@ -504,7 +578,14 @@ const StatusManagementCard = ({ task, statuses, disabled, onChange, onPostProgre
|
|||||||
disabled:opacity-40 disabled:cursor-not-allowed`}
|
disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
<span>{s}</span>
|
<span className="text-center leading-tight">
|
||||||
|
{s}
|
||||||
|
{s === 'Post-Work Review' && (
|
||||||
|
<span className="block text-[10px] font-normal normal-case opacity-70 leading-tight mt-0.5">
|
||||||
|
Submit for owner review
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{isCurrent && <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />}
|
{isCurrent && <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user