Seth commited on
Commit ·
4191766
1
Parent(s): 97b08c9
update
Browse files- frontend/src/pages/Deals.jsx +165 -2
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
-
import { Loader2, LayoutGrid, Pencil } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 6 |
import AppShell from '@/components/layout/AppShell';
|
|
@@ -32,6 +32,16 @@ const GROUP_BAR_ROTATING = [
|
|
| 32 |
'border-l-[6px] border-l-rose-500 bg-rose-50/70',
|
| 33 |
];
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const STAGE_GROUP_HEADER = {
|
| 36 |
new: 'border-l-[6px] border-l-violet-600 bg-violet-50/90',
|
| 37 |
discovery: 'border-l-[6px] border-l-cyan-500 bg-cyan-50/70',
|
|
@@ -169,6 +179,134 @@ function GroupedDealTbody({
|
|
| 169 |
);
|
| 170 |
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateStage, openDeal }) {
|
| 173 |
const meta = stageMeta(deal.stage);
|
| 174 |
const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
|
|
@@ -498,6 +636,19 @@ export default function Deals() {
|
|
| 498 |
return groups;
|
| 499 |
}, [deals]);
|
| 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
const seedDemo = async () => {
|
| 502 |
setSeedBusy(true);
|
| 503 |
try {
|
|
@@ -620,7 +771,11 @@ export default function Deals() {
|
|
| 620 |
active: dealsView === 'byOwner',
|
| 621 |
onClick: () => setDealsView('byOwner'),
|
| 622 |
},
|
| 623 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
]}
|
| 625 |
primaryAction={{
|
| 626 |
label: 'New deal',
|
|
@@ -653,6 +808,14 @@ export default function Deals() {
|
|
| 653 |
<div className="flex justify-center py-16 text-slate-500">
|
| 654 |
<Loader2 className="h-8 w-8 animate-spin" />
|
| 655 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
) : deals.length === 0 ? (
|
| 657 |
<div className="text-center py-16 text-slate-500 space-y-3">
|
| 658 |
<p>No deals yet. Convert leads from the Leads page, load demo data, or create a deal.</p>
|
|
|
|
| 1 |
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
+
import { GitBranch, Loader2, LayoutGrid, MessageSquare, Pencil } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 6 |
import AppShell from '@/components/layout/AppShell';
|
|
|
|
| 32 |
'border-l-[6px] border-l-rose-500 bg-rose-50/70',
|
| 33 |
];
|
| 34 |
|
| 35 |
+
/** Kanban column header backgrounds (Monday-style saturated bars). */
|
| 36 |
+
const PIPELINE_HEADER_BG = {
|
| 37 |
+
new: 'bg-violet-600',
|
| 38 |
+
discovery: 'bg-cyan-500',
|
| 39 |
+
proposal: 'bg-sky-500',
|
| 40 |
+
negotiation: 'bg-teal-600',
|
| 41 |
+
won: 'bg-emerald-600',
|
| 42 |
+
lost: 'bg-red-600',
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
const STAGE_GROUP_HEADER = {
|
| 46 |
new: 'border-l-[6px] border-l-violet-600 bg-violet-50/90',
|
| 47 |
discovery: 'border-l-[6px] border-l-cyan-500 bg-cyan-50/70',
|
|
|
|
| 179 |
);
|
| 180 |
}
|
| 181 |
|
| 182 |
+
/** Chevron-tab clip for pipeline column headers (points right like Monday). */
|
| 183 |
+
const pipelineHeaderClip = {
|
| 184 |
+
clipPath: 'polygon(0 0, calc(100% - 16px) 0, 100% 50%, calc(100% - 16px) 100%, 0 100%)',
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
function PipelineDealCard({ deal, openDeal }) {
|
| 188 |
+
const initials = (deal.owner_initials || '?').toString().slice(0, 2).toUpperCase();
|
| 189 |
+
return (
|
| 190 |
+
<div
|
| 191 |
+
role="button"
|
| 192 |
+
tabIndex={0}
|
| 193 |
+
draggable
|
| 194 |
+
onDragStart={(e) => {
|
| 195 |
+
e.dataTransfer.setData('application/deal-id', String(deal.id));
|
| 196 |
+
e.dataTransfer.setData('text/plain', String(deal.id));
|
| 197 |
+
e.dataTransfer.effectAllowed = 'move';
|
| 198 |
+
}}
|
| 199 |
+
onClick={(ev) => {
|
| 200 |
+
if (ev.defaultPrevented) return;
|
| 201 |
+
openDeal(deal);
|
| 202 |
+
}}
|
| 203 |
+
onKeyDown={(e) => {
|
| 204 |
+
if (e.key === 'Enter' || e.key === ' ') {
|
| 205 |
+
e.preventDefault();
|
| 206 |
+
openDeal(deal);
|
| 207 |
+
}
|
| 208 |
+
}}
|
| 209 |
+
className={cn(
|
| 210 |
+
'w-full cursor-grab rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm outline-none',
|
| 211 |
+
'transition hover:border-violet-300 hover:shadow-md active:cursor-grabbing'
|
| 212 |
+
)}
|
| 213 |
+
>
|
| 214 |
+
<div className="line-clamp-2 text-sm font-semibold leading-snug text-slate-900">
|
| 215 |
+
{deal.name || 'Untitled'}
|
| 216 |
+
</div>
|
| 217 |
+
<div className="mt-2 break-words text-base font-semibold tabular-nums leading-tight text-slate-800">
|
| 218 |
+
{fmtMoney(deal.deal_value)}
|
| 219 |
+
</div>
|
| 220 |
+
<div className="mt-3 flex items-end justify-between gap-2">
|
| 221 |
+
<div className="min-w-0 flex flex-1 flex-wrap items-center gap-2">
|
| 222 |
+
<div
|
| 223 |
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-violet-200 bg-violet-100 text-xs font-bold text-violet-800"
|
| 224 |
+
title="Owner"
|
| 225 |
+
>
|
| 226 |
+
{initials}
|
| 227 |
+
</div>
|
| 228 |
+
{deal.contact_display ? (
|
| 229 |
+
<span className="truncate rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-800">
|
| 230 |
+
{deal.contact_display}
|
| 231 |
+
</span>
|
| 232 |
+
) : null}
|
| 233 |
+
</div>
|
| 234 |
+
<div className="flex shrink-0 gap-1.5 text-slate-400">
|
| 235 |
+
<MessageSquare className="h-4 w-4" aria-hidden />
|
| 236 |
+
<GitBranch className="h-4 w-4" aria-hidden />
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function PipelineBoard({ columns, openDeal, patchDeal, createDeal, createBusy }) {
|
| 244 |
+
return (
|
| 245 |
+
<div className="flex min-h-[min(75vh,720px)] w-full min-w-0 gap-3 overflow-x-auto pb-2 pt-1 [scrollbar-gutter:stable]">
|
| 246 |
+
{columns.map((col) => {
|
| 247 |
+
const sumDeal = sumNumeric(col.deals, 'deal_value');
|
| 248 |
+
const headerBg = PIPELINE_HEADER_BG[col.value] || 'bg-slate-600';
|
| 249 |
+
return (
|
| 250 |
+
<div
|
| 251 |
+
key={col.value}
|
| 252 |
+
className="flex w-[min(100%,320px)] min-w-[260px] max-w-[360px] shrink-0 flex-col rounded-xl border border-slate-200 bg-slate-100/90 shadow-sm"
|
| 253 |
+
onDragOver={(e) => {
|
| 254 |
+
e.preventDefault();
|
| 255 |
+
e.dataTransfer.dropEffect = 'move';
|
| 256 |
+
}}
|
| 257 |
+
onDrop={(e) => {
|
| 258 |
+
e.preventDefault();
|
| 259 |
+
const raw =
|
| 260 |
+
e.dataTransfer.getData('application/deal-id') ||
|
| 261 |
+
e.dataTransfer.getData('text/plain');
|
| 262 |
+
const id = Number(raw);
|
| 263 |
+
if (!Number.isFinite(id)) return;
|
| 264 |
+
patchDeal(id, { stage: col.value });
|
| 265 |
+
}}
|
| 266 |
+
>
|
| 267 |
+
<div
|
| 268 |
+
className={cn(
|
| 269 |
+
'relative shrink-0 px-3 pb-3 pt-2.5 text-white shadow-sm',
|
| 270 |
+
headerBg
|
| 271 |
+
)}
|
| 272 |
+
style={pipelineHeaderClip}
|
| 273 |
+
>
|
| 274 |
+
<div className="flex items-start justify-between gap-2 pr-5">
|
| 275 |
+
<span className="text-[13px] font-bold leading-tight tracking-wide">
|
| 276 |
+
{col.label}
|
| 277 |
+
</span>
|
| 278 |
+
<span className="shrink-0 rounded-full bg-black/15 px-2 py-0.5 text-xs font-bold tabular-nums">
|
| 279 |
+
{col.deals.length}
|
| 280 |
+
</span>
|
| 281 |
+
</div>
|
| 282 |
+
<div className="mt-2 text-xs font-semibold tabular-nums text-white/95">
|
| 283 |
+
{fmtMoney(sumDeal)}
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
<div className="flex max-h-[min(62vh,560px)] min-h-[120px] flex-1 flex-col gap-2 overflow-y-auto p-2">
|
| 287 |
+
{col.deals.map((deal) => (
|
| 288 |
+
<PipelineDealCard key={deal.id} deal={deal} openDeal={openDeal} />
|
| 289 |
+
))}
|
| 290 |
+
<div className="mt-auto shrink-0 pt-1">
|
| 291 |
+
<Button
|
| 292 |
+
type="button"
|
| 293 |
+
variant="ghost"
|
| 294 |
+
size="sm"
|
| 295 |
+
className="h-8 w-full justify-center text-violet-700 hover:bg-violet-50"
|
| 296 |
+
disabled={createBusy}
|
| 297 |
+
onClick={() => createDeal(col.value)}
|
| 298 |
+
>
|
| 299 |
+
+ Add deal
|
| 300 |
+
</Button>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
);
|
| 305 |
+
})}
|
| 306 |
+
</div>
|
| 307 |
+
);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateStage, openDeal }) {
|
| 311 |
const meta = stageMeta(deal.stage);
|
| 312 |
const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
|
|
|
|
| 636 |
return groups;
|
| 637 |
}, [deals]);
|
| 638 |
|
| 639 |
+
/** All stages as Kanban columns (including empty). */
|
| 640 |
+
const pipelineColumns = useMemo(
|
| 641 |
+
() =>
|
| 642 |
+
STAGES.map((s) => ({
|
| 643 |
+
...s,
|
| 644 |
+
deals: deals.filter((d) => {
|
| 645 |
+
const st = STAGES.some((x) => x.value === d.stage) ? d.stage : 'new';
|
| 646 |
+
return st === s.value;
|
| 647 |
+
}),
|
| 648 |
+
})),
|
| 649 |
+
[deals]
|
| 650 |
+
);
|
| 651 |
+
|
| 652 |
const seedDemo = async () => {
|
| 653 |
setSeedBusy(true);
|
| 654 |
try {
|
|
|
|
| 771 |
active: dealsView === 'byOwner',
|
| 772 |
onClick: () => setDealsView('byOwner'),
|
| 773 |
},
|
| 774 |
+
{
|
| 775 |
+
label: 'Pipeline',
|
| 776 |
+
active: dealsView === 'pipeline',
|
| 777 |
+
onClick: () => setDealsView('pipeline'),
|
| 778 |
+
},
|
| 779 |
]}
|
| 780 |
primaryAction={{
|
| 781 |
label: 'New deal',
|
|
|
|
| 808 |
<div className="flex justify-center py-16 text-slate-500">
|
| 809 |
<Loader2 className="h-8 w-8 animate-spin" />
|
| 810 |
</div>
|
| 811 |
+
) : dealsView === 'pipeline' ? (
|
| 812 |
+
<PipelineBoard
|
| 813 |
+
columns={pipelineColumns}
|
| 814 |
+
openDeal={openDeal}
|
| 815 |
+
patchDeal={patchDeal}
|
| 816 |
+
createDeal={createDeal}
|
| 817 |
+
createBusy={createBusy}
|
| 818 |
+
/>
|
| 819 |
) : deals.length === 0 ? (
|
| 820 |
<div className="text-center py-16 text-slate-500 space-y-3">
|
| 821 |
<p>No deals yet. Convert leads from the Leads page, load demo data, or create a deal.</p>
|