@open-mercato/core 0.6.6-develop.6387.1.f4896ad565 → 0.6.6-develop.6389.1.135a466c47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +38 -47
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +57 -42
|
@@ -4,8 +4,9 @@ import * as React from "react";
|
|
|
4
4
|
import { useDraggable } from "@dnd-kit/core";
|
|
5
5
|
import { AlertTriangle, Building2, Calendar, Clock, Mail, Phone, StickyNote } from "lucide-react";
|
|
6
6
|
import { Avatar } from "@open-mercato/ui/primitives/avatar";
|
|
7
|
-
import { Button } from "@open-mercato/ui/primitives/button";
|
|
8
7
|
import { Checkbox } from "@open-mercato/ui/primitives/checkbox";
|
|
8
|
+
import { IconButton } from "@open-mercato/ui/primitives/icon-button";
|
|
9
|
+
import { SimpleTooltip } from "@open-mercato/ui/primitives/tooltip";
|
|
9
10
|
import { DealCardMenu } from "./DealCardMenu.js";
|
|
10
11
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
11
12
|
import { translateWithFallback } from "@open-mercato/shared/lib/i18n/translate";
|
|
@@ -208,68 +209,58 @@ function DealCardImpl({
|
|
|
208
209
|
"div",
|
|
209
210
|
{
|
|
210
211
|
"data-card-action": "true",
|
|
211
|
-
className: "flex items-center gap-
|
|
212
|
+
className: "flex items-center justify-center gap-2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto",
|
|
212
213
|
onClick: (event) => event.stopPropagation(),
|
|
213
214
|
onPointerDown: stopPointerDown,
|
|
214
215
|
children: (() => {
|
|
215
216
|
const disabled = !deal.primaryCompany;
|
|
216
|
-
const
|
|
217
|
+
const disabledTooltip = disabled ? translateWithFallback(
|
|
217
218
|
t,
|
|
218
219
|
"customers.deals.kanban.card.action.disabledNoCompany",
|
|
219
220
|
"Link a company to this deal before logging activities."
|
|
220
221
|
) : void 0;
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
Button,
|
|
222
|
+
const renderActionButton = (type, label, icon) => {
|
|
223
|
+
const button = /* @__PURE__ */ jsx(
|
|
224
|
+
IconButton,
|
|
225
225
|
{
|
|
226
226
|
variant: "ghost",
|
|
227
|
-
size: "
|
|
228
|
-
|
|
229
|
-
onClick: handleActionClick("call"),
|
|
227
|
+
size: "default",
|
|
228
|
+
onClick: handleActionClick(type),
|
|
230
229
|
disabled,
|
|
231
|
-
title: disabledTitle,
|
|
232
230
|
"aria-disabled": disabled || void 0,
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
/* @__PURE__ */ jsx("span", { children: translateWithFallback(t, "customers.deals.kanban.card.action.call", "Call") })
|
|
237
|
-
]
|
|
231
|
+
"aria-label": label,
|
|
232
|
+
className: "shrink-0 text-muted-foreground hover:text-foreground",
|
|
233
|
+
children: icon
|
|
238
234
|
}
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
|
|
235
|
+
);
|
|
236
|
+
if (!disabled) {
|
|
237
|
+
return /* @__PURE__ */ jsx(SimpleTooltip, { content: label, size: "sm", children: button }, type);
|
|
238
|
+
}
|
|
239
|
+
return /* @__PURE__ */ jsx(SimpleTooltip, { content: disabledTooltip, size: "sm", children: /* @__PURE__ */ jsx(
|
|
240
|
+
"span",
|
|
242
241
|
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
disabled,
|
|
248
|
-
title: disabledTitle,
|
|
249
|
-
"aria-disabled": disabled || void 0,
|
|
250
|
-
className: baseClass,
|
|
251
|
-
children: [
|
|
252
|
-
/* @__PURE__ */ jsx(Mail, { className: "size-3.5 shrink-0", "aria-hidden": "true" }),
|
|
253
|
-
/* @__PURE__ */ jsx("span", { children: translateWithFallback(t, "customers.deals.kanban.card.action.email", "Email") })
|
|
254
|
-
]
|
|
242
|
+
className: "inline-flex shrink-0 rounded-md focus-visible:outline-none focus-visible:shadow-focus",
|
|
243
|
+
tabIndex: 0,
|
|
244
|
+
"aria-label": `${label}: ${disabledTooltip}`,
|
|
245
|
+
children: button
|
|
255
246
|
}
|
|
247
|
+
) }, type);
|
|
248
|
+
};
|
|
249
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
250
|
+
renderActionButton(
|
|
251
|
+
"call",
|
|
252
|
+
translateWithFallback(t, "customers.deals.kanban.card.action.call", "Call"),
|
|
253
|
+
/* @__PURE__ */ jsx(Phone, { className: "size-5", "aria-hidden": "true" })
|
|
256
254
|
),
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
"aria-disabled": disabled || void 0,
|
|
267
|
-
className: baseClass,
|
|
268
|
-
children: [
|
|
269
|
-
/* @__PURE__ */ jsx(StickyNote, { className: "size-3.5 shrink-0", "aria-hidden": "true" }),
|
|
270
|
-
/* @__PURE__ */ jsx("span", { children: translateWithFallback(t, "customers.deals.kanban.card.action.note", "Note") })
|
|
271
|
-
]
|
|
272
|
-
}
|
|
255
|
+
renderActionButton(
|
|
256
|
+
"email",
|
|
257
|
+
translateWithFallback(t, "customers.deals.kanban.card.action.email", "Email"),
|
|
258
|
+
/* @__PURE__ */ jsx(Mail, { className: "size-5", "aria-hidden": "true" })
|
|
259
|
+
),
|
|
260
|
+
renderActionButton(
|
|
261
|
+
"note",
|
|
262
|
+
translateWithFallback(t, "customers.deals.kanban.card.action.note", "Note"),
|
|
263
|
+
/* @__PURE__ */ jsx(StickyNote, { className: "size-5", "aria-hidden": "true" })
|
|
273
264
|
)
|
|
274
265
|
] });
|
|
275
266
|
})()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useDraggable } from '@dnd-kit/core'\nimport { AlertTriangle, Building2, Calendar, Clock, Mail, Phone, StickyNote } from 'lucide-react'\nimport { Avatar } from '@open-mercato/ui/primitives/avatar'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Checkbox } from '@open-mercato/ui/primitives/checkbox'\nimport type { RowActionItem } from '@open-mercato/ui/backend/RowActions'\nimport { DealCardMenu } from './DealCardMenu'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\n\nexport type DealCardPipelineState = {\n openActivitiesCount: number\n daysInCurrentStage: number\n isStuck: boolean\n isOverdue: boolean\n}\n\nexport type DealCardAssociation = {\n id: string\n label: string\n}\n\nexport type DealCardOwner = {\n userId: string\n label: string\n}\n\nexport type DealCardData = {\n id: string\n title: string\n status: string | null\n valueAmount: number | null\n valueCurrency: string | null\n probability: number | null\n expectedCloseAt: string | null\n createdAt: string | null\n /** ISO timestamp of last update \u2014 drives the \"Updated (newest/oldest)\" client-side sort fallback. */\n updatedAt: string | null\n owner: DealCardOwner | null\n primaryCompany: DealCardAssociation | null\n pipelineState: DealCardPipelineState\n}\n\ntype DealCardProps = {\n deal: DealCardData\n selected: boolean\n /**\n * `true` whenever at least one deal anywhere on the board is selected. Forces the\n * checkbox to render even when this card is not yet selected and not currently hovered,\n * so the operator can multi-select without re-aiming at the small 16px target after\n * every click. Without this, the second-click had to land precisely on the still-hidden\n * checkbox area before the `:hover` made it visible, which is what the spec called\n * \"persistent visibility once any card is selected\".\n */\n bulkSelectionActive?: boolean\n /** Stable function called once per render to build the row-action items for this deal */\n buildMenuItems: (deal: DealCardData) => RowActionItem[]\n extraCompaniesCount?: number\n extraOwners?: DealCardOwner[]\n isActiveDrag?: boolean\n onToggleSelect: (dealId: string) => void\n onComposeActivity: (dealId: string, type: 'call' | 'email' | 'note') => void\n onOpenDetail: (dealId: string) => void\n}\n\nconst ACTIVITY_BADGE_CAP = 9\nconst ACTIVITY_BADGE_WARNING_THRESHOLD = 9\nconst AVATAR_STACK_MAX = 3\n\n// Hash-derived avatar accent classes that map to semantic DS tokens (no hardcoded oklch/hex).\n// Each entry uses the saturated `*-icon` background (Figma calls for vivid, not pale, avatars)\n// paired with white-on-tone text since `*-icon` tokens are saturated foreground colors. The\n// choice is deterministic-but-arbitrary \u2014 we only need stable visual differentiation between\n// different owners, not semantic mapping to status meanings.\nconst AVATAR_ACCENT_CLASSES: string[] = [\n 'bg-status-info-icon text-white',\n 'bg-brand-violet text-brand-violet-foreground',\n 'bg-status-warning-icon text-white',\n 'bg-status-neutral-icon text-white',\n 'bg-status-success-icon text-white',\n 'bg-status-pink-icon text-white',\n]\n\nfunction hashAccent(seed: string): string {\n // Deterministic hash (Java string-hash variant). The bit pattern is not cryptographically\n // meaningful \u2014 it only needs to spread inputs across the palette indices.\n let h = 0\n for (let i = 0; i < seed.length; i += 1) {\n h = ((h << 5) - h + seed.charCodeAt(i)) | 0\n }\n const idx = Math.abs(h) % AVATAR_ACCENT_CLASSES.length\n return AVATAR_ACCENT_CLASSES[idx]\n}\n\nfunction splitCurrencyAmount(amount: number, currency: string | null): { display: string; code: string | null } {\n const code = currency && currency.length === 3 ? currency.toUpperCase() : null\n try {\n const formatter = new Intl.NumberFormat(undefined, {\n style: 'decimal',\n maximumFractionDigits: 0,\n useGrouping: true,\n })\n return { display: formatter.format(amount), code }\n } catch {\n return { display: String(Math.round(amount)), code }\n }\n}\n\nfunction formatProbability(value: number | null): string | null {\n if (typeof value !== 'number' || Number.isNaN(value)) return null\n return `${Math.min(Math.max(Math.round(value), 0), 100)}%`\n}\n\nconst shortDateFormatter = new Intl.DateTimeFormat(undefined, {\n month: 'short',\n day: '2-digit',\n})\n\nfunction formatShortDate(value: string | null): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return shortDateFormatter.format(date)\n}\n\nfunction shortDealRef(id: string): string {\n return `DEAL-${id.slice(0, 6).toUpperCase()}`\n}\n\nfunction DealCardImpl({\n deal,\n selected,\n bulkSelectionActive = false,\n buildMenuItems,\n extraCompaniesCount = 0,\n extraOwners,\n isActiveDrag = false,\n onToggleSelect,\n onComposeActivity,\n onOpenDetail,\n}: DealCardProps): React.ReactElement {\n const menuItems = React.useMemo(() => buildMenuItems(deal), [buildMenuItems, deal])\n const t = useT()\n // useDraggable is significantly cheaper than useSortable: dnd-kit doesn't recompute sibling\n // transforms during a drag. We don't reorder within a lane (sortBy controls order), so we just\n // need a draggable handle that drops into a Lane droppable.\n const { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n id: deal.id,\n data: { type: 'deal' },\n })\n const dimmed = isActiveDrag || isDragging\n\n // Note: no transform CSS \u2014 the DragOverlay shows the moving copy, while the source card stays\n // visually in place (just dimmed). This removes per-frame transform recomputation.\n const style: React.CSSProperties = {}\n\n const activityCount = deal.pipelineState.openActivitiesCount\n const activityBadgeLabel =\n activityCount > 0\n ? activityCount > ACTIVITY_BADGE_CAP\n ? `${ACTIVITY_BADGE_CAP}+`\n : String(activityCount)\n : null\n const activityWarning = activityCount >= ACTIVITY_BADGE_WARNING_THRESHOLD\n\n const probabilityLabel = formatProbability(deal.probability)\n const dateLabel = formatShortDate(deal.expectedCloseAt ?? deal.createdAt)\n const stageDaysLabel = deal.pipelineState.isOverdue\n ? translateWithFallback(t, 'customers.deals.kanban.card.overdueLabel', 'Overdue')\n : translateWithFallback(t, 'customers.deals.kanban.card.daysInStage', 'in {days}d', {\n days: deal.pipelineState.daysInCurrentStage,\n })\n\n const showOverdue = deal.pipelineState.isOverdue\n const showStuck = !showOverdue && deal.pipelineState.isStuck\n\n const handleSelectChange = (next: boolean | 'indeterminate') => {\n if (typeof next === 'boolean' && next !== selected) {\n onToggleSelect(deal.id)\n }\n }\n\n const handleCardClick = (event: React.MouseEvent<HTMLDivElement>) => {\n const target = event.target as HTMLElement\n if (target.closest('[data-card-action=\"true\"]')) return\n // Once at least one card is selected the board is in \"bulk-select mode\" \u2014 clicking\n // anywhere on a card toggles that card's selection rather than navigating to the\n // detail page. This matches Gmail/Asana selection semantics and rescues the\n // user-reported failure mode where a slight mis-aim at the 16px checkbox button\n // navigated to the deal instead of adding it to the selection.\n if (bulkSelectionActive) {\n onToggleSelect(deal.id)\n return\n }\n onOpenDetail(deal.id)\n }\n\n const handleActionClick = (type: 'call' | 'email' | 'note') => (event: React.MouseEvent) => {\n event.stopPropagation()\n onComposeActivity(deal.id, type)\n }\n\n // dnd-kit's `useDraggable` listeners set pointer capture on the card when a pointerdown lands\n // anywhere inside it. With capture active, the subsequent `click` event's `target` is the card\n // itself rather than the inner button/checkbox the user actually clicked \u2014 which made\n // `handleCardClick`'s `closest('[data-card-action=\"true\"]')` check return null, so every click\n // inside Select / Call / Email / Note / kebab navigated to the deal detail instead of running\n // the intended handler.\n //\n // We stop propagation of `onPointerDown` at the `data-card-action=\"true\"` boundary so dnd-kit's\n // listener never sees the pointerdown and never sets capture. React's `onClick` handlers on the\n // children then run normally with the right `event.target`.\n const stopPointerDown = React.useCallback((event: React.PointerEvent) => {\n event.stopPropagation()\n }, [])\n\n const ariaLabel = translateWithFallback(t, 'customers.deals.kanban.card.aria', 'Deal: {title}', {\n title: deal.title,\n })\n const ariaRoleDescription = translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.roledescription',\n 'deal card',\n )\n\n const valuePieces =\n deal.valueAmount !== null && Number.isFinite(deal.valueAmount)\n ? splitCurrencyAmount(deal.valueAmount, deal.valueCurrency)\n : null\n\n // Probability pill tone tracks the probability VALUE, not stage state. Figma uses three\n // tiers (nodes 982:369/417/674/745/797/928/1002):\n // <40% \u2192 de-emphasized (gray bg + faded text) \u2014 Figma `bg-[#f4f5f7] text-[#808794]`\n // 40-69% \u2192 warning tone (orange bg + warning text) \u2014 Figma `bg-[#fff2e0] text-[#fe9a00]`\n // \u226570% \u2192 emphasized (gray bg + dark text) \u2014 Figma `bg-[#f4f5f7] text-[#474c5a]`\n //\n // Importantly the high-probability tier is NOT green: Figma keeps the neutral surface and\n // signals confidence with darker foreground text instead. Stuck/overdue state is already\n // surfaced by the separate badge above, so coloring this pill by stage state (the previous\n // behavior) double-stacked the signal and made e.g. \"80% on a red background\" read as\n // \"80% is bad\" \u2014 the inverse of intent.\n const probabilityValue = typeof deal.probability === 'number' ? deal.probability : null\n const probabilityPillClass =\n probabilityValue !== null && probabilityValue >= 70\n ? 'bg-muted text-foreground'\n : probabilityValue !== null && probabilityValue >= 40\n ? 'bg-status-warning-bg text-status-warning-text'\n : 'bg-muted text-muted-foreground'\n\n const activityBadgeClass = activityWarning\n ? 'bg-status-warning-bg text-status-warning-text'\n : 'bg-muted text-foreground'\n\n const ownersStack: DealCardOwner[] = React.useMemo(() => {\n if (!deal.owner) return extraOwners ?? []\n return [deal.owner, ...(extraOwners ?? [])]\n }, [deal.owner, extraOwners])\n\n const visibleOwners = ownersStack.slice(0, AVATAR_STACK_MAX)\n const ownerOverflow = ownersStack.length - visibleOwners.length\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n {...attributes}\n {...listeners}\n aria-label={ariaLabel}\n aria-roledescription={ariaRoleDescription}\n onClick={handleCardClick}\n className={`group relative flex w-full flex-col gap-3 rounded-lg border border-border bg-card px-4 py-3.5 shadow-xs transition-shadow ${\n dimmed\n ? 'cursor-grabbing opacity-30'\n : bulkSelectionActive\n ? 'cursor-pointer hover:shadow-sm'\n : 'cursor-grab hover:shadow-sm active:cursor-grabbing'\n } ${selected ? 'ring-2 ring-accent-indigo' : ''}`}\n >\n <div className=\"flex items-start justify-between gap-2.5\">\n <div\n data-card-action=\"true\"\n className={`mr-0.5 mt-0.5 flex shrink-0 transition-opacity ${\n selected || bulkSelectionActive\n ? 'opacity-100'\n // `opacity-0` alone leaves the element in the DOM with hit-testable pointer\n // areas \u2014 its `onPointerDown={stopPointerDown}` would then swallow drag\n // starts whenever the operator grabs the card near the (invisible) checkbox,\n // and dnd-kit never sees the gesture. Pairing with `pointer-events-none`\n // lets the drag pass through while preserving tab/focus reachability (focus\n // events ignore `pointer-events`), and the matching `group-hover` /\n // `focus-within` variants restore hit testing the moment the affordance\n // becomes visible. `[@media(hover:none)]` keeps it always-on for touch.\n : 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto'\n }`}\n onClick={(event) => event.stopPropagation()}\n onPointerDown={stopPointerDown}\n >\n <Checkbox\n checked={selected}\n onCheckedChange={handleSelectChange}\n aria-label={translateWithFallback(t, 'customers.deals.kanban.card.aria.select', 'Select deal')}\n />\n </div>\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n <h3 className=\"line-clamp-2 text-base font-semibold leading-normal text-foreground\">\n {deal.title}\n </h3>\n <span className=\"text-xs leading-normal text-muted-foreground\">{shortDealRef(deal.id)}</span>\n </div>\n <div\n className=\"flex shrink-0 items-center gap-2\"\n data-card-action=\"true\"\n onClick={(event) => event.stopPropagation()}\n onPointerDown={stopPointerDown}\n >\n {activityBadgeLabel ? (\n <span\n className={`inline-flex items-center justify-center rounded-full px-2 py-px text-xs font-bold leading-normal ${activityBadgeClass}`}\n aria-label={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.openActivities',\n '{count} open activities',\n { count: activityCount },\n )}\n >\n {activityBadgeLabel}\n </span>\n ) : null}\n <DealCardMenu\n items={menuItems}\n ariaLabel={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.menu',\n 'Deal actions',\n )}\n />\n </div>\n </div>\n\n {showOverdue || showStuck ? (\n <div>\n {showOverdue ? (\n <span className=\"inline-flex items-center gap-1.5 rounded-md bg-status-error-bg px-2.5 py-1 text-xs font-semibold leading-normal text-status-error-text\">\n <AlertTriangle className=\"size-3.5\" aria-hidden=\"true\" />\n {translateWithFallback(t, 'customers.deals.kanban.card.statusOverdue', 'OVERDUE')}\n </span>\n ) : (\n <span className=\"inline-flex items-center gap-1.5 rounded-md bg-status-warning-bg px-2.5 py-1 text-xs font-semibold leading-normal text-status-warning-text\">\n <Clock className=\"size-3.5\" aria-hidden=\"true\" />\n {translateWithFallback(t, 'customers.deals.kanban.card.statusStuck', 'STUCK')}\n </span>\n )}\n </div>\n ) : null}\n\n {/*\n Quick-log activity actions. `customers.activities` are scoped to a parent entity\n (person/company), so without a primary company on this deal the activity composer\n has nowhere to anchor a new record. The page-level handler previously flashed a\n toast and bailed silently, which was confusing \u2014 we surface the same constraint\n here as a disabled-with-tooltip state so the operator can fix the underlying data.\n */}\n <div\n data-card-action=\"true\"\n // See note on the checkbox wrapper above \u2014 pairing `opacity-0` with `pointer-events-none`\n // is what restores drag-and-drop. Without it the (invisible) Call/Email/Note row sits in\n // the middle of the card and intercepts every pointer-down, so dnd-kit never gets the\n // gesture and the card snaps back to the source lane.\n className=\"flex items-center gap-1 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto\"\n onClick={(event) => event.stopPropagation()}\n onPointerDown={stopPointerDown}\n >\n {(() => {\n const disabled = !deal.primaryCompany\n const disabledTitle = disabled\n ? translateWithFallback(\n t,\n 'customers.deals.kanban.card.action.disabledNoCompany',\n 'Link a company to this deal before logging activities.',\n )\n : undefined\n const baseClass =\n 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'\n return (\n <>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n type=\"button\"\n onClick={handleActionClick('call')}\n disabled={disabled}\n title={disabledTitle}\n aria-disabled={disabled || undefined}\n className={baseClass}\n >\n <Phone className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n <span>{translateWithFallback(t, 'customers.deals.kanban.card.action.call', 'Call')}</span>\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n type=\"button\"\n onClick={handleActionClick('email')}\n disabled={disabled}\n title={disabledTitle}\n aria-disabled={disabled || undefined}\n className={baseClass}\n >\n <Mail className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n <span>{translateWithFallback(t, 'customers.deals.kanban.card.action.email', 'Email')}</span>\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n type=\"button\"\n onClick={handleActionClick('note')}\n disabled={disabled}\n title={disabledTitle}\n aria-disabled={disabled || undefined}\n className={baseClass}\n >\n <StickyNote className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n <span>{translateWithFallback(t, 'customers.deals.kanban.card.action.note', 'Note')}</span>\n </Button>\n </>\n )\n })()}\n </div>\n\n <div className=\"flex items-center justify-between gap-2.5\">\n {valuePieces ? (\n <div className=\"flex items-baseline gap-1.5\">\n <span className=\"text-lg font-bold leading-normal text-foreground\">\n {valuePieces.display}\n </span>\n {valuePieces.code ? (\n <span className=\"text-sm font-semibold leading-normal text-muted-foreground\">\n {valuePieces.code}\n </span>\n ) : null}\n </div>\n ) : (\n <span className=\"text-sm text-muted-foreground\">\n {translateWithFallback(t, 'customers.deals.kanban.card.noValue', 'No value')}\n </span>\n )}\n {probabilityLabel ? (\n <span\n className={`inline-flex items-center rounded-md px-2.5 py-1 text-sm font-semibold leading-normal ${probabilityPillClass}`}\n aria-label={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.probability',\n 'Probability: {value}',\n { value: probabilityLabel },\n )}\n >\n {probabilityLabel}\n </span>\n ) : null}\n </div>\n\n {deal.primaryCompany || extraCompaniesCount > 0 ? (\n <div className=\"flex items-center gap-1.5\">\n {deal.primaryCompany ? (\n <span className=\"inline-flex max-w-full items-center gap-1.5 overflow-hidden rounded-md bg-muted px-2.5 py-1 text-sm font-semibold leading-normal text-foreground\">\n <Building2 className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"truncate\">{deal.primaryCompany.label}</span>\n </span>\n ) : null}\n {extraCompaniesCount > 0 ? (\n <span\n className=\"inline-flex shrink-0 items-center rounded-md bg-muted px-2.5 py-1 text-sm font-semibold leading-normal text-muted-foreground\"\n aria-label={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.moreCompanies',\n '{count} more companies',\n { count: extraCompaniesCount },\n )}\n >\n +{extraCompaniesCount}\n </span>\n ) : null}\n </div>\n ) : null}\n\n <div className=\"h-px w-full bg-border\" aria-hidden=\"true\" />\n\n <div className=\"flex items-center justify-between gap-2.5\">\n <div className=\"flex items-center gap-2 text-sm leading-normal\">\n {dateLabel ? (\n <span className=\"inline-flex items-center gap-2\">\n <Calendar\n className={`size-3.5 ${showOverdue ? 'text-status-error-icon' : 'text-muted-foreground'}`}\n aria-hidden=\"true\"\n />\n <span\n className={\n showOverdue\n ? 'font-semibold text-status-error-text'\n : 'font-semibold text-foreground'\n }\n >\n {dateLabel}\n </span>\n </span>\n ) : null}\n <span\n className={\n showOverdue\n ? 'font-semibold text-status-error-text'\n : 'font-normal text-muted-foreground'\n }\n >\n {stageDaysLabel}\n </span>\n </div>\n {ownersStack.length > 0 ? (\n <div className=\"flex items-center\">\n {visibleOwners.map((owner, idx) => {\n const accentClass = hashAccent(owner.userId)\n return (\n <Avatar\n key={`${owner.userId}-${idx}`}\n label={owner.label || owner.userId.slice(0, 2).toUpperCase()}\n size=\"sm\"\n className={`size-7 text-xs font-bold ring-2 ring-card ${accentClass} ${idx > 0 ? '-ml-2.5' : ''}`}\n />\n )\n })}\n {ownerOverflow > 0 ? (\n <Avatar\n label={`+${ownerOverflow}`}\n size=\"sm\"\n variant=\"monochrome\"\n className=\"size-7 -ml-2.5 text-xs font-bold ring-2 ring-card\"\n />\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n\n// Memoize so cards in lanes far from the drag don't re-render on drag-state changes.\n// Custom equality keeps the comparison cheap (we only re-render when something visible changes).\nexport const DealCard = React.memo(DealCardImpl, (prev, next) => {\n if (prev.deal !== next.deal) return false\n if (prev.selected !== next.selected) return false\n if (prev.bulkSelectionActive !== next.bulkSelectionActive) return false\n if (prev.isActiveDrag !== next.isActiveDrag) return false\n if (prev.buildMenuItems !== next.buildMenuItems) return false\n if (prev.extraCompaniesCount !== next.extraCompaniesCount) return false\n if (prev.extraOwners !== next.extraOwners) return false\n // Callback identity changes do NOT trigger re-renders \u2014 they're stable handlers from the page\n return true\n})\n\nexport default DealCard\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useDraggable } from '@dnd-kit/core'\nimport { AlertTriangle, Building2, Calendar, Clock, Mail, Phone, StickyNote } from 'lucide-react'\nimport { Avatar } from '@open-mercato/ui/primitives/avatar'\nimport { Checkbox } from '@open-mercato/ui/primitives/checkbox'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip'\nimport type { RowActionItem } from '@open-mercato/ui/backend/RowActions'\nimport { DealCardMenu } from './DealCardMenu'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\n\nexport type DealCardPipelineState = {\n openActivitiesCount: number\n daysInCurrentStage: number\n isStuck: boolean\n isOverdue: boolean\n}\n\nexport type DealCardAssociation = {\n id: string\n label: string\n}\n\nexport type DealCardOwner = {\n userId: string\n label: string\n}\n\nexport type DealCardData = {\n id: string\n title: string\n status: string | null\n valueAmount: number | null\n valueCurrency: string | null\n probability: number | null\n expectedCloseAt: string | null\n createdAt: string | null\n /** ISO timestamp of last update \u2014 drives the \"Updated (newest/oldest)\" client-side sort fallback. */\n updatedAt: string | null\n owner: DealCardOwner | null\n primaryCompany: DealCardAssociation | null\n pipelineState: DealCardPipelineState\n}\n\ntype DealCardProps = {\n deal: DealCardData\n selected: boolean\n /**\n * `true` whenever at least one deal anywhere on the board is selected. Forces the\n * checkbox to render even when this card is not yet selected and not currently hovered,\n * so the operator can multi-select without re-aiming at the small 16px target after\n * every click. Without this, the second-click had to land precisely on the still-hidden\n * checkbox area before the `:hover` made it visible, which is what the spec called\n * \"persistent visibility once any card is selected\".\n */\n bulkSelectionActive?: boolean\n /** Stable function called once per render to build the row-action items for this deal */\n buildMenuItems: (deal: DealCardData) => RowActionItem[]\n extraCompaniesCount?: number\n extraOwners?: DealCardOwner[]\n isActiveDrag?: boolean\n onToggleSelect: (dealId: string) => void\n onComposeActivity: (dealId: string, type: 'call' | 'email' | 'note') => void\n onOpenDetail: (dealId: string) => void\n}\n\nconst ACTIVITY_BADGE_CAP = 9\nconst ACTIVITY_BADGE_WARNING_THRESHOLD = 9\nconst AVATAR_STACK_MAX = 3\n\n// Hash-derived avatar accent classes that map to semantic DS tokens (no hardcoded oklch/hex).\n// Each entry uses the saturated `*-icon` background (Figma calls for vivid, not pale, avatars)\n// paired with white-on-tone text since `*-icon` tokens are saturated foreground colors. The\n// choice is deterministic-but-arbitrary \u2014 we only need stable visual differentiation between\n// different owners, not semantic mapping to status meanings.\nconst AVATAR_ACCENT_CLASSES: string[] = [\n 'bg-status-info-icon text-white',\n 'bg-brand-violet text-brand-violet-foreground',\n 'bg-status-warning-icon text-white',\n 'bg-status-neutral-icon text-white',\n 'bg-status-success-icon text-white',\n 'bg-status-pink-icon text-white',\n]\n\nfunction hashAccent(seed: string): string {\n // Deterministic hash (Java string-hash variant). The bit pattern is not cryptographically\n // meaningful \u2014 it only needs to spread inputs across the palette indices.\n let h = 0\n for (let i = 0; i < seed.length; i += 1) {\n h = ((h << 5) - h + seed.charCodeAt(i)) | 0\n }\n const idx = Math.abs(h) % AVATAR_ACCENT_CLASSES.length\n return AVATAR_ACCENT_CLASSES[idx]\n}\n\nfunction splitCurrencyAmount(amount: number, currency: string | null): { display: string; code: string | null } {\n const code = currency && currency.length === 3 ? currency.toUpperCase() : null\n try {\n const formatter = new Intl.NumberFormat(undefined, {\n style: 'decimal',\n maximumFractionDigits: 0,\n useGrouping: true,\n })\n return { display: formatter.format(amount), code }\n } catch {\n return { display: String(Math.round(amount)), code }\n }\n}\n\nfunction formatProbability(value: number | null): string | null {\n if (typeof value !== 'number' || Number.isNaN(value)) return null\n return `${Math.min(Math.max(Math.round(value), 0), 100)}%`\n}\n\nconst shortDateFormatter = new Intl.DateTimeFormat(undefined, {\n month: 'short',\n day: '2-digit',\n})\n\nfunction formatShortDate(value: string | null): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return shortDateFormatter.format(date)\n}\n\nfunction shortDealRef(id: string): string {\n return `DEAL-${id.slice(0, 6).toUpperCase()}`\n}\n\nfunction DealCardImpl({\n deal,\n selected,\n bulkSelectionActive = false,\n buildMenuItems,\n extraCompaniesCount = 0,\n extraOwners,\n isActiveDrag = false,\n onToggleSelect,\n onComposeActivity,\n onOpenDetail,\n}: DealCardProps): React.ReactElement {\n const menuItems = React.useMemo(() => buildMenuItems(deal), [buildMenuItems, deal])\n const t = useT()\n // useDraggable is significantly cheaper than useSortable: dnd-kit doesn't recompute sibling\n // transforms during a drag. We don't reorder within a lane (sortBy controls order), so we just\n // need a draggable handle that drops into a Lane droppable.\n const { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n id: deal.id,\n data: { type: 'deal' },\n })\n const dimmed = isActiveDrag || isDragging\n\n // Note: no transform CSS \u2014 the DragOverlay shows the moving copy, while the source card stays\n // visually in place (just dimmed). This removes per-frame transform recomputation.\n const style: React.CSSProperties = {}\n\n const activityCount = deal.pipelineState.openActivitiesCount\n const activityBadgeLabel =\n activityCount > 0\n ? activityCount > ACTIVITY_BADGE_CAP\n ? `${ACTIVITY_BADGE_CAP}+`\n : String(activityCount)\n : null\n const activityWarning = activityCount >= ACTIVITY_BADGE_WARNING_THRESHOLD\n\n const probabilityLabel = formatProbability(deal.probability)\n const dateLabel = formatShortDate(deal.expectedCloseAt ?? deal.createdAt)\n const stageDaysLabel = deal.pipelineState.isOverdue\n ? translateWithFallback(t, 'customers.deals.kanban.card.overdueLabel', 'Overdue')\n : translateWithFallback(t, 'customers.deals.kanban.card.daysInStage', 'in {days}d', {\n days: deal.pipelineState.daysInCurrentStage,\n })\n\n const showOverdue = deal.pipelineState.isOverdue\n const showStuck = !showOverdue && deal.pipelineState.isStuck\n\n const handleSelectChange = (next: boolean | 'indeterminate') => {\n if (typeof next === 'boolean' && next !== selected) {\n onToggleSelect(deal.id)\n }\n }\n\n const handleCardClick = (event: React.MouseEvent<HTMLDivElement>) => {\n const target = event.target as HTMLElement\n if (target.closest('[data-card-action=\"true\"]')) return\n // Once at least one card is selected the board is in \"bulk-select mode\" \u2014 clicking\n // anywhere on a card toggles that card's selection rather than navigating to the\n // detail page. This matches Gmail/Asana selection semantics and rescues the\n // user-reported failure mode where a slight mis-aim at the 16px checkbox button\n // navigated to the deal instead of adding it to the selection.\n if (bulkSelectionActive) {\n onToggleSelect(deal.id)\n return\n }\n onOpenDetail(deal.id)\n }\n\n const handleActionClick = (type: 'call' | 'email' | 'note') => (event: React.MouseEvent) => {\n event.stopPropagation()\n onComposeActivity(deal.id, type)\n }\n\n // dnd-kit's `useDraggable` listeners set pointer capture on the card when a pointerdown lands\n // anywhere inside it. With capture active, the subsequent `click` event's `target` is the card\n // itself rather than the inner button/checkbox the user actually clicked \u2014 which made\n // `handleCardClick`'s `closest('[data-card-action=\"true\"]')` check return null, so every click\n // inside Select / Call / Email / Note / kebab navigated to the deal detail instead of running\n // the intended handler.\n //\n // We stop propagation of `onPointerDown` at the `data-card-action=\"true\"` boundary so dnd-kit's\n // listener never sees the pointerdown and never sets capture. React's `onClick` handlers on the\n // children then run normally with the right `event.target`.\n const stopPointerDown = React.useCallback((event: React.PointerEvent) => {\n event.stopPropagation()\n }, [])\n\n const ariaLabel = translateWithFallback(t, 'customers.deals.kanban.card.aria', 'Deal: {title}', {\n title: deal.title,\n })\n const ariaRoleDescription = translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.roledescription',\n 'deal card',\n )\n\n const valuePieces =\n deal.valueAmount !== null && Number.isFinite(deal.valueAmount)\n ? splitCurrencyAmount(deal.valueAmount, deal.valueCurrency)\n : null\n\n // Probability pill tone tracks the probability VALUE, not stage state. Figma uses three\n // tiers (nodes 982:369/417/674/745/797/928/1002):\n // <40% \u2192 de-emphasized (gray bg + faded text) \u2014 Figma `bg-[#f4f5f7] text-[#808794]`\n // 40-69% \u2192 warning tone (orange bg + warning text) \u2014 Figma `bg-[#fff2e0] text-[#fe9a00]`\n // \u226570% \u2192 emphasized (gray bg + dark text) \u2014 Figma `bg-[#f4f5f7] text-[#474c5a]`\n //\n // Importantly the high-probability tier is NOT green: Figma keeps the neutral surface and\n // signals confidence with darker foreground text instead. Stuck/overdue state is already\n // surfaced by the separate badge above, so coloring this pill by stage state (the previous\n // behavior) double-stacked the signal and made e.g. \"80% on a red background\" read as\n // \"80% is bad\" \u2014 the inverse of intent.\n const probabilityValue = typeof deal.probability === 'number' ? deal.probability : null\n const probabilityPillClass =\n probabilityValue !== null && probabilityValue >= 70\n ? 'bg-muted text-foreground'\n : probabilityValue !== null && probabilityValue >= 40\n ? 'bg-status-warning-bg text-status-warning-text'\n : 'bg-muted text-muted-foreground'\n\n const activityBadgeClass = activityWarning\n ? 'bg-status-warning-bg text-status-warning-text'\n : 'bg-muted text-foreground'\n\n const ownersStack: DealCardOwner[] = React.useMemo(() => {\n if (!deal.owner) return extraOwners ?? []\n return [deal.owner, ...(extraOwners ?? [])]\n }, [deal.owner, extraOwners])\n\n const visibleOwners = ownersStack.slice(0, AVATAR_STACK_MAX)\n const ownerOverflow = ownersStack.length - visibleOwners.length\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n {...attributes}\n {...listeners}\n aria-label={ariaLabel}\n aria-roledescription={ariaRoleDescription}\n onClick={handleCardClick}\n className={`group relative flex w-full flex-col gap-3 rounded-lg border border-border bg-card px-4 py-3.5 shadow-xs transition-shadow ${\n dimmed\n ? 'cursor-grabbing opacity-30'\n : bulkSelectionActive\n ? 'cursor-pointer hover:shadow-sm'\n : 'cursor-grab hover:shadow-sm active:cursor-grabbing'\n } ${selected ? 'ring-2 ring-accent-indigo' : ''}`}\n >\n <div className=\"flex items-start justify-between gap-2.5\">\n <div\n data-card-action=\"true\"\n className={`mr-0.5 mt-0.5 flex shrink-0 transition-opacity ${\n selected || bulkSelectionActive\n ? 'opacity-100'\n // `opacity-0` alone leaves the element in the DOM with hit-testable pointer\n // areas \u2014 its `onPointerDown={stopPointerDown}` would then swallow drag\n // starts whenever the operator grabs the card near the (invisible) checkbox,\n // and dnd-kit never sees the gesture. Pairing with `pointer-events-none`\n // lets the drag pass through while preserving tab/focus reachability (focus\n // events ignore `pointer-events`), and the matching `group-hover` /\n // `focus-within` variants restore hit testing the moment the affordance\n // becomes visible. `[@media(hover:none)]` keeps it always-on for touch.\n : 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto'\n }`}\n onClick={(event) => event.stopPropagation()}\n onPointerDown={stopPointerDown}\n >\n <Checkbox\n checked={selected}\n onCheckedChange={handleSelectChange}\n aria-label={translateWithFallback(t, 'customers.deals.kanban.card.aria.select', 'Select deal')}\n />\n </div>\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n <h3 className=\"line-clamp-2 text-base font-semibold leading-normal text-foreground\">\n {deal.title}\n </h3>\n <span className=\"text-xs leading-normal text-muted-foreground\">{shortDealRef(deal.id)}</span>\n </div>\n <div\n className=\"flex shrink-0 items-center gap-2\"\n data-card-action=\"true\"\n onClick={(event) => event.stopPropagation()}\n onPointerDown={stopPointerDown}\n >\n {activityBadgeLabel ? (\n <span\n className={`inline-flex items-center justify-center rounded-full px-2 py-px text-xs font-bold leading-normal ${activityBadgeClass}`}\n aria-label={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.openActivities',\n '{count} open activities',\n { count: activityCount },\n )}\n >\n {activityBadgeLabel}\n </span>\n ) : null}\n <DealCardMenu\n items={menuItems}\n ariaLabel={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.menu',\n 'Deal actions',\n )}\n />\n </div>\n </div>\n\n {showOverdue || showStuck ? (\n <div>\n {showOverdue ? (\n <span className=\"inline-flex items-center gap-1.5 rounded-md bg-status-error-bg px-2.5 py-1 text-xs font-semibold leading-normal text-status-error-text\">\n <AlertTriangle className=\"size-3.5\" aria-hidden=\"true\" />\n {translateWithFallback(t, 'customers.deals.kanban.card.statusOverdue', 'OVERDUE')}\n </span>\n ) : (\n <span className=\"inline-flex items-center gap-1.5 rounded-md bg-status-warning-bg px-2.5 py-1 text-xs font-semibold leading-normal text-status-warning-text\">\n <Clock className=\"size-3.5\" aria-hidden=\"true\" />\n {translateWithFallback(t, 'customers.deals.kanban.card.statusStuck', 'STUCK')}\n </span>\n )}\n </div>\n ) : null}\n\n {/*\n Quick-log activity actions. `customers.activities` are scoped to a parent entity\n (person/company), so without a primary company on this deal the activity composer\n has nowhere to anchor a new record. The page-level handler previously flashed a\n toast and bailed silently, which was confusing \u2014 we surface the same constraint\n here as a disabled-with-tooltip state so the operator can fix the underlying data.\n */}\n <div\n data-card-action=\"true\"\n // See note on the checkbox wrapper above \u2014 pairing `opacity-0` with `pointer-events-none`\n // is what restores drag-and-drop. Without it the (invisible) Call/Email/Note row sits in\n // the middle of the card and intercepts every pointer-down, so dnd-kit never gets the\n // gesture and the card snaps back to the source lane.\n className=\"flex items-center justify-center gap-2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto\"\n onClick={(event) => event.stopPropagation()}\n onPointerDown={stopPointerDown}\n >\n {(() => {\n const disabled = !deal.primaryCompany\n const disabledTooltip = disabled\n ? translateWithFallback(\n t,\n 'customers.deals.kanban.card.action.disabledNoCompany',\n 'Link a company to this deal before logging activities.',\n )\n : undefined\n const renderActionButton = (\n type: 'call' | 'email' | 'note',\n label: string,\n icon: React.ReactNode,\n ) => {\n const button = (\n <IconButton\n variant=\"ghost\"\n size=\"default\"\n onClick={handleActionClick(type)}\n disabled={disabled}\n aria-disabled={disabled || undefined}\n aria-label={label}\n className=\"shrink-0 text-muted-foreground hover:text-foreground\"\n >\n {icon}\n </IconButton>\n )\n\n if (!disabled) {\n return (\n <SimpleTooltip key={type} content={label} size=\"sm\">\n {button}\n </SimpleTooltip>\n )\n }\n\n return (\n <SimpleTooltip key={type} content={disabledTooltip} size=\"sm\">\n <span\n className=\"inline-flex shrink-0 rounded-md focus-visible:outline-none focus-visible:shadow-focus\"\n tabIndex={0}\n aria-label={`${label}: ${disabledTooltip}`}\n >\n {button}\n </span>\n </SimpleTooltip>\n )\n }\n\n return (\n <>\n {renderActionButton(\n 'call',\n translateWithFallback(t, 'customers.deals.kanban.card.action.call', 'Call'),\n <Phone className=\"size-5\" aria-hidden=\"true\" />,\n )}\n {renderActionButton(\n 'email',\n translateWithFallback(t, 'customers.deals.kanban.card.action.email', 'Email'),\n <Mail className=\"size-5\" aria-hidden=\"true\" />,\n )}\n {renderActionButton(\n 'note',\n translateWithFallback(t, 'customers.deals.kanban.card.action.note', 'Note'),\n <StickyNote className=\"size-5\" aria-hidden=\"true\" />,\n )}\n </>\n )\n })()}\n </div>\n\n <div className=\"flex items-center justify-between gap-2.5\">\n {valuePieces ? (\n <div className=\"flex items-baseline gap-1.5\">\n <span className=\"text-lg font-bold leading-normal text-foreground\">\n {valuePieces.display}\n </span>\n {valuePieces.code ? (\n <span className=\"text-sm font-semibold leading-normal text-muted-foreground\">\n {valuePieces.code}\n </span>\n ) : null}\n </div>\n ) : (\n <span className=\"text-sm text-muted-foreground\">\n {translateWithFallback(t, 'customers.deals.kanban.card.noValue', 'No value')}\n </span>\n )}\n {probabilityLabel ? (\n <span\n className={`inline-flex items-center rounded-md px-2.5 py-1 text-sm font-semibold leading-normal ${probabilityPillClass}`}\n aria-label={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.probability',\n 'Probability: {value}',\n { value: probabilityLabel },\n )}\n >\n {probabilityLabel}\n </span>\n ) : null}\n </div>\n\n {deal.primaryCompany || extraCompaniesCount > 0 ? (\n <div className=\"flex items-center gap-1.5\">\n {deal.primaryCompany ? (\n <span className=\"inline-flex max-w-full items-center gap-1.5 overflow-hidden rounded-md bg-muted px-2.5 py-1 text-sm font-semibold leading-normal text-foreground\">\n <Building2 className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"truncate\">{deal.primaryCompany.label}</span>\n </span>\n ) : null}\n {extraCompaniesCount > 0 ? (\n <span\n className=\"inline-flex shrink-0 items-center rounded-md bg-muted px-2.5 py-1 text-sm font-semibold leading-normal text-muted-foreground\"\n aria-label={translateWithFallback(\n t,\n 'customers.deals.kanban.card.aria.moreCompanies',\n '{count} more companies',\n { count: extraCompaniesCount },\n )}\n >\n +{extraCompaniesCount}\n </span>\n ) : null}\n </div>\n ) : null}\n\n <div className=\"h-px w-full bg-border\" aria-hidden=\"true\" />\n\n <div className=\"flex items-center justify-between gap-2.5\">\n <div className=\"flex items-center gap-2 text-sm leading-normal\">\n {dateLabel ? (\n <span className=\"inline-flex items-center gap-2\">\n <Calendar\n className={`size-3.5 ${showOverdue ? 'text-status-error-icon' : 'text-muted-foreground'}`}\n aria-hidden=\"true\"\n />\n <span\n className={\n showOverdue\n ? 'font-semibold text-status-error-text'\n : 'font-semibold text-foreground'\n }\n >\n {dateLabel}\n </span>\n </span>\n ) : null}\n <span\n className={\n showOverdue\n ? 'font-semibold text-status-error-text'\n : 'font-normal text-muted-foreground'\n }\n >\n {stageDaysLabel}\n </span>\n </div>\n {ownersStack.length > 0 ? (\n <div className=\"flex items-center\">\n {visibleOwners.map((owner, idx) => {\n const accentClass = hashAccent(owner.userId)\n return (\n <Avatar\n key={`${owner.userId}-${idx}`}\n label={owner.label || owner.userId.slice(0, 2).toUpperCase()}\n size=\"sm\"\n className={`size-7 text-xs font-bold ring-2 ring-card ${accentClass} ${idx > 0 ? '-ml-2.5' : ''}`}\n />\n )\n })}\n {ownerOverflow > 0 ? (\n <Avatar\n label={`+${ownerOverflow}`}\n size=\"sm\"\n variant=\"monochrome\"\n className=\"size-7 -ml-2.5 text-xs font-bold ring-2 ring-card\"\n />\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n\n// Memoize so cards in lanes far from the drag don't re-render on drag-state changes.\n// Custom equality keeps the comparison cheap (we only re-render when something visible changes).\nexport const DealCard = React.memo(DealCardImpl, (prev, next) => {\n if (prev.deal !== next.deal) return false\n if (prev.selected !== next.selected) return false\n if (prev.bulkSelectionActive !== next.bulkSelectionActive) return false\n if (prev.isActiveDrag !== next.isActiveDrag) return false\n if (prev.buildMenuItems !== next.buildMenuItems) return false\n if (prev.extraCompaniesCount !== next.extraCompaniesCount) return false\n if (prev.extraOwners !== next.extraOwners) return false\n // Callback identity changes do NOT trigger re-renders \u2014 they're stable handlers from the page\n return true\n})\n\nexport default DealCard\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6389.1.135a466c47",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -248,16 +248,16 @@
|
|
|
248
248
|
"zod": "^4.4.3"
|
|
249
249
|
},
|
|
250
250
|
"peerDependencies": {
|
|
251
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
252
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
253
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
251
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6389.1.135a466c47",
|
|
252
|
+
"@open-mercato/shared": "0.6.6-develop.6389.1.135a466c47",
|
|
253
|
+
"@open-mercato/ui": "0.6.6-develop.6389.1.135a466c47",
|
|
254
254
|
"react": "^19.0.0",
|
|
255
255
|
"react-dom": "^19.0.0"
|
|
256
256
|
},
|
|
257
257
|
"devDependencies": {
|
|
258
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
259
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
260
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
258
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6389.1.135a466c47",
|
|
259
|
+
"@open-mercato/shared": "0.6.6-develop.6389.1.135a466c47",
|
|
260
|
+
"@open-mercato/ui": "0.6.6-develop.6389.1.135a466c47",
|
|
261
261
|
"@testing-library/dom": "^10.4.1",
|
|
262
262
|
"@testing-library/jest-dom": "^6.9.1",
|
|
263
263
|
"@testing-library/react": "^16.3.1",
|
|
@@ -4,8 +4,9 @@ import * as React from 'react'
|
|
|
4
4
|
import { useDraggable } from '@dnd-kit/core'
|
|
5
5
|
import { AlertTriangle, Building2, Calendar, Clock, Mail, Phone, StickyNote } from 'lucide-react'
|
|
6
6
|
import { Avatar } from '@open-mercato/ui/primitives/avatar'
|
|
7
|
-
import { Button } from '@open-mercato/ui/primitives/button'
|
|
8
7
|
import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
|
|
8
|
+
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
9
|
+
import { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip'
|
|
9
10
|
import type { RowActionItem } from '@open-mercato/ui/backend/RowActions'
|
|
10
11
|
import { DealCardMenu } from './DealCardMenu'
|
|
11
12
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -369,62 +370,76 @@ function DealCardImpl({
|
|
|
369
370
|
// is what restores drag-and-drop. Without it the (invisible) Call/Email/Note row sits in
|
|
370
371
|
// the middle of the card and intercepts every pointer-down, so dnd-kit never gets the
|
|
371
372
|
// gesture and the card snaps back to the source lane.
|
|
372
|
-
className="flex items-center gap-
|
|
373
|
+
className="flex items-center justify-center gap-2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto"
|
|
373
374
|
onClick={(event) => event.stopPropagation()}
|
|
374
375
|
onPointerDown={stopPointerDown}
|
|
375
376
|
>
|
|
376
377
|
{(() => {
|
|
377
378
|
const disabled = !deal.primaryCompany
|
|
378
|
-
const
|
|
379
|
+
const disabledTooltip = disabled
|
|
379
380
|
? translateWithFallback(
|
|
380
381
|
t,
|
|
381
382
|
'customers.deals.kanban.card.action.disabledNoCompany',
|
|
382
383
|
'Link a company to this deal before logging activities.',
|
|
383
384
|
)
|
|
384
385
|
: undefined
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
type="button"
|
|
393
|
-
onClick={handleActionClick('call')}
|
|
394
|
-
disabled={disabled}
|
|
395
|
-
title={disabledTitle}
|
|
396
|
-
aria-disabled={disabled || undefined}
|
|
397
|
-
className={baseClass}
|
|
398
|
-
>
|
|
399
|
-
<Phone className="size-3.5 shrink-0" aria-hidden="true" />
|
|
400
|
-
<span>{translateWithFallback(t, 'customers.deals.kanban.card.action.call', 'Call')}</span>
|
|
401
|
-
</Button>
|
|
402
|
-
<Button
|
|
403
|
-
variant="ghost"
|
|
404
|
-
size="sm"
|
|
405
|
-
type="button"
|
|
406
|
-
onClick={handleActionClick('email')}
|
|
407
|
-
disabled={disabled}
|
|
408
|
-
title={disabledTitle}
|
|
409
|
-
aria-disabled={disabled || undefined}
|
|
410
|
-
className={baseClass}
|
|
411
|
-
>
|
|
412
|
-
<Mail className="size-3.5 shrink-0" aria-hidden="true" />
|
|
413
|
-
<span>{translateWithFallback(t, 'customers.deals.kanban.card.action.email', 'Email')}</span>
|
|
414
|
-
</Button>
|
|
415
|
-
<Button
|
|
386
|
+
const renderActionButton = (
|
|
387
|
+
type: 'call' | 'email' | 'note',
|
|
388
|
+
label: string,
|
|
389
|
+
icon: React.ReactNode,
|
|
390
|
+
) => {
|
|
391
|
+
const button = (
|
|
392
|
+
<IconButton
|
|
416
393
|
variant="ghost"
|
|
417
|
-
size="
|
|
418
|
-
type
|
|
419
|
-
onClick={handleActionClick('note')}
|
|
394
|
+
size="default"
|
|
395
|
+
onClick={handleActionClick(type)}
|
|
420
396
|
disabled={disabled}
|
|
421
|
-
title={disabledTitle}
|
|
422
397
|
aria-disabled={disabled || undefined}
|
|
423
|
-
|
|
398
|
+
aria-label={label}
|
|
399
|
+
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
424
400
|
>
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
401
|
+
{icon}
|
|
402
|
+
</IconButton>
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if (!disabled) {
|
|
406
|
+
return (
|
|
407
|
+
<SimpleTooltip key={type} content={label} size="sm">
|
|
408
|
+
{button}
|
|
409
|
+
</SimpleTooltip>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<SimpleTooltip key={type} content={disabledTooltip} size="sm">
|
|
415
|
+
<span
|
|
416
|
+
className="inline-flex shrink-0 rounded-md focus-visible:outline-none focus-visible:shadow-focus"
|
|
417
|
+
tabIndex={0}
|
|
418
|
+
aria-label={`${label}: ${disabledTooltip}`}
|
|
419
|
+
>
|
|
420
|
+
{button}
|
|
421
|
+
</span>
|
|
422
|
+
</SimpleTooltip>
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<>
|
|
428
|
+
{renderActionButton(
|
|
429
|
+
'call',
|
|
430
|
+
translateWithFallback(t, 'customers.deals.kanban.card.action.call', 'Call'),
|
|
431
|
+
<Phone className="size-5" aria-hidden="true" />,
|
|
432
|
+
)}
|
|
433
|
+
{renderActionButton(
|
|
434
|
+
'email',
|
|
435
|
+
translateWithFallback(t, 'customers.deals.kanban.card.action.email', 'Email'),
|
|
436
|
+
<Mail className="size-5" aria-hidden="true" />,
|
|
437
|
+
)}
|
|
438
|
+
{renderActionButton(
|
|
439
|
+
'note',
|
|
440
|
+
translateWithFallback(t, 'customers.deals.kanban.card.action.note', 'Note'),
|
|
441
|
+
<StickyNote className="size-5" aria-hidden="true" />,
|
|
442
|
+
)}
|
|
428
443
|
</>
|
|
429
444
|
)
|
|
430
445
|
})()}
|