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