feat(kanban): Project Pipeline board with drag-and-drop, lead drawer, and full project view
- 44 mock leads across 7 stage columns + 2 bucket columns (Stuck, Follow-up) with seeded activity logs - dnd-kit drag-and-drop: cross-column card movement, bucket logic, DragOverlay preview - Campaign toast on stage drop: 20s countdown, Edit button opens message editor, auto-sends if ignored - KanbanColumn: droppable zones, stage progress bar (position/total), column color accents, Add/Rename/Delete (Admin+Owner) - KanbanCard: initials avatar, job type pill, insurance badge, assigned agent, days-in-stage counter, left color accent bar - ColumnManagerModal: add/rename/delete stages with name, color picker, campaign message; delete blocked if leads exist - LeadInfoDrawer: right slide-in with progress bar + stage steps, Details and Activity tabs, More Details CTA - ActivityTimeline: color-coded entries (created/forward/regression/bucket), stage arrows, notes, agent + date - LeadProjectPage: full project view at /*/leads/:leadId — progress bar, stage mover dropdown, info panels, full activity log, stats - Routes: /*/kanban + /*/leads/:leadId for Owner, Admin, Field Agent - Pipeline nav item added for all three roles
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
@@ -20,6 +23,8 @@
|
||||
"framer-motion": "^12.34.0",
|
||||
"groq-sdk": "^0.37.0",
|
||||
"gsap": "^3.14.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"lenis": "^1.3.17",
|
||||
"lucide-react": "^0.563.0",
|
||||
|
||||
Generated
+246
@@ -8,6 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
'@react-three/drei':
|
||||
specifier: ^10.7.7
|
||||
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.10)(immer@11.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.10)(@types/three@0.182.0)(immer@11.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)
|
||||
@@ -35,6 +44,12 @@ importers:
|
||||
gsap:
|
||||
specifier: ^3.14.2
|
||||
version: 3.14.2
|
||||
jspdf:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
jspdf-autotable:
|
||||
specifier: ^5.0.7
|
||||
version: 5.0.7(jspdf@4.2.1)
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -202,6 +217,28 @@ packages:
|
||||
'@dimforge/rapier3d-compat@0.12.0':
|
||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -539,66 +576,79 @@ packages:
|
||||
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
|
||||
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.1':
|
||||
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
|
||||
@@ -711,6 +761,12 @@ packages:
|
||||
'@types/offscreencanvas@2019.7.3':
|
||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -730,6 +786,9 @@ packages:
|
||||
'@types/three@0.182.0':
|
||||
resolution: {integrity: sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
@@ -842,6 +901,10 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -892,6 +955,10 @@ packages:
|
||||
caniuse-lite@1.0.30001766:
|
||||
resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
|
||||
|
||||
canvg@3.0.11:
|
||||
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -950,6 +1017,9 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
cross-env@7.0.3:
|
||||
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||
@@ -959,6 +1029,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1049,6 +1122,9 @@ packages:
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
dompurify@3.3.3:
|
||||
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
|
||||
|
||||
draco3d@1.5.7:
|
||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||
|
||||
@@ -1170,6 +1246,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-png@6.4.0:
|
||||
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -1318,6 +1397,10 @@ packages:
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
@@ -1352,6 +1435,9 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
iobuffer@5.4.0:
|
||||
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
|
||||
|
||||
is-alphabetical@2.0.1:
|
||||
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
|
||||
|
||||
@@ -1429,6 +1515,14 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jspdf-autotable@5.0.7:
|
||||
resolution: {integrity: sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==}
|
||||
peerDependencies:
|
||||
jspdf: ^2 || ^3 || ^4
|
||||
|
||||
jspdf@4.2.1:
|
||||
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -1666,6 +1760,9 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1684,6 +1781,9 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1770,6 +1870,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
raf@3.4.1:
|
||||
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
@@ -1860,6 +1963,9 @@ packages:
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
remark-parse@11.0.0:
|
||||
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
||||
|
||||
@@ -1886,6 +1992,10 @@ packages:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||
engines: {node: '>= 0.8.15'}
|
||||
|
||||
rollup@4.57.1:
|
||||
resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -1925,6 +2035,10 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||
engines: {node: '>=0.1.14'}
|
||||
|
||||
stats-gl@2.4.2:
|
||||
resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
|
||||
peerDependencies:
|
||||
@@ -1965,6 +2079,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=17.0'
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tailwind-merge@3.4.0:
|
||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||
|
||||
@@ -1973,6 +2091,9 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -2081,6 +2202,9 @@ packages:
|
||||
resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -2327,6 +2451,31 @@ snapshots:
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
@@ -2734,6 +2883,11 @@ snapshots:
|
||||
|
||||
'@types/offscreencanvas@2019.7.3': {}
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.10)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
@@ -2758,6 +2912,9 @@ snapshots:
|
||||
fflate: 0.8.2
|
||||
meshoptimizer: 0.22.0
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -2844,6 +3001,9 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
optional: true
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.9.19: {}
|
||||
@@ -2891,6 +3051,18 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001766: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@types/raf': 3.4.3
|
||||
core-js: 3.49.0
|
||||
raf: 3.4.1
|
||||
regenerator-runtime: 0.13.11
|
||||
rgbcolor: 1.0.1
|
||||
stackblur-canvas: 2.7.0
|
||||
svg-pathdata: 6.0.3
|
||||
optional: true
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
@@ -2944,6 +3116,9 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-js@3.49.0:
|
||||
optional: true
|
||||
|
||||
cross-env@7.0.3:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -2954,6 +3129,11 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
@@ -3024,6 +3204,11 @@ snapshots:
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dompurify@3.3.3:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
optional: true
|
||||
|
||||
draco3d@1.5.7: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -3189,6 +3374,12 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-png@6.4.0:
|
||||
dependencies:
|
||||
'@types/pako': 2.0.4
|
||||
iobuffer: 5.4.0
|
||||
pako: 2.1.0
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -3348,6 +3539,12 @@ snapshots:
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
optional: true
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -3373,6 +3570,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
iobuffer@5.4.0: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
|
||||
is-alphanumerical@2.0.1:
|
||||
@@ -3431,6 +3630,21 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jspdf-autotable@5.0.7(jspdf@4.2.1):
|
||||
dependencies:
|
||||
jspdf: 4.2.1
|
||||
|
||||
jspdf@4.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
fast-png: 6.4.0
|
||||
fflate: 0.8.2
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.3.3
|
||||
html2canvas: 1.4.1
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -3771,6 +3985,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -3791,6 +4007,9 @@ snapshots:
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
@@ -3853,6 +4072,11 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
raf@3.4.1:
|
||||
dependencies:
|
||||
performance-now: 2.1.0
|
||||
optional: true
|
||||
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -3952,6 +4176,9 @@ snapshots:
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
optional: true
|
||||
|
||||
remark-parse@11.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -3983,6 +4210,9 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
optional: true
|
||||
|
||||
rollup@4.57.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -4039,6 +4269,9 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
optional: true
|
||||
|
||||
stats-gl@2.4.2(@types/three@0.182.0)(three@0.182.0):
|
||||
dependencies:
|
||||
'@types/three': 0.182.0
|
||||
@@ -4081,6 +4314,9 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
optional: true
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
|
||||
tailwindcss@3.4.19:
|
||||
@@ -4111,6 +4347,11 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@@ -4235,6 +4476,11 @@ snapshots:
|
||||
|
||||
utility-types@3.11.0: {}
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
optional: true
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
+44
@@ -33,6 +33,8 @@ import CreateLeadPage from './pages/CreateLeadPage';
|
||||
import LeadsListPage from './pages/LeadsListPage';
|
||||
import LynkDispatchPage from './pages/LynkDispatchPage';
|
||||
import EstimatesPage from './pages/EstimatesPage';
|
||||
import KanbanPage from './pages/KanbanPage';
|
||||
import LeadProjectPage from './pages/LeadProjectPage';
|
||||
|
||||
// ... (existing imports)
|
||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||
@@ -110,6 +112,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emp/fa/kanban"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||
<KanbanPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emp/fa/leads/:leadId"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||
<LeadProjectPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emp/fa/estimate"
|
||||
element={
|
||||
@@ -180,6 +198,16 @@ function App() {
|
||||
<EstimatesPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/owner/kanban" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||
<KanbanPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/owner/leads/:leadId" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||
<LeadProjectPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/owner/estimate" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||
<EstimateBuilder />
|
||||
@@ -252,6 +280,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/kanban"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||
<KanbanPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/leads/:leadId"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||
<LeadProjectPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/estimate"
|
||||
element={
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
||||
import {
|
||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid
|
||||
} from 'lucide-react';
|
||||
|
||||
import PageTransition from './PageTransition';
|
||||
@@ -138,6 +138,7 @@ const Layout = () => {
|
||||
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/owner/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
|
||||
{
|
||||
@@ -156,6 +157,7 @@ const Layout = () => {
|
||||
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/admin/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
||||
{
|
||||
@@ -184,6 +186,7 @@ const Layout = () => {
|
||||
{ to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{
|
||||
to: "/emp/fa/pro-canvas",
|
||||
icon: LayoutDashboard,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Layers, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3B82F6','#6366F1','#8B5CF6','#EC4899',
|
||||
'#F59E0B','#10B981','#06B6D4','#22C55E',
|
||||
'#EF4444','#F97316','#14B8A6','#A855F7',
|
||||
];
|
||||
|
||||
export default function ColumnManagerModal({ mode, column, leadsInColumn, onSave, onClose }) {
|
||||
const isDelete = mode === 'delete';
|
||||
const isRename = mode === 'rename';
|
||||
const isAdd = mode === 'add';
|
||||
|
||||
const [name, setName] = useState(column?.name ?? '');
|
||||
const [color, setColor] = useState(column?.color ?? '#3B82F6');
|
||||
const [campaign, setCampaign] = useState(column?.campaignMessage ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
setName(column?.name ?? '');
|
||||
setColor(column?.color ?? '#3B82F6');
|
||||
setCampaign(column?.campaignMessage ?? '');
|
||||
}, [column, mode]);
|
||||
|
||||
const canDelete = leadsInColumn === 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isDelete) { onSave(); return; }
|
||||
if (!name.trim()) return;
|
||||
onSave({ name: name.trim(), color, campaignMessage: campaign.trim() || null });
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-md"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isDelete
|
||||
? <AlertTriangle size={16} className="text-red-500" />
|
||||
: <Layers size={16} className="text-blue-500" />
|
||||
}
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white text-sm">
|
||||
{isAdd ? 'Add Stage' : isRename ? 'Rename Stage' : 'Delete Stage'}
|
||||
</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{isDelete ? (
|
||||
<>
|
||||
{canDelete ? (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
Are you sure you want to delete <strong className="text-zinc-800 dark:text-zinc-100">"{column?.name}"</strong>? This action cannot be undone.
|
||||
</p>
|
||||
) : (
|
||||
<div className="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-semibold text-amber-700 dark:text-amber-400 mb-1">Cannot Delete</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
<strong>{leadsInColumn}</strong> {leadsInColumn === 1 ? 'lead is' : 'leads are'} currently in <strong>"{column?.name}"</strong>. Move them to another column first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 mb-1.5 block">Stage Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Site Survey"
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 mb-1.5 block">Color</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className="w-7 h-7 rounded-full transition-transform hover:scale-110 focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
ring: color === c ? `3px solid ${c}` : undefined,
|
||||
outline: color === c ? `2px solid ${c}` : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign message */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 mb-1.5 block">
|
||||
Campaign Message <span className="font-normal text-zinc-400">(optional — sent when card drops here)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={campaign}
|
||||
onChange={e => setCampaign(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Hi {name}, just a quick update..."
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow resize-none"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 px-5 py-4 border-t border-zinc-100 dark:border-white/[0.07]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{(!isDelete || canDelete) && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isDelete && !name.trim()}
|
||||
className={`flex-1 px-4 py-2 rounded-xl text-sm font-semibold transition-colors ${
|
||||
isDelete
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isAdd ? 'Add Stage' : isRename ? 'Save' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { MapPin, User, Clock } from 'lucide-react';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function getDaysInStage(activity) {
|
||||
if (!activity?.length) return 0;
|
||||
const last = activity[0];
|
||||
const lastDate = new Date(last.date);
|
||||
const now = new Date('2026-04-07');
|
||||
return Math.max(0, Math.floor((now - lastDate) / 86400000));
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
|
||||
'bg-violet-100 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300',
|
||||
'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300',
|
||||
'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
|
||||
'bg-rose-100 dark:bg-rose-500/20 text-rose-700 dark:text-rose-300',
|
||||
'bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300',
|
||||
];
|
||||
|
||||
function avatarColor(name) {
|
||||
const idx = (name || '').charCodeAt(0) % AVATAR_COLORS.length;
|
||||
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',
|
||||
};
|
||||
|
||||
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]'
|
||||
}`}
|
||||
>
|
||||
{/* Left color accent bar */}
|
||||
<div
|
||||
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-full"
|
||||
style={{ backgroundColor: columnColor }}
|
||||
/>
|
||||
|
||||
<div className="pl-4 pr-3 py-3">
|
||||
{/* Name row */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 mt-0.5 ${avatarCls}`}>
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white leading-tight truncate">{lead.name}</p>
|
||||
{lead.address && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 truncate mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={9} className="shrink-0" />
|
||||
{lead.address.split(',')[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job type pill */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: columnColor + '18', color: columnColor }}>
|
||||
{lead.jobType}
|
||||
</span>
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
||||
lead.insuranceType === 'Insurance'
|
||||
? 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400'
|
||||
}`}>
|
||||
{lead.insuranceType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<User size={10} className="shrink-0 text-zinc-400" />
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 truncate">
|
||||
{lead.assignedAgentName ?? 'Unassigned'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Clock size={10} className="text-zinc-400" />
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500">{days}d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 KanbanCard from './KanbanCard';
|
||||
|
||||
export default function KanbanColumn({
|
||||
column, leads, stageColumns, canManage,
|
||||
onCardClick, onAddColumn, onRenameColumn, onDeleteColumn,
|
||||
isLast,
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||
|
||||
const stageCount = stageColumns.filter(c => c.type === 'stage').length;
|
||||
const stageIdx = stageColumns.filter(c => c.type === 'stage').findIndex(c => c.id === column.id);
|
||||
const isBucket = column.type === 'bucket';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 w-[272px]">
|
||||
{/* Column header */}
|
||||
<div className="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 }} />
|
||||
<h3
|
||||
className="text-xs font-black uppercase tracking-widest truncate text-zinc-700 dark:text-zinc-200"
|
||||
style={{ fontFamily: 'Barlow Condensed, sans-serif', letterSpacing: '0.08em' }}
|
||||
>
|
||||
{column.name}
|
||||
</h3>
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: column.color + '18', color: column.color }}
|
||||
>
|
||||
{leads.length}
|
||||
</span>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
|
||||
<button
|
||||
onClick={() => onRenameColumn(column)}
|
||||
className="p-1 rounded-md text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteColumn(column)}
|
||||
className="p-1 rounded-md text-zinc-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage progress bar — only for stage columns */}
|
||||
{!isBucket && stageIdx >= 0 && (
|
||||
<div className="mt-2 px-1">
|
||||
<div className="h-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${((stageIdx + 1) / stageCount) * 100}%`,
|
||||
backgroundColor: column.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[9px] text-zinc-400 mt-0.5 text-right">
|
||||
{stageIdx + 1}/{stageCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`group flex-1 min-h-[120px] rounded-2xl p-2 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">
|
||||
<AnimatePresence initial={false}>
|
||||
{leads.map((lead, i) => (
|
||||
<motion.div
|
||||
key={lead.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.15, delay: i * 0.03 }}
|
||||
>
|
||||
<KanbanCard
|
||||
lead={lead}
|
||||
columnColor={column.color}
|
||||
onClick={() => onCardClick(lead)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{leads.length === 0 && (
|
||||
<div className="flex items-center justify-center h-16 rounded-xl border-2 border-dashed border-zinc-200 dark:border-zinc-700/60">
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-600 font-medium">Drop here</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import {
|
||||
X, Phone, Mail, MapPin, Briefcase, Shield, User,
|
||||
Clock, ArrowRight, ExternalLink, ChevronRight, FileText,
|
||||
CheckCircle, ArrowUpRight, ArrowDownLeft, MessageSquare
|
||||
} from 'lucide-react';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
const dt = new Date(d + 'T00:00:00');
|
||||
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function ProgressBar({ lead, columns }) {
|
||||
const stageColumns = columns.filter(c => c.type === 'stage');
|
||||
const currentStageId = lead.columnId;
|
||||
const stageIdx = stageColumns.findIndex(c => c.id === currentStageId);
|
||||
const totalStages = stageColumns.length;
|
||||
const pct = totalStages > 0 ? Math.round(((stageIdx + 1) / totalStages) * 100) : 0;
|
||||
const currentCol = stageColumns[stageIdx];
|
||||
const bucketCol = lead.bucketId ? columns.find(c => c.id === lead.bucketId) : null;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{currentCol && (
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: currentCol.color + '18', color: currentCol.color }}>
|
||||
{currentCol.name}
|
||||
</span>
|
||||
)}
|
||||
{bucketCol && (
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: bucketCol.color + '18', color: bucketCol.color }}>
|
||||
{bucketCol.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-zinc-500 dark:text-zinc-400">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: currentCol?.color ?? '#3B82F6' }}
|
||||
/>
|
||||
</div>
|
||||
{/* Stage steps */}
|
||||
<div className="flex items-center gap-0 mt-2 overflow-x-auto scrollbar-hide">
|
||||
{stageColumns.map((col, i) => (
|
||||
<React.Fragment key={col.id}>
|
||||
<div className={`shrink-0 flex items-center gap-1 text-[9px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||
i <= stageIdx ? 'text-zinc-700 dark:text-zinc-200' : 'text-zinc-300 dark:text-zinc-600'
|
||||
}`}>
|
||||
{i <= stageIdx && (
|
||||
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
||||
)}
|
||||
{col.name}
|
||||
</div>
|
||||
{i < stageColumns.length - 1 && (
|
||||
<ChevronRight size={9} className="shrink-0 text-zinc-300 dark:text-zinc-700 mx-0.5" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTimeline({ activity }) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{(activity || []).map((entry, i) => {
|
||||
const isRegression = entry.from && entry.to &&
|
||||
entry.to === 'Contacted' && entry.from !== null;
|
||||
const isFirst = !entry.from;
|
||||
const isBucket = entry.to === 'Stuck' || entry.to === 'Follow-up';
|
||||
|
||||
return (
|
||||
<div key={i} className="flex gap-3">
|
||||
{/* Timeline line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center shrink-0 mt-1 ${
|
||||
isFirst ? 'bg-blue-100 dark:bg-blue-500/20' :
|
||||
isBucket ? 'bg-amber-100 dark:bg-amber-500/20' :
|
||||
isRegression ? 'bg-orange-100 dark:bg-orange-500/20' :
|
||||
'bg-emerald-100 dark:bg-emerald-500/20'
|
||||
}`}>
|
||||
{isFirst ? <ArrowRight size={9} className="text-blue-600 dark:text-blue-400" /> :
|
||||
isBucket ? <MessageSquare size={9} className="text-amber-600 dark:text-amber-400" /> :
|
||||
isRegression ? <ArrowDownLeft size={9} className="text-orange-600 dark:text-orange-400" /> :
|
||||
<ArrowUpRight size={9} className="text-emerald-600 dark:text-emerald-400" />}
|
||||
</div>
|
||||
{i < activity.length - 1 && (
|
||||
<div className="w-px flex-1 bg-zinc-100 dark:bg-zinc-800 my-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="pb-4 flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
{entry.from ? (
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span className={isRegression ? 'text-orange-600 dark:text-orange-400' : 'text-zinc-500 dark:text-zinc-400'}>
|
||||
{entry.from}
|
||||
</span>
|
||||
<ArrowRight size={10} className="inline mx-1 text-zinc-400" />
|
||||
<span style={{ color: isBucket ? '#F59E0B' : undefined }}>
|
||||
{entry.to}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs font-semibold text-blue-600 dark:text-blue-400">Lead Created</p>
|
||||
)}
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">by {entry.by} · {formatDate(entry.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{entry.note && (
|
||||
<p className="mt-1.5 text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed bg-zinc-50 dark:bg-zinc-800/60 px-2.5 py-2 rounded-lg border border-zinc-100 dark:border-white/[0.05]">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeadInfoDrawer({ lead, columns, isOpen, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useState('details');
|
||||
|
||||
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
||||
|
||||
if (!lead) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[70] bg-black/30 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 38 }}
|
||||
className="fixed right-0 top-0 bottom-0 z-[71] w-full sm:w-[460px] bg-white dark:bg-zinc-950 shadow-2xl border-l border-zinc-200 dark:border-white/[0.07] flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center text-sm font-bold text-blue-700 dark:text-blue-300 shrink-0">
|
||||
{getInitials(lead.name)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-bold text-zinc-900 dark:text-white text-sm truncate">{lead.name}</h2>
|
||||
<p className="text-[11px] text-zinc-400 truncate">{lead.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors shrink-0"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<ProgressBar lead={lead} columns={columns} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-5 pt-3 pb-0 border-b border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||
{['details', 'activity'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`relative pb-2.5 px-3 text-xs font-semibold capitalize transition-colors ${
|
||||
tab === t
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
{tab === t && (
|
||||
<motion.div layoutId="drawer-tab-line" className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{tab === 'details' && (
|
||||
<div className="space-y-5">
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Contact</p>
|
||||
<div className="space-y-2">
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Phone size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200">{lead.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.email && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Mail size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200 truncate">{lead.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.address && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin size={13} className="text-zinc-400 shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200">{lead.address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Job */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Job Details</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5">
|
||||
<p className="text-[10px] text-zinc-400 mb-0.5">Type</p>
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.jobType}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5">
|
||||
<p className="text-[10px] text-zinc-400 mb-0.5">Category</p>
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.insuranceType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Assignment */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Assignment</p>
|
||||
<div className="flex items-center gap-2.5 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5">
|
||||
<User size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200 font-medium">
|
||||
{lead.assignedAgentName ?? <span className="text-zinc-400 italic">Unassigned</span>}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
{lead.notes && (
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Notes</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed bg-amber-50 dark:bg-amber-500/5 border border-amber-100 dark:border-amber-500/10 rounded-xl px-3 py-2.5">
|
||||
{lead.notes}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Timeline</p>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Clock size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">Created {formatDate(lead.createdDate)}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'activity' && (
|
||||
<ActivityTimeline activity={lead.activity} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(`${basePath}/leads/${lead.id}`);
|
||||
}}
|
||||
className="w-full 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 shadow-sm"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
More Details
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -3617,6 +3617,708 @@ const MOCK_ESTIMATES_INITIAL = [
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KANBAN BOARD DATA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KANBAN_COLUMNS_INITIAL = [
|
||||
{ id: 'kc_new', type: 'stage', name: 'New Lead', order: 0, color: '#3B82F6', campaignMessage: "Hi {name}, thanks for reaching out to Plano Realty! We'll be in touch shortly to schedule your free roof inspection. — Plano Roofing Team" },
|
||||
{ id: 'kc_contacted',type: 'stage', name: 'Contacted', order: 1, color: '#6366F1', campaignMessage: "Hi {name}, great connecting with you today! I'll send over some info and follow up with next steps this week. — Plano Roofing Team" },
|
||||
{ id: 'kc_appt', type: 'stage', name: 'Appt Scheduled', order: 2, color: '#8B5CF6', campaignMessage: "Hi {name}, your inspection appointment is confirmed! We look forward to meeting you and assessing your roof. — Plano Roofing Team" },
|
||||
{ id: 'kc_estimate', type: 'stage', name: 'Estimate Sent', order: 3, color: '#F59E0B', campaignMessage: "Hi {name}, your detailed estimate has been sent! Please review at your convenience and reach out with any questions. — Plano Roofing Team" },
|
||||
{ id: 'kc_signed', type: 'stage', name: 'Signed', order: 4, color: '#10B981', campaignMessage: "Hi {name}, welcome to the Plano Roofing family! Your contract is signed and we're excited to get your project underway. — Plano Roofing Team" },
|
||||
{ id: 'kc_progress', type: 'stage', name: 'In Progress', order: 5, color: '#06B6D4', campaignMessage: "Hi {name}, just a quick update — your roofing project is now actively in progress! We'll keep you posted on key milestones. — Plano Roofing Team" },
|
||||
{ id: 'kc_complete', type: 'stage', name: 'Complete', order: 6, color: '#22C55E', campaignMessage: "Hi {name}, your project is complete! We hope you love the results. A 5-star review would mean the world to us. — Plano Roofing Team" },
|
||||
{ id: 'kc_stuck', type: 'bucket', name: 'Stuck', order: 7, color: '#EF4444', campaignMessage: null },
|
||||
{ id: 'kc_followup', type: 'bucket', name: 'Follow-up', order: 8, color: '#F97316', campaignMessage: null },
|
||||
];
|
||||
|
||||
const KANBAN_LEADS_INITIAL = [
|
||||
// ── NEW LEAD (6 leads) ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_001', name: 'Derek Holloway', address: '2814 Ravenswood Dr, Plano TX 75023',
|
||||
phone: '(972) 555-0101', email: 'derek.holloway@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_new', bucketId: null,
|
||||
notes: 'Hail damage from March storm. Insurance adjuster coming next week.',
|
||||
createdDate: '2026-03-31',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-03-31', note: 'Lead created via door knock — visible hail damage on ridge line.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_002', name: 'Brenda Castillo', address: '5501 Shady Brook Ln, Plano TX 75093',
|
||||
phone: '(972) 555-0102', email: 'bcastillo@yahoo.com',
|
||||
jobType: 'Roof Inspection', insuranceType: 'Retail',
|
||||
assignedAgentId: null, assignedAgentName: null,
|
||||
columnId: 'kc_new', bucketId: null,
|
||||
notes: 'Walked in via website form. No agent assigned yet.',
|
||||
createdDate: '2026-04-01',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'System', date: '2026-04-01', note: 'Lead submitted via website form — Google Ads campaign.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_003', name: 'Antonio Reyes', address: '1122 Custer Rd, Plano TX 75075',
|
||||
phone: '(972) 555-0103', email: 'a.reyes@outlook.com',
|
||||
jobType: 'Gutter Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_new', bucketId: null,
|
||||
notes: 'Referred by neighbor on Oak Creek Blvd. Wants full gutter system.',
|
||||
createdDate: '2026-04-02',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-04-02', note: 'Referral from existing client Tom Hargrove.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_004', name: 'Sylvia Nguyen', address: '3308 Roundrock Trl, Plano TX 75023',
|
||||
phone: '(972) 555-0104', email: 'sylvia.nguyen@icloud.com',
|
||||
jobType: 'Siding Repair', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_new', bucketId: null,
|
||||
notes: 'Storm damage to siding on north face. Photos sent.',
|
||||
createdDate: '2026-04-03',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-04-03', note: 'Canvassing lead — storm damage clearly visible.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_005', name: 'Raymond Osei', address: '4720 Preston Rd, Plano TX 75024',
|
||||
phone: '(972) 555-0105', email: 'rosei@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: null, assignedAgentName: null,
|
||||
columnId: 'kc_new', bucketId: null,
|
||||
notes: 'Called in from radio ad. Wants urgent inspection.',
|
||||
createdDate: '2026-04-04',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'System', date: '2026-04-04', note: 'Inbound call — radio ad source.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_006', name: 'Carolyn Estrada', address: '6013 Ohio Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0106', email: 'carolyn.e@gmail.com',
|
||||
jobType: 'Window Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e5', assignedAgentName: 'Felix Fixer',
|
||||
columnId: 'kc_new', bucketId: null,
|
||||
notes: 'Wants 4 windows replaced. Budget is flexible.',
|
||||
createdDate: '2026-04-05',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felix Fixer', date: '2026-04-05', note: 'Door knock — homeowner interested in window upgrade.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── CONTACTED (5 leads) ─────────────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_007', name: 'Marcus Tillman', address: '2201 Willow Bend Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0107', email: 'mtillman@hotmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_contacted', bucketId: null,
|
||||
notes: 'Spoke on the phone. Wants appointment next Tuesday.',
|
||||
createdDate: '2026-03-25',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-03-25', note: 'Canvassing — hail dents visible on ridge cap.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-03-27', note: 'Spoke by phone, confirmed homeowner interest. Appointment TBD.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_008', name: 'Diane Kowalski', address: '815 Independence Pkwy, Plano TX 75023',
|
||||
phone: '(972) 555-0108', email: 'diane.k@gmail.com',
|
||||
jobType: 'Roof Inspection', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_contacted', bucketId: null,
|
||||
notes: 'Older roof, no storm damage. Wants inspection for peace of mind.',
|
||||
createdDate: '2026-03-26',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-03-26', note: 'Website form submission.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-03-28', note: 'Left voicemail and sent email. Customer responded via email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_009', name: 'Joel Fernandez', address: '3901 Legacy Dr, Plano TX 75023',
|
||||
phone: '(972) 555-0109', email: 'joel.f@yahoo.com',
|
||||
jobType: 'Siding Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_contacted', bucketId: null,
|
||||
notes: 'Insurance claim in process. Wants estimate ASAP.',
|
||||
createdDate: '2026-03-24',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-03-24', note: 'Door knock — wind damage to siding panels.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-03-26', note: 'Phone call completed. Adjuster visit scheduled for Mar 30.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_010', name: 'Patricia Yuen', address: '7742 Kings Rd, Plano TX 75024',
|
||||
phone: '(972) 555-0110', email: 'pyuen@outlook.com',
|
||||
jobType: 'Gutter Repair', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e3', assignedAgentName: 'Fred Flyer',
|
||||
columnId: 'kc_contacted', bucketId: null,
|
||||
notes: 'Gutters pulling away from fascia. Straightforward repair job.',
|
||||
createdDate: '2026-03-27',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fred Flyer', date: '2026-03-27', note: 'Referral from neighborhood Facebook group.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fred Flyer', date: '2026-03-29', note: 'Quick call — confirmed issue and availability. Working on appointment.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_011', name: 'Gerald Obi', address: '5220 Midway Rd, Plano TX 75093',
|
||||
phone: '(972) 555-0111', email: 'gerald.obi@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_contacted', bucketId: null,
|
||||
notes: 'Large home — 3,200 sq ft. Multiple storm claims history.',
|
||||
createdDate: '2026-03-22',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-03-22', note: 'Canvassing — prior storm claim visible in public records.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-03-25', note: 'Met in person at door. Very interested — asked for references.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── APPT SCHEDULED (6 leads) ────────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_012', name: 'Vanessa Obrien', address: '1450 Park Blvd, Plano TX 75074',
|
||||
phone: '(972) 555-0112', email: 'vanessa.ob@icloud.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_appt', bucketId: null,
|
||||
notes: 'Appointment Apr 9 at 10am. Adjuster already completed visit.',
|
||||
createdDate: '2026-03-18',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-03-18', note: 'Storm canvassing — visible hail damage.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-03-20', note: 'Phone call — very interested, adjuster already came out.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-03-22', note: 'Appointment confirmed for Apr 9 at 10am.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_013', name: 'Thomas Greer', address: '892 Mapleshade Ln, Plano TX 75075',
|
||||
phone: '(972) 555-0113', email: 'thomas.g@yahoo.com',
|
||||
jobType: 'Roof Inspection', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_appt', bucketId: null,
|
||||
notes: 'Appointment Apr 8 at 2pm. Retired homeowner, very detail-oriented.',
|
||||
createdDate: '2026-03-20',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-03-20', note: 'Referral from existing client.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-03-22', note: 'Email exchange — wants full report after inspection.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-03-25', note: 'Confirmed Apr 8 at 2pm via email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_014', name: 'Lashonda Webb', address: '3015 Plano Pkwy, Plano TX 75074',
|
||||
phone: '(972) 555-0114', email: 'lashonda.w@gmail.com',
|
||||
jobType: 'Siding + Gutters', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_appt', bucketId: null,
|
||||
notes: 'Combo job — siding and gutters. Apr 10 morning slot.',
|
||||
createdDate: '2026-03-15',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-03-15', note: 'Door knock — wind damage to siding and gutters overflowing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-03-17', note: 'Called and texted. Customer confirmed she filed insurance.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-03-21', note: 'Apr 10 morning slot — customer will have spouse present.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_015', name: 'Winston Park', address: '6601 Ohio Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0115', email: 'wpark@outlook.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e5', assignedAgentName: 'Felix Fixer',
|
||||
columnId: 'kc_appt', bucketId: null,
|
||||
notes: 'Large flat-roof section at the back. Appointment Apr 11.',
|
||||
createdDate: '2026-03-19',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felix Fixer', date: '2026-03-19', note: 'Website lead — searched storm damage repair.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felix Fixer', date: '2026-03-21', note: 'Spoke on phone. Mentioned flat roof section in addition to pitched.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felix Fixer', date: '2026-03-24', note: 'Confirmed Apr 11 at 9am. Will need extra time for flat section.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_016', name: 'Ingrid Solis', address: '2244 Aster Ct, Plano TX 75025',
|
||||
phone: '(972) 555-0116', email: 'ingrid.s@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_appt', bucketId: null,
|
||||
notes: 'Cash purchase — no insurance. Appointment Apr 12.',
|
||||
createdDate: '2026-03-23',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-03-23', note: 'Referral. Cash buyer — no insurance involved.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-03-25', note: 'Spoke briefly. She is very decisive — ready to move fast.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-03-27', note: 'Apr 12 at 11am confirmed.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_017', name: 'Darnell Brooks', address: '4408 Springbrook Dr, Plano TX 75024',
|
||||
phone: '(972) 555-0117', email: 'd.brooks@yahoo.com',
|
||||
jobType: 'Chimney + Roof Repair', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_appt', bucketId: null,
|
||||
notes: 'Chimney flashing leaking badly. Appointment rescheduled twice — Apr 14 final.',
|
||||
createdDate: '2026-03-10',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-03-10', note: 'Inbound call — chimney leak causing interior ceiling damage.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-03-12', note: 'Full conversation — insurance claim open. Adjuster not yet assigned.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-03-14', note: 'Apr 1 appointment set.' },
|
||||
{ from: 'Appt Scheduled', to: 'Contacted', by: 'Fiona Field', date: '2026-03-30', note: 'Customer rescheduled — work conflict. Moved back to Contacted.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-04-01', note: 'Rescheduled again — Apr 14 this time. Confirmed via text.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── ESTIMATE SENT (5 leads) ─────────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_018', name: 'Gloria Hutchins', address: '1738 Roundrock Trl, Plano TX 75023',
|
||||
phone: '(972) 555-0118', email: 'gloria.h@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_estimate', bucketId: null,
|
||||
notes: 'Estimate sent Mar 28. Waiting on her to review with husband.',
|
||||
createdDate: '2026-03-05',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-03-05', note: 'Canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-03-07', note: 'Phone call — confirmed hail damage.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-03-10', note: 'Appointment set for Mar 15.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Frank Agent', date: '2026-03-28', note: 'Inspection completed. Estimate emailed + printed copy left.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_019', name: 'Devon Mathis', address: '5902 Braewood Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0119', email: 'devon.m@hotmail.com',
|
||||
jobType: 'Siding Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_estimate', bucketId: null,
|
||||
notes: 'Estimate under review. Customer comparing quotes.',
|
||||
createdDate: '2026-03-08',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-03-08', note: 'Storm canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-03-10', note: 'Met at door — comparing two companies.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-03-13', note: 'Appointment Mar 18.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-03-25', note: 'Estimate sent via email. Mentioned competitor quote is $1,200 less.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_020', name: 'Karen Blankenship', address: '3127 Arbor Creek Dr, Plano TX 75025',
|
||||
phone: '(972) 555-0120', email: 'karen.b@yahoo.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_estimate', bucketId: null,
|
||||
notes: 'Cash deal. Estimate $24K. Very interested — just reviewing financing options.',
|
||||
createdDate: '2026-03-12',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-03-12', note: 'Referral from sister who is an existing client.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-03-14', note: 'Long first call — very motivated buyer.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-03-16', note: 'Inspection Mar 20 at 9am.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Fiona Field', date: '2026-03-22', note: 'Detailed estimate sent. Customer asked about payment plans.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_021', name: 'Lionel Chambers', address: '720 Coit Rd, Plano TX 75075',
|
||||
phone: '(972) 555-0121', email: 'lionel.c@gmail.com',
|
||||
jobType: 'Gutters + Fascia', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e5', assignedAgentName: 'Felix Fixer',
|
||||
columnId: 'kc_estimate', bucketId: null,
|
||||
notes: 'Adjuster approved full replacement. Estimate matches scope perfectly.',
|
||||
createdDate: '2026-03-01',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felix Fixer', date: '2026-03-01', note: 'Referral — gutters completely detached on one side.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felix Fixer', date: '2026-03-03', note: 'Spoke on phone — insurance claim already filed.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felix Fixer', date: '2026-03-05', note: 'Mar 10 appointment.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felix Fixer', date: '2026-03-18', note: 'Estimate emailed. Adjuster scope matches ours exactly — good sign.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_022', name: 'Ruthanne Patel', address: '4321 Hedgcoxe Rd, Plano TX 75024',
|
||||
phone: '(972) 555-0122', email: 'rpatel@outlook.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_estimate', bucketId: null,
|
||||
notes: 'Estimate sent 3 weeks ago. Not responding to follow-ups.',
|
||||
createdDate: '2026-02-20',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-02-20', note: 'Canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-02-22', note: 'Brief call — seemed interested.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-02-25', note: 'Mar 1 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Frank Agent', date: '2026-03-10', note: 'Estimate sent. No response to 3 follow-up texts since.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── SIGNED (5 leads) ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_023', name: 'Charlotte Norris', address: '2908 Roundabout Ln, Plano TX 75023',
|
||||
phone: '(972) 555-0123', email: 'charlotte.n@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_signed', bucketId: null,
|
||||
notes: 'Signed Mar 22. Install scheduled for Apr 14.',
|
||||
createdDate: '2026-03-01',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-03-01', note: 'Storm canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-03-03', note: 'Phone call — eager to move quickly.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-03-05', note: 'Inspection Mar 8.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-03-10', note: 'Estimate sent — $18,200.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felicity Fast', date: '2026-03-22', note: 'Contract signed! Install Apr 14. Deposit received.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_024', name: 'Henry Castaneda', address: '5614 Ridgewood Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0124', email: 'hcastaneda@yahoo.com',
|
||||
jobType: 'Siding Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_signed', bucketId: null,
|
||||
notes: 'Signed. Full siding replacement — 2,400 sq ft. Production queue.',
|
||||
createdDate: '2026-02-28',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-02-28', note: 'Referral.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-03-02', note: 'Phone call and site visit same day.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-03-04', note: 'Mar 7 appointment.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Fiona Field', date: '2026-03-12', note: 'Estimate $31,500 for full siding.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Fiona Field', date: '2026-03-20', note: 'Signed. Insurance approved full scope.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_025', name: 'Yvonne Blackwell', address: '3401 Parker Rd, Plano TX 75023',
|
||||
phone: '(972) 555-0125', email: 'yblackwell@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_signed', bucketId: null,
|
||||
notes: 'Cash client. Signed fast — paid 50% deposit upfront.',
|
||||
createdDate: '2026-03-05',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-03-05', note: 'Referral — cash buyer, wants premium materials.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-03-06', note: 'Met same day — very decisive.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-03-07', note: 'Next-day inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Frank Agent', date: '2026-03-09', note: 'Same-day estimate — $27,800 for full replacement + ice/water barrier.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Frank Agent', date: '2026-03-11', note: 'Signed within 48 hrs. 50% deposit paid.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_026', name: 'Emmanuel Okafor', address: '4812 Mapleshade Ln, Plano TX 75075',
|
||||
phone: '(972) 555-0126', email: 'e.okafor@outlook.com',
|
||||
jobType: 'Roof + Gutters', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e5', assignedAgentName: 'Felix Fixer',
|
||||
columnId: 'kc_signed', bucketId: null,
|
||||
notes: 'Combined roof and gutter job. Production scheduled Apr 18.',
|
||||
createdDate: '2026-03-03',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felix Fixer', date: '2026-03-03', note: 'Storm canvassing — gutters completely detached on west side.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felix Fixer', date: '2026-03-05', note: 'Called and texted. Confirmed both roof and gutter damage.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felix Fixer', date: '2026-03-08', note: 'Mar 12 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felix Fixer', date: '2026-03-15', note: 'Combined estimate $22,400 sent.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felix Fixer', date: '2026-03-25', note: 'Signed after adjuster confirmation. Install Apr 18.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_027', name: 'Adriana Voss', address: '1820 Custer Rd, Plano TX 75075',
|
||||
phone: '(972) 555-0127', email: 'adriana.v@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_signed', bucketId: null,
|
||||
notes: 'Signed after lengthy negotiation on supplemental items.',
|
||||
createdDate: '2026-02-15',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-02-15', note: 'Canvassing after January wind event.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-02-17', note: 'Called — open to inspection.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-02-19', note: 'Feb 22 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-02-25', note: 'Estimate sent $19,600.' },
|
||||
{ from: 'Estimate Sent', to: 'Contacted', by: 'Felicity Fast', date: '2026-03-05', note: 'Customer pushed back on supplement items — moved back to review.' },
|
||||
{ from: 'Contacted', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-03-10', note: 'Revised estimate sent after supplement agreement with adjuster.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felicity Fast', date: '2026-03-28', note: 'Signed after adjuster approved supplemental items.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── IN PROGRESS (6 leads) ───────────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_028', name: 'Marvin Delgado', address: '6820 Windhaven Pkwy, Plano TX 75024',
|
||||
phone: '(972) 555-0128', email: 'mdelgado@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_progress', bucketId: null,
|
||||
notes: 'Tear-off complete. New decking going on today.',
|
||||
createdDate: '2026-02-10',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-02-10', note: 'Storm canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-02-12', note: 'Call completed.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-02-14', note: 'Feb 17 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Frank Agent', date: '2026-02-20', note: 'Estimate sent $16,800.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Frank Agent', date: '2026-02-28', note: 'Signed. Production team notified.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Adam Admin', date: '2026-03-18', note: 'Production started — tear-off crew on site.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_029', name: 'Sandra Tran', address: '2506 Springpark Dr, Plano TX 75023',
|
||||
phone: '(972) 555-0129', email: 's.tran@yahoo.com',
|
||||
jobType: 'Siding Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_progress', bucketId: null,
|
||||
notes: 'Day 3 of install. On track for completion Friday.',
|
||||
createdDate: '2026-02-08',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-02-08', note: 'Referral.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-02-10', note: 'Initial call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-02-12', note: 'Feb 15 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Fiona Field', date: '2026-02-18', note: 'Estimate $28,500 for full siding.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Fiona Field', date: '2026-02-25', note: 'Signed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Adam Admin', date: '2026-03-25', note: 'Siding crew on site — Day 1.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_030', name: 'Reginald Hopper', address: '1504 Estates Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0130', email: 'reg.hopper@icloud.com',
|
||||
jobType: 'Roof + Gutters', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_progress', bucketId: null,
|
||||
notes: 'Roofing done. Gutters crew arriving tomorrow.',
|
||||
createdDate: '2026-02-05',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-02-05', note: 'Door knock.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-02-07', note: 'Call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-02-10', note: 'Feb 14 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-02-17', note: 'Estimate sent $21,200.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felicity Fast', date: '2026-02-24', note: 'Signed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Amanda Manager',date: '2026-03-20', note: 'Production underway — roofing crew Day 1.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_031', name: 'Nadia Thornton', address: '5150 Preston Rd, Plano TX 75024',
|
||||
phone: '(972) 555-0131', email: 'nadia.t@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e5', assignedAgentName: 'Felix Fixer',
|
||||
columnId: 'kc_progress', bucketId: null,
|
||||
notes: 'Premium GAF Timberline HDZ install. Custom color.',
|
||||
createdDate: '2026-02-12',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felix Fixer', date: '2026-02-12', note: 'Referral — high-end home.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felix Fixer', date: '2026-02-14', note: 'Met in person. Requested premium material options.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felix Fixer', date: '2026-02-17', note: 'Feb 20 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felix Fixer', date: '2026-02-24', note: 'Premium estimate $34,500 including ice/water and lifetime warranty.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felix Fixer', date: '2026-03-03', note: 'Signed — wants Barkwood color. Special order placed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Amanda Manager',date: '2026-03-28', note: 'Materials arrived. Install started today.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_032', name: 'Clifford Aguilar', address: '3822 Haverwood Ln, Plano TX 75023',
|
||||
phone: '(972) 555-0132', email: 'c.aguilar@outlook.com',
|
||||
jobType: 'Siding Repair', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_progress', bucketId: null,
|
||||
notes: 'Partial siding repair only — south and west faces. 2 days left.',
|
||||
createdDate: '2026-02-18',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-02-18', note: 'Storm canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-02-20', note: 'Called.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-02-22', note: 'Feb 25 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Frank Agent', date: '2026-02-28', note: 'Partial repair estimate $9,400.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Frank Agent', date: '2026-03-07', note: 'Signed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Adam Admin', date: '2026-04-01', note: 'Crew on site — south face complete.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_033', name: 'Tamara Owens', address: '2010 Oak Creek Blvd, Plano TX 75023',
|
||||
phone: '(972) 555-0133', email: 'tamara.o@yahoo.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_progress', bucketId: null,
|
||||
notes: 'Large job — 3,600 sq ft. Day 1 of 3.',
|
||||
createdDate: '2026-02-01',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-02-01', note: 'Referral — large home, significant hail damage.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-02-03', note: 'Met in person — urgent, water coming in.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-02-04', note: 'Next-day inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Fiona Field', date: '2026-02-08', note: 'Estimate $42,000 for 3,600 sq ft.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Fiona Field', date: '2026-02-15', note: 'Signed — largest job this quarter.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Adam Admin', date: '2026-04-05', note: 'Production started — 3-day job. Two crews deployed.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── COMPLETE (5 leads) ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_034', name: 'Craig Singleton', address: '4110 Lorimar Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0134', email: 'craig.s@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_complete', bucketId: null,
|
||||
notes: 'Job complete. Final walkthrough done. Client extremely happy.',
|
||||
createdDate: '2026-01-15',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-01-15', note: 'Canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-01-17', note: 'Call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-01-20', note: 'Jan 23 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Frank Agent', date: '2026-01-25', note: 'Estimate $17,200.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Frank Agent', date: '2026-02-01', note: 'Signed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Adam Admin', date: '2026-02-20', note: 'Install Day 1.' },
|
||||
{ from: 'In Progress', to: 'Complete', by: 'Adam Admin', date: '2026-03-01', note: 'Job complete. Final walkthrough passed. Client left 5-star review.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_035', name: 'Bertha Fleming', address: '6230 West Parker Rd, Plano TX 75093',
|
||||
phone: '(972) 555-0135', email: 'bertha.f@hotmail.com',
|
||||
jobType: 'Siding Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_complete', bucketId: null,
|
||||
notes: 'Complete. Insurance check received and deposited.',
|
||||
createdDate: '2026-01-10',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-01-10', note: 'Referral.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-01-12', note: 'Call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-01-14', note: 'Jan 17 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-01-20', note: 'Estimate $26,400.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felicity Fast', date: '2026-01-28', note: 'Signed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Amanda Manager',date: '2026-02-15', note: 'Production started.' },
|
||||
{ from: 'In Progress', to: 'Complete', by: 'Amanda Manager',date: '2026-02-22', note: 'Complete. Insurance proceeds processed.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_036', name: 'Oliver Stanton', address: '3750 Legacy Dr, Plano TX 75023',
|
||||
phone: '(972) 555-0136', email: 'oliver.st@outlook.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_complete', bucketId: null,
|
||||
notes: 'Cash deal done. Referral bonus owed to Oliver per agreement.',
|
||||
createdDate: '2026-01-20',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-01-20', note: 'Referral — cash buyer.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-01-22', note: 'Same-day call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fiona Field', date: '2026-01-23', note: 'Jan 25 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Fiona Field', date: '2026-01-27', note: 'Estimate $22,100.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Fiona Field', date: '2026-01-30', note: 'Signed within 72 hrs. Full payment upfront.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Adam Admin', date: '2026-02-18', note: 'Install started.' },
|
||||
{ from: 'In Progress', to: 'Complete', by: 'Adam Admin', date: '2026-02-20', note: 'Complete — quick 2-day job. Outstanding quality.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_037', name: 'Daphne Garrett', address: '1912 Mira Vista Dr, Plano TX 75025',
|
||||
phone: '(972) 555-0137', email: 'daphne.g@gmail.com',
|
||||
jobType: 'Gutters + Fascia', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e5', assignedAgentName: 'Felix Fixer',
|
||||
columnId: 'kc_complete', bucketId: null,
|
||||
notes: 'Complete. Customer requested referral card — strong close.',
|
||||
createdDate: '2026-01-25',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felix Fixer', date: '2026-01-25', note: 'Door knock.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felix Fixer', date: '2026-01-27', note: 'Call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felix Fixer', date: '2026-01-29', note: 'Feb 1 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felix Fixer', date: '2026-02-04', note: 'Estimate $8,900.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felix Fixer', date: '2026-02-10', note: 'Signed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Adam Admin', date: '2026-02-28', note: 'Install started.' },
|
||||
{ from: 'In Progress', to: 'Complete', by: 'Adam Admin', date: '2026-03-02', note: 'Job done. Customer gave referral card to 3 neighbors.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_038', name: 'Nathaniel Cruz', address: '5808 Tennyson Pkwy, Plano TX 75024',
|
||||
phone: '(972) 555-0138', email: 'ncruz@icloud.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_complete', bucketId: null,
|
||||
notes: 'Complete. Longest pipeline — 7 week close. Insurance supplemental was complex.',
|
||||
createdDate: '2026-01-05',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-01-05', note: 'Canvassing post-December storm.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-01-07', note: 'Call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felicity Fast', date: '2026-01-10', note: 'Jan 14 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-01-17', note: 'Estimate $19,400.' },
|
||||
{ from: 'Estimate Sent', to: 'Contacted', by: 'Felicity Fast', date: '2026-01-28', note: 'Insurance underpaid — back to negotiation.' },
|
||||
{ from: 'Contacted', to: 'Estimate Sent', by: 'Felicity Fast', date: '2026-02-05', note: 'Revised estimate submitted to adjuster.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Felicity Fast', date: '2026-02-18', note: 'Adjuster approved supplement. Signed.' },
|
||||
{ from: 'Signed', to: 'In Progress', by: 'Amanda Manager',date: '2026-03-05', note: 'Production started.' },
|
||||
{ from: 'In Progress', to: 'Complete', by: 'Amanda Manager',date: '2026-03-12', note: 'Job complete. Long road but great outcome.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── STUCK bucket (3 leads — have columnId from last stage + bucketId) ───
|
||||
{
|
||||
id: 'kl_039', name: 'Jasmine Fowler', address: '2730 Hedgcoxe Rd, Plano TX 75024',
|
||||
phone: '(972) 555-0139', email: 'jasmine.f@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e3', assignedAgentName: 'Fred Flyer',
|
||||
columnId: 'kc_estimate', bucketId: 'kc_stuck',
|
||||
notes: 'Stuck — adjuster denied claim. Customer disputing. No movement for 3 weeks.',
|
||||
createdDate: '2026-02-25',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fred Flyer', date: '2026-02-25', note: 'Canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fred Flyer', date: '2026-02-27', note: 'Call — insurance claim just filed.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fred Flyer', date: '2026-03-01', note: 'Mar 5 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Fred Flyer', date: '2026-03-10', note: 'Estimate sent $15,800. Adjuster denied most of scope.' },
|
||||
{ from: 'Estimate Sent', to: 'Stuck', by: 'Fred Flyer', date: '2026-03-18', note: 'Moved to Stuck — adjuster denial under dispute. Waiting on public adjuster.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_040', name: 'Calvin Merritt', address: '3640 Alma Dr, Plano TX 75023',
|
||||
phone: '(972) 555-0140', email: 'calvin.m@yahoo.com',
|
||||
jobType: 'Siding Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e2', assignedAgentName: 'Fiona Field',
|
||||
columnId: 'kc_contacted', bucketId: 'kc_stuck',
|
||||
notes: 'Stuck — homeowner going through divorce. Put project on hold indefinitely.',
|
||||
createdDate: '2026-03-10',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fiona Field', date: '2026-03-10', note: 'Referral.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fiona Field', date: '2026-03-12', note: 'Good initial call but complicated personal situation mentioned.' },
|
||||
{ from: 'Contacted', to: 'Stuck', by: 'Fiona Field', date: '2026-03-20', note: 'Moved to Stuck — customer put everything on hold due to divorce proceedings.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_041', name: 'Harriet Simmons', address: '4905 Ohio Dr, Plano TX 75093',
|
||||
phone: '(972) 555-0141', email: 'harriet.s@outlook.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e5', assignedAgentName: 'Felix Fixer',
|
||||
columnId: 'kc_appt', bucketId: 'kc_stuck',
|
||||
notes: 'Stuck — missed 3 appointments. No answer on phone. Left door hanger.',
|
||||
createdDate: '2026-03-05',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felix Fixer', date: '2026-03-05', note: 'Door knock — interested in retail replacement.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felix Fixer', date: '2026-03-07', note: 'Brief call — set appointment.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Felix Fixer', date: '2026-03-09', note: 'Mar 14 appointment set.' },
|
||||
{ from: 'Appt Scheduled',to: 'Stuck', by: 'Felix Fixer', date: '2026-03-28', note: 'No-showed 3 appointments. Phone goes to voicemail. Marked stuck.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── FOLLOW-UP bucket (3 leads) ───────────────────────────────────────────
|
||||
{
|
||||
id: 'kl_042', name: 'Floyd Saunders', address: '1344 Spring Creek Pkwy, Plano TX 75023',
|
||||
phone: '(972) 555-0142', email: 'floyd.s@gmail.com',
|
||||
jobType: 'Roof Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e1', assignedAgentName: 'Frank Agent',
|
||||
columnId: 'kc_estimate', bucketId: 'kc_followup',
|
||||
notes: 'Follow-up Apr 10 — customer said to call after spring break. Very warm lead.',
|
||||
createdDate: '2026-03-15',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Frank Agent', date: '2026-03-15', note: 'Storm canvassing.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Frank Agent', date: '2026-03-17', note: 'Call — interested but said call back after spring break.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Frank Agent', date: '2026-03-18', note: 'Mar 20 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Frank Agent', date: '2026-03-22', note: 'Estimate sent $17,600.' },
|
||||
{ from: 'Estimate Sent', to: 'Follow-up', by: 'Frank Agent', date: '2026-03-25', note: 'Moved to Follow-up — scheduled callback Apr 10.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_043', name: 'Miriam Obando', address: '2217 Independence Pkwy, Plano TX 75025',
|
||||
phone: '(972) 555-0143', email: 'miriam.o@icloud.com',
|
||||
jobType: 'Roof Inspection', insuranceType: 'Retail',
|
||||
assignedAgentId: 'e4', assignedAgentName: 'Felicity Fast',
|
||||
columnId: 'kc_contacted', bucketId: 'kc_followup',
|
||||
notes: 'Follow-up Apr 15 — currently traveling abroad. Callback confirmed via text.',
|
||||
createdDate: '2026-03-20',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Felicity Fast', date: '2026-03-20', note: 'Website form lead.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Felicity Fast', date: '2026-03-22', note: 'Text conversation — customer is abroad until Apr 13.' },
|
||||
{ from: 'Contacted', to: 'Follow-up', by: 'Felicity Fast', date: '2026-03-23', note: 'Moved to Follow-up — callback Apr 15 confirmed via text.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kl_044', name: 'Jerome Watkins', address: '3908 Plano Pkwy, Plano TX 75074',
|
||||
phone: '(972) 555-0144', email: 'jerome.w@yahoo.com',
|
||||
jobType: 'Gutter Replacement', insuranceType: 'Insurance',
|
||||
assignedAgentId: 'e3', assignedAgentName: 'Fred Flyer',
|
||||
columnId: 'kc_signed', bucketId: 'kc_followup',
|
||||
notes: 'Signed but waiting on HOA approval before starting. Follow-up Apr 20.',
|
||||
createdDate: '2026-03-08',
|
||||
activity: [
|
||||
{ from: null, to: 'New Lead', by: 'Fred Flyer', date: '2026-03-08', note: 'Referral.' },
|
||||
{ from: 'New Lead', to: 'Contacted', by: 'Fred Flyer', date: '2026-03-10', note: 'Call.' },
|
||||
{ from: 'Contacted', to: 'Appt Scheduled', by: 'Fred Flyer', date: '2026-03-12', note: 'Mar 15 inspection.' },
|
||||
{ from: 'Appt Scheduled', to: 'Estimate Sent', by: 'Fred Flyer', date: '2026-03-18', note: 'Estimate $6,200.' },
|
||||
{ from: 'Estimate Sent', to: 'Signed', by: 'Fred Flyer', date: '2026-03-25', note: 'Signed — waiting on HOA approval to start.' },
|
||||
{ from: 'Signed', to: 'Follow-up', by: 'Fred Flyer', date: '2026-03-28', note: 'HOA review pending. Follow-up Apr 20 for approval status.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// --- CONTEXT SETUP ---
|
||||
|
||||
const MockStoreContext = createContext();
|
||||
@@ -3642,6 +4344,8 @@ export const MockStoreProvider = ({ children }) => {
|
||||
const [templateAccessRoles, setTemplateAccessRoles] = useState([]);
|
||||
const [templateAccessUsers, setTemplateAccessUsers] = useState([]);
|
||||
const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]);
|
||||
const [kanbanColumns, setKanbanColumns] = useState(KANBAN_COLUMNS_INITIAL);
|
||||
const [kanbanLeads, setKanbanLeads] = useState(KANBAN_LEADS_INITIAL);
|
||||
|
||||
// Initialize properties once
|
||||
useEffect(() => {
|
||||
@@ -3762,6 +4466,42 @@ export const MockStoreProvider = ({ children }) => {
|
||||
setTemplateAccessUsers,
|
||||
setTemplateAccessExcluded,
|
||||
|
||||
// Kanban
|
||||
kanbanColumns,
|
||||
kanbanLeads,
|
||||
moveKanbanLead: (leadId, newColumnId, newBucketId) => {
|
||||
setKanbanLeads(prev => prev.map(l => {
|
||||
if (l.id !== leadId) return l;
|
||||
const col = KANBAN_COLUMNS_INITIAL.find(c => c.id === newColumnId) ||
|
||||
kanbanColumns.find(c => c.id === newColumnId);
|
||||
const stageName = col?.name ?? newColumnId;
|
||||
const newActivity = {
|
||||
from: l.bucketId
|
||||
? l.kanbanColumns?.find?.(c => c.id === l.bucketId)?.name ?? 'Bucket'
|
||||
: (kanbanColumns.find(c => c.id === l.columnId)?.name ?? l.columnId),
|
||||
to: stageName,
|
||||
by: 'You',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
note: null,
|
||||
};
|
||||
return { ...l, columnId: newColumnId, bucketId: newBucketId ?? null, activity: [newActivity, ...l.activity] };
|
||||
}));
|
||||
},
|
||||
addKanbanColumn: (col) => {
|
||||
const newCol = { ...col, id: `kc_custom_${Date.now()}` };
|
||||
setKanbanColumns(prev => [...prev, newCol]);
|
||||
return newCol;
|
||||
},
|
||||
updateKanbanColumn: (id, data) => {
|
||||
setKanbanColumns(prev => prev.map(c => c.id === id ? { ...c, ...data } : c));
|
||||
},
|
||||
deleteKanbanColumn: (id) => {
|
||||
setKanbanColumns(prev => prev.filter(c => c.id !== id));
|
||||
},
|
||||
updateKanbanLead: (id, data) => {
|
||||
setKanbanLeads(prev => prev.map(l => l.id === id ? { ...l, ...data } : l));
|
||||
},
|
||||
|
||||
// Estimates
|
||||
estimates,
|
||||
addEstimate: (est) => {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext, DragOverlay, PointerSensor, TouchSensor,
|
||||
useSensor, useSensors, closestCorners
|
||||
} 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 KanbanCard from '../components/kanban/KanbanCard';
|
||||
import KanbanColumn from '../components/kanban/KanbanColumn';
|
||||
import LeadInfoDrawer from '../components/kanban/LeadInfoDrawer';
|
||||
import ColumnManagerModal from '../components/kanban/ColumnManagerModal';
|
||||
|
||||
// Campaign toast with countdown + Edit option
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ duration: 20000, position: 'bottom-right' }
|
||||
);
|
||||
}
|
||||
|
||||
// Campaign Edit Modal
|
||||
function CampaignEditModal({ message, lead, column, onClose }) {
|
||||
const [text, setText] = useState(message);
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-md p-5"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white text-sm">Edit Campaign Message</h3>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">To: {lead?.name} · via {column?.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"><X size={15} /></button>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 resize-none mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 px-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">Cancel</button>
|
||||
<button
|
||||
onClick={() => { toast.success('Campaign message sent'); onClose(); }}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanbanPage() {
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
kanbanColumns, kanbanLeads,
|
||||
moveKanbanLead, addKanbanColumn, updateKanbanColumn, deleteKanbanColumn,
|
||||
} = useMockStore();
|
||||
|
||||
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);
|
||||
|
||||
// Column manager modal
|
||||
const [colModal, setColModal] = useState(null); // { mode: 'add'|'rename'|'delete', column?, leadsCount? }
|
||||
|
||||
// Campaign edit modal
|
||||
const [campaignEdit, setCampaignEdit] = useState(null); // { message, lead, column }
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
);
|
||||
|
||||
const stageColumns = useMemo(() =>
|
||||
[...kanbanColumns].filter(c => c.type === 'stage').sort((a, b) => a.order - b.order),
|
||||
[kanbanColumns]);
|
||||
|
||||
const bucketColumns = useMemo(() =>
|
||||
[...kanbanColumns].filter(c => c.type === 'bucket').sort((a, b) => a.order - b.order),
|
||||
[kanbanColumns]);
|
||||
|
||||
const allColumns = useMemo(() => [...stageColumns, ...bucketColumns], [stageColumns, bucketColumns]);
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (!search.trim()) return kanbanLeads;
|
||||
const q = search.toLowerCase();
|
||||
return kanbanLeads.filter(l =>
|
||||
l.name.toLowerCase().includes(q) ||
|
||||
l.address.toLowerCase().includes(q) ||
|
||||
(l.assignedAgentName || '').toLowerCase().includes(q) ||
|
||||
l.jobType.toLowerCase().includes(q)
|
||||
);
|
||||
}, [kanbanLeads, search]);
|
||||
|
||||
const leadsForColumn = useCallback((colId) => {
|
||||
return filteredLeads.filter(l => {
|
||||
const col = kanbanColumns.find(c => c.id === colId);
|
||||
if (col?.type === 'bucket') return l.bucketId === colId;
|
||||
return l.columnId === colId && !l.bucketId;
|
||||
});
|
||||
}, [filteredLeads, kanbanColumns]);
|
||||
|
||||
const handleDragStart = ({ active }) => {
|
||||
const lead = kanbanLeads.find(l => l.id === active.id);
|
||||
setDraggingLead(lead ?? null);
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ active, over }) => {
|
||||
setDraggingLead(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const targetCol = kanbanColumns.find(c => c.id === over.id);
|
||||
if (!targetCol) return;
|
||||
|
||||
const lead = kanbanLeads.find(l => l.id === active.id);
|
||||
if (!lead) return;
|
||||
|
||||
const isBucket = targetCol.type === 'bucket';
|
||||
const newColumnId = isBucket ? lead.columnId : targetCol.id;
|
||||
const newBucketId = isBucket ? targetCol.id : null;
|
||||
|
||||
if (newColumnId === lead.columnId && newBucketId === lead.bucketId) return;
|
||||
|
||||
moveKanbanLead(active.id, newColumnId, newBucketId);
|
||||
|
||||
// Fire campaign toast for stage drops only
|
||||
if (!isBucket) {
|
||||
setTimeout(() => {
|
||||
fireCampaignToast(lead, targetCol, (msg, l, col) => {
|
||||
setCampaignEdit({ message: msg, lead: l, column: col });
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const openCard = (lead) => {
|
||||
setSelectedLead(lead);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
// Column manager handlers
|
||||
const handleAddColumn = () => setColModal({ mode: 'add' });
|
||||
const handleRenameColumn = (col) => setColModal({ mode: 'rename', column: col });
|
||||
const handleDeleteColumn = (col) => {
|
||||
const count = leadsForColumn(col.id).length;
|
||||
setColModal({ mode: 'delete', column: col, leadsCount: count });
|
||||
};
|
||||
|
||||
const handleColModalSave = (data) => {
|
||||
const { mode, column } = colModal;
|
||||
if (mode === 'add') {
|
||||
const maxOrder = Math.max(...stageColumns.map(c => c.order), 0);
|
||||
addKanbanColumn({ ...data, type: 'stage', order: maxOrder + 1 });
|
||||
toast.success(`"${data.name}" stage added`);
|
||||
} else if (mode === 'rename') {
|
||||
updateKanbanColumn(column.id, data);
|
||||
toast.success('Stage updated');
|
||||
} else if (mode === 'delete') {
|
||||
deleteKanbanColumn(column.id);
|
||||
toast.success(`"${column.name}" deleted`);
|
||||
}
|
||||
setColModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen 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="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>
|
||||
<div>
|
||||
<h1 className="text-xl font-black uppercase tracking-tight text-zinc-900 dark:text-white leading-none"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search leads..."
|
||||
className="w-full pl-8 pr-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{/* Stage columns */}
|
||||
{stageColumns.map((col, i) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
leads={leadsForColumn(col.id)}
|
||||
stageColumns={stageColumns}
|
||||
canManage={canManage}
|
||||
onCardClick={openCard}
|
||||
onAddColumn={handleAddColumn}
|
||||
onRenameColumn={handleRenameColumn}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
isLast={i === stageColumns.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Bucket columns */}
|
||||
{bucketColumns.map((col) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
leads={leadsForColumn(col.id)}
|
||||
stageColumns={allColumns}
|
||||
canManage={false}
|
||||
onCardClick={openCard}
|
||||
onAddColumn={() => {}}
|
||||
onRenameColumn={() => {}}
|
||||
onDeleteColumn={() => {}}
|
||||
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>
|
||||
|
||||
{/* Lead info drawer */}
|
||||
<LeadInfoDrawer
|
||||
lead={selectedLead}
|
||||
columns={kanbanColumns}
|
||||
isOpen={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Column manager modal */}
|
||||
<AnimatePresence>
|
||||
{colModal && (
|
||||
<ColumnManagerModal
|
||||
mode={colModal.mode}
|
||||
column={colModal.column}
|
||||
leadsInColumn={colModal.leadsCount ?? 0}
|
||||
onSave={handleColModalSave}
|
||||
onClose={() => setColModal(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Campaign edit modal */}
|
||||
<AnimatePresence>
|
||||
{campaignEdit && (
|
||||
<CampaignEditModal
|
||||
{...campaignEdit}
|
||||
onClose={() => setCampaignEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useAuth, ROLES } from '../context/AuthContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowLeft, Phone, Mail, MapPin, User, Briefcase,
|
||||
Shield, Clock, FileText, ChevronRight, ArrowUpRight,
|
||||
ArrowDownLeft, ArrowRight, MessageSquare, CheckCircle,
|
||||
LayoutGrid, ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-zinc-100 dark:border-white/[0.05] last:border-0">
|
||||
<Icon size={14} className="text-zinc-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] text-zinc-400 mb-0.5">{label}</p>
|
||||
<p className="text-sm text-zinc-800 dark:text-zinc-100 font-medium">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressHeader({ lead, columns, onStageChange }) {
|
||||
const [stageOpen, setStageOpen] = useState(false);
|
||||
const stageColumns = columns.filter(c => c.type === 'stage').sort((a, b) => a.order - b.order);
|
||||
const bucketColumns = columns.filter(c => c.type === 'bucket');
|
||||
const stageIdx = stageColumns.findIndex(c => c.id === lead.columnId);
|
||||
const totalStages = stageColumns.length;
|
||||
const pct = totalStages > 0 ? Math.round(((stageIdx + 1) / totalStages) * 100) : 0;
|
||||
const currentStageCol = stageColumns[stageIdx];
|
||||
const bucketCol = lead.bucketId ? columns.find(c => c.id === lead.bucketId) : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5 mb-5">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{currentStageCol && (
|
||||
<span className="text-sm font-bold px-3 py-1 rounded-full"
|
||||
style={{ backgroundColor: currentStageCol.color + '18', color: currentStageCol.color }}>
|
||||
{currentStageCol.name}
|
||||
</span>
|
||||
)}
|
||||
{bucketCol && (
|
||||
<span className="text-sm font-bold px-3 py-1 rounded-full"
|
||||
style={{ backgroundColor: bucketCol.color + '18', color: bucketCol.color }}>
|
||||
{bucketCol.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-bold text-zinc-500 dark:text-zinc-400">{pct}% complete</span>
|
||||
</div>
|
||||
|
||||
{/* Stage mover */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setStageOpen(v => !v)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 text-sm font-semibold hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
Move Stage <ChevronDown size={13} />
|
||||
</button>
|
||||
{stageOpen && (
|
||||
<div className="absolute right-0 top-full mt-1.5 z-20 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl shadow-lg overflow-hidden min-w-[180px]">
|
||||
<p className="px-3 py-2 text-[10px] font-bold text-zinc-400 uppercase tracking-widest border-b border-zinc-100 dark:border-white/[0.07]">Stages</p>
|
||||
{stageColumns.map(col => (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={() => { onStageChange(col.id, null); setStageOpen(false); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-left hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors ${col.id === lead.columnId && !lead.bucketId ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
||||
{col.name}
|
||||
{col.id === lead.columnId && !lead.bucketId && <CheckCircle size={12} className="ml-auto text-emerald-500" />}
|
||||
</button>
|
||||
))}
|
||||
<p className="px-3 py-2 text-[10px] font-bold text-zinc-400 uppercase tracking-widest border-t border-zinc-100 dark:border-white/[0.07]">Buckets</p>
|
||||
{bucketColumns.map(col => (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={() => { onStageChange(lead.columnId, col.id); setStageOpen(false); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-left hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors ${col.id === lead.bucketId ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
||||
{col.name}
|
||||
{col.id === lead.bucketId && <CheckCircle size={12} className="ml-auto text-emerald-500" />}
|
||||
</button>
|
||||
))}
|
||||
{lead.bucketId && (
|
||||
<>
|
||||
<div className="border-t border-zinc-100 dark:border-white/[0.07]" />
|
||||
<button
|
||||
onClick={() => { onStageChange(lead.columnId, null); setStageOpen(false); }}
|
||||
className="w-full px-3 py-2.5 text-sm text-left text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Clear bucket
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2.5 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden mb-3">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.7, ease: 'easeOut' }}
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: currentStageCol?.color ?? '#3B82F6' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stage steps breadcrumb */}
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{stageColumns.map((col, i) => (
|
||||
<React.Fragment key={col.id}>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${
|
||||
i < stageIdx ? 'text-zinc-400 dark:text-zinc-500' :
|
||||
i === stageIdx ? 'text-zinc-800 dark:text-zinc-100' :
|
||||
'text-zinc-300 dark:text-zinc-700'
|
||||
}`}>
|
||||
{col.name}
|
||||
</span>
|
||||
{i < stageColumns.length - 1 && (
|
||||
<ChevronRight size={10} className="text-zinc-300 dark:text-zinc-700 shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityItem({ entry, isLast }) {
|
||||
const isFirst = !entry.from;
|
||||
const isRegression = entry.from && (
|
||||
entry.to === 'Contacted' || entry.to === 'New Lead' ||
|
||||
(entry.from !== null && entry.activity_type === 'regression')
|
||||
);
|
||||
const isBucket = entry.to === 'Stuck' || entry.to === 'Follow-up';
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||||
isFirst ? 'bg-blue-100 dark:bg-blue-500/20' :
|
||||
isBucket ? 'bg-amber-100 dark:bg-amber-500/20' :
|
||||
isRegression ? 'bg-orange-100 dark:bg-orange-500/20' :
|
||||
'bg-emerald-100 dark:bg-emerald-500/20'
|
||||
}`}>
|
||||
{isFirst ? <ArrowRight size={13} className="text-blue-600 dark:text-blue-400" /> :
|
||||
isBucket ? <MessageSquare size={13} className="text-amber-600 dark:text-amber-400" /> :
|
||||
isRegression ? <ArrowDownLeft size={13} className="text-orange-600 dark:text-orange-400" /> :
|
||||
<ArrowUpRight size={13} className="text-emerald-600 dark:text-emerald-400" />}
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-zinc-100 dark:bg-zinc-800 my-2 min-h-[20px]" />}
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 min-w-0 ${!isLast ? 'pb-6' : ''}`}>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-100 dark:border-white/[0.07] rounded-xl px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div>
|
||||
{entry.from ? (
|
||||
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span className="text-zinc-400">{entry.from}</span>
|
||||
<ArrowRight size={12} className="inline mx-1.5 text-zinc-400" />
|
||||
<span style={{ color: isBucket ? '#F59E0B' : isRegression ? '#F97316' : undefined }}>
|
||||
{entry.to}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">Lead Created</p>
|
||||
)}
|
||||
<p className="text-xs text-zinc-400 mt-0.5">by <span className="font-medium text-zinc-600 dark:text-zinc-300">{entry.by}</span> · {formatDate(entry.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{entry.note && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mt-2 pt-2 border-t border-zinc-100 dark:border-white/[0.05]">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeadProjectPage() {
|
||||
const { leadId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const { kanbanLeads, kanbanColumns, moveKanbanLead } = useMockStore();
|
||||
|
||||
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
||||
|
||||
const lead = kanbanLeads.find(l => l.id === leadId);
|
||||
|
||||
const handleStageChange = (newColumnId, newBucketId) => {
|
||||
if (!lead) return;
|
||||
if (newColumnId === lead.columnId && newBucketId === lead.bucketId) return;
|
||||
moveKanbanLead(lead.id, newColumnId, newBucketId);
|
||||
toast.success('Stage updated');
|
||||
};
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
<div className="text-center">
|
||||
<FileText size={32} className="mx-auto mb-3 text-zinc-300 dark:text-zinc-700" />
|
||||
<p className="text-sm font-semibold text-zinc-500">Lead not found</p>
|
||||
<button onClick={() => navigate(-1)} className="mt-3 text-xs text-blue-600 dark:text-blue-400 hover:underline">Go back</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stageColumns = kanbanColumns.filter(c => c.type === 'stage').sort((a, b) => a.order - b.order);
|
||||
const currentStageCol = stageColumns.find(c => c.id === lead.columnId);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-20 bg-white dark:bg-zinc-950 border-b border-zinc-200 dark:border-white/[0.07] px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center gap-3 max-w-6xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(`${basePath}/kanban`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 text-xs font-semibold hover:bg-zinc-50 dark:hover:bg-white/5 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors shrink-0"
|
||||
>
|
||||
<ArrowLeft size={13} />
|
||||
<span className="hidden sm:inline">Pipeline</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center text-sm font-bold text-blue-700 dark:text-blue-300 shrink-0">
|
||||
{getInitials(lead.name)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-black text-zinc-900 dark:text-white text-base truncate"
|
||||
style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
{lead.name}
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 truncate">{lead.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStageCol && (
|
||||
<span className="text-xs font-bold px-2.5 py-1 rounded-full shrink-0"
|
||||
style={{ backgroundColor: currentStageCol.color + '18', color: currentStageCol.color }}>
|
||||
{currentStageCol.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
||||
{/* Progress */}
|
||||
<ProgressHeader lead={lead} columns={kanbanColumns} onStageChange={handleStageChange} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
||||
{/* Left — info panels */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Contact */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-3">Contact</p>
|
||||
<InfoRow icon={Phone} label="Phone" value={lead.phone} />
|
||||
<InfoRow icon={Mail} label="Email" value={lead.email} />
|
||||
<InfoRow icon={MapPin} label="Address" value={lead.address} />
|
||||
</div>
|
||||
|
||||
{/* Job details */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-3">Job Details</p>
|
||||
<InfoRow icon={Briefcase} label="Job Type" value={lead.jobType} />
|
||||
<InfoRow icon={Shield} label="Category" value={lead.insuranceType} />
|
||||
<InfoRow icon={User} label="Assigned To" value={lead.assignedAgentName ?? 'Unassigned'} />
|
||||
<InfoRow icon={Clock} label="Created" value={formatDate(lead.createdDate)} />
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{lead.notes && (
|
||||
<div className="bg-amber-50 dark:bg-amber-500/5 border border-amber-100 dark:border-amber-500/10 rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-amber-600 dark:text-amber-400 uppercase tracking-widest mb-2">Notes</p>
|
||||
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">{lead.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-3">Stats</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5 text-center">
|
||||
<p className="text-xl font-black text-zinc-900 dark:text-white" style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
{lead.activity?.length ?? 0}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400">Stage Moves</p>
|
||||
</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5 text-center">
|
||||
<p className="text-xl font-black text-zinc-900 dark:text-white" style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
{lead.activity?.[0]
|
||||
? Math.max(0, Math.floor((new Date('2026-04-07') - new Date(lead.activity[0].date + 'T00:00:00')) / 86400000))
|
||||
: 0}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400">Days in Stage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — activity log */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Activity Log</p>
|
||||
<span className="text-[10px] font-bold text-zinc-400 bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full">
|
||||
{lead.activity?.length ?? 0} entries
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{(lead.activity || []).map((entry, i) => (
|
||||
<ActivityItem
|
||||
key={i}
|
||||
entry={entry}
|
||||
isLast={i === (lead.activity?.length ?? 1) - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user