Seth commited on
Commit
4191766
·
1 Parent(s): 97b08c9
Files changed (1) hide show
  1. 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
- { label: 'Pipeline', active: false, disabled: true },
 
 
 
 
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>