feat(subtasks): owner table category/assigned-date columns, owner modal stage timeline + review gate, subcontractor picker stages

This commit is contained in:
Satyam Rastogi
2026-05-29 19:44:33 +05:30
parent 1e8b72f18d
commit 9625aafd73
3 changed files with 385 additions and 30 deletions
+257 -7
View File
@@ -1,6 +1,8 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
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 = {
low: { label: 'Low', cls: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300' },
@@ -9,11 +11,25 @@ const PRIORITY_STYLES = {
};
const STATUS_STYLES = {
Assigned: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-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',
Completed: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400',
Cancelled: 'bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300',
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',
'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',
'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',
};
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) => {
@@ -22,21 +38,85 @@ const formatDate = (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 { user } = useAuth();
const { setSubcontractorTaskStatus } = useMockStore();
const [reviewNote, setReviewNote] = useState('');
const [reworkNote, setReworkNote] = useState('');
const [showReworkInput, setShowReworkInput] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
if (isOpen) window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
// Reset review state when task changes
useEffect(() => {
setReviewNote('');
setReworkNote('');
setShowReworkInput(false);
setSubmitting(false);
}, [task?.id]);
if (!isOpen || !task) return null;
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(
<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="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="flex items-center gap-3 min-w-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>
)}
{/* 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 */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-zinc-500 mb-2 flex items-center gap-1.5">