Seth commited on
Commit
b52b265
·
1 Parent(s): 4399645
Files changed (1) hide show
  1. frontend/src/pages/Deals.jsx +232 -69
frontend/src/pages/Deals.jsx CHANGED
@@ -9,6 +9,7 @@ import SlideOverPanel from '@/components/workspace/SlideOverPanel';
9
  import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
10
  import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
11
  import { cn } from '@/lib/utils';
 
12
 
13
  const STAGES = [
14
  { value: 'new', label: 'New', className: 'bg-slate-800 text-white' },
@@ -19,6 +20,18 @@ const STAGES = [
19
  { value: 'lost', label: 'Lost', className: 'bg-red-500 text-white' },
20
  ];
21
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  const STAGE_GROUP_HEADER = {
23
  new: 'border-l-[6px] border-l-violet-600 bg-violet-50/90',
24
  discovery: 'border-l-[6px] border-l-cyan-500 bg-cyan-50/70',
@@ -77,6 +90,85 @@ function sumNumeric(arr, key) {
77
  }, 0);
78
  }
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateStage, openDeal }) {
81
  const meta = stageMeta(deal.stage);
82
  const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
@@ -365,6 +457,45 @@ export default function Deals() {
365
  })).filter((g) => g.deals.length > 0);
366
  }, [deals]);
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  const seedDemo = async () => {
369
  setSeedBusy(true);
370
  try {
@@ -477,6 +608,16 @@ export default function Deals() {
477
  active: dealsView === 'byStage',
478
  onClick: () => setDealsView('byStage'),
479
  },
 
 
 
 
 
 
 
 
 
 
480
  { label: 'Pipeline', active: false, disabled: true },
481
  ]}
482
  primaryAction={{
@@ -563,75 +704,97 @@ export default function Deals() {
563
  />
564
  ))}
565
  </tbody>
566
- ) : (
567
- dealsByStage.map((group) => {
568
- const sumDeal = sumNumeric(group.deals, 'deal_value');
569
- const sumForecast = sumNumeric(group.deals, 'forecast_value');
570
- const bar =
571
- STAGE_GROUP_HEADER[group.value] ||
572
- 'border-l-[6px] border-l-slate-400 bg-slate-50';
573
- return (
574
- <tbody key={group.value} className="border-b border-slate-200">
575
- <tr className={cn(bar)}>
576
- <td colSpan={12} className="px-3 py-2.5">
577
- <div className="flex flex-wrap items-center gap-2">
578
- <span
579
- className={cn(
580
- 'rounded-full px-2.5 py-0.5 text-xs font-semibold',
581
- group.className
582
- )}
583
- >
584
- {group.label}
585
- </span>
586
- <span className="text-sm text-slate-600">
587
- {group.deals.length}{' '}
588
- {group.deals.length === 1 ? 'deal' : 'deals'}
589
- </span>
590
- </div>
591
- </td>
592
- </tr>
593
- {group.deals.map((deal) => (
594
- <DealRow
595
- key={deal.id}
596
- deal={deal}
597
- tableEditRowId={tableEditRowId}
598
- setTableEditRowId={setTableEditRowId}
599
- patchDeal={patchDeal}
600
- updateStage={updateStage}
601
- openDeal={openDeal}
602
- />
603
- ))}
604
- <tr className="bg-slate-50/90 text-slate-700 border-b border-slate-100">
605
- <td colSpan={4} className="px-3 py-2 text-right text-xs text-slate-500">
606
- Group total
607
- </td>
608
- <td className="px-3 py-2 font-semibold tabular-nums">
609
- {fmtMoney(sumDeal)}
610
- </td>
611
- <td colSpan={4} />
612
- <td className="px-3 py-2 font-semibold tabular-nums">
613
- {fmtMoney(sumForecast)}
614
- </td>
615
- <td colSpan={2} />
616
- </tr>
617
- <tr>
618
- <td colSpan={12} className="px-3 py-1.5">
619
- <Button
620
- type="button"
621
- variant="ghost"
622
- size="sm"
623
- className="h-8 text-violet-700 hover:text-violet-900 hover:bg-violet-50"
624
- disabled={createBusy}
625
- onClick={() => createDeal(group.value)}
626
- >
627
- + Add deal
628
- </Button>
629
- </td>
630
- </tr>
631
- </tbody>
632
- );
633
- })
634
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  </table>
636
  )}
637
  </div>
 
9
  import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
10
  import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
11
  import { cn } from '@/lib/utils';
12
+ import { flagEmojiFromCode, getAllCountries, matchCountry } from '@/lib/countries';
13
 
14
  const STAGES = [
15
  { value: 'new', label: 'New', className: 'bg-slate-800 text-white' },
 
20
  { value: 'lost', label: 'Lost', className: 'bg-red-500 text-white' },
21
  ];
22
 
23
+ const EMPTY_COUNTRY_KEY = '__country_none__';
24
+ const EMPTY_OWNER_KEY = '__owner_none__';
25
+
26
+ /** Accent bars for By Country / By Owner groups (rotate). */
27
+ const GROUP_BAR_ROTATING = [
28
+ 'border-l-[6px] border-l-indigo-500 bg-indigo-50/75',
29
+ 'border-l-[6px] border-l-sky-600 bg-sky-50/70',
30
+ 'border-l-[6px] border-l-amber-500 bg-amber-50/70',
31
+ 'border-l-[6px] border-l-teal-600 bg-teal-50/70',
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',
 
90
  }, 0);
91
  }
92
 
93
+ function CountryGroupHeaderTitle({ displayLabel, dealCount }) {
94
+ const countries = useMemo(() => getAllCountries(), []);
95
+ const matched =
96
+ displayLabel === 'No country' ? null : matchCountry(displayLabel, countries);
97
+ const flag = matched ? flagEmojiFromCode(matched.code) : '🏳️';
98
+ return (
99
+ <div className="flex flex-wrap items-center gap-2">
100
+ <span className="text-xl leading-none" aria-hidden>
101
+ {flag}
102
+ </span>
103
+ <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-800">
104
+ {displayLabel}
105
+ </span>
106
+ <span className="text-sm text-slate-600">
107
+ {dealCount} {dealCount === 1 ? 'deal' : 'deals'}
108
+ </span>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function GroupedDealTbody({
114
+ barClassName,
115
+ headerContent,
116
+ deals,
117
+ tableEditRowId,
118
+ setTableEditRowId,
119
+ patchDeal,
120
+ updateStage,
121
+ openDeal,
122
+ createBusy,
123
+ onAddDeal,
124
+ }) {
125
+ const sumDeal = sumNumeric(deals, 'deal_value');
126
+ const sumForecast = sumNumeric(deals, 'forecast_value');
127
+ return (
128
+ <tbody className="border-b border-slate-200">
129
+ <tr className={barClassName}>
130
+ <td colSpan={12} className="px-3 py-2.5">
131
+ {headerContent}
132
+ </td>
133
+ </tr>
134
+ {deals.map((deal) => (
135
+ <DealRow
136
+ key={deal.id}
137
+ deal={deal}
138
+ tableEditRowId={tableEditRowId}
139
+ setTableEditRowId={setTableEditRowId}
140
+ patchDeal={patchDeal}
141
+ updateStage={updateStage}
142
+ openDeal={openDeal}
143
+ />
144
+ ))}
145
+ <tr className="bg-slate-50/90 text-slate-700 border-b border-slate-100">
146
+ <td colSpan={4} className="px-3 py-2 text-right text-xs text-slate-500">
147
+ Group total
148
+ </td>
149
+ <td className="px-3 py-2 font-semibold tabular-nums">{fmtMoney(sumDeal)}</td>
150
+ <td colSpan={4} />
151
+ <td className="px-3 py-2 font-semibold tabular-nums">{fmtMoney(sumForecast)}</td>
152
+ <td colSpan={2} />
153
+ </tr>
154
+ <tr>
155
+ <td colSpan={12} className="px-3 py-1.5">
156
+ <Button
157
+ type="button"
158
+ variant="ghost"
159
+ size="sm"
160
+ className="h-8 text-violet-700 hover:text-violet-900 hover:bg-violet-50"
161
+ disabled={createBusy}
162
+ onClick={onAddDeal}
163
+ >
164
+ + Add deal
165
+ </Button>
166
+ </td>
167
+ </tr>
168
+ </tbody>
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';
 
457
  })).filter((g) => g.deals.length > 0);
458
  }, [deals]);
459
 
460
+ const dealsByCountry = useMemo(() => {
461
+ const map = new Map();
462
+ for (const d of deals) {
463
+ const raw = (d.country || '').trim();
464
+ const sortKey = raw ? raw.toLowerCase() : EMPTY_COUNTRY_KEY;
465
+ if (!map.has(sortKey)) {
466
+ map.set(sortKey, { sortKey, displayLabel: raw || 'No country', deals: [] });
467
+ }
468
+ map.get(sortKey).deals.push(d);
469
+ }
470
+ const groups = [...map.values()].filter((g) => g.deals.length > 0);
471
+ groups.sort((a, b) => {
472
+ if (a.sortKey === EMPTY_COUNTRY_KEY) return 1;
473
+ if (b.sortKey === EMPTY_COUNTRY_KEY) return -1;
474
+ return a.displayLabel.localeCompare(b.displayLabel, undefined, { sensitivity: 'base' });
475
+ });
476
+ return groups;
477
+ }, [deals]);
478
+
479
+ const dealsByOwner = useMemo(() => {
480
+ const map = new Map();
481
+ for (const d of deals) {
482
+ const raw = (d.owner_initials || '').trim();
483
+ const sortKey = raw ? raw.toUpperCase() : EMPTY_OWNER_KEY;
484
+ if (!map.has(sortKey)) {
485
+ const label = raw ? raw.toUpperCase() : 'Unassigned';
486
+ map.set(sortKey, { sortKey, displayLabel: label, deals: [] });
487
+ }
488
+ map.get(sortKey).deals.push(d);
489
+ }
490
+ const groups = [...map.values()].filter((g) => g.deals.length > 0);
491
+ groups.sort((a, b) => {
492
+ if (a.sortKey === EMPTY_OWNER_KEY) return 1;
493
+ if (b.sortKey === EMPTY_OWNER_KEY) return -1;
494
+ return a.displayLabel.localeCompare(b.displayLabel);
495
+ });
496
+ return groups;
497
+ }, [deals]);
498
+
499
  const seedDemo = async () => {
500
  setSeedBusy(true);
501
  try {
 
608
  active: dealsView === 'byStage',
609
  onClick: () => setDealsView('byStage'),
610
  },
611
+ {
612
+ label: 'By Country',
613
+ active: dealsView === 'byCountry',
614
+ onClick: () => setDealsView('byCountry'),
615
+ },
616
+ {
617
+ label: 'By Owner',
618
+ active: dealsView === 'byOwner',
619
+ onClick: () => setDealsView('byOwner'),
620
+ },
621
  { label: 'Pipeline', active: false, disabled: true },
622
  ]}
623
  primaryAction={{
 
704
  />
705
  ))}
706
  </tbody>
707
+ ) : dealsView === 'byStage' ? (
708
+ dealsByStage.map((group) => (
709
+ <GroupedDealTbody
710
+ key={`stage-${group.value}`}
711
+ barClassName={
712
+ STAGE_GROUP_HEADER[group.value] ||
713
+ 'border-l-[6px] border-l-slate-400 bg-slate-50'
714
+ }
715
+ headerContent={
716
+ <div className="flex flex-wrap items-center gap-2">
717
+ <span
718
+ className={cn(
719
+ 'rounded-full px-2.5 py-0.5 text-xs font-semibold',
720
+ group.className
721
+ )}
722
+ >
723
+ {group.label}
724
+ </span>
725
+ <span className="text-sm text-slate-600">
726
+ {group.deals.length}{' '}
727
+ {group.deals.length === 1 ? 'deal' : 'deals'}
728
+ </span>
729
+ </div>
730
+ }
731
+ deals={group.deals}
732
+ tableEditRowId={tableEditRowId}
733
+ setTableEditRowId={setTableEditRowId}
734
+ patchDeal={patchDeal}
735
+ updateStage={updateStage}
736
+ openDeal={openDeal}
737
+ createBusy={createBusy}
738
+ onAddDeal={() => createDeal(group.value)}
739
+ />
740
+ ))
741
+ ) : dealsView === 'byCountry' ? (
742
+ dealsByCountry.map((group, idx) => (
743
+ <GroupedDealTbody
744
+ key={`country-${group.sortKey}`}
745
+ barClassName={
746
+ GROUP_BAR_ROTATING[idx % GROUP_BAR_ROTATING.length]
747
+ }
748
+ headerContent={
749
+ <CountryGroupHeaderTitle
750
+ displayLabel={group.displayLabel}
751
+ dealCount={group.deals.length}
752
+ />
753
+ }
754
+ deals={group.deals}
755
+ tableEditRowId={tableEditRowId}
756
+ setTableEditRowId={setTableEditRowId}
757
+ patchDeal={patchDeal}
758
+ updateStage={updateStage}
759
+ openDeal={openDeal}
760
+ createBusy={createBusy}
761
+ onAddDeal={() => createDeal('new')}
762
+ />
763
+ ))
764
+ ) : dealsView === 'byOwner' ? (
765
+ dealsByOwner.map((group, idx) => (
766
+ <GroupedDealTbody
767
+ key={`owner-${group.sortKey}`}
768
+ barClassName={
769
+ GROUP_BAR_ROTATING[idx % GROUP_BAR_ROTATING.length]
770
+ }
771
+ headerContent={
772
+ <div className="flex flex-wrap items-center gap-2">
773
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-violet-100 text-xs font-semibold text-violet-800 border border-violet-200">
774
+ {group.sortKey === EMPTY_OWNER_KEY
775
+ ? '?'
776
+ : group.displayLabel.slice(0, 2)}
777
+ </div>
778
+ <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-800">
779
+ {group.displayLabel}
780
+ </span>
781
+ <span className="text-sm text-slate-600">
782
+ {group.deals.length}{' '}
783
+ {group.deals.length === 1 ? 'deal' : 'deals'}
784
+ </span>
785
+ </div>
786
+ }
787
+ deals={group.deals}
788
+ tableEditRowId={tableEditRowId}
789
+ setTableEditRowId={setTableEditRowId}
790
+ patchDeal={patchDeal}
791
+ updateStage={updateStage}
792
+ openDeal={openDeal}
793
+ createBusy={createBusy}
794
+ onAddDeal={() => createDeal('new')}
795
+ />
796
+ ))
797
+ ) : null}
798
  </table>
799
  )}
800
  </div>