fix(kanban): responsive layout, drag-drop offset, and project view enhancements
Kanban pipeline board: - Fix h-full flex-col layout so board fills viewport height on all screen sizes - KanbanColumn drop zone: flex-1 min-h-0 so it expands to fill full column height - KanbanCard: extract KanbanCardDisplay as named export for DragOverlay rendering above overflow containers - KanbanPage: lift DndContext to page root; add "Add Stage" shortcut to page header - PageTransition: remove slide-in-from-bottom-4 transform — the CSS transform ancestor was creating a containing block for position:fixed DragOverlay, causing drag ghost to render at a viewport offset - Migrate fireCampaignToast to toast.custom() Sonner v2 API with explicit card styling Project & lead views: - LeadProjectPage: enrich project detail with richer info sections aligned with project stage - OwnerProjectDetail: expand construction project detail with additional data panels - OwnerProjectList: project list view improvements - mockStore: extend lead/project data to support richer project detail rendering
This commit is contained in:
@@ -13,7 +13,7 @@ const PageTransition = ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={location.pathname}
|
key={location.pathname}
|
||||||
className="h-full animate-in fade-in slide-in-from-bottom-4 duration-500 ease-out fill-mode-forwards"
|
className="h-full animate-in fade-in duration-300 ease-out"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,32 +29,22 @@ function avatarColor(name) {
|
|||||||
return AVATAR_COLORS[idx];
|
return AVATAR_COLORS[idx];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KanbanCard({ lead, columnColor, onClick, isDragOverlay }) {
|
// ── Pure visual display — no dnd bindings. Used both inside KanbanCard and
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
// inside DragOverlay (so the dragged clone renders above overflow containers).
|
||||||
id: lead.id,
|
export function KanbanCardDisplay({ lead, columnColor, isOverlay = false, onClick, forwardRef, dragProps = {} }) {
|
||||||
data: { lead },
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
opacity: isDragging ? 0.35 : 1,
|
|
||||||
zIndex: isDragging ? 999 : 'auto',
|
|
||||||
};
|
|
||||||
|
|
||||||
const days = getDaysInStage(lead.activity);
|
const days = getDaysInStage(lead.activity);
|
||||||
const initials = getInitials(lead.name);
|
const initials = getInitials(lead.name);
|
||||||
const avatarCls = avatarColor(lead.name);
|
const avatarCls = avatarColor(lead.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={isDragOverlay ? undefined : setNodeRef}
|
ref={forwardRef}
|
||||||
style={isDragOverlay ? undefined : style}
|
{...dragProps}
|
||||||
{...(isDragOverlay ? {} : { ...attributes, ...listeners })}
|
onClick={onClick}
|
||||||
onClick={isDragOverlay ? undefined : onClick}
|
className={`group relative bg-white dark:bg-zinc-900 border rounded-xl select-none transition-[border-color,box-shadow] ${
|
||||||
className={`group relative bg-white dark:bg-zinc-900 border rounded-xl cursor-grab active:cursor-grabbing transition-all select-none
|
isOverlay
|
||||||
${isDragOverlay
|
? 'border-blue-400 dark:border-blue-500 rotate-1 shadow-[0_20px_40px_rgba(0,0,0,0.35)] cursor-grabbing scale-[1.03]'
|
||||||
? 'shadow-2xl border-zinc-200 dark:border-white/20 rotate-1 scale-[1.02]'
|
: 'border-zinc-200 dark:border-white/[0.07] cursor-grab active:cursor-grabbing hover:shadow-md dark:hover:shadow-black/30 hover:border-zinc-300 dark:hover:border-white/[0.14]'
|
||||||
: 'border-zinc-200 dark:border-white/[0.07] hover:shadow-md dark:hover:shadow-black/30 hover:border-zinc-300 dark:hover:border-white/[0.14]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Left color accent bar */}
|
{/* Left color accent bar */}
|
||||||
@@ -112,3 +102,24 @@ export default function KanbanCard({ lead, columnColor, onClick, isDragOverlay }
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Draggable wrapper — ghost stays frozen in place (no transform applied),
|
||||||
|
// DragOverlay handles all visual movement above overflow containers.
|
||||||
|
export default function KanbanCard({ lead, columnColor, onClick }) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: lead.id,
|
||||||
|
data: { lead, columnColor },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ opacity: isDragging ? 0.3 : 1 }}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={isDragging ? undefined : onClick}
|
||||||
|
>
|
||||||
|
<KanbanCardDisplay lead={lead} columnColor={columnColor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Plus, Settings, Trash2, Pencil } from 'lucide-react';
|
import { Trash2, Pencil } from 'lucide-react';
|
||||||
import KanbanCard from './KanbanCard';
|
import KanbanCard from './KanbanCard';
|
||||||
|
|
||||||
export default function KanbanColumn({
|
export default function KanbanColumn({
|
||||||
@@ -16,9 +16,9 @@ export default function KanbanColumn({
|
|||||||
const isBucket = column.type === 'bucket';
|
const isBucket = column.type === 'bucket';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col shrink-0 w-[272px]">
|
<div className="flex flex-col shrink-0 w-[272px] h-full">
|
||||||
{/* Column header */}
|
{/* Column header */}
|
||||||
<div className="mb-3">
|
<div className="shrink-0 mb-3">
|
||||||
<div className="flex items-center justify-between gap-2 px-1">
|
<div className="flex items-center justify-between gap-2 px-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: column.color }} />
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: column.color }} />
|
||||||
@@ -72,16 +72,17 @@ export default function KanbanColumn({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop zone */}
|
{/* Drop zone — flex-1 + min-h-0 so it fills remaining column height */}
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={`group flex-1 min-h-[120px] rounded-2xl p-2 transition-all duration-150 ${
|
className={`group flex-1 min-h-0 rounded-2xl transition-all duration-150 ${
|
||||||
isOver
|
isOver
|
||||||
? 'bg-blue-50/80 dark:bg-blue-500/10 ring-2 ring-inset ring-blue-400/40'
|
? 'bg-blue-50/80 dark:bg-blue-500/10 ring-2 ring-inset ring-blue-400/40'
|
||||||
: 'bg-zinc-100/60 dark:bg-zinc-800/30'
|
: 'bg-zinc-100/60 dark:bg-zinc-800/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
{/* Inner scroll container — cards scroll within the column */}
|
||||||
|
<div className="flex flex-col gap-2 p-2 h-full overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent">
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{leads.map((lead, i) => (
|
{leads.map((lead, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -109,15 +110,6 @@ export default function KanbanColumn({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add column button after last stage column */}
|
|
||||||
{isLast && canManage && (
|
|
||||||
<button
|
|
||||||
onClick={onAddColumn}
|
|
||||||
className="mt-3 flex items-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:border-zinc-400 dark:hover:border-zinc-500 text-xs font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={12} /> Add Stage
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4319,6 +4319,528 @@ const KANBAN_LEADS_INITIAL = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Enriched project data for signed/in-progress/complete leads ──────────────
|
||||||
|
// Keyed by lead ID. Only present for leads that have become active jobs.
|
||||||
|
const KANBAN_PROJECT_DATA = {
|
||||||
|
// ── SIGNED LEADS ──────────────────────────────────────────────────────────
|
||||||
|
kl_023: {
|
||||||
|
estimatedAmount: 18200, actualCost: 0, variancePercent: 0, completionPct: 25, healthScore: 88,
|
||||||
|
contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 9800, committed: 9800, actual: 0 },
|
||||||
|
{ category: 'Labor', allocated: 6200, committed: 6200, actual: 0 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 0 },
|
||||||
|
{ category: 'Contingency',allocated: 1600, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-22', notes: 'Deposit received.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-03-24', notes: 'GAF Timberline HDZ — Charcoal ordered.' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'in_progress',date: '2026-04-05', notes: 'City permit submitted, pending approval.' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 30%', amount: 5460, status: 'paid', dueDate: '2026-03-22', paidDate: '2026-03-22' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 40%', amount: 7280, status: 'pending', dueDate: '2026-04-14', paidDate: null },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 5460, status: 'pending', dueDate: '2026-04-15', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-03-22', action: 'Contract Executed', user: 'Felicity Fast', details: 'Client signed. 30% deposit collected.' },
|
||||||
|
{ date: '2026-03-24', action: 'Materials Ordered', user: 'Adam Admin', details: 'GAF Timberline HDZ Charcoal — 24 squares ordered.' },
|
||||||
|
{ date: '2026-04-05', action: 'Permit Submitted', user: 'Adam Admin', details: 'Plano city permit application filed.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_024: {
|
||||||
|
estimatedAmount: 31500, actualCost: 0, variancePercent: 0, completionPct: 20, healthScore: 85,
|
||||||
|
contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 17000, committed: 17000, actual: 0 },
|
||||||
|
{ category: 'Labor', allocated: 11500, committed: 11500, actual: 0 },
|
||||||
|
{ category: 'Permits', allocated: 800, committed: 800, actual: 0 },
|
||||||
|
{ category: 'Contingency',allocated: 2200, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-20', notes: 'Insurance approved full scope.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered',status: 'completed', date: '2026-03-23', notes: 'James Hardie Board & Batten ordered.' },
|
||||||
|
{ id: 'm3', name: 'Permit Pulled', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm4', name: 'Prep & Demo', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm5', name: 'Siding Install', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm6', name: 'Trim & Caulk', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm8', name: 'Invoice & Payment',status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [
|
||||||
|
{ id: 'rfi1', subject: 'HOA Color Approval', status: 'open', submittedBy: 'Fiona Field', openedDate: '2026-03-25', closedDate: null, description: 'Need written HOA approval for Cobblestone color before install.' },
|
||||||
|
],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 25%', amount: 7875, status: 'paid', dueDate: '2026-03-20', paidDate: '2026-03-20' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 50%', amount: 15750,status: 'pending', dueDate: '2026-04-25', paidDate: null },
|
||||||
|
{ id: 'inv3', description: 'Final — 25%', amount: 7875, status: 'pending', dueDate: '2026-05-01', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-03-20', action: 'Contract Executed', user: 'Fiona Field', details: 'Insurance-approved scope. Full 2,400 sq ft siding.' },
|
||||||
|
{ date: '2026-03-23', action: 'Materials Ordered', user: 'Adam Admin', details: 'James Hardie Board & Batten — Cobblestone, 2,400 sq ft.' },
|
||||||
|
{ date: '2026-03-25', action: 'RFI Opened', user: 'Fiona Field', details: 'HOA color approval required before start.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_025: {
|
||||||
|
estimatedAmount: 27800, actualCost: 0, variancePercent: 0, completionPct: 28, healthScore: 92,
|
||||||
|
contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 15200, committed: 15200, actual: 0 },
|
||||||
|
{ category: 'Labor', allocated: 9800, committed: 9800, actual: 0 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 0 },
|
||||||
|
{ category: 'Contingency',allocated: 2200, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-11', notes: '50% deposit received.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-03-12', notes: 'Owens Corning TruDefinition — Driftwood.' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-03-20', notes: 'Permit approved.' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 50%', amount: 13900, status: 'paid', dueDate: '2026-03-11', paidDate: '2026-03-11' },
|
||||||
|
{ id: 'inv2', description: 'Final — 50%', amount: 13900, status: 'pending', dueDate: '2026-04-20', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-03-11', action: 'Contract Executed', user: 'Frank Agent', details: 'Cash client. 50% deposit paid same day.' },
|
||||||
|
{ date: '2026-03-12', action: 'Materials Ordered', user: 'Adam Admin', details: 'Owens Corning TruDefinition Duration — 28 squares.' },
|
||||||
|
{ date: '2026-03-20', action: 'Permit Approved', user: 'Adam Admin', details: 'City of Plano residential roofing permit #RFP-2026-1148.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_026: {
|
||||||
|
estimatedAmount: 22400, actualCost: 0, variancePercent: 0, completionPct: 22, healthScore: 86,
|
||||||
|
contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Roofing Materials', allocated: 10800, committed: 10800, actual: 0 },
|
||||||
|
{ category: 'Gutter Materials', allocated: 3400, committed: 3400, actual: 0 },
|
||||||
|
{ category: 'Labor', allocated: 6400, committed: 6400, actual: 0 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 0 },
|
||||||
|
{ category: 'Contingency', allocated: 1200, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-25', notes: 'Install Apr 18.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-03-27', notes: 'Roofing + gutters ordered.' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm4', name: 'Roofing Install', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm5', name: 'Gutter Install', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 30%', amount: 6720, status: 'paid', dueDate: '2026-03-25', paidDate: '2026-03-25' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 40%', amount: 8960, status: 'pending', dueDate: '2026-04-18', paidDate: null },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 6720, status: 'pending', dueDate: '2026-04-22', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-03-25', action: 'Contract Executed', user: 'Felix Fixer', details: 'Insurance approved full roof + gutter scope.' },
|
||||||
|
{ date: '2026-03-27', action: 'Materials Ordered', user: 'Adam Admin', details: 'GAF shingles + K-style 6" gutters ordered.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_027: {
|
||||||
|
estimatedAmount: 19600, actualCost: 0, variancePercent: 0, completionPct: 24, healthScore: 80,
|
||||||
|
contractorName: 'Plano Roofing Crew C', contractorPhone: '(972) 555-9004',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 10500, committed: 10500, actual: 0 },
|
||||||
|
{ category: 'Labor', allocated: 7000, committed: 7000, actual: 0 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 0 },
|
||||||
|
{ category: 'Contingency',allocated: 1500, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-28', notes: 'Supplement approved after adjuster re-inspection.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'in_progress',date: '2026-04-02', notes: 'Waiting on special color availability.' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking',status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [
|
||||||
|
{ id: 'co1', title: 'Supplemental — Ice & Water Barrier', amount: 1800, status: 'approved', requestedBy: 'Felicity Fast', date: '2026-03-10', description: 'Insurance adjuster agreed to add ice/water shield on all valleys and eaves.' },
|
||||||
|
],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 30%', amount: 5880, status: 'paid', dueDate: '2026-03-28', paidDate: '2026-03-28' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 40%', amount: 7840, status: 'pending', dueDate: '2026-04-25', paidDate: null },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 5880, status: 'pending', dueDate: '2026-04-28', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-03-28', action: 'Contract Executed', user: 'Felicity Fast', details: 'Signed after 6-week negotiation on supplement items.' },
|
||||||
|
{ date: '2026-04-02', action: 'Materials Ordered', user: 'Adam Admin', details: 'Special color availability causing 1-week delay.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── IN PROGRESS LEADS ─────────────────────────────────────────────────────
|
||||||
|
kl_028: {
|
||||||
|
estimatedAmount: 16800, actualCost: 9200, variancePercent: -3.6, completionPct: 65, healthScore: 82,
|
||||||
|
contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 9000, committed: 9000, actual: 8600 },
|
||||||
|
{ category: 'Labor', allocated: 5800, committed: 5800, actual: 600 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 600 },
|
||||||
|
{ category: 'Contingency',allocated: 1400, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-28', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-03-02', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-03-10', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'completed', date: '2026-03-18', notes: 'Tear-off complete. Two rotted deck boards replaced.' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'in_progress',date: '2026-04-07', notes: 'New decking being installed today.' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [
|
||||||
|
{ id: 'co1', title: 'Decking Replacement — 2 Sheets', amount: 480, status: 'approved', requestedBy: 'Frank Agent', date: '2026-03-18', description: 'Two rotted OSB sheets discovered during tear-off. Replaced at cost.' },
|
||||||
|
],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 30%', amount: 5040, status: 'paid', dueDate: '2026-02-28', paidDate: '2026-02-28' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 40%', amount: 6720, status: 'paid', dueDate: '2026-03-20', paidDate: '2026-03-20' },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 5040, status: 'pending', dueDate: '2026-04-10', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-02-28', action: 'Contract Executed', user: 'Frank Agent', details: 'Signed. Production team notified.' },
|
||||||
|
{ date: '2026-03-18', action: 'Production Started', user: 'Adam Admin', details: 'Tear-off crew on site. Two rotted deck boards found and replaced.' },
|
||||||
|
{ date: '2026-03-20', action: 'Progress Invoice Paid', user: 'Adam Admin', details: 'Client paid progress invoice on time.' },
|
||||||
|
{ date: '2026-04-07', action: 'Decking Install', user: 'Adam Admin', details: 'New decking going on today. Roofing shingles start tomorrow.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_029: {
|
||||||
|
estimatedAmount: 28500, actualCost: 14800, variancePercent: 1.4, completionPct: 55, healthScore: 78,
|
||||||
|
contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 15400, committed: 15400, actual: 14200 },
|
||||||
|
{ category: 'Labor', allocated: 10400, committed: 10400, actual: 600 },
|
||||||
|
{ category: 'Permits', allocated: 800, committed: 800, actual: 800 },
|
||||||
|
{ category: 'Contingency', allocated: 1900, committed: 400, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-25', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered',status: 'completed', date: '2026-02-27', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permit Pulled', status: 'completed', date: '2026-03-05', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Prep & Demo', status: 'completed', date: '2026-03-25', notes: 'Old siding removed. Minor rot on 2 panels fixed.' },
|
||||||
|
{ id: 'm5', name: 'Siding Install', status: 'in_progress',date: '2026-04-05', notes: 'Day 3 of install. On track.' },
|
||||||
|
{ id: 'm6', name: 'Trim & Caulk', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm8', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [
|
||||||
|
{ id: 'co1', title: 'Rot Repair — 2 Panels', amount: 380, status: 'approved', requestedBy: 'Fiona Field', date: '2026-03-25', description: 'Two panels of rotted OSB substrate discovered during demo. Repaired.' },
|
||||||
|
],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 25%', amount: 7125, status: 'paid', dueDate: '2026-02-25', paidDate: '2026-02-25' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 50%', amount: 14250,status: 'pending', dueDate: '2026-04-08', paidDate: null },
|
||||||
|
{ id: 'inv3', description: 'Final — 25%', amount: 7125, status: 'pending', dueDate: '2026-04-15', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-02-25', action: 'Contract Executed', user: 'Fiona Field', details: 'Insurance approved full scope.' },
|
||||||
|
{ date: '2026-03-25', action: 'Production Started', user: 'Adam Admin', details: 'Demo complete. Minor rot on 2 panels — change order raised.' },
|
||||||
|
{ date: '2026-04-05', action: 'Install Underway', user: 'Adam Admin', details: 'Siding install Day 3. On track for Friday completion.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_030: {
|
||||||
|
estimatedAmount: 21200, actualCost: 16100, variancePercent: -4.2, completionPct: 78, healthScore: 88,
|
||||||
|
contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Roofing Materials', allocated: 10200, committed: 10200, actual: 9800 },
|
||||||
|
{ category: 'Gutter Materials', allocated: 3200, committed: 3200, actual: 3100 },
|
||||||
|
{ category: 'Labor', allocated: 6000, committed: 6000, actual: 3200 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 600 },
|
||||||
|
{ category: 'Contingency', allocated: 1200, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-24', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-02-26', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-03-05', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking',status: 'completed', date: '2026-03-20', notes: 'Clean tear-off. No deck issues.' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'completed', date: '2026-03-22', notes: 'Roofing complete.' },
|
||||||
|
{ id: 'm6', name: 'Gutter Install', status: 'in_progress',date: '2026-04-07', notes: 'Gutter crew arriving today.' },
|
||||||
|
{ id: 'm7', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm8', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 30%', amount: 6360, status: 'paid', dueDate: '2026-02-24', paidDate: '2026-02-24' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 40%', amount: 8480, status: 'paid', dueDate: '2026-03-22', paidDate: '2026-03-22' },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 6360, status: 'pending', dueDate: '2026-04-10', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-02-24', action: 'Contract Executed', user: 'Felicity Fast', details: '' },
|
||||||
|
{ date: '2026-03-20', action: 'Roofing Started', user: 'Amanda Manager', details: 'Production underway — roofing crew Day 1.' },
|
||||||
|
{ date: '2026-03-22', action: 'Roofing Complete', user: 'Amanda Manager', details: 'Roof done. Progress invoice sent.' },
|
||||||
|
{ date: '2026-04-07', action: 'Gutter Install', user: 'Amanda Manager', details: 'Gutter crew arriving today for 1-day install.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_031: {
|
||||||
|
estimatedAmount: 34500, actualCost: 22000, variancePercent: 2.9, completionPct: 60, healthScore: 90,
|
||||||
|
contractorName: 'Plano Roofing Crew C', contractorPhone: '(972) 555-9004',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Premium Materials', allocated: 19200, committed: 19200, actual: 18800 },
|
||||||
|
{ category: 'Labor', allocated: 12000, committed: 12000, actual: 3200 },
|
||||||
|
{ category: 'Permits', allocated: 700, committed: 700, actual: 700 },
|
||||||
|
{ category: 'Contingency', allocated: 2600, committed: 1000, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-03', notes: 'Cash client. Barkwood color selected.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-03-05', notes: 'Special order — GAF Timberline HDZ Barkwood. 3-week lead time.' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-03-12', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'completed', date: '2026-03-28', notes: 'Premium deck prep complete. No issues.' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'in_progress',date: '2026-03-28', notes: 'Premium HDZ install underway.' },
|
||||||
|
{ id: 'm6', name: 'Ridge & Detail', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm8', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [
|
||||||
|
{ id: 'rfi1', subject: 'Lifetime Warranty Registration', status: 'open', submittedBy: 'Felix Fixer', openedDate: '2026-03-05', closedDate: null, description: 'GAF factory warranty registration requires installer certification — need to confirm with crew.' },
|
||||||
|
],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 100%', amount: 34500, status: 'paid', dueDate: '2026-03-03', paidDate: '2026-03-03' },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-03-03', action: 'Contract Executed', user: 'Felix Fixer', details: 'Cash client — full payment upfront.' },
|
||||||
|
{ date: '2026-03-28', action: 'Production Started', user: 'Amanda Manager',details: 'Materials arrived. Premium install started.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_032: {
|
||||||
|
estimatedAmount: 9400, actualCost: 6200, variancePercent: -1.1, completionPct: 70, healthScore: 84,
|
||||||
|
contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 4800, committed: 4800, actual: 4600 },
|
||||||
|
{ category: 'Labor', allocated: 3600, committed: 3600, actual: 1600 },
|
||||||
|
{ category: 'Permits', allocated: 500, committed: 500, actual: 500 },
|
||||||
|
{ category: 'Contingency',allocated: 500, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-03-07', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered',status: 'completed', date: '2026-03-09', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permit Pulled', status: 'completed', date: '2026-03-20', notes: '' },
|
||||||
|
{ id: 'm4', name: 'South Face', status: 'completed', date: '2026-04-01', notes: 'South face complete.' },
|
||||||
|
{ id: 'm5', name: 'West Face', status: 'in_progress',date: '2026-04-06', notes: '2 days remaining.' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment',status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 40%', amount: 3760, status: 'paid', dueDate: '2026-03-07', paidDate: '2026-03-07' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 30%', amount: 2820, status: 'paid', dueDate: '2026-04-01', paidDate: '2026-04-02' },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 2820, status: 'pending', dueDate: '2026-04-10', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-03-07', action: 'Contract Executed', user: 'Frank Agent', details: 'Partial repair — south and west faces only.' },
|
||||||
|
{ date: '2026-04-01', action: 'South Face Done', user: 'Adam Admin', details: 'South face siding complete. Starting west face.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_033: {
|
||||||
|
estimatedAmount: 42000, actualCost: 18000, variancePercent: 0.8, completionPct: 45, healthScore: 74,
|
||||||
|
contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 22000, committed: 22000, actual: 17200 },
|
||||||
|
{ category: 'Labor', allocated: 15500, committed: 15500, actual: 800 },
|
||||||
|
{ category: 'Permits', allocated: 900, committed: 900, actual: 900 },
|
||||||
|
{ category: 'Contingency', allocated: 3600, committed: 2000, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-15', notes: 'Largest job this quarter.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-02-18', notes: '3 pallets — 36 squares.' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-03-01', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'in_progress',date: '2026-04-05', notes: 'Day 1 of 3. Two crews deployed.' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'pending', date: null, notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'pending', date: null, notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [
|
||||||
|
{ id: 'rfi1', subject: 'Structural Load — Chimney Flashing', status: 'open', submittedBy: 'Fiona Field', openedDate: '2026-04-05', closedDate: null, description: 'Large chimney requires custom step flashing — need architect sign-off for load specs.' },
|
||||||
|
],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 25%', amount: 10500, status: 'paid', dueDate: '2026-02-15', paidDate: '2026-02-15' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 50%', amount: 21000, status: 'pending', dueDate: '2026-04-08', paidDate: null },
|
||||||
|
{ id: 'inv3', description: 'Final — 25%', amount: 10500, status: 'pending', dueDate: '2026-04-15', paidDate: null },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-02-15', action: 'Contract Executed', user: 'Fiona Field', details: 'Largest job this quarter — $42,000, 3,600 sq ft.' },
|
||||||
|
{ date: '2026-04-05', action: 'Production Started', user: 'Adam Admin', details: 'Day 1 of 3-day job. Two crews on site.' },
|
||||||
|
{ date: '2026-04-05', action: 'RFI Opened', user: 'Fiona Field', details: 'Custom chimney flashing — architect sign-off required.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── COMPLETE LEADS ────────────────────────────────────────────────────────
|
||||||
|
kl_034: {
|
||||||
|
estimatedAmount: 17200, actualCost: 16950, variancePercent: -1.5, completionPct: 100, healthScore: 95,
|
||||||
|
contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 9200, committed: 9200, actual: 8980 },
|
||||||
|
{ category: 'Labor', allocated: 6000, committed: 6000, actual: 6000 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 600 },
|
||||||
|
{ category: 'Contingency', allocated: 1400, committed: 400, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-01', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-02-03', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-02-12', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'completed', date: '2026-02-20', notes: 'Clean tear-off.' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'completed', date: '2026-02-21', notes: 'Install complete in 1 day.' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'completed', date: '2026-02-28', notes: 'Passed. Client gave 5-star review.' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'completed', date: '2026-03-01', notes: 'Full payment received.' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 30%', amount: 5160, status: 'paid', dueDate: '2026-02-01', paidDate: '2026-02-01' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 40%', amount: 6880, status: 'paid', dueDate: '2026-02-21', paidDate: '2026-02-22' },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 5160, status: 'paid', dueDate: '2026-03-01', paidDate: '2026-03-01' },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-02-01', action: 'Contract Executed', user: 'Frank Agent', details: '' },
|
||||||
|
{ date: '2026-02-20', action: 'Production Started', user: 'Adam Admin', details: 'Tear-off crew on site.' },
|
||||||
|
{ date: '2026-02-21', action: 'Install Complete', user: 'Adam Admin', details: 'Full install done in 1 day.' },
|
||||||
|
{ date: '2026-02-28', action: 'Final Inspection', user: 'Adam Admin', details: 'Passed. Client extremely happy. 5-star review.' },
|
||||||
|
{ date: '2026-03-01', action: 'Project Closed', user: 'Adam Admin', details: 'Final payment received. File closed.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_035: {
|
||||||
|
estimatedAmount: 26400, actualCost: 26100, variancePercent: -1.1, completionPct: 100, healthScore: 93,
|
||||||
|
contractorName: 'Plano Siding Crew', contractorPhone: '(972) 555-9002',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 14200, committed: 14200, actual: 13900 },
|
||||||
|
{ category: 'Labor', allocated: 9600, committed: 9600, actual: 9600 },
|
||||||
|
{ category: 'Permits', allocated: 800, committed: 800, actual: 800 },
|
||||||
|
{ category: 'Contingency', allocated: 1800, committed: 200, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-01-28', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered',status: 'completed', date: '2026-01-30', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permit Pulled', status: 'completed', date: '2026-02-05', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Prep & Demo', status: 'completed', date: '2026-02-15', notes: 'Old siding removed cleanly.' },
|
||||||
|
{ id: 'm5', name: 'Siding Install', status: 'completed', date: '2026-02-19', notes: '' },
|
||||||
|
{ id: 'm6', name: 'Trim & Caulk', status: 'completed', date: '2026-02-20', notes: '' },
|
||||||
|
{ id: 'm7', name: 'Final Inspection', status: 'completed', date: '2026-02-21', notes: 'Insurance proceeds released.' },
|
||||||
|
{ id: 'm8', name: 'Invoice & Payment',status: 'completed', date: '2026-02-22', notes: 'Full payment received.' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 25%', amount: 6600, status: 'paid', dueDate: '2026-01-28', paidDate: '2026-01-28' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 50%', amount: 13200,status: 'paid', dueDate: '2026-02-19', paidDate: '2026-02-19' },
|
||||||
|
{ id: 'inv3', description: 'Final — 25%', amount: 6600, status: 'paid', dueDate: '2026-02-22', paidDate: '2026-02-22' },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-01-28', action: 'Contract Executed', user: 'Felicity Fast', details: '' },
|
||||||
|
{ date: '2026-02-15', action: 'Production Started', user: 'Amanda Manager', details: 'Demo and install started same day.' },
|
||||||
|
{ date: '2026-02-22', action: 'Project Closed', user: 'Amanda Manager', details: 'Insurance check received and deposited. File closed.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_036: {
|
||||||
|
estimatedAmount: 22100, actualCost: 21800, variancePercent: -1.4, completionPct: 100, healthScore: 97,
|
||||||
|
contractorName: 'Plano Roofing Crew C', contractorPhone: '(972) 555-9004',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 12000, committed: 12000, actual: 11800 },
|
||||||
|
{ category: 'Labor', allocated: 7800, committed: 7800, actual: 7800 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 600 },
|
||||||
|
{ category: 'Contingency', allocated: 1700, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-01-30', notes: 'Full payment upfront.' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-01-31', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-02-07', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'completed', date: '2026-02-18', notes: 'Clean. No deck issues.' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'completed', date: '2026-02-19', notes: '2-day install complete.' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'completed', date: '2026-02-20', notes: 'Outstanding quality noted.' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'completed', date: '2026-01-30', notes: 'Pre-paid.' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Full Payment — 100%', amount: 22100, status: 'paid', dueDate: '2026-01-30', paidDate: '2026-01-30' },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-01-30', action: 'Contract Executed', user: 'Fiona Field', details: 'Cash client. Full payment upfront.' },
|
||||||
|
{ date: '2026-02-18', action: 'Production Started', user: 'Adam Admin', details: 'Tear-off started.' },
|
||||||
|
{ date: '2026-02-20', action: 'Project Closed', user: 'Adam Admin', details: 'Outstanding quality. Referral bonus owed per agreement.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_037: {
|
||||||
|
estimatedAmount: 8900, actualCost: 8650, variancePercent: -2.8, completionPct: 100, healthScore: 96,
|
||||||
|
contractorName: 'Plano Roofing Crew A', contractorPhone: '(972) 555-9001',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Gutter Materials', allocated: 3800, committed: 3800, actual: 3600 },
|
||||||
|
{ category: 'Fascia Materials', allocated: 1800, committed: 1800, actual: 1800 },
|
||||||
|
{ category: 'Labor', allocated: 2600, committed: 2600, actual: 2600 },
|
||||||
|
{ category: 'Permits', allocated: 400, committed: 400, actual: 400 },
|
||||||
|
{ category: 'Contingency', allocated: 300, committed: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-10', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-02-12', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Fascia Repair', status: 'completed', date: '2026-02-28', notes: 'Fascia replaced on all 4 sides.' },
|
||||||
|
{ id: 'm4', name: 'Gutter Install', status: 'completed', date: '2026-03-01', notes: '1-day install.' },
|
||||||
|
{ id: 'm5', name: 'Final Walkthrough', status: 'completed', date: '2026-03-02', notes: 'Customer gave 3 referral cards.' },
|
||||||
|
{ id: 'm6', name: 'Invoice & Payment', status: 'completed', date: '2026-03-02', notes: '' },
|
||||||
|
],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 50%', amount: 4450, status: 'paid', dueDate: '2026-02-10', paidDate: '2026-02-10' },
|
||||||
|
{ id: 'inv2', description: 'Final — 50%', amount: 4450, status: 'paid', dueDate: '2026-03-02', paidDate: '2026-03-02' },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-02-10', action: 'Contract Executed', user: 'Felix Fixer', details: '' },
|
||||||
|
{ date: '2026-02-28', action: 'Production Started', user: 'Adam Admin', details: 'Fascia repair started.' },
|
||||||
|
{ date: '2026-03-02', action: 'Project Closed', user: 'Adam Admin', details: 'Complete. Customer gave referral cards to 3 neighbors.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kl_038: {
|
||||||
|
estimatedAmount: 19400, actualCost: 20100, variancePercent: 3.6, completionPct: 100, healthScore: 87,
|
||||||
|
contractorName: 'Plano Roofing Crew B', contractorPhone: '(972) 555-9003',
|
||||||
|
budgetBreakdown: [
|
||||||
|
{ category: 'Materials', allocated: 10400, committed: 10400, actual: 11200 },
|
||||||
|
{ category: 'Labor', allocated: 6800, committed: 6800, actual: 6800 },
|
||||||
|
{ category: 'Permits', allocated: 600, committed: 600, actual: 600 },
|
||||||
|
{ category: 'Contingency', allocated: 1600, committed: 1600, actual: 0 },
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{ id: 'm1', name: 'Contract Signed', status: 'completed', date: '2026-02-04', notes: '' },
|
||||||
|
{ id: 'm2', name: 'Materials Ordered', status: 'completed', date: '2026-02-06', notes: '' },
|
||||||
|
{ id: 'm3', name: 'Permits Pulled', status: 'completed', date: '2026-02-15', notes: '' },
|
||||||
|
{ id: 'm4', name: 'Tear-off & Decking', status: 'completed', date: '2026-03-10', notes: '3 rotted deck sheets replaced — CO raised.' },
|
||||||
|
{ id: 'm5', name: 'Roofing Install', status: 'completed', date: '2026-03-12', notes: '' },
|
||||||
|
{ id: 'm6', name: 'Final Inspection', status: 'completed', date: '2026-03-18', notes: '' },
|
||||||
|
{ id: 'm7', name: 'Invoice & Payment', status: 'completed', date: '2026-03-20', notes: 'Complex insurance supplemental took 2 extra weeks.' },
|
||||||
|
],
|
||||||
|
changeOrders: [
|
||||||
|
{ id: 'co1', title: 'Decking Replacement — 3 Sheets', amount: 720, status: 'approved', requestedBy: 'Felicity Fast', date: '2026-03-10', description: 'Three rotted OSB sheets discovered during tear-off. Replaced.' },
|
||||||
|
],
|
||||||
|
rfis: [],
|
||||||
|
invoices: [
|
||||||
|
{ id: 'inv1', description: 'Deposit — 30%', amount: 5820, status: 'paid', dueDate: '2026-02-04', paidDate: '2026-02-04' },
|
||||||
|
{ id: 'inv2', description: 'Progress — 40%', amount: 7760, status: 'paid', dueDate: '2026-03-12', paidDate: '2026-03-14' },
|
||||||
|
{ id: 'inv3', description: 'Final — 30%', amount: 5820, status: 'paid', dueDate: '2026-03-20', paidDate: '2026-03-20' },
|
||||||
|
],
|
||||||
|
workTimeline: [
|
||||||
|
{ date: '2026-02-04', action: 'Contract Executed', user: 'Felicity Fast', details: '' },
|
||||||
|
{ date: '2026-03-10', action: 'Production Started', user: 'Adam Admin', details: 'Tear-off found 3 rotted deck sheets. CO raised.' },
|
||||||
|
{ date: '2026-03-12', action: 'Install Complete', user: 'Adam Admin', details: 'Roofing complete.' },
|
||||||
|
{ date: '2026-03-20', action: 'Project Closed', user: 'Adam Admin', details: 'Longest close — 7 weeks due to complex insurance supplemental.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// --- CONTEXT SETUP ---
|
// --- CONTEXT SETUP ---
|
||||||
|
|
||||||
const MockStoreContext = createContext();
|
const MockStoreContext = createContext();
|
||||||
@@ -4501,6 +5023,7 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
updateKanbanLead: (id, data) => {
|
updateKanbanLead: (id, data) => {
|
||||||
setKanbanLeads(prev => prev.map(l => l.id === id ? { ...l, ...data } : l));
|
setKanbanLeads(prev => prev.map(l => l.id === id ? { ...l, ...data } : l));
|
||||||
},
|
},
|
||||||
|
kanbanProjectData: KANBAN_PROJECT_DATA,
|
||||||
|
|
||||||
// Estimates
|
// Estimates
|
||||||
estimates,
|
estimates,
|
||||||
|
|||||||
+56
-43
@@ -1,16 +1,16 @@
|
|||||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
DndContext, DragOverlay, PointerSensor, TouchSensor,
|
DndContext, DragOverlay, PointerSensor, TouchSensor,
|
||||||
useSensor, useSensors, closestCorners
|
useSensor, useSensors, pointerWithin, rectIntersection
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { useAuth, ROLES } from '../context/AuthContext';
|
import { useAuth, ROLES } from '../context/AuthContext';
|
||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { LayoutGrid, Search, Filter, X, Send, Pencil } from 'lucide-react';
|
import { LayoutGrid, Search, Filter, X, Send, Pencil, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import KanbanCard from '../components/kanban/KanbanCard';
|
import KanbanCard, { KanbanCardDisplay } from '../components/kanban/KanbanCard';
|
||||||
import KanbanColumn from '../components/kanban/KanbanColumn';
|
import KanbanColumn from '../components/kanban/KanbanColumn';
|
||||||
import LeadInfoDrawer from '../components/kanban/LeadInfoDrawer';
|
import LeadInfoDrawer from '../components/kanban/LeadInfoDrawer';
|
||||||
import ColumnManagerModal from '../components/kanban/ColumnManagerModal';
|
import ColumnManagerModal from '../components/kanban/ColumnManagerModal';
|
||||||
@@ -20,18 +20,18 @@ function fireCampaignToast(lead, column, onEdit) {
|
|||||||
if (!column?.campaignMessage) return;
|
if (!column?.campaignMessage) return;
|
||||||
const msg = column.campaignMessage.replace('{name}', lead.name.split(' ')[0]);
|
const msg = column.campaignMessage.replace('{name}', lead.name.split(' ')[0]);
|
||||||
|
|
||||||
toast(
|
// Sonner v2: toast.custom(fn) where fn receives the toast ID directly (string)
|
||||||
({ id }) => {
|
toast.custom((toastId) => {
|
||||||
const [secs, setSecs] = React.useState(20);
|
const [secs, setSecs] = React.useState(20);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const t = setInterval(() => setSecs(s => {
|
const t = setInterval(() => setSecs(s => {
|
||||||
if (s <= 1) { clearInterval(t); toast.dismiss(id); return 0; }
|
if (s <= 1) { clearInterval(t); toast.dismiss(toastId); return 0; }
|
||||||
return s - 1;
|
return s - 1;
|
||||||
}), 1000);
|
}), 1000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-xl p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Send size={13} className="text-blue-500 shrink-0" />
|
<Send size={13} className="text-blue-500 shrink-0" />
|
||||||
@@ -42,13 +42,13 @@ function fireCampaignToast(lead, column, onEdit) {
|
|||||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">{msg}</p>
|
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">{msg}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { toast.dismiss(id); onEdit(msg, lead, column); }}
|
onClick={() => { toast.dismiss(toastId); onEdit(msg, lead, column); }}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-[11px] font-semibold hover:bg-blue-700 transition-colors"
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-[11px] font-semibold hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Pencil size={10} /> Edit
|
<Pencil size={10} /> Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { toast.dismiss(id); toast.success('Campaign message sent'); }}
|
onClick={() => { toast.dismiss(toastId); toast.success('Campaign message sent'); }}
|
||||||
className="px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 text-[11px] font-semibold hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
className="px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 text-[11px] font-semibold hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||||
>
|
>
|
||||||
Send Now
|
Send Now
|
||||||
@@ -56,9 +56,7 @@ function fireCampaignToast(lead, column, onEdit) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}, { duration: 20000, position: 'bottom-right' });
|
||||||
{ duration: 20000, position: 'bottom-right' }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campaign Edit Modal
|
// Campaign Edit Modal
|
||||||
@@ -110,7 +108,6 @@ export default function KanbanPage() {
|
|||||||
const canManage = user?.role === ROLES.OWNER || user?.role === ROLES.ADMIN;
|
const canManage = user?.role === ROLES.OWNER || user?.role === ROLES.ADMIN;
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [draggingLead, setDraggingLead] = useState(null);
|
|
||||||
const [selectedLead, setSelectedLead] = useState(null);
|
const [selectedLead, setSelectedLead] = useState(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
@@ -120,6 +117,9 @@ export default function KanbanPage() {
|
|||||||
// Campaign edit modal
|
// Campaign edit modal
|
||||||
const [campaignEdit, setCampaignEdit] = useState(null); // { message, lead, column }
|
const [campaignEdit, setCampaignEdit] = useState(null); // { message, lead, column }
|
||||||
|
|
||||||
|
// Active drag — tracked so DragOverlay can render the floating clone
|
||||||
|
const [draggingLead, setDraggingLead] = useState(null); // { lead, columnColor }
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||||
@@ -156,7 +156,9 @@ export default function KanbanPage() {
|
|||||||
|
|
||||||
const handleDragStart = ({ active }) => {
|
const handleDragStart = ({ active }) => {
|
||||||
const lead = kanbanLeads.find(l => l.id === active.id);
|
const lead = kanbanLeads.find(l => l.id === active.id);
|
||||||
setDraggingLead(lead ?? null);
|
if (!lead) return;
|
||||||
|
const col = kanbanColumns.find(c => c.id === lead.columnId);
|
||||||
|
setDraggingLead({ lead, columnColor: col?.color ?? '#6366f1' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = ({ active, over }) => {
|
const handleDragEnd = ({ active, over }) => {
|
||||||
@@ -217,11 +219,17 @@ export default function KanbanPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={(args) => pointerWithin(args).length > 0 ? pointerWithin(args) : rectIntersection(args)}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className={`h-full flex flex-col bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div className="px-4 sm:px-6 py-5 border-b border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-950 sticky top-0 z-20">
|
<div className="shrink-0 px-4 sm:px-6 py-4 border-b border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-950 z-20">
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<div className="w-8 h-8 rounded-xl bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-xl bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center">
|
||||||
<LayoutGrid size={16} className="text-blue-600 dark:text-blue-400" />
|
<LayoutGrid size={16} className="text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -230,12 +238,22 @@ export default function KanbanPage() {
|
|||||||
style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||||
Pipeline
|
Pipeline
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-400 mt-0.5">{kanbanLeads.length} leads across {stageColumns.length} stages</p>
|
<p className="text-xs text-zinc-400 mt-0.5">{kanbanLeads.length} leads · {stageColumns.length} stages</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Stage — header shortcut for owners/admins */}
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
onClick={handleAddColumn}
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:border-zinc-400 dark:hover:border-zinc-500 text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} /> Add Stage
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
<div className="relative flex-1 min-w-[180px] max-w-xs ml-auto">
|
||||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
@@ -252,15 +270,9 @@ export default function KanbanPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Board */}
|
{/* Board — fills remaining height, horizontal scroll only */}
|
||||||
<div className="overflow-x-auto pb-8">
|
<div className="flex-1 overflow-x-auto overflow-y-hidden min-h-0">
|
||||||
<DndContext
|
<div className="flex gap-4 px-4 sm:px-6 py-5 min-w-max h-full">
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCorners}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div className="flex gap-4 px-4 sm:px-6 pt-5 min-w-max">
|
|
||||||
{/* Stage columns */}
|
{/* Stage columns */}
|
||||||
{stageColumns.map((col, i) => (
|
{stageColumns.map((col, i) => (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
@@ -278,8 +290,8 @@ export default function KanbanPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="flex flex-col gap-2 shrink-0 self-start pt-1">
|
<div className="shrink-0 self-stretch flex items-stretch px-2">
|
||||||
<div className="w-px h-full min-h-[200px] bg-zinc-200 dark:bg-zinc-800 mx-2" />
|
<div className="w-px bg-zinc-200 dark:bg-zinc-800" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bucket columns */}
|
{/* Bucket columns */}
|
||||||
@@ -298,18 +310,6 @@ export default function KanbanPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag overlay */}
|
|
||||||
<DragOverlay dropAnimation={null}>
|
|
||||||
{draggingLead ? (
|
|
||||||
<KanbanCard
|
|
||||||
lead={draggingLead}
|
|
||||||
columnColor={kanbanColumns.find(c => c.id === draggingLead.columnId)?.color ?? '#3B82F6'}
|
|
||||||
isDragOverlay
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lead info drawer */}
|
{/* Lead info drawer */}
|
||||||
@@ -343,5 +343,18 @@ export default function KanbanPage() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* DragOverlay — dnd-kit already portals this to document.body internally,
|
||||||
|
so position:fixed is always viewport-relative regardless of ancestors. */}
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{draggingLead ? (
|
||||||
|
<KanbanCardDisplay
|
||||||
|
lead={draggingLead.lead}
|
||||||
|
columnColor={draggingLead.columnColor}
|
||||||
|
isOverlay
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+960
-246
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
|
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
|
||||||
FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone,
|
FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone,
|
||||||
Shield, ChevronRight, Phone, MessageSquare, Mail, AlertOctagon, TrendingUp, Key, Zap, Camera, Plus, Map, User, Crosshair, Image as ImageIcon, MapPin
|
Shield, ChevronRight, Phone, MessageSquare, Mail, AlertOctagon, TrendingUp, Key, Zap, Camera, Plus, Map, User, Crosshair, Image as ImageIcon, MapPin,
|
||||||
|
Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
||||||
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||||
@@ -68,8 +69,42 @@ const tabs = [
|
|||||||
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
||||||
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
||||||
{ id: 'activity', label: 'Activity', icon: Clock },
|
{ id: 'activity', label: 'Activity', icon: Clock },
|
||||||
|
{ id: 'docs', label: 'Docs', icon: FolderOpen },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Doc helpers ───────────────────────────────────────────────────────────────
|
||||||
|
const DOC_TYPE_CONFIG = {
|
||||||
|
pdf: { color: '#ef4444', bg: 'rgba(239,68,68,0.15)', icon: FileText, label: 'PDF' },
|
||||||
|
docx: { color: '#3b82f6', bg: 'rgba(59,130,246,0.15)', icon: FileText, label: 'DOCX' },
|
||||||
|
doc: { color: '#3b82f6', bg: 'rgba(59,130,246,0.15)', icon: FileText, label: 'DOC' },
|
||||||
|
md: { color: '#a855f7', bg: 'rgba(168,85,247,0.15)', icon: File, label: 'MD' },
|
||||||
|
txt: { color: '#6b7280', bg: 'rgba(107,114,128,0.15)',icon: File, label: 'TXT' },
|
||||||
|
jpg: { color: '#10b981', bg: 'rgba(16,185,129,0.15)', icon: ImageIcon, label: 'JPG' },
|
||||||
|
jpeg: { color: '#10b981', bg: 'rgba(16,185,129,0.15)', icon: ImageIcon, label: 'JPG' },
|
||||||
|
png: { color: '#06b6d4', bg: 'rgba(6,182,212,0.15)', icon: ImageIcon, label: 'PNG' },
|
||||||
|
webp: { color: '#06b6d4', bg: 'rgba(6,182,212,0.15)', icon: ImageIcon, label: 'IMG' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDocType(filename) {
|
||||||
|
const ext = (filename || '').split('.').pop().toLowerCase();
|
||||||
|
return DOC_TYPE_CONFIG[ext] || { color: '#9ca3af', bg: 'rgba(156,163,175,0.15)', icon: File, label: ext.toUpperCase() || 'FILE' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedDocsMock(projectId) {
|
||||||
|
const all = [
|
||||||
|
{ id: 'doc_1', name: 'Insurance Claim Form.pdf', uploadedBy: 'Sarah Owner', uploadedDate: '2026-01-15', size: '245 KB', notes: 'Original claim submitted. Claim #TX-9920-881.' },
|
||||||
|
{ id: 'doc_2', name: 'Roof Inspection Report.pdf', uploadedBy: 'Frank Agent', uploadedDate: '2026-01-18', size: '1.2 MB', notes: 'Hail damage confirmed on north face. See page 7.' },
|
||||||
|
{ id: 'doc_3', name: 'Adjuster Notes.docx', uploadedBy: 'Sarah Owner', uploadedDate: '2026-01-22', size: '38 KB', notes: '' },
|
||||||
|
{ id: 'doc_4', name: 'Signed Contract.pdf', uploadedBy: 'Frank Agent', uploadedDate: '2026-02-01', size: '512 KB', notes: 'Fully executed. Watch payment schedule on page 4.' },
|
||||||
|
{ id: 'doc_5', name: 'Before Photo - Front.jpg', uploadedBy: 'Frank Agent', uploadedDate: '2026-02-03', size: '3.1 MB', notes: 'Taken before work started.' },
|
||||||
|
{ id: 'doc_6', name: 'Material Spec Sheet.md', uploadedBy: 'Sarah Owner', uploadedDate: '2026-02-05', size: '12 KB', notes: 'Owens Corning Duration — Class 4 IR.' },
|
||||||
|
{ id: 'doc_7', name: 'Site Sketch.png', uploadedBy: 'Frank Agent', uploadedDate: '2026-02-08', size: '890 KB', notes: '' },
|
||||||
|
{ id: 'doc_8', name: 'Work Order.txt', uploadedBy: 'Sarah Owner', uploadedDate: '2026-02-10', size: '4 KB', notes: 'Internal work order #WO-2026-044.' },
|
||||||
|
];
|
||||||
|
const seed = projectId.charCodeAt(projectId.length - 1) % 4;
|
||||||
|
return all.slice(0, 4 + seed);
|
||||||
|
}
|
||||||
|
|
||||||
const OwnerProjectDetail = () => {
|
const OwnerProjectDetail = () => {
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -80,6 +115,46 @@ const OwnerProjectDetail = () => {
|
|||||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||||
const [createModalConfig, setCreateModalConfig] = useState(null);
|
const [createModalConfig, setCreateModalConfig] = useState(null);
|
||||||
|
|
||||||
|
// ── Docs state ────────────────────────────────────────────────────────────
|
||||||
|
const [docs, setDocs] = useState(() => seedDocsMock(projectId));
|
||||||
|
const [viewDoc, setViewDoc] = useState(null); // doc object being viewed
|
||||||
|
const [editNoteDoc, setEditNoteDoc] = useState(null); // { doc, noteText }
|
||||||
|
const [deleteDocId, setDeleteDocId] = useState(null); // id to confirm delete
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [uploadForm, setUploadForm] = useState({ name: '', type: 'pdf', notes: '' });
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleDocUpload = () => {
|
||||||
|
if (!uploadForm.name.trim()) return;
|
||||||
|
const newDoc = {
|
||||||
|
id: `doc_${Date.now()}`,
|
||||||
|
name: uploadForm.name.trim().includes('.') ? uploadForm.name.trim() : `${uploadForm.name.trim()}.${uploadForm.type}`,
|
||||||
|
uploadedBy: user?.name || 'Owner',
|
||||||
|
uploadedDate: '2026-04-08',
|
||||||
|
size: '—',
|
||||||
|
notes: uploadForm.notes.trim(),
|
||||||
|
};
|
||||||
|
setDocs(prev => [newDoc, ...prev]);
|
||||||
|
setUploadOpen(false);
|
||||||
|
setUploadForm({ name: '', type: 'pdf', notes: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) setUploadForm(prev => ({ ...prev, name: file.name }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNote = () => {
|
||||||
|
if (!editNoteDoc) return;
|
||||||
|
setDocs(prev => prev.map(d => d.id === editNoteDoc.doc.id ? { ...d, notes: editNoteDoc.noteText } : d));
|
||||||
|
setEditNoteDoc(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDoc = () => {
|
||||||
|
setDocs(prev => prev.filter(d => d.id !== deleteDocId));
|
||||||
|
setDeleteDocId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateSubmit = (data) => {
|
const handleCreateSubmit = (data) => {
|
||||||
console.log("Mock submitted data:", data);
|
console.log("Mock submitted data:", data);
|
||||||
alert("Action successful! (Mock interface)");
|
alert("Action successful! (Mock interface)");
|
||||||
@@ -921,6 +996,110 @@ const OwnerProjectDetail = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ACTIVITY TAB */}
|
{/* ACTIVITY TAB */}
|
||||||
|
{/* ── DOCS TAB ── */}
|
||||||
|
{activeTab === 'docs' && (
|
||||||
|
<NeoCard spotlightColor="rgba(59,130,246,0.1)">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FolderOpen className={NEON_BLUE} size={20} />
|
||||||
|
<h3 className="text-lg font-bold text-white tracking-widest uppercase">Project Documents</h3>
|
||||||
|
<span className="text-xs font-bold bg-white/5 border border-white/5 px-2 py-0.5 rounded-full text-zinc-400">
|
||||||
|
{docs.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={14} /> Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<FolderOpen size={48} className="mx-auto mb-4 text-zinc-700" />
|
||||||
|
<p className="font-bold text-lg text-white">No documents yet</p>
|
||||||
|
<p className="text-sm text-zinc-500 mt-1">Upload contracts, photos, inspection reports, and more.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{docs.map(doc => {
|
||||||
|
const cfg = getDocType(doc.name);
|
||||||
|
const DocIcon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex items-start gap-4 px-4 py-4 rounded-2xl bg-white/[0.03] border border-white/[0.06] hover:bg-white/[0.07] transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Type icon */}
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 mt-0.5"
|
||||||
|
style={{ backgroundColor: cfg.bg }}
|
||||||
|
>
|
||||||
|
<DocIcon size={18} style={{ color: cfg.color }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-semibold text-white text-sm truncate">{doc.name}</p>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-bold px-1.5 py-0.5 rounded-md uppercase"
|
||||||
|
style={{ backgroundColor: cfg.bg, color: cfg.color }}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-600 mt-0.5">
|
||||||
|
{doc.size} · Uploaded by {doc.uploadedBy} · {doc.uploadedDate}
|
||||||
|
</p>
|
||||||
|
{doc.notes ? (
|
||||||
|
<p className="text-xs text-zinc-500 mt-1.5 flex items-start gap-1">
|
||||||
|
<StickyNote size={11} className="text-amber-500/60 shrink-0 mt-px" />
|
||||||
|
<span className="line-clamp-2">{doc.notes}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditNoteDoc({ doc, noteText: '' })}
|
||||||
|
className="text-xs text-zinc-700 hover:text-zinc-400 mt-1 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add note
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewDoc(doc)}
|
||||||
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-[#00f0ff] transition-colors"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditNoteDoc({ doc, noteText: doc.notes || '' })}
|
||||||
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-amber-400 transition-colors"
|
||||||
|
title="Edit note"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteDocId(doc.id)}
|
||||||
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-red-500/20 text-zinc-400 hover:text-red-400 transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</NeoCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'activity' && (
|
{activeTab === 'activity' && (
|
||||||
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)">
|
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)">
|
||||||
<div className="flex items-center gap-3 mb-8">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
@@ -979,6 +1158,208 @@ const OwnerProjectDetail = () => {
|
|||||||
{...createModalConfig}
|
{...createModalConfig}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* ── Upload Modal ── */}
|
||||||
|
{uploadOpen && (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-md p-6"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-base">Upload Document</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">Supports PDF, DOCX, MD, TXT, JPG, PNG, WEBP</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setUploadOpen(false)} className="p-1.5 rounded-lg text-zinc-500 hover:bg-white/10 transition-colors">
|
||||||
|
<XIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed border-white/10 hover:border-blue-500/50 rounded-2xl p-8 text-center cursor-pointer transition-colors mb-4 bg-white/[0.02] hover:bg-blue-500/5"
|
||||||
|
>
|
||||||
|
<Upload size={28} className="mx-auto mb-3 text-zinc-600" />
|
||||||
|
<p className="text-sm font-semibold text-zinc-400">Click to select a file</p>
|
||||||
|
<p className="text-xs text-zinc-600 mt-1">or drag and drop here</p>
|
||||||
|
{uploadForm.name && (
|
||||||
|
<p className="text-xs font-bold text-blue-400 mt-2">{uploadForm.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileSelect}
|
||||||
|
accept=".pdf,.docx,.doc,.md,.txt,.jpg,.jpeg,.png,.webp" />
|
||||||
|
|
||||||
|
{/* Manual filename if no file selected */}
|
||||||
|
{!uploadForm.name && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Or type filename (e.g. Contract.pdf)"
|
||||||
|
value={uploadForm.name}
|
||||||
|
onChange={e => setUploadForm(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-black/40 border border-white/10 text-zinc-200 text-sm placeholder-zinc-600 outline-none focus:ring-2 focus:ring-blue-500/30 mb-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<textarea
|
||||||
|
value={uploadForm.notes}
|
||||||
|
onChange={e => setUploadForm(prev => ({ ...prev, notes: e.target.value }))}
|
||||||
|
placeholder="Notes about this document (optional)..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-black/40 border border-white/10 text-zinc-200 text-sm placeholder-zinc-600 outline-none focus:ring-2 focus:ring-blue-500/30 resize-none mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setUploadOpen(false)}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleDocUpload}
|
||||||
|
disabled={!uploadForm.name.trim()}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold transition-colors">
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── View Doc Modal ── */}
|
||||||
|
{viewDoc && (() => {
|
||||||
|
const cfg = getDocType(viewDoc.name);
|
||||||
|
const DocIcon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={() => setViewDoc(null)}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-md p-6"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: cfg.bg }}>
|
||||||
|
<DocIcon size={18} style={{ color: cfg.color }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-sm">{viewDoc.name}</h3>
|
||||||
|
<p className="text-xs text-zinc-500">{viewDoc.size} · {viewDoc.uploadedDate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setViewDoc(null)} className="p-1.5 rounded-lg text-zinc-500 hover:bg-white/10 transition-colors">
|
||||||
|
<XIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-black/40 rounded-2xl border border-white/5 p-8 flex flex-col items-center justify-center mb-4 min-h-[160px]">
|
||||||
|
<DocIcon size={48} style={{ color: cfg.color }} className="mb-3" />
|
||||||
|
<p className="text-sm text-zinc-400 font-semibold">{viewDoc.name}</p>
|
||||||
|
<p className="text-xs text-zinc-600 mt-1">Uploaded by {viewDoc.uploadedBy}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewDoc.notes && (
|
||||||
|
<div className="bg-amber-500/5 border border-amber-500/10 rounded-xl px-4 py-3 mb-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<StickyNote size={11} className="text-amber-500/60" />
|
||||||
|
<p className="text-[10px] font-bold text-amber-500/70 uppercase tracking-widest">Note</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-300">{viewDoc.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setViewDoc(null)}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { alert('Download simulated — file stored in backend.'); }}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={14} /> Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ── Edit Note Modal ── */}
|
||||||
|
{editNoteDoc && (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={() => setEditNoteDoc(null)}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-md p-6"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-base">Edit Note</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-xs">{editNoteDoc.doc.name}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setEditNoteDoc(null)} className="p-1.5 rounded-lg text-zinc-500 hover:bg-white/10 transition-colors">
|
||||||
|
<XIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={editNoteDoc.noteText}
|
||||||
|
onChange={e => setEditNoteDoc(prev => ({ ...prev, noteText: e.target.value }))}
|
||||||
|
placeholder="Add a note about this document..."
|
||||||
|
rows={4}
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-black/40 border border-white/10 text-zinc-200 text-sm placeholder-zinc-600 outline-none focus:ring-2 focus:ring-amber-500/30 resize-none mb-4"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setEditNoteDoc(null)}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSaveNote}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-600 text-black text-sm font-bold transition-colors">
|
||||||
|
Save Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Delete Confirm ── */}
|
||||||
|
{deleteDocId && (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={() => setDeleteDocId(null)}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-sm p-6"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-red-500/15 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Trash2 size={22} className="text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-white text-base text-center">Delete Document?</h3>
|
||||||
|
<p className="text-sm text-zinc-500 text-center mt-1 mb-5">
|
||||||
|
{docs.find(d => d.id === deleteDocId)?.name} will be permanently removed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setDeleteDocId(null)}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleDeleteDoc}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-bold transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useAuth } from '../../context/AuthContext';
|
|||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
import {
|
import {
|
||||||
Search, Filter, ChevronRight, ArrowUpDown, Briefcase,
|
Search, ChevronRight, ArrowUpDown, Briefcase, LayoutGrid,
|
||||||
CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert
|
CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, User
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
@@ -18,15 +18,51 @@ const statusConfig = {
|
|||||||
|
|
||||||
const phaseOrder = ['Planning', 'Foundation', 'Structural', 'MEP', 'Finishing', 'Handover'];
|
const phaseOrder = ['Planning', 'Foundation', 'Structural', 'MEP', 'Finishing', 'Handover'];
|
||||||
|
|
||||||
|
// ---------- Pipeline lead helpers ----------
|
||||||
|
const PIPELINE_STATUS_CONFIG = {
|
||||||
|
active: { label: 'Active', color: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10', icon: Clock },
|
||||||
|
completed: { label: 'Complete', color: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10', icon: CheckCircle },
|
||||||
|
on_hold: { label: 'Stuck', color: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-500/10', icon: PauseCircle },
|
||||||
|
delayed: { label: 'Follow-up', color: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10', icon: AlertTriangle },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPipelineStatus(lead) {
|
||||||
|
if (lead.bucketId === 'kc_stuck') return 'on_hold';
|
||||||
|
if (lead.bucketId === 'kc_followup') return 'delayed';
|
||||||
|
if (lead.columnId === 'kc_complete') return 'completed';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPipelineProgress(lead, stageColumns) {
|
||||||
|
const idx = stageColumns.findIndex(c => c.id === lead.columnId);
|
||||||
|
if (idx < 0 || stageColumns.length === 0) return 0;
|
||||||
|
return Math.round(((idx + 1) / stageColumns.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPipelineHealth(lead) {
|
||||||
|
if (!lead.activity?.length) return 50;
|
||||||
|
const days = Math.max(0, Math.floor((new Date('2026-04-07') - new Date(lead.activity[0].date + 'T00:00:00')) / 86400000));
|
||||||
|
if (days < 7) return 90;
|
||||||
|
if (days < 14) return 65;
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
|
||||||
const OwnerProjectList = () => {
|
const OwnerProjectList = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { projects, vendors } = useMockStore();
|
const { projects, vendors, kanbanLeads, kanbanColumns } = useMockStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [activeSection, setActiveSection] = useState('construction'); // 'construction' | 'pipeline'
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [phaseFilter, setPhaseFilter] = useState('all');
|
const [phaseFilter, setPhaseFilter] = useState('all');
|
||||||
const [sortConfig, setSortConfig] = useState({ key: 'healthScore', direction: 'asc' });
|
const [sortConfig, setSortConfig] = useState({ key: 'healthScore', direction: 'asc' });
|
||||||
|
const [pipelineSearch, setPipelineSearch] = useState('');
|
||||||
|
const [pipelineStatusFilter, setPipelineStatusFilter] = useState('all');
|
||||||
|
|
||||||
|
const stageColumns = useMemo(() =>
|
||||||
|
[...(kanbanColumns || [])].filter(c => c.type === 'stage').sort((a, b) => a.order - b.order),
|
||||||
|
[kanbanColumns]);
|
||||||
|
|
||||||
// Filter projects for this owner
|
// Filter projects for this owner
|
||||||
const ownerProjects = useMemo(() =>
|
const ownerProjects = useMemo(() =>
|
||||||
@@ -70,6 +106,21 @@ const OwnerProjectList = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pipeline leads filtering
|
||||||
|
const filteredPipelineLeads = useMemo(() => {
|
||||||
|
return (kanbanLeads || []).filter(l => {
|
||||||
|
const q = pipelineSearch.toLowerCase();
|
||||||
|
const matchSearch = !q ||
|
||||||
|
l.name.toLowerCase().includes(q) ||
|
||||||
|
l.address.toLowerCase().includes(q) ||
|
||||||
|
l.jobType.toLowerCase().includes(q) ||
|
||||||
|
(l.assignedAgentName || '').toLowerCase().includes(q);
|
||||||
|
const status = getPipelineStatus(l);
|
||||||
|
const matchStatus = pipelineStatusFilter === 'all' || status === pipelineStatusFilter;
|
||||||
|
return matchSearch && matchStatus;
|
||||||
|
});
|
||||||
|
}, [kanbanLeads, pipelineSearch, pipelineStatusFilter]);
|
||||||
|
|
||||||
const formatCurrency = (amount) =>
|
const formatCurrency = (amount) =>
|
||||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount);
|
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount);
|
||||||
|
|
||||||
@@ -109,7 +160,7 @@ const OwnerProjectList = () => {
|
|||||||
Projects
|
Projects
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
{ownerProjects.length} projects · {formatCurrency(totalBudget)} total budget · {formatCurrency(totalSpent)} spent
|
{ownerProjects.length} construction · {(kanbanLeads || []).length} pipeline leads · {formatCurrency(totalBudget)} budget
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -120,6 +171,40 @@ const OwnerProjectList = () => {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Section Toggle */}
|
||||||
|
<div className="flex items-center gap-2 p-1 bg-zinc-100 dark:bg-white/5 rounded-xl w-fit border border-zinc-200 dark:border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSection('construction')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold transition-colors ${
|
||||||
|
activeSection === 'construction'
|
||||||
|
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Briefcase size={14} />
|
||||||
|
Construction
|
||||||
|
<span className="text-[10px] font-black bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 px-1.5 py-0.5 rounded-full">
|
||||||
|
{ownerProjects.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSection('pipeline')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold transition-colors ${
|
||||||
|
activeSection === 'pipeline'
|
||||||
|
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={14} />
|
||||||
|
Pipeline Leads
|
||||||
|
<span className="text-[10px] font-black bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 px-1.5 py-0.5 rounded-full">
|
||||||
|
{(kanbanLeads || []).length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── CONSTRUCTION SECTION ── */}
|
||||||
|
{activeSection === 'construction' && (<>
|
||||||
{/* Filters & Search */}
|
{/* Filters & Search */}
|
||||||
<SpotlightCard className="p-4">
|
<SpotlightCard className="p-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
@@ -363,6 +448,193 @@ const OwnerProjectList = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ── PIPELINE SECTION ── */}
|
||||||
|
{activeSection === 'pipeline' && (<>
|
||||||
|
{/* Pipeline filters */}
|
||||||
|
<SpotlightCard className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search leads by name, address, job type, agent..."
|
||||||
|
value={pipelineSearch}
|
||||||
|
onChange={(e) => setPipelineSearch(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-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[['all','All'], ['active','Active'], ['completed','Complete'], ['on_hold','Stuck'], ['delayed','Follow-up']].map(([val, label]) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setPipelineStatusFilter(val)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors ${pipelineStatusFilter === val
|
||||||
|
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Pipeline table */}
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
{/* Mobile card view */}
|
||||||
|
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{filteredPipelineLeads.length > 0 ? filteredPipelineLeads.map(lead => {
|
||||||
|
const status = getPipelineStatus(lead);
|
||||||
|
const pcfg = PIPELINE_STATUS_CONFIG[status] || PIPELINE_STATUS_CONFIG.active;
|
||||||
|
const PIcon = pcfg.icon;
|
||||||
|
const pct = getPipelineProgress(lead, stageColumns);
|
||||||
|
const stageName = stageColumns.find(c => c.id === lead.columnId)?.name ?? '—';
|
||||||
|
const stageColor = stageColumns.find(c => c.id === lead.columnId)?.color ?? '#3B82F6';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lead.id}
|
||||||
|
onClick={() => navigate(`/owner/leads/${lead.id}`)}
|
||||||
|
className="p-4 active:bg-zinc-50 dark:active:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1 min-w-0 mr-3">
|
||||||
|
<h4 className="font-semibold text-zinc-900 dark:text-white truncate">{lead.name}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 truncate mt-0.5">{lead.jobType} · {lead.address.split(',')[0]}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-zinc-400 shrink-0 mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${pcfg.color}`}>
|
||||||
|
<PIcon size={10} /> {pcfg.label}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide"
|
||||||
|
style={{ backgroundColor: stageColor + '18', color: stageColor }}>
|
||||||
|
{stageName}
|
||||||
|
</span>
|
||||||
|
{lead.assignedAgentName && (
|
||||||
|
<span className="text-xs text-zinc-400">{lead.assignedAgentName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-1.5 bg-zinc-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: stageColor }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-zinc-500 w-8 text-right">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div className="py-16 text-center text-zinc-500">
|
||||||
|
<LayoutGrid size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No pipeline leads match your filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop table view */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Lead</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Status</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Stage</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Job Type</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Agent</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 text-right">Progress</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 text-right">Health</th>
|
||||||
|
<th className="px-5 py-4 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{filteredPipelineLeads.length > 0 ? filteredPipelineLeads.map(lead => {
|
||||||
|
const status = getPipelineStatus(lead);
|
||||||
|
const pcfg = PIPELINE_STATUS_CONFIG[status] || PIPELINE_STATUS_CONFIG.active;
|
||||||
|
const PIcon = pcfg.icon;
|
||||||
|
const pct = getPipelineProgress(lead, stageColumns);
|
||||||
|
const health = getPipelineHealth(lead);
|
||||||
|
const stageCol = stageColumns.find(c => c.id === lead.columnId);
|
||||||
|
const stageName = stageCol?.name ?? '—';
|
||||||
|
const stageColor = stageCol?.color ?? '#3B82F6';
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={lead.id}
|
||||||
|
onClick={() => navigate(`/owner/leads/${lead.id}`)}
|
||||||
|
className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors cursor-pointer group"
|
||||||
|
>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
|
||||||
|
{lead.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5 max-w-xs truncate">{lead.address}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide ${pcfg.color}`}>
|
||||||
|
<PIcon size={12} />
|
||||||
|
{pcfg.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: stageColor + '18', color: stageColor }}>
|
||||||
|
{stageName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{lead.jobType}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User size={12} className="text-zinc-400 shrink-0" />
|
||||||
|
<span className="text-sm text-zinc-600 dark:text-zinc-400 truncate max-w-[120px]">
|
||||||
|
{lead.assignedAgentName ?? 'Unassigned'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<div className="w-16 h-1.5 bg-zinc-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: stageColor }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-zinc-600 dark:text-zinc-400 w-8 text-right">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<span className={`text-sm font-bold ${getHealthColor(health)}`}>{health}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<ChevronRight size={16} className="text-zinc-400 group-hover:text-amber-500 transition-colors" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="8" className="py-20 text-center text-zinc-500">
|
||||||
|
<LayoutGrid size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No pipeline leads match your filters.</p>
|
||||||
|
<p className="text-sm mt-1">Try adjusting your search or filter criteria.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 text-sm text-zinc-500 flex justify-between items-center">
|
||||||
|
<span>Showing {filteredPipelineLeads.length} of {(kanbanLeads || []).length} leads</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/owner/kanban')}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={12} /> Open Pipeline Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</>)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user