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 (
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
|
||||
@@ -29,33 +29,23 @@ function avatarColor(name) {
|
||||
return AVATAR_COLORS[idx];
|
||||
}
|
||||
|
||||
export default function KanbanCard({ lead, columnColor, onClick, isDragOverlay }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: lead.id,
|
||||
data: { lead },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
opacity: isDragging ? 0.35 : 1,
|
||||
zIndex: isDragging ? 999 : 'auto',
|
||||
};
|
||||
|
||||
// ── Pure visual display — no dnd bindings. Used both inside KanbanCard and
|
||||
// inside DragOverlay (so the dragged clone renders above overflow containers).
|
||||
export function KanbanCardDisplay({ lead, columnColor, isOverlay = false, onClick, forwardRef, dragProps = {} }) {
|
||||
const days = getDaysInStage(lead.activity);
|
||||
const initials = getInitials(lead.name);
|
||||
const avatarCls = avatarColor(lead.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isDragOverlay ? undefined : setNodeRef}
|
||||
style={isDragOverlay ? undefined : style}
|
||||
{...(isDragOverlay ? {} : { ...attributes, ...listeners })}
|
||||
onClick={isDragOverlay ? undefined : onClick}
|
||||
className={`group relative bg-white dark:bg-zinc-900 border rounded-xl cursor-grab active:cursor-grabbing transition-all select-none
|
||||
${isDragOverlay
|
||||
? 'shadow-2xl border-zinc-200 dark:border-white/20 rotate-1 scale-[1.02]'
|
||||
: '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]'
|
||||
}`}
|
||||
ref={forwardRef}
|
||||
{...dragProps}
|
||||
onClick={onClick}
|
||||
className={`group relative bg-white dark:bg-zinc-900 border rounded-xl select-none transition-[border-color,box-shadow] ${
|
||||
isOverlay
|
||||
? 'border-blue-400 dark:border-blue-500 rotate-1 shadow-[0_20px_40px_rgba(0,0,0,0.35)] cursor-grabbing scale-[1.03]'
|
||||
: '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]'
|
||||
}`}
|
||||
>
|
||||
{/* Left color accent bar */}
|
||||
<div
|
||||
@@ -112,3 +102,24 @@ export default function KanbanCard({ lead, columnColor, onClick, isDragOverlay }
|
||||
</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 { useDroppable } from '@dnd-kit/core';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Plus, Settings, Trash2, Pencil } from 'lucide-react';
|
||||
import { Trash2, Pencil } from 'lucide-react';
|
||||
import KanbanCard from './KanbanCard';
|
||||
|
||||
export default function KanbanColumn({
|
||||
@@ -16,9 +16,9 @@ export default function KanbanColumn({
|
||||
const isBucket = column.type === 'bucket';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 w-[272px]">
|
||||
<div className="flex flex-col shrink-0 w-[272px] h-full">
|
||||
{/* 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 gap-2 min-w-0">
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: column.color }} />
|
||||
@@ -72,16 +72,17 @@ export default function KanbanColumn({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
{/* Drop zone — flex-1 + min-h-0 so it fills remaining column height */}
|
||||
<div
|
||||
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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<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}>
|
||||
{leads.map((lead, i) => (
|
||||
<motion.div
|
||||
@@ -109,15 +110,6 @@ export default function KanbanColumn({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
const MockStoreContext = createContext();
|
||||
@@ -4501,6 +5023,7 @@ export const MockStoreProvider = ({ children }) => {
|
||||
updateKanbanLead: (id, data) => {
|
||||
setKanbanLeads(prev => prev.map(l => l.id === id ? { ...l, ...data } : l));
|
||||
},
|
||||
kanbanProjectData: KANBAN_PROJECT_DATA,
|
||||
|
||||
// Estimates
|
||||
estimates,
|
||||
|
||||
+85
-72
@@ -1,16 +1,16 @@
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext, DragOverlay, PointerSensor, TouchSensor,
|
||||
useSensor, useSensors, closestCorners
|
||||
useSensor, useSensors, pointerWithin, rectIntersection
|
||||
} from '@dnd-kit/core';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useAuth, ROLES } from '../context/AuthContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
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 LeadInfoDrawer from '../components/kanban/LeadInfoDrawer';
|
||||
import ColumnManagerModal from '../components/kanban/ColumnManagerModal';
|
||||
@@ -20,45 +20,43 @@ function fireCampaignToast(lead, column, onEdit) {
|
||||
if (!column?.campaignMessage) return;
|
||||
const msg = column.campaignMessage.replace('{name}', lead.name.split(' ')[0]);
|
||||
|
||||
toast(
|
||||
({ id }) => {
|
||||
const [secs, setSecs] = React.useState(20);
|
||||
React.useEffect(() => {
|
||||
const t = setInterval(() => setSecs(s => {
|
||||
if (s <= 1) { clearInterval(t); toast.dismiss(id); return 0; }
|
||||
return s - 1;
|
||||
}), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Send size={13} className="text-blue-500 shrink-0" />
|
||||
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-100">Campaign Message</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-zinc-400">Sending in {secs}s</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">{msg}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { toast.dismiss(id); 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"
|
||||
>
|
||||
<Pencil size={10} /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { toast.dismiss(id); 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"
|
||||
>
|
||||
Send Now
|
||||
</button>
|
||||
// Sonner v2: toast.custom(fn) where fn receives the toast ID directly (string)
|
||||
toast.custom((toastId) => {
|
||||
const [secs, setSecs] = React.useState(20);
|
||||
React.useEffect(() => {
|
||||
const t = setInterval(() => setSecs(s => {
|
||||
if (s <= 1) { clearInterval(t); toast.dismiss(toastId); return 0; }
|
||||
return s - 1;
|
||||
}), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<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 gap-1.5">
|
||||
<Send size={13} className="text-blue-500 shrink-0" />
|
||||
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-100">Campaign Message</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-zinc-400">Sending in {secs}s</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ duration: 20000, position: 'bottom-right' }
|
||||
);
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">{msg}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Pencil size={10} /> Edit
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Send Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, { duration: 20000, position: 'bottom-right' });
|
||||
}
|
||||
|
||||
// Campaign Edit Modal
|
||||
@@ -110,7 +108,6 @@ export default function KanbanPage() {
|
||||
const canManage = user?.role === ROLES.OWNER || user?.role === ROLES.ADMIN;
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [draggingLead, setDraggingLead] = useState(null);
|
||||
const [selectedLead, setSelectedLead] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
@@ -120,6 +117,9 @@ export default function KanbanPage() {
|
||||
// Campaign edit modal
|
||||
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(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
@@ -156,7 +156,9 @@ export default function KanbanPage() {
|
||||
|
||||
const handleDragStart = ({ active }) => {
|
||||
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 }) => {
|
||||
@@ -217,11 +219,17 @@ export default function KanbanPage() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<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="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 gap-3 flex-wrap">
|
||||
<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">
|
||||
<LayoutGrid size={16} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
@@ -230,12 +238,22 @@ export default function KanbanPage() {
|
||||
style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
Pipeline
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<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" />
|
||||
<input
|
||||
value={search}
|
||||
@@ -252,15 +270,9 @@ export default function KanbanPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board */}
|
||||
<div className="overflow-x-auto pb-8">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 px-4 sm:px-6 pt-5 min-w-max">
|
||||
{/* Board — fills remaining height, horizontal scroll only */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-hidden min-h-0">
|
||||
<div className="flex gap-4 px-4 sm:px-6 py-5 min-w-max h-full">
|
||||
{/* Stage columns */}
|
||||
{stageColumns.map((col, i) => (
|
||||
<KanbanColumn
|
||||
@@ -278,8 +290,8 @@ export default function KanbanPage() {
|
||||
))}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex flex-col gap-2 shrink-0 self-start pt-1">
|
||||
<div className="w-px h-full min-h-[200px] bg-zinc-200 dark:bg-zinc-800 mx-2" />
|
||||
<div className="shrink-0 self-stretch flex items-stretch px-2">
|
||||
<div className="w-px bg-zinc-200 dark:bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
{/* Bucket columns */}
|
||||
@@ -297,19 +309,7 @@ export default function KanbanPage() {
|
||||
isLast={false}
|
||||
/>
|
||||
))}
|
||||
</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 */}
|
||||
@@ -343,5 +343,18 @@ export default function KanbanPage() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+983
-269
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 { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import {
|
||||
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
|
||||
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';
|
||||
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
||||
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||
@@ -68,8 +69,42 @@ const tabs = [
|
||||
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
||||
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
||||
{ 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 { projectId } = useParams();
|
||||
const { user } = useAuth();
|
||||
@@ -80,6 +115,46 @@ const OwnerProjectDetail = () => {
|
||||
const [selectedInvoice, setSelectedInvoice] = 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) => {
|
||||
console.log("Mock submitted data:", data);
|
||||
alert("Action successful! (Mock interface)");
|
||||
@@ -921,6 +996,110 @@ const OwnerProjectDetail = () => {
|
||||
)}
|
||||
|
||||
{/* 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' && (
|
||||
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
@@ -979,6 +1158,208 @@ const OwnerProjectDetail = () => {
|
||||
{...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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import {
|
||||
Search, Filter, ChevronRight, ArrowUpDown, Briefcase,
|
||||
CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert
|
||||
Search, ChevronRight, ArrowUpDown, Briefcase, LayoutGrid,
|
||||
CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, User
|
||||
} from 'lucide-react';
|
||||
|
||||
const statusConfig = {
|
||||
@@ -18,15 +18,51 @@ const statusConfig = {
|
||||
|
||||
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 { user } = useAuth();
|
||||
const { projects, vendors } = useMockStore();
|
||||
const { projects, vendors, kanbanLeads, kanbanColumns } = useMockStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeSection, setActiveSection] = useState('construction'); // 'construction' | 'pipeline'
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [phaseFilter, setPhaseFilter] = useState('all');
|
||||
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
|
||||
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) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount);
|
||||
|
||||
@@ -109,7 +160,7 @@ const OwnerProjectList = () => {
|
||||
Projects
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -120,6 +171,40 @@ const OwnerProjectList = () => {
|
||||
</button>
|
||||
</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 */}
|
||||
<SpotlightCard className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
@@ -363,6 +448,193 @@ const OwnerProjectList = () => {
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user