Seth commited on
Commit ·
1129913
1
Parent(s): 7f301e6
update
Browse files- 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 |
-
<
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 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
|
| 479 |
-
'
|
| 480 |
)}
|
| 481 |
title="Change owner"
|
|
|
|
| 482 |
>
|
| 483 |
-
<
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 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 |
-
<
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
>
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 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 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 1189 |
-
{group.
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 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>
|