Seth commited on
Commit
1129913
·
1 Parent(s): 7f301e6
Files changed (1) hide show
  1. frontend/src/pages/Deals.jsx +151 -49
frontend/src/pages/Deals.jsx CHANGED
@@ -27,6 +27,69 @@ const STAGES = [
27
 
28
  const EMPTY_COUNTRY_KEY = '__country_none__';
29
  const EMPTY_OWNER_KEY = '__owner_none__';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  /** Accent bars for By Country / By Owner groups (rotate). */
32
  const GROUP_BAR_ROTATING = [
@@ -203,7 +266,6 @@ const pipelineHeaderClip = {
203
  };
204
 
205
  function PipelineDealCard({ deal, openDeal }) {
206
- const initials = (deal.owner_initials || '?').toString().slice(0, 2).toUpperCase();
207
  return (
208
  <div
209
  role="button"
@@ -237,12 +299,11 @@ function PipelineDealCard({ deal, openDeal }) {
237
  </div>
238
  <div className="mt-2 flex items-end justify-between gap-1.5 sm:mt-3 sm:gap-2">
239
  <div className="min-w-0 flex flex-1 flex-wrap items-center gap-2">
240
- <div
241
- 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"
242
- title="Owner"
243
- >
244
- {initials}
245
- </div>
246
  {deal.contact_display ? (
247
  <span className="truncate rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-800">
248
  {deal.contact_display}
@@ -475,25 +536,34 @@ function DealRow({
475
  >
476
  <SelectTrigger
477
  className={cn(
478
- 'h-9 min-w-[10.5rem] max-w-[14rem] border-slate-200 shadow-none px-2 py-1',
479
- 'gap-2 [&>svg]:shrink-0'
480
  )}
481
  title="Change owner"
 
482
  >
483
- <div className="flex min-w-0 flex-1 items-center gap-2 text-left">
484
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-violet-200 bg-violet-100 text-xs font-semibold text-violet-800">
485
- {(deal.owner_initials || '?').toString().slice(0, 2).toUpperCase()}
486
- </div>
487
- <span className="truncate text-xs font-medium text-slate-800">
488
- {deal.owner_user_id == null
489
- ? 'Unassigned'
490
- : deal.owner_display_name ||
491
- deal.owner_initials ||
492
- `User ${deal.owner_user_id}`}
493
- </span>
494
- </div>
495
  </SelectTrigger>
496
  <SelectContent className="min-w-[12rem]">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  <SelectItem value={EMPTY_OWNER_KEY}>Unassigned</SelectItem>
498
  {(tenantMembers || []).map((m) => (
499
  <SelectItem key={m.user_id} value={String(m.user_id)}>
@@ -503,30 +573,56 @@ function DealRow({
503
  </SelectContent>
504
  </Select>
505
  ) : (
506
- <div className="flex flex-wrap items-center gap-2">
507
- <div
508
- className="h-8 w-8 shrink-0 rounded-full bg-violet-100 text-violet-700 text-xs font-semibold flex items-center justify-center border border-violet-200"
509
- title={deal.owner_display_name || deal.owner_initials || 'Unassigned'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  >
511
- {(deal.owner_initials || '?').toString().slice(0, 2).toUpperCase()}
512
- </div>
513
- {!isAdmin &&
514
- currentUserId != null &&
515
- (deal.owner_user_id == null || deal.owner_user_id === '') ? (
516
- <Button
517
- type="button"
518
- variant="outline"
519
- size="sm"
520
- className="h-7 text-xs shrink-0"
521
- onClick={(e) => {
522
- e.stopPropagation();
523
- patchDeal(deal.id, { owner_user_id: currentUserId });
524
- }}
525
  >
526
- Take ownership
527
- </Button>
528
- ) : null}
529
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  )}
531
  </td>
532
  <td className="px-3 py-2 align-top tabular-nums min-w-[10rem] w-40 max-w-[16rem]">
@@ -770,7 +866,13 @@ export default function Deals() {
770
  `User ${d.owner_user_id}`
771
  ).trim();
772
  if (!map.has(sortKey)) {
773
- map.set(sortKey, { sortKey, displayLabel, deals: [] });
 
 
 
 
 
 
774
  }
775
  map.get(sortKey).deals.push(d);
776
  }
@@ -1185,11 +1287,11 @@ export default function Deals() {
1185
  }
1186
  headerContent={
1187
  <div className="flex flex-wrap items-center gap-2">
1188
- <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">
1189
- {group.sortKey === EMPTY_OWNER_KEY
1190
- ? '?'
1191
- : group.displayLabel.slice(0, 2).toUpperCase()}
1192
- </div>
1193
  <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-800">
1194
  {group.displayLabel}
1195
  </span>
 
27
 
28
  const EMPTY_COUNTRY_KEY = '__country_none__';
29
  const EMPTY_OWNER_KEY = '__owner_none__';
30
+ const MEMBER_TAKE_OWNERSHIP_VALUE = '__member_take_ownership__';
31
+
32
+ /** Stable accent per user id for owner avatars (table, pipeline, grouped headers). */
33
+ const OWNER_AVATAR_PALETTES = [
34
+ { ring: 'border-rose-200', bg: 'bg-rose-100', text: 'text-rose-800' },
35
+ { ring: 'border-amber-200', bg: 'bg-amber-100', text: 'text-amber-900' },
36
+ { ring: 'border-emerald-200', bg: 'bg-emerald-100', text: 'text-emerald-900' },
37
+ { ring: 'border-sky-200', bg: 'bg-sky-100', text: 'text-sky-900' },
38
+ { ring: 'border-violet-200', bg: 'bg-violet-100', text: 'text-violet-800' },
39
+ { ring: 'border-fuchsia-200', bg: 'bg-fuchsia-100', text: 'text-fuchsia-900' },
40
+ { ring: 'border-cyan-200', bg: 'bg-cyan-100', text: 'text-cyan-900' },
41
+ { ring: 'border-orange-200', bg: 'bg-orange-100', text: 'text-orange-900' },
42
+ { ring: 'border-lime-200', bg: 'bg-lime-100', text: 'text-lime-900' },
43
+ { ring: 'border-indigo-200', bg: 'bg-indigo-100', text: 'text-indigo-900' },
44
+ { ring: 'border-teal-200', bg: 'bg-teal-100', text: 'text-teal-900' },
45
+ ];
46
+
47
+ const UNASSIGNED_AVATAR_PALETTE = {
48
+ ring: 'border-slate-200',
49
+ bg: 'bg-slate-100',
50
+ text: 'text-slate-500',
51
+ };
52
+
53
+ function hashStringToUint(s) {
54
+ let h = 0;
55
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
56
+ return Math.abs(h);
57
+ }
58
+
59
+ function ownerAvatarPalette(userId) {
60
+ if (userId == null || userId === '') return UNASSIGNED_AVATAR_PALETTE;
61
+ const idx = hashStringToUint(String(userId)) % OWNER_AVATAR_PALETTES.length;
62
+ return OWNER_AVATAR_PALETTES[idx];
63
+ }
64
+
65
+ function ownerDisplayLabel(deal) {
66
+ if (deal.owner_user_id == null || deal.owner_user_id === '') return 'Unassigned';
67
+ return (
68
+ deal.owner_display_name ||
69
+ deal.owner_initials ||
70
+ `User ${deal.owner_user_id}`
71
+ );
72
+ }
73
+
74
+ function OwnerAvatarCircle({ userId, initials, className }) {
75
+ const pal = ownerAvatarPalette(userId);
76
+ const unassigned = userId == null || userId === '';
77
+ const label = unassigned ? '?' : (initials || '?').toString().slice(0, 2).toUpperCase();
78
+ return (
79
+ <span
80
+ className={cn(
81
+ 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none',
82
+ pal.ring,
83
+ pal.bg,
84
+ pal.text,
85
+ className
86
+ )}
87
+ aria-hidden
88
+ >
89
+ {label}
90
+ </span>
91
+ );
92
+ }
93
 
94
  /** Accent bars for By Country / By Owner groups (rotate). */
95
  const GROUP_BAR_ROTATING = [
 
266
  };
267
 
268
  function PipelineDealCard({ deal, openDeal }) {
 
269
  return (
270
  <div
271
  role="button"
 
299
  </div>
300
  <div className="mt-2 flex items-end justify-between gap-1.5 sm:mt-3 sm:gap-2">
301
  <div className="min-w-0 flex flex-1 flex-wrap items-center gap-2">
302
+ <OwnerAvatarCircle
303
+ userId={deal.owner_user_id}
304
+ initials={deal.owner_initials}
305
+ className="h-8 w-8 font-bold"
306
+ />
 
307
  {deal.contact_display ? (
308
  <span className="truncate rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-800">
309
  {deal.contact_display}
 
536
  >
537
  <SelectTrigger
538
  className={cn(
539
+ 'h-9 w-9 shrink-0 p-0 justify-center border-slate-200 shadow-none',
540
+ '[&>svg]:hidden'
541
  )}
542
  title="Change owner"
543
+ aria-label={`Owner: ${ownerDisplayLabel(deal)}. Open menu to change.`}
544
  >
545
+ <OwnerAvatarCircle
546
+ userId={deal.owner_user_id}
547
+ initials={deal.owner_initials}
548
+ />
 
 
 
 
 
 
 
 
549
  </SelectTrigger>
550
  <SelectContent className="min-w-[12rem]">
551
+ <div
552
+ role="presentation"
553
+ className="pointer-events-none border-b border-slate-100 px-2 py-2 mb-1"
554
+ >
555
+ <div className="flex items-center gap-2">
556
+ <OwnerAvatarCircle
557
+ userId={deal.owner_user_id}
558
+ initials={deal.owner_initials}
559
+ />
560
+ <div className="min-w-0 flex-1">
561
+ <div className="truncate text-sm font-semibold text-slate-900">
562
+ {ownerDisplayLabel(deal)}
563
+ </div>
564
+ </div>
565
+ </div>
566
+ </div>
567
  <SelectItem value={EMPTY_OWNER_KEY}>Unassigned</SelectItem>
568
  {(tenantMembers || []).map((m) => (
569
  <SelectItem key={m.user_id} value={String(m.user_id)}>
 
573
  </SelectContent>
574
  </Select>
575
  ) : (
576
+ <Select
577
+ value={
578
+ deal.owner_user_id != null && deal.owner_user_id !== ''
579
+ ? String(deal.owner_user_id)
580
+ : EMPTY_OWNER_KEY
581
+ }
582
+ onValueChange={(v) => {
583
+ if (v === MEMBER_TAKE_OWNERSHIP_VALUE) {
584
+ patchDeal(deal.id, { owner_user_id: currentUserId });
585
+ }
586
+ }}
587
+ >
588
+ <SelectTrigger
589
+ className={cn(
590
+ 'h-9 w-9 shrink-0 p-0 justify-center border-slate-200 shadow-none',
591
+ '[&>svg]:hidden'
592
+ )}
593
+ title={ownerDisplayLabel(deal)}
594
+ aria-label={`Owner: ${ownerDisplayLabel(deal)}. Open menu for details.`}
595
  >
596
+ <OwnerAvatarCircle
597
+ userId={deal.owner_user_id}
598
+ initials={deal.owner_initials}
599
+ />
600
+ </SelectTrigger>
601
+ <SelectContent className="min-w-[12rem]">
602
+ <div
603
+ role="presentation"
604
+ className="pointer-events-none border-b border-slate-100 px-2 py-2 mb-1"
 
 
 
 
 
605
  >
606
+ <div className="flex items-center gap-2">
607
+ <OwnerAvatarCircle
608
+ userId={deal.owner_user_id}
609
+ initials={deal.owner_initials}
610
+ />
611
+ <div className="min-w-0 flex-1">
612
+ <div className="truncate text-sm font-semibold text-slate-900">
613
+ {ownerDisplayLabel(deal)}
614
+ </div>
615
+ </div>
616
+ </div>
617
+ </div>
618
+ {currentUserId != null &&
619
+ (deal.owner_user_id == null || deal.owner_user_id === '') ? (
620
+ <SelectItem value={MEMBER_TAKE_OWNERSHIP_VALUE}>
621
+ Take ownership
622
+ </SelectItem>
623
+ ) : null}
624
+ </SelectContent>
625
+ </Select>
626
  )}
627
  </td>
628
  <td className="px-3 py-2 align-top tabular-nums min-w-[10rem] w-40 max-w-[16rem]">
 
866
  `User ${d.owner_user_id}`
867
  ).trim();
868
  if (!map.has(sortKey)) {
869
+ map.set(sortKey, {
870
+ sortKey,
871
+ displayLabel,
872
+ ownerUserId: unassigned ? null : d.owner_user_id,
873
+ ownerInitials: unassigned ? null : d.owner_initials,
874
+ deals: [],
875
+ });
876
  }
877
  map.get(sortKey).deals.push(d);
878
  }
 
1287
  }
1288
  headerContent={
1289
  <div className="flex flex-wrap items-center gap-2">
1290
+ <OwnerAvatarCircle
1291
+ userId={group.ownerUserId}
1292
+ initials={group.ownerInitials}
1293
+ className="font-bold"
1294
+ />
1295
  <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-800">
1296
  {group.displayLabel}
1297
  </span>