@aglyn/plugins-crm 1.0.0-beta.292 → 1.0.0-beta.296

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/deal-board.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { CrmDealStage } from '@aglyn/aglyn'\nimport {\n mdiChevronRight,\n mdiOpenInNew,\n mdiThumbDownOutline,\n mdiTrophyOutline,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport {\n closestCorners,\n DndContext,\n type DragEndEvent,\n DragOverlay,\n type DragStartEvent,\n KeyboardSensor,\n PointerSensor,\n useDraggable,\n useDroppable,\n useSensor,\n useSensors,\n} from '@dnd-kit/core'\nimport { CSS } from '@dnd-kit/utilities'\nimport {\n Box,\n Button,\n Chip,\n CircularProgress,\n Paper,\n Stack,\n Typography,\n} from '@mui/material'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n daysInStage,\n type DealDoc,\n formatMoney,\n openStages,\n type PipelineDoc,\n} from '../model/deal-board-model'\nimport { OwnerAvatar } from './owner-avatar'\n\n/** The droppable id a stage column answers to. */\nconst columnId = (stageId: string) => `stage:${stageId}`\nconst stageOfColumn = (id: unknown) =>\n typeof id === 'string' && id.startsWith('stage:') ? id.slice('stage:'.length) : null\n\nexport interface DealBoardProps {\n pipeline: PipelineDoc\n /** Every open deal in the pipeline; the board sorts them into columns. */\n deals: readonly DealDoc[]\n /** The closed columns' rows once expanded; `null` while collapsed. */\n won: readonly DealDoc[] | null\n lost: readonly DealDoc[] | null\n closedExpanded: boolean\n onToggleClosed: () => void\n /** The owner's name for a uid, from the roster. */\n labelFor: (uid: string | undefined) => string\n /** Deals whose move is in flight — drawn dimmed, not draggable. */\n moving: ReadonlySet<string>\n onOpen: (deal: DealDoc) => void\n onMove: (deal: DealDoc, stageId: string) => void\n onWon: (deal: DealDoc) => void\n onLost: (deal: DealDoc) => void\n nowMs: number\n}\n\n/**\n * The pipeline as columns (AGL-2598): one per open stage, Won and Lost at\n * the end, cards dragged between them.\n *\n * ## Dragging is a request, not a write\n *\n * `@dnd-kit` handles the pointer; when a card is released over another\n * column the board calls `onMove`, and the SECTION calls the stage route.\n * The card is not moved locally: it dims while the request is in flight\n * and lands in its new column when the listener hears the write. An\n * optimistic move that the server refused — no permission, a stage the\n * pipeline lost meanwhile — would have to be animated back, and a card that\n * jumps back with no explanation reads as a bug in the board rather than a\n * refusal by the server, which is what it was.\n *\n * The pointer sensor needs a few pixels of travel before a drag begins, so\n * a click on the title still opens the deal and a click on the menu still\n * opens the menu. The keyboard sensor makes the same move reachable without\n * a mouse; the card menu's \"Move to\" items are the other route.\n *\n * ## Won and Lost fold away\n *\n * A closed deal is history, and a board that drew every won deal of the\n * year beside the four open stages would push the work off the screen. The\n * two closed columns start collapsed to a count-free stub and expand on\n * request, which is also when their rows are read — a collapsed column\n * costs no listener.\n */\nexport function DealBoard(props: DealBoardProps) {\n const {\n pipeline,\n deals,\n won,\n lost,\n closedExpanded,\n onToggleClosed,\n labelFor,\n moving,\n onOpen,\n onMove,\n onWon,\n onLost,\n nowMs,\n } = props\n const stages = useMemo(() => openStages(pipeline), [pipeline])\n const byStage = useMemo(() => {\n const map = new Map<string, DealDoc[]>()\n for (const stage of stages) map.set(stage.id, [])\n const orphans: DealDoc[] = []\n for (const deal of deals) {\n const column = map.get(deal.stageId)\n if (column) column.push(deal)\n else orphans.push(deal)\n }\n return { map, orphans }\n }, [stages, deals])\n\n const sensors = useSensors(\n useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),\n useSensor(KeyboardSensor),\n )\n const [dragging, setDragging] = useState<DealDoc | null>(null)\n const handleDragStart = useCallback((event: DragStartEvent) => {\n setDragging((event.active.data.current?.['deal'] as DealDoc) ?? null)\n }, [])\n const handleDragEnd = useCallback(\n (event: DragEndEvent) => {\n setDragging(null)\n const deal = event.active.data.current?.['deal'] as DealDoc | undefined\n const target = stageOfColumn(event.over?.id)\n if (!deal || !target || target === deal.stageId) return\n const stage = pipeline.stages.find((entry) => entry.id === target)\n if (!stage) return\n if (stage.kind === 'won') return onWon(deal)\n if (stage.kind === 'lost') return onLost(deal)\n onMove(deal, target)\n },\n [pipeline, onMove, onWon, onLost],\n )\n\n const cardActions = useCallback(\n (deal: DealDoc): RowActionsMenuItem[] => [\n {\n key: 'open',\n label: 'Open',\n icon: <MdiIcon path={mdiOpenInNew.path} size={0.8} />,\n onClick: () => onOpen(deal),\n },\n ...stages\n .filter((stage) => stage.id !== deal.stageId)\n .map((stage) => ({\n key: `move:${stage.id}`,\n label: `Move to ${stage.name}`,\n icon: <MdiIcon path={mdiChevronRight.path} size={0.8} />,\n onClick: () => onMove(deal, stage.id),\n })),\n {\n key: 'won',\n label: 'Mark won',\n icon: <MdiIcon path={mdiTrophyOutline.path} size={0.8} />,\n onClick: () => onWon(deal),\n },\n {\n key: 'lost',\n label: 'Mark lost',\n icon: <MdiIcon path={mdiThumbDownOutline.path} size={0.8} />,\n onClick: () => onLost(deal),\n destructive: true,\n },\n ],\n [stages, onOpen, onMove, onWon, onLost],\n )\n\n const renderCard = (deal: DealDoc) => (\n <DealCard\n key={deal.$id}\n deal={deal}\n ownerLabel={labelFor(deal.ownerUid)}\n days={daysInStage(deal, nowMs)}\n pending={moving.has(deal.$id)}\n actions={cardActions(deal)}\n onOpen={() => onOpen(deal)}\n />\n )\n\n const closedStages = pipeline.stages.filter((stage) => stage.kind !== 'open')\n\n return (\n <DndContext\n sensors={sensors}\n collisionDetection={closestCorners}\n onDragStart={handleDragStart}\n onDragEnd={handleDragEnd}\n onDragCancel={() => setDragging(null)}\n >\n {/*\n The board scrolls sideways; the page does not. The columns are\n fixed-width and refuse to shrink, so the row is as wide as its\n stages — and a flex item's implicit `min-width: auto` would hand\n that width up to the card and the page rather than clip it here.\n `minWidth: 0` is what lets this box be narrower than its content,\n which is the whole condition for the scrollbar to appear on it.\n */}\n <Box sx={{ overflowX: 'auto', pb: 1, minWidth: 0, width: '100%' }}>\n <Stack direction=\"row\" spacing={1.5} sx={{ alignItems: 'stretch', minWidth: 'max-content' }}>\n {stages.map((stage) => (\n <StageColumn\n key={stage.id}\n stage={stage}\n count={byStage.map.get(stage.id)?.length ?? 0}\n total={(byStage.map.get(stage.id) ?? []).reduce(\n (sum, deal) => sum + (Number(deal.amountCents) || 0),\n 0,\n )}\n currency={byStage.map.get(stage.id)?.[0]?.currency}\n >\n {(byStage.map.get(stage.id) ?? []).map(renderCard)}\n </StageColumn>\n ))}\n {byStage.orphans.length ? (\n <StageColumn\n stage={{\n id: '__orphans',\n name: 'No stage',\n order: Number.MAX_SAFE_INTEGER,\n probability: 0,\n kind: 'open',\n }}\n count={byStage.orphans.length}\n total={0}\n currency={undefined}\n droppable={false}\n caption=\"These deals sit in a stage the pipeline no longer has. Move them.\"\n >\n {byStage.orphans.map(renderCard)}\n </StageColumn>\n ) : null}\n {closedStages.map((stage) => {\n const rows = stage.kind === 'won' ? won : lost\n return (\n <StageColumn\n key={stage.id}\n stage={stage}\n count={rows?.length ?? null}\n total={null}\n currency={undefined}\n collapsed={!closedExpanded}\n onToggle={onToggleClosed}\n >\n {closedExpanded && rows === null ? (\n <Stack sx={{ alignItems: 'center', py: 2 }}>\n <CircularProgress size={20} />\n </Stack>\n ) : (\n (rows ?? []).map(renderCard)\n )}\n </StageColumn>\n )\n })}\n </Stack>\n </Box>\n <DragOverlay dropAnimation={null}>\n {dragging ? (\n <DealCardBody\n deal={dragging}\n ownerLabel={labelFor(dragging.ownerUid)}\n days={daysInStage(dragging, nowMs)}\n lifted\n />\n ) : null}\n </DragOverlay>\n </DndContext>\n )\n}\nDealBoard.displayName = 'DealBoard'\n\ninterface StageColumnProps {\n stage: CrmDealStage\n /** Cards in the column, or null while a closed column has not loaded. */\n count: number | null\n /** The column's open value in cents, or null for a closed column. */\n total: number | null\n currency: string | undefined\n droppable?: boolean\n collapsed?: boolean\n onToggle?: () => void\n caption?: string\n children?: React.ReactNode\n}\n\nfunction StageColumn(props: StageColumnProps) {\n const {\n stage,\n count,\n total,\n currency,\n droppable = true,\n collapsed = false,\n onToggle,\n caption,\n children,\n } = props\n const { setNodeRef, isOver } = useDroppable({\n id: columnId(stage.id),\n disabled: !droppable || collapsed,\n })\n const closed = stage.kind !== 'open'\n return (\n <Paper\n ref={setNodeRef}\n variant=\"outlined\"\n sx={{\n width: collapsed ? 56 : 260,\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n bgcolor: isOver ? 'action.hover' : 'background.default',\n borderColor: isOver ? 'primary.main' : undefined,\n transition: (theme) => theme.transitions.create(['background-color', 'border-color', 'width']),\n minHeight: 240,\n }}\n >\n {collapsed ? (\n <Button\n onClick={onToggle}\n sx={{\n flex: 1,\n writingMode: 'vertical-rl',\n textOrientation: 'mixed',\n py: 2,\n typography: 'subtitle2',\n }}\n color={stage.kind === 'won' ? 'success' : 'inherit'}\n >\n {stage.name}\n </Button>\n ) : (\n <>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', px: 1.5, py: 1 }}\n >\n <Typography variant=\"subtitle2\" sx={{ flex: 1 }} noWrap>\n {stage.name}\n </Typography>\n {count !== null ? <Chip size=\"small\" label={count} /> : null}\n {closed && onToggle ? (\n <Button size=\"small\" onClick={onToggle}>\n {'Hide'}\n </Button>\n ) : null}\n </Stack>\n {!closed ? (\n <Typography variant=\"caption\" color=\"text.secondary\" sx={{ px: 1.5 }}>\n {`${stage.probability}% · ${total ? formatMoney(total, currency) : 'no value yet'}`}\n </Typography>\n ) : null}\n {caption ? (\n <Typography variant=\"caption\" color=\"warning.main\" sx={{ px: 1.5 }}>\n {caption}\n </Typography>\n ) : null}\n <Stack spacing={1} sx={{ p: 1, flex: 1 }}>\n {children}\n </Stack>\n </>\n )}\n </Paper>\n )\n}\nStageColumn.displayName = 'StageColumn'\n\ninterface DealCardProps {\n deal: DealDoc\n ownerLabel: string\n days: number\n pending: boolean\n actions: RowActionsMenuItem[]\n onOpen: () => void\n}\n\nfunction DealCard(props: DealCardProps) {\n const { deal, ownerLabel, days, pending, actions, onOpen } = props\n const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({\n id: deal.$id,\n data: { deal },\n disabled: pending || deal.status !== 'open',\n })\n return (\n <Box\n ref={setNodeRef}\n sx={{\n transform: CSS.Translate.toString(transform),\n opacity: isDragging ? 0.3 : pending ? 0.5 : 1,\n cursor: deal.status === 'open' && !pending ? 'grab' : 'default',\n outline: 'none',\n '&:focus-visible': {\n borderRadius: 1,\n boxShadow: (theme) => `0 0 0 2px ${theme.palette.primary.main}`,\n },\n }}\n // The sensor's attributes and listeners carry no `sx`, and they come\n // AFTER it so the card's own styles are what the rule sees first.\n {...attributes}\n {...listeners}\n >\n <DealCardBody\n deal={deal}\n ownerLabel={ownerLabel}\n days={days}\n menu={<RowActionsMenu items={actions} label={deal.title} />}\n onOpen={onOpen}\n />\n </Box>\n )\n}\nDealCard.displayName = 'DealCard'\n\ninterface DealCardBodyProps {\n deal: DealDoc\n ownerLabel: string\n days: number\n menu?: React.ReactNode\n onOpen?: () => void\n lifted?: boolean\n}\n\n/**\n * What a card shows: the title, the amount, who it is with, the owner and\n * how long it has sat here. Shared by the card in its column and the copy\n * that follows the pointer, so the two cannot drift.\n */\nexport function DealCardBody(props: DealCardBodyProps) {\n const { deal, ownerLabel, days, menu, onOpen, lifted } = props\n const withWhom = [deal.contactName, deal.companyName].filter(Boolean).join(' · ')\n return (\n <Paper\n elevation={lifted ? 6 : 1}\n sx={{ p: 1.25, width: lifted ? 244 : undefined }}\n >\n <Stack direction=\"row\" spacing={0.5} sx={{ alignItems: 'flex-start' }}>\n <Typography\n variant=\"body2\"\n component={onOpen ? 'button' : 'span'}\n onClick={onOpen}\n // The title is the link into the deal, drawn as text: a button\n // element so it is focusable and announced, unstyled so it reads\n // as the card's heading rather than a control beside it.\n sx={{\n flex: 1,\n textAlign: 'left',\n font: 'inherit',\n fontWeight: 'medium',\n color: 'inherit',\n background: 'none',\n border: 0,\n p: 0,\n cursor: onOpen ? 'pointer' : 'inherit',\n '&:hover': onOpen ? { textDecoration: 'underline' } : undefined,\n }}\n >\n {deal.title || 'Untitled deal'}\n </Typography>\n {menu ? (\n <Box\n // The menu must not start a drag, and the sensor listens on the\n // whole card, so pointer-down here stops before it reaches it.\n onPointerDown={(event) => event.stopPropagation()}\n onKeyDown={(event) => event.stopPropagation()}\n sx={{ m: -0.5 }}\n >\n {menu}\n </Box>\n ) : null}\n </Stack>\n <Typography variant=\"subtitle2\" sx={{ mt: 0.5 }}>\n {typeof deal.amountCents === 'number'\n ? formatMoney(deal.amountCents, deal.currency)\n : '—'}\n </Typography>\n {withWhom ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap sx={{ display: 'block' }}>\n {withWhom}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', mt: 1 }}>\n <OwnerAvatar label={ownerLabel} size={22} />\n <Typography variant=\"caption\" color=\"text.secondary\" sx={{ flex: 1 }}>\n {deal.status === 'open'\n ? `${days} ${days === 1 ? 'day' : 'days'} in stage`\n : deal.status === 'won'\n ? 'Won'\n : 'Lost'}\n </Typography>\n </Stack>\n </Paper>\n )\n}\nDealCardBody.displayName = 'DealCardBody'\n\nexport default DealBoard\n"],"names":["mdiChevronRight","mdiOpenInNew","mdiThumbDownOutline","mdiTrophyOutline","MdiIcon","RowActionsMenu","closestCorners","DndContext","DragOverlay","KeyboardSensor","PointerSensor","useDraggable","useDroppable","useSensor","useSensors","CSS","Box","Button","Chip","CircularProgress","Paper","Stack","Typography","useCallback","useMemo","useState","daysInStage","formatMoney","openStages","OwnerAvatar","columnId","stageId","stageOfColumn","id","startsWith","slice","length","DealBoard","props","pipeline","deals","won","lost","closedExpanded","onToggleClosed","labelFor","moving","onOpen","onMove","onWon","onLost","nowMs","stages","byStage","map","Map","stage","set","orphans","deal","column","get","push","sensors","activationConstraint","distance","dragging","setDragging","handleDragStart","event","active","data","current","handleDragEnd","target","over","find","entry","kind","cardActions","key","label","icon","path","size","onClick","filter","name","destructive","renderCard","DealCard","ownerLabel","ownerUid","days","pending","has","$id","actions","closedStages","collisionDetection","onDragStart","onDragEnd","onDragCancel","sx","overflowX","pb","minWidth","width","direction","spacing","alignItems","StageColumn","count","total","reduce","sum","Number","amountCents","currency","order","MAX_SAFE_INTEGER","probability","undefined","droppable","caption","rows","collapsed","onToggle","py","dropAnimation","DealCardBody","lifted","displayName","children","setNodeRef","isOver","disabled","closed","ref","variant","flexShrink","display","flexDirection","bgcolor","borderColor","transition","theme","transitions","create","minHeight","flex","writingMode","textOrientation","typography","color","px","noWrap","p","attributes","listeners","transform","isDragging","status","Translate","toString","opacity","cursor","outline","borderRadius","boxShadow","palette","primary","main","menu","items","title","withWhom","contactName","companyName","Boolean","join","elevation","component","textAlign","font","fontWeight","background","border","textDecoration","onPointerDown","stopPropagation","onKeyDown","m","mt"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/deal-board.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { CrmDealStage } from '@aglyn/aglyn'\nimport {\n mdiChevronRight,\n mdiOpenInNew,\n mdiThumbDownOutline,\n mdiTrophyOutline,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport {\n closestCorners,\n DndContext,\n type DragEndEvent,\n DragOverlay,\n type DragStartEvent,\n KeyboardSensor,\n PointerSensor,\n useDraggable,\n useDroppable,\n useSensor,\n useSensors,\n} from '@dnd-kit/core'\nimport { CSS } from '@dnd-kit/utilities'\nimport {\n Box,\n Button,\n Chip,\n CircularProgress,\n Paper,\n Stack,\n TabScrollButton,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useScrollEdges } from '../hooks/use-scroll-edges'\nimport {\n daysInStage,\n type DealDoc,\n formatMoney,\n openStages,\n type PipelineDoc,\n} from '../model/deal-board-model'\nimport { OwnerAvatar } from './owner-avatar'\n\n/** The droppable id a stage column answers to. */\nconst columnId = (stageId: string) => `stage:${stageId}`\nconst stageOfColumn = (id: unknown) =>\n typeof id === 'string' && id.startsWith('stage:') ? id.slice('stage:'.length) : null\n\nexport interface DealBoardProps {\n pipeline: PipelineDoc\n /** Every open deal in the pipeline; the board sorts them into columns. */\n deals: readonly DealDoc[]\n /** The closed columns' rows once expanded; `null` while collapsed. */\n won: readonly DealDoc[] | null\n lost: readonly DealDoc[] | null\n closedExpanded: boolean\n onToggleClosed: () => void\n /** The owner's name for a uid, from the roster. */\n labelFor: (uid: string | undefined) => string\n /** Deals whose move is in flight — drawn dimmed, not draggable. */\n moving: ReadonlySet<string>\n onOpen: (deal: DealDoc) => void\n onMove: (deal: DealDoc, stageId: string) => void\n onWon: (deal: DealDoc) => void\n onLost: (deal: DealDoc) => void\n nowMs: number\n}\n\n/**\n * The pipeline as columns (AGL-2598): one per open stage, Won and Lost at\n * the end, cards dragged between them.\n *\n * ## Dragging is a request, not a write\n *\n * `@dnd-kit` handles the pointer; when a card is released over another\n * column the board calls `onMove`, and the SECTION calls the stage route.\n * The card is not moved locally: it dims while the request is in flight\n * and lands in its new column when the listener hears the write. An\n * optimistic move that the server refused — no permission, a stage the\n * pipeline lost meanwhile — would have to be animated back, and a card that\n * jumps back with no explanation reads as a bug in the board rather than a\n * refusal by the server, which is what it was.\n *\n * The pointer sensor needs a few pixels of travel before a drag begins, so\n * a click on the title still opens the deal and a click on the menu still\n * opens the menu. The keyboard sensor makes the same move reachable without\n * a mouse; the card menu's \"Move to\" items are the other route.\n *\n * ## Won and Lost fold away\n *\n * A closed deal is history, and a board that drew every won deal of the\n * year beside the four open stages would push the work off the screen. The\n * two closed columns start collapsed to a count-free stub and expand on\n * request, which is also when their rows are read — a collapsed column\n * costs no listener.\n *\n * ## Empty stages fold too, and the board says when there is more\n *\n * A pipeline with Salesforce's ten stages is wider than the card, and the\n * stages with deals in them were off the edge with nothing to say so. An\n * open stage with no deals folds to the same narrow stub, labelled and still\n * a drop target, so the stages that hold work get the room. When the row is\n * still wider than the card, chevrons at either end page it sideways the way\n * MUI's scrollable Tabs do — the scrollbar alone stays hidden on a trackpad\n * until the reader already knows to scroll.\n */\nexport function DealBoard(props: DealBoardProps) {\n const {\n pipeline,\n deals,\n won,\n lost,\n closedExpanded,\n onToggleClosed,\n labelFor,\n moving,\n onOpen,\n onMove,\n onWon,\n onLost,\n nowMs,\n } = props\n const stages = useMemo(() => openStages(pipeline), [pipeline])\n const edges = useScrollEdges()\n const overflowing = edges.start || edges.end\n const byStage = useMemo(() => {\n const map = new Map<string, DealDoc[]>()\n for (const stage of stages) map.set(stage.id, [])\n const orphans: DealDoc[] = []\n for (const deal of deals) {\n const column = map.get(deal.stageId)\n if (column) column.push(deal)\n else orphans.push(deal)\n }\n return { map, orphans }\n }, [stages, deals])\n\n const sensors = useSensors(\n useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),\n useSensor(KeyboardSensor),\n )\n const [dragging, setDragging] = useState<DealDoc | null>(null)\n const handleDragStart = useCallback((event: DragStartEvent) => {\n setDragging((event.active.data.current?.['deal'] as DealDoc) ?? null)\n }, [])\n const handleDragEnd = useCallback(\n (event: DragEndEvent) => {\n setDragging(null)\n const deal = event.active.data.current?.['deal'] as DealDoc | undefined\n const target = stageOfColumn(event.over?.id)\n if (!deal || !target || target === deal.stageId) return\n const stage = pipeline.stages.find((entry) => entry.id === target)\n if (!stage) return\n if (stage.kind === 'won') return onWon(deal)\n if (stage.kind === 'lost') return onLost(deal)\n onMove(deal, target)\n },\n [pipeline, onMove, onWon, onLost],\n )\n\n const cardActions = useCallback(\n (deal: DealDoc): RowActionsMenuItem[] => [\n {\n key: 'open',\n label: 'Open',\n icon: <MdiIcon path={mdiOpenInNew.path} size={0.8} />,\n onClick: () => onOpen(deal),\n },\n ...stages\n .filter((stage) => stage.id !== deal.stageId)\n .map((stage) => ({\n key: `move:${stage.id}`,\n label: `Move to ${stage.name}`,\n icon: <MdiIcon path={mdiChevronRight.path} size={0.8} />,\n onClick: () => onMove(deal, stage.id),\n })),\n {\n key: 'won',\n label: 'Mark won',\n icon: <MdiIcon path={mdiTrophyOutline.path} size={0.8} />,\n onClick: () => onWon(deal),\n },\n {\n key: 'lost',\n label: 'Mark lost',\n icon: <MdiIcon path={mdiThumbDownOutline.path} size={0.8} />,\n onClick: () => onLost(deal),\n destructive: true,\n },\n ],\n [stages, onOpen, onMove, onWon, onLost],\n )\n\n const renderCard = (deal: DealDoc) => (\n <DealCard\n key={deal.$id}\n deal={deal}\n ownerLabel={labelFor(deal.ownerUid)}\n days={daysInStage(deal, nowMs)}\n pending={moving.has(deal.$id)}\n actions={cardActions(deal)}\n onOpen={() => onOpen(deal)}\n />\n )\n\n const closedStages = pipeline.stages.filter((stage) => stage.kind !== 'open')\n\n return (\n <DndContext\n sensors={sensors}\n collisionDetection={closestCorners}\n onDragStart={handleDragStart}\n onDragEnd={handleDragEnd}\n onDragCancel={() => setDragging(null)}\n >\n {/*\n The board scrolls sideways; the page does not. The columns are\n fixed-width and refuse to shrink, so the row is as wide as its\n stages — and a flex item's implicit `min-width: auto` would hand\n that width up to the card and the page rather than clip it here.\n `minWidth: 0` is what lets this box be narrower than its content,\n which is the whole condition for the scrollbar to appear on it.\n */}\n <Stack direction=\"row\" sx={{ alignItems: 'stretch', minWidth: 0, width: '100%' }}>\n {overflowing ? (\n <TabScrollButton\n direction=\"left\"\n orientation=\"horizontal\"\n disabled={!edges.start}\n onClick={() => edges.page(-1)}\n aria-label=\"Show earlier stages\"\n data-testid=\"deal-board-scroll-start\"\n />\n ) : null}\n <Box ref={edges.ref} sx={{ overflowX: 'auto', pb: 1, minWidth: 0, flex: 1 }}>\n <Stack direction=\"row\" spacing={1.5} sx={{ alignItems: 'stretch', minWidth: 'max-content' }}>\n {stages.map((stage) => (\n <StageColumn\n key={stage.id}\n stage={stage}\n folded={!byStage.map.get(stage.id)?.length}\n count={byStage.map.get(stage.id)?.length ?? 0}\n total={(byStage.map.get(stage.id) ?? []).reduce(\n (sum, deal) => sum + (Number(deal.amountCents) || 0),\n 0,\n )}\n currency={byStage.map.get(stage.id)?.[0]?.currency}\n >\n {(byStage.map.get(stage.id) ?? []).map(renderCard)}\n </StageColumn>\n ))}\n {byStage.orphans.length ? (\n <StageColumn\n stage={{\n id: '__orphans',\n name: 'No stage',\n order: Number.MAX_SAFE_INTEGER,\n probability: 0,\n kind: 'open',\n }}\n count={byStage.orphans.length}\n total={0}\n currency={undefined}\n droppable={false}\n caption=\"These deals sit in a stage the pipeline no longer has. Move them.\"\n >\n {byStage.orphans.map(renderCard)}\n </StageColumn>\n ) : null}\n {closedStages.map((stage) => {\n const rows = stage.kind === 'won' ? won : lost\n return (\n <StageColumn\n key={stage.id}\n stage={stage}\n count={rows?.length ?? null}\n total={null}\n currency={undefined}\n collapsed={!closedExpanded}\n onToggle={onToggleClosed}\n >\n {closedExpanded && rows === null ? (\n <Stack sx={{ alignItems: 'center', py: 2 }}>\n <CircularProgress size={20} />\n </Stack>\n ) : (\n (rows ?? []).map(renderCard)\n )}\n </StageColumn>\n )\n })}\n </Stack>\n </Box>\n {overflowing ? (\n <TabScrollButton\n direction=\"right\"\n orientation=\"horizontal\"\n disabled={!edges.end}\n onClick={() => edges.page(1)}\n aria-label=\"Show later stages\"\n data-testid=\"deal-board-scroll-end\"\n />\n ) : null}\n </Stack>\n <DragOverlay dropAnimation={null}>\n {dragging ? (\n <DealCardBody\n deal={dragging}\n ownerLabel={labelFor(dragging.ownerUid)}\n days={daysInStage(dragging, nowMs)}\n lifted\n />\n ) : null}\n </DragOverlay>\n </DndContext>\n )\n}\nDealBoard.displayName = 'DealBoard'\n\ninterface StageColumnProps {\n stage: CrmDealStage\n /** Cards in the column, or null while a closed column has not loaded. */\n count: number | null\n /** The column's open value in cents, or null for a closed column. */\n total: number | null\n currency: string | undefined\n droppable?: boolean\n collapsed?: boolean\n /**\n * An open stage with no deals, drawn as a narrow labelled stub — still a\n * drop target, unlike a collapsed closed column, so a deal can be dragged\n * into it.\n */\n folded?: boolean\n onToggle?: () => void\n caption?: string\n children?: React.ReactNode\n}\n\nfunction StageColumn(props: StageColumnProps) {\n const {\n stage,\n count,\n total,\n currency,\n droppable = true,\n collapsed = false,\n folded = false,\n onToggle,\n caption,\n children,\n } = props\n const { setNodeRef, isOver } = useDroppable({\n id: columnId(stage.id),\n disabled: !droppable || collapsed,\n })\n const closed = stage.kind !== 'open'\n return (\n <Paper\n ref={setNodeRef}\n variant=\"outlined\"\n sx={{\n width: collapsed || folded ? 56 : 260,\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n bgcolor: isOver ? 'action.hover' : 'background.default',\n borderColor: isOver ? 'primary.main' : undefined,\n transition: (theme) => theme.transitions.create(['background-color', 'border-color', 'width']),\n minHeight: 240,\n }}\n >\n {folded && !collapsed ? (\n <Tooltip title={`${stage.name}: no deals · ${stage.probability}% likely to close`} placement=\"top\">\n <Typography\n variant=\"subtitle2\"\n color=\"text.secondary\"\n data-testid=\"deal-board-folded-stage\"\n sx={{\n flex: 1,\n writingMode: 'vertical-rl',\n textOrientation: 'mixed',\n textAlign: 'center',\n py: 2,\n alignSelf: 'center',\n }}\n >\n {stage.name}\n </Typography>\n </Tooltip>\n ) : collapsed ? (\n <Button\n onClick={onToggle}\n sx={{\n flex: 1,\n writingMode: 'vertical-rl',\n textOrientation: 'mixed',\n py: 2,\n typography: 'subtitle2',\n }}\n color={stage.kind === 'won' ? 'success' : 'inherit'}\n >\n {stage.name}\n </Button>\n ) : (\n <>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', px: 1.5, py: 1 }}\n >\n <Typography variant=\"subtitle2\" sx={{ flex: 1 }} noWrap>\n {stage.name}\n </Typography>\n {count !== null ? <Chip size=\"small\" label={count} /> : null}\n {closed && onToggle ? (\n <Button size=\"small\" onClick={onToggle}>\n {'Hide'}\n </Button>\n ) : null}\n </Stack>\n {!closed ? (\n <Typography variant=\"caption\" color=\"text.secondary\" sx={{ px: 1.5 }}>\n {`${stage.probability}% · ${total ? formatMoney(total, currency) : 'no value yet'}`}\n </Typography>\n ) : null}\n {caption ? (\n <Typography variant=\"caption\" color=\"warning.main\" sx={{ px: 1.5 }}>\n {caption}\n </Typography>\n ) : null}\n <Stack spacing={1} sx={{ p: 1, flex: 1 }}>\n {children}\n </Stack>\n </>\n )}\n </Paper>\n )\n}\nStageColumn.displayName = 'StageColumn'\n\ninterface DealCardProps {\n deal: DealDoc\n ownerLabel: string\n days: number\n pending: boolean\n actions: RowActionsMenuItem[]\n onOpen: () => void\n}\n\nfunction DealCard(props: DealCardProps) {\n const { deal, ownerLabel, days, pending, actions, onOpen } = props\n const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({\n id: deal.$id,\n data: { deal },\n disabled: pending || deal.status !== 'open',\n })\n return (\n <Box\n ref={setNodeRef}\n sx={{\n transform: CSS.Translate.toString(transform),\n opacity: isDragging ? 0.3 : pending ? 0.5 : 1,\n cursor: deal.status === 'open' && !pending ? 'grab' : 'default',\n outline: 'none',\n '&:focus-visible': {\n borderRadius: 1,\n boxShadow: (theme) => `0 0 0 2px ${theme.palette.primary.main}`,\n },\n }}\n // The sensor's attributes and listeners carry no `sx`, and they come\n // AFTER it so the card's own styles are what the rule sees first.\n {...attributes}\n {...listeners}\n >\n <DealCardBody\n deal={deal}\n ownerLabel={ownerLabel}\n days={days}\n menu={<RowActionsMenu items={actions} label={deal.title} />}\n onOpen={onOpen}\n />\n </Box>\n )\n}\nDealCard.displayName = 'DealCard'\n\ninterface DealCardBodyProps {\n deal: DealDoc\n ownerLabel: string\n days: number\n menu?: React.ReactNode\n onOpen?: () => void\n lifted?: boolean\n}\n\n/**\n * What a card shows: the title, the amount, who it is with, the owner and\n * how long it has sat here. Shared by the card in its column and the copy\n * that follows the pointer, so the two cannot drift.\n */\nexport function DealCardBody(props: DealCardBodyProps) {\n const { deal, ownerLabel, days, menu, onOpen, lifted } = props\n const withWhom = [deal.contactName, deal.companyName].filter(Boolean).join(' · ')\n return (\n <Paper\n elevation={lifted ? 6 : 1}\n sx={{ p: 1.25, width: lifted ? 244 : undefined }}\n >\n <Stack direction=\"row\" spacing={0.5} sx={{ alignItems: 'flex-start' }}>\n <Typography\n variant=\"body2\"\n component={onOpen ? 'button' : 'span'}\n onClick={onOpen}\n // The title is the link into the deal, drawn as text: a button\n // element so it is focusable and announced, unstyled so it reads\n // as the card's heading rather than a control beside it.\n sx={{\n flex: 1,\n textAlign: 'left',\n font: 'inherit',\n fontWeight: 'medium',\n color: 'inherit',\n background: 'none',\n border: 0,\n p: 0,\n cursor: onOpen ? 'pointer' : 'inherit',\n '&:hover': onOpen ? { textDecoration: 'underline' } : undefined,\n }}\n >\n {deal.title || 'Untitled deal'}\n </Typography>\n {menu ? (\n <Box\n // The menu must not start a drag, and the sensor listens on the\n // whole card, so pointer-down here stops before it reaches it.\n onPointerDown={(event) => event.stopPropagation()}\n onKeyDown={(event) => event.stopPropagation()}\n sx={{ m: -0.5 }}\n >\n {menu}\n </Box>\n ) : null}\n </Stack>\n <Typography variant=\"subtitle2\" sx={{ mt: 0.5 }}>\n {typeof deal.amountCents === 'number'\n ? formatMoney(deal.amountCents, deal.currency)\n : '—'}\n </Typography>\n {withWhom ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap sx={{ display: 'block' }}>\n {withWhom}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', mt: 1 }}>\n <OwnerAvatar label={ownerLabel} size={22} />\n <Typography variant=\"caption\" color=\"text.secondary\" sx={{ flex: 1 }}>\n {deal.status === 'open'\n ? `${days} ${days === 1 ? 'day' : 'days'} in stage`\n : deal.status === 'won'\n ? 'Won'\n : 'Lost'}\n </Typography>\n </Stack>\n </Paper>\n )\n}\nDealCardBody.displayName = 'DealCardBody'\n\nexport default DealBoard\n"],"names":["mdiChevronRight","mdiOpenInNew","mdiThumbDownOutline","mdiTrophyOutline","MdiIcon","RowActionsMenu","closestCorners","DndContext","DragOverlay","KeyboardSensor","PointerSensor","useDraggable","useDroppable","useSensor","useSensors","CSS","Box","Button","Chip","CircularProgress","Paper","Stack","TabScrollButton","Tooltip","Typography","useCallback","useMemo","useState","useScrollEdges","daysInStage","formatMoney","openStages","OwnerAvatar","columnId","stageId","stageOfColumn","id","startsWith","slice","length","DealBoard","props","pipeline","deals","won","lost","closedExpanded","onToggleClosed","labelFor","moving","onOpen","onMove","onWon","onLost","nowMs","stages","edges","overflowing","start","end","byStage","map","Map","stage","set","orphans","deal","column","get","push","sensors","activationConstraint","distance","dragging","setDragging","handleDragStart","event","active","data","current","handleDragEnd","target","over","find","entry","kind","cardActions","key","label","icon","path","size","onClick","filter","name","destructive","renderCard","DealCard","ownerLabel","ownerUid","days","pending","has","$id","actions","closedStages","collisionDetection","onDragStart","onDragEnd","onDragCancel","direction","sx","alignItems","minWidth","width","orientation","disabled","page","aria-label","data-testid","ref","overflowX","pb","flex","spacing","StageColumn","folded","count","total","reduce","sum","Number","amountCents","currency","order","MAX_SAFE_INTEGER","probability","undefined","droppable","caption","rows","collapsed","onToggle","py","dropAnimation","DealCardBody","lifted","displayName","children","setNodeRef","isOver","closed","variant","flexShrink","display","flexDirection","bgcolor","borderColor","transition","theme","transitions","create","minHeight","title","placement","color","writingMode","textOrientation","textAlign","alignSelf","typography","px","noWrap","p","attributes","listeners","transform","isDragging","status","Translate","toString","opacity","cursor","outline","borderRadius","boxShadow","palette","primary","main","menu","items","withWhom","contactName","companyName","Boolean","join","elevation","component","font","fontWeight","background","border","textDecoration","onPointerDown","stopPropagation","onKeyDown","m","mt"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAGA,SACEA,eAAe,EACfC,YAAY,EACZC,mBAAmB,EACnBC,gBAAgB,QACX,yBAAwB;AAC/B,SAASC,OAAO,QAAQ,uBAAsB;AAC9C,OAAOC,oBAEA,6DAA4D;AACnE,SACEC,cAAc,EACdC,UAAU,EAEVC,WAAW,EAEXC,cAAc,EACdC,aAAa,EACbC,YAAY,EACZC,YAAY,EACZC,SAAS,EACTC,UAAU,QACL,gBAAe;AACtB,SAASC,GAAG,QAAQ,qBAAoB;AACxC,SACEC,GAAG,EACHC,MAAM,EACNC,IAAI,EACJC,gBAAgB,EAChBC,KAAK,EACLC,KAAK,EACLC,eAAe,EACfC,OAAO,EACPC,UAAU,QACL,gBAAe;AACtB,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,cAAc,QAAQ,+BAA2B;AAC1D,SACEC,WAAW,EAEXC,WAAW,EACXC,UAAU,QAEL,+BAA2B;AAClC,SAASC,WAAW,QAAQ,oBAAgB;AAE5C,gDAAgD,GAChD,MAAMC,WAAW,CAACC,UAAoB,CAAC,MAAM,EAAEA,SAAS;AACxD,MAAMC,gBAAgB,CAACC,KACrB,OAAOA,OAAO,YAAYA,GAAGC,UAAU,CAAC,YAAYD,GAAGE,KAAK,CAAC,SAASC,MAAM,IAAI;AAsBlF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GACD,OAAO,SAASC,UAAUC,KAAqB;IAC7C,MAAM,EACJC,QAAQ,EACRC,KAAK,EACLC,GAAG,EACHC,IAAI,EACJC,cAAc,EACdC,cAAc,EACdC,QAAQ,EACRC,MAAM,EACNC,MAAM,EACNC,MAAM,EACNC,KAAK,EACLC,MAAM,EACNC,KAAK,EACN,GAAGb;IACJ,MAAMc,SAAS7B,QAAQ,IAAMK,WAAWW,WAAW;QAACA;KAAS;IAC7D,MAAMc,QAAQ5B;IACd,MAAM6B,cAAcD,MAAME,KAAK,IAAIF,MAAMG,GAAG;IAC5C,MAAMC,UAAUlC,QAAQ;QACtB,MAAMmC,MAAM,IAAIC;QAChB,KAAK,MAAMC,SAASR,OAAQM,IAAIG,GAAG,CAACD,MAAM3B,EAAE,EAAE,EAAE;QAChD,MAAM6B,UAAqB,EAAE;QAC7B,KAAK,MAAMC,QAAQvB,MAAO;YACxB,MAAMwB,SAASN,IAAIO,GAAG,CAACF,KAAKhC,OAAO;YACnC,IAAIiC,QAAQA,OAAOE,IAAI,CAACH;iBACnBD,QAAQI,IAAI,CAACH;QACpB;QACA,OAAO;YAAEL;YAAKI;QAAQ;IACxB,GAAG;QAACV;QAAQZ;KAAM;IAElB,MAAM2B,UAAUxD,WACdD,UAAUH,eAAe;QAAE6D,sBAAsB;YAAEC,UAAU;QAAE;IAAE,IACjE3D,UAAUJ;IAEZ,MAAM,CAACgE,UAAUC,YAAY,GAAG/C,SAAyB;IACzD,MAAMgD,kBAAkBlD,YAAY,CAACmD;;YACtBA;QAAbF,qBAAaE,6BAAAA,MAAMC,MAAM,CAACC,IAAI,CAACC,OAAO,qBAAzBH,0BAA2B,CAAC,OAAO,mBAAgB;IAClE,GAAG,EAAE;IACL,MAAMI,gBAAgBvD,YACpB,CAACmD;YAEcA,4BACgBA;QAF7BF,YAAY;QACZ,MAAMR,QAAOU,6BAAAA,MAAMC,MAAM,CAACC,IAAI,CAACC,OAAO,qBAAzBH,0BAA2B,CAAC,OAAO;QAChD,MAAMK,SAAS9C,eAAcyC,cAAAA,MAAMM,IAAI,qBAAVN,YAAYxC,EAAE;QAC3C,IAAI,CAAC8B,QAAQ,CAACe,UAAUA,WAAWf,KAAKhC,OAAO,EAAE;QACjD,MAAM6B,QAAQrB,SAASa,MAAM,CAAC4B,IAAI,CAAC,CAACC,QAAUA,MAAMhD,EAAE,KAAK6C;QAC3D,IAAI,CAAClB,OAAO;QACZ,IAAIA,MAAMsB,IAAI,KAAK,OAAO,OAAOjC,MAAMc;QACvC,IAAIH,MAAMsB,IAAI,KAAK,QAAQ,OAAOhC,OAAOa;QACzCf,OAAOe,MAAMe;IACf,GACA;QAACvC;QAAUS;QAAQC;QAAOC;KAAO;IAGnC,MAAMiC,cAAc7D,YAClB,CAACyC,OAAwC;YACvC;gBACEqB,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAACrF;oBAAQsF,MAAMzF,aAAayF,IAAI;oBAAEC,MAAM;;gBAC9CC,SAAS,IAAM1C,OAAOgB;YACxB;eACGX,OACAsC,MAAM,CAAC,CAAC9B,QAAUA,MAAM3B,EAAE,KAAK8B,KAAKhC,OAAO,EAC3C2B,GAAG,CAAC,CAACE,QAAW,CAAA;oBACfwB,KAAK,CAAC,KAAK,EAAExB,MAAM3B,EAAE,EAAE;oBACvBoD,OAAO,CAAC,QAAQ,EAAEzB,MAAM+B,IAAI,EAAE;oBAC9BL,oBAAM,KAACrF;wBAAQsF,MAAM1F,gBAAgB0F,IAAI;wBAAEC,MAAM;;oBACjDC,SAAS,IAAMzC,OAAOe,MAAMH,MAAM3B,EAAE;gBACtC,CAAA;YACF;gBACEmD,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAACrF;oBAAQsF,MAAMvF,iBAAiBuF,IAAI;oBAAEC,MAAM;;gBAClDC,SAAS,IAAMxC,MAAMc;YACvB;YACA;gBACEqB,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAACrF;oBAAQsF,MAAMxF,oBAAoBwF,IAAI;oBAAEC,MAAM;;gBACrDC,SAAS,IAAMvC,OAAOa;gBACtB6B,aAAa;YACf;SACD,EACD;QAACxC;QAAQL;QAAQC;QAAQC;QAAOC;KAAO;IAGzC,MAAM2C,aAAa,CAAC9B,qBAClB,KAAC+B;YAEC/B,MAAMA;YACNgC,YAAYlD,SAASkB,KAAKiC,QAAQ;YAClCC,MAAMvE,YAAYqC,MAAMZ;YACxB+C,SAASpD,OAAOqD,GAAG,CAACpC,KAAKqC,GAAG;YAC5BC,SAASlB,YAAYpB;YACrBhB,QAAQ,IAAMA,OAAOgB;WANhBA,KAAKqC,GAAG;IAUjB,MAAME,eAAe/D,SAASa,MAAM,CAACsC,MAAM,CAAC,CAAC9B,QAAUA,MAAMsB,IAAI,KAAK;IAEtE,qBACE,MAAC9E;QACC+D,SAASA;QACToC,oBAAoBpG;QACpBqG,aAAahC;QACbiC,WAAW5B;QACX6B,cAAc,IAAMnC,YAAY;;0BAUhC,MAACrD;gBAAMyF,WAAU;gBAAMC,IAAI;oBAAEC,YAAY;oBAAWC,UAAU;oBAAGC,OAAO;gBAAO;;oBAC5EzD,4BACC,KAACnC;wBACCwF,WAAU;wBACVK,aAAY;wBACZC,UAAU,CAAC5D,MAAME,KAAK;wBACtBkC,SAAS,IAAMpC,MAAM6D,IAAI,CAAC,CAAC;wBAC3BC,cAAW;wBACXC,eAAY;yBAEZ;kCACJ,KAACvG;wBAAIwG,KAAKhE,MAAMgE,GAAG;wBAAET,IAAI;4BAAEU,WAAW;4BAAQC,IAAI;4BAAGT,UAAU;4BAAGU,MAAM;wBAAE;kCACxE,cAAA,MAACtG;4BAAMyF,WAAU;4BAAMc,SAAS;4BAAKb,IAAI;gCAAEC,YAAY;gCAAWC,UAAU;4BAAc;;gCACvF1D,OAAOM,GAAG,CAAC,CAACE;8CAMDH,kBAMNA;wCAROA,mBACFA,mBAKGA,mBAAAA;yDATZ,KAACiE;wCAEC9D,OAAOA;wCACP+D,QAAQ,GAAClE,oBAAAA,QAAQC,GAAG,CAACO,GAAG,CAACL,MAAM3B,EAAE,sBAAxBwB,kBAA2BrB,MAAM;wCAC1CwF,KAAK,WAAEnE,oBAAAA,QAAQC,GAAG,CAACO,GAAG,CAACL,MAAM3B,EAAE,sBAAxBwB,kBAA2BrB,MAAM,mBAAI;wCAC5CyF,OAAO,EAACpE,mBAAAA,QAAQC,GAAG,CAACO,GAAG,CAACL,MAAM3B,EAAE,aAAxBwB,mBAA6B,EAAE,EAAEqE,MAAM,CAC7C,CAACC,KAAKhE,OAASgE,MAAOC,CAAAA,OAAOjE,KAAKkE,WAAW,KAAK,CAAA,GAClD;wCAEFC,QAAQ,GAAEzE,oBAAAA,QAAQC,GAAG,CAACO,GAAG,CAACL,MAAM3B,EAAE,uBAAxBwB,oBAAAA,iBAA2B,CAAC,EAAE,qBAA9BA,kBAAgCyE,QAAQ;kDAEjD,EAACzE,oBAAAA,QAAQC,GAAG,CAACO,GAAG,CAACL,MAAM3B,EAAE,aAAxBwB,oBAA6B,EAAE,EAAEC,GAAG,CAACmC;uCAVlCjC,MAAM3B,EAAE;;gCAahBwB,QAAQK,OAAO,CAAC1B,MAAM,iBACrB,KAACsF;oCACC9D,OAAO;wCACL3B,IAAI;wCACJ0D,MAAM;wCACNwC,OAAOH,OAAOI,gBAAgB;wCAC9BC,aAAa;wCACbnD,MAAM;oCACR;oCACA0C,OAAOnE,QAAQK,OAAO,CAAC1B,MAAM;oCAC7ByF,OAAO;oCACPK,UAAUI;oCACVC,WAAW;oCACXC,SAAQ;8CAEP/E,QAAQK,OAAO,CAACJ,GAAG,CAACmC;qCAErB;gCACHS,aAAa5C,GAAG,CAAC,CAACE;;oCACjB,MAAM6E,OAAO7E,MAAMsB,IAAI,KAAK,QAAQzC,MAAMC;oCAC1C,qBACE,KAACgF;wCAEC9D,OAAOA;wCACPgE,KAAK,UAAEa,wBAAAA,KAAMrG,MAAM,mBAAI;wCACvByF,OAAO;wCACPK,UAAUI;wCACVI,WAAW,CAAC/F;wCACZgG,UAAU/F;kDAETD,kBAAkB8F,SAAS,qBAC1B,KAACvH;4CAAM0F,IAAI;gDAAEC,YAAY;gDAAU+B,IAAI;4CAAE;sDACvC,cAAA,KAAC5H;gDAAiBwE,MAAM;;6CAG1B,CAACiD,eAAAA,OAAQ,EAAE,EAAE/E,GAAG,CAACmC;uCAbdjC,MAAM3B,EAAE;gCAiBnB;;;;oBAGHqB,4BACC,KAACnC;wBACCwF,WAAU;wBACVK,aAAY;wBACZC,UAAU,CAAC5D,MAAMG,GAAG;wBACpBiC,SAAS,IAAMpC,MAAM6D,IAAI,CAAC;wBAC1BC,cAAW;wBACXC,eAAY;yBAEZ;;;0BAEN,KAAC/G;gBAAYwI,eAAe;0BACzBvE,yBACC,KAACwE;oBACC/E,MAAMO;oBACNyB,YAAYlD,SAASyB,SAAS0B,QAAQ;oBACtCC,MAAMvE,YAAY4C,UAAUnB;oBAC5B4F,MAAM;qBAEN;;;;AAIZ;AACA1G,UAAU2G,WAAW,GAAG;AAsBxB,SAAStB,YAAYpF,KAAuB;IAC1C,MAAM,EACJsB,KAAK,EACLgE,KAAK,EACLC,KAAK,EACLK,QAAQ,EACRK,YAAY,IAAI,EAChBG,YAAY,KAAK,EACjBf,SAAS,KAAK,EACdgB,QAAQ,EACRH,OAAO,EACPS,QAAQ,EACT,GAAG3G;IACJ,MAAM,EAAE4G,UAAU,EAAEC,MAAM,EAAE,GAAG1I,aAAa;QAC1CwB,IAAIH,SAAS8B,MAAM3B,EAAE;QACrBgF,UAAU,CAACsB,aAAaG;IAC1B;IACA,MAAMU,SAASxF,MAAMsB,IAAI,KAAK;IAC9B,qBACE,KAACjE;QACCoG,KAAK6B;QACLG,SAAQ;QACRzC,IAAI;YACFG,OAAO2B,aAAaf,SAAS,KAAK;YAClC2B,YAAY;YACZC,SAAS;YACTC,eAAe;YACfC,SAASN,SAAS,iBAAiB;YACnCO,aAAaP,SAAS,iBAAiBb;YACvCqB,YAAY,CAACC,QAAUA,MAAMC,WAAW,CAACC,MAAM,CAAC;oBAAC;oBAAoB;oBAAgB;iBAAQ;YAC7FC,WAAW;QACb;kBAECpC,UAAU,CAACe,0BACV,KAACtH;YAAQ4I,OAAO,GAAGpG,MAAM+B,IAAI,CAAC,aAAa,EAAE/B,MAAMyE,WAAW,CAAC,iBAAiB,CAAC;YAAE4B,WAAU;sBAC3F,cAAA,KAAC5I;gBACCgI,SAAQ;gBACRa,OAAM;gBACN9C,eAAY;gBACZR,IAAI;oBACFY,MAAM;oBACN2C,aAAa;oBACbC,iBAAiB;oBACjBC,WAAW;oBACXzB,IAAI;oBACJ0B,WAAW;gBACb;0BAEC1G,MAAM+B,IAAI;;aAGb+C,0BACF,KAAC5H;YACC2E,SAASkD;YACT/B,IAAI;gBACFY,MAAM;gBACN2C,aAAa;gBACbC,iBAAiB;gBACjBxB,IAAI;gBACJ2B,YAAY;YACd;YACAL,OAAOtG,MAAMsB,IAAI,KAAK,QAAQ,YAAY;sBAEzCtB,MAAM+B,IAAI;2BAGb;;8BACE,MAACzE;oBACCyF,WAAU;oBACVc,SAAS;oBACTb,IAAI;wBAAEC,YAAY;wBAAU2D,IAAI;wBAAK5B,IAAI;oBAAE;;sCAE3C,KAACvH;4BAAWgI,SAAQ;4BAAYzC,IAAI;gCAAEY,MAAM;4BAAE;4BAAGiD,MAAM;sCACpD7G,MAAM+B,IAAI;;wBAEZiC,UAAU,qBAAO,KAAC7G;4BAAKyE,MAAK;4BAAQH,OAAOuC;6BAAY;wBACvDwB,UAAUT,yBACT,KAAC7H;4BAAO0E,MAAK;4BAAQC,SAASkD;sCAC3B;6BAED;;;gBAEL,CAACS,uBACA,KAAC/H;oBAAWgI,SAAQ;oBAAUa,OAAM;oBAAiBtD,IAAI;wBAAE4D,IAAI;oBAAI;8BAChE,GAAG5G,MAAMyE,WAAW,CAAC,IAAI,EAAER,QAAQlG,YAAYkG,OAAOK,YAAY,gBAAgB;qBAEnF;gBACHM,wBACC,KAACnH;oBAAWgI,SAAQ;oBAAUa,OAAM;oBAAetD,IAAI;wBAAE4D,IAAI;oBAAI;8BAC9DhC;qBAED;8BACJ,KAACtH;oBAAMuG,SAAS;oBAAGb,IAAI;wBAAE8D,GAAG;wBAAGlD,MAAM;oBAAE;8BACpCyB;;;;;AAMb;AACAvB,YAAYsB,WAAW,GAAG;AAW1B,SAASlD,SAASxD,KAAoB;IACpC,MAAM,EAAEyB,IAAI,EAAEgC,UAAU,EAAEE,IAAI,EAAEC,OAAO,EAAEG,OAAO,EAAEtD,MAAM,EAAE,GAAGT;IAC7D,MAAM,EAAEqI,UAAU,EAAEC,SAAS,EAAE1B,UAAU,EAAE2B,SAAS,EAAEC,UAAU,EAAE,GAAGtK,aAAa;QAChFyB,IAAI8B,KAAKqC,GAAG;QACZzB,MAAM;YAAEZ;QAAK;QACbkD,UAAUf,WAAWnC,KAAKgH,MAAM,KAAK;IACvC;IACA,qBACE,KAAClK;QACCwG,KAAK6B;QACLtC,IAAI;YACFiE,WAAWjK,IAAIoK,SAAS,CAACC,QAAQ,CAACJ;YAClCK,SAASJ,aAAa,MAAM5E,UAAU,MAAM;YAC5CiF,QAAQpH,KAAKgH,MAAM,KAAK,UAAU,CAAC7E,UAAU,SAAS;YACtDkF,SAAS;YACT,mBAAmB;gBACjBC,cAAc;gBACdC,WAAW,CAAC1B,QAAU,CAAC,UAAU,EAAEA,MAAM2B,OAAO,CAACC,OAAO,CAACC,IAAI,EAAE;YACjE;QACF;OAGId,YACAC;kBAEJ,cAAA,KAAC9B;YACC/E,MAAMA;YACNgC,YAAYA;YACZE,MAAMA;YACNyF,oBAAM,KAACxL;gBAAeyL,OAAOtF;gBAAShB,OAAOtB,KAAKiG,KAAK;;YACvDjH,QAAQA;;;AAIhB;AACA+C,SAASkD,WAAW,GAAG;AAWvB;;;;CAIC,GACD,OAAO,SAASF,aAAaxG,KAAwB;IACnD,MAAM,EAAEyB,IAAI,EAAEgC,UAAU,EAAEE,IAAI,EAAEyF,IAAI,EAAE3I,MAAM,EAAEgG,MAAM,EAAE,GAAGzG;IACzD,MAAMsJ,WAAW;QAAC7H,KAAK8H,WAAW;QAAE9H,KAAK+H,WAAW;KAAC,CAACpG,MAAM,CAACqG,SAASC,IAAI,CAAC;IAC3E,qBACE,MAAC/K;QACCgL,WAAWlD,SAAS,IAAI;QACxBnC,IAAI;YAAE8D,GAAG;YAAM3D,OAAOgC,SAAS,MAAMT;QAAU;;0BAE/C,MAACpH;gBAAMyF,WAAU;gBAAMc,SAAS;gBAAKb,IAAI;oBAAEC,YAAY;gBAAa;;kCAClE,KAACxF;wBACCgI,SAAQ;wBACR6C,WAAWnJ,SAAS,WAAW;wBAC/B0C,SAAS1C;wBACT,+DAA+D;wBAC/D,iEAAiE;wBACjE,yDAAyD;wBACzD6D,IAAI;4BACFY,MAAM;4BACN6C,WAAW;4BACX8B,MAAM;4BACNC,YAAY;4BACZlC,OAAO;4BACPmC,YAAY;4BACZC,QAAQ;4BACR5B,GAAG;4BACHS,QAAQpI,SAAS,YAAY;4BAC7B,WAAWA,SAAS;gCAAEwJ,gBAAgB;4BAAY,IAAIjE;wBACxD;kCAECvE,KAAKiG,KAAK,IAAI;;oBAEhB0B,qBACC,KAAC7K;wBACC,gEAAgE;wBAChE,+DAA+D;wBAC/D2L,eAAe,CAAC/H,QAAUA,MAAMgI,eAAe;wBAC/CC,WAAW,CAACjI,QAAUA,MAAMgI,eAAe;wBAC3C7F,IAAI;4BAAE+F,GAAG,CAAC;wBAAI;kCAEbjB;yBAED;;;0BAEN,KAACrK;gBAAWgI,SAAQ;gBAAYzC,IAAI;oBAAEgG,IAAI;gBAAI;0BAC3C,OAAO7I,KAAKkE,WAAW,KAAK,WACzBtG,YAAYoC,KAAKkE,WAAW,EAAElE,KAAKmE,QAAQ,IAC3C;;YAEL0D,yBACC,KAACvK;gBAAWgI,SAAQ;gBAAUa,OAAM;gBAAiBO,MAAM;gBAAC7D,IAAI;oBAAE2C,SAAS;gBAAQ;0BAChFqC;iBAED;0BACJ,MAAC1K;gBAAMyF,WAAU;gBAAMc,SAAS;gBAAGb,IAAI;oBAAEC,YAAY;oBAAU+F,IAAI;gBAAE;;kCACnE,KAAC/K;wBAAYwD,OAAOU;wBAAYP,MAAM;;kCACtC,KAACnE;wBAAWgI,SAAQ;wBAAUa,OAAM;wBAAiBtD,IAAI;4BAAEY,MAAM;wBAAE;kCAChEzD,KAAKgH,MAAM,KAAK,SACb,GAAG9E,KAAK,CAAC,EAAEA,SAAS,IAAI,QAAQ,OAAO,SAAS,CAAC,GACjDlC,KAAKgH,MAAM,KAAK,QACd,QACA;;;;;;AAKhB;AACAjC,aAAaE,WAAW,GAAG;AAE3B,eAAe3G,UAAS"}
@@ -9,13 +9,17 @@ export interface DealStageCardProps {
9
9
  /**
10
10
  * Where the deal is, and the controls that move it (AGL-2598).
11
11
  *
12
- * A stepper across the open stages — click one to move there — and the two
13
- * closing buttons. Every one of them calls the stage route, never Firestore:
14
- * the move is what an automation listens for, and the route is the only
15
- * writer that emits it. On a closed deal the stepper gives way to the
16
- * verdict — Won or Lost, when, and why — and a way back: reopening is a
17
- * move into an open stage, which the route emits as `dealStageChanged` like
18
- * any other move.
12
+ * A path across the whole pipeline, the way Salesforce draws one: every open
13
+ * stage — click one to move there — and then one closing step, so the stages
14
+ * still ahead of the deal are always in view and the path ends where every
15
+ * deal ends. The closing step reads "Closed" while the deal is open and asks
16
+ * won or lost when chosen, the same two moves as the buttons in the header;
17
+ * once the deal is closed it reads the pipeline's own name for the verdict.
18
+ * Every move calls the stage route, never Firestore: the move is what an
19
+ * automation listens for, and the route is the only writer that emits it.
20
+ * Under a closed deal's path sits the verdict — when, and why — and a way
21
+ * back: reopening is a move into an open stage, which the route emits as
22
+ * `dealStageChanged` like any other move.
19
23
  *
20
24
  * The line under the stepper reads the deal's own probability and forecast
21
25
  * category (AGL-3516) — a move gives it the new stage's, which the route
@@ -19,31 +19,40 @@ import { CRM_FORECAST_CATEGORY_LABELS, dealForecastCategory, dealProbability, pl
19
19
  import { mdiThumbDownOutline, mdiTrophyOutline } from "@aglyn/shared-data-mdi";
20
20
  import { CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
21
21
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
22
- import { Button, Chip, MenuItem, Stack, Step, StepButton, Stepper, TextField, Typography } from "@mui/material";
22
+ import { Button, Chip, ListItemIcon, ListItemText, Menu, MenuItem, Stack, Step, StepButton, StepLabel, Stepper, TextField, Typography } from "@mui/material";
23
23
  import { useCallback, useState } from "react";
24
- import { daysInStage, DEAL_STATUS_LABELS, openStages } from "../model/deal-board-model.js";
24
+ import { closingStage, daysInStage, DEAL_STATUS_LABELS, openStages } from "../model/deal-board-model.js";
25
25
  import { LostReasonDialog } from "./lost-reason-dialog.js";
26
26
  /**
27
27
  * Where the deal is, and the controls that move it (AGL-2598).
28
28
  *
29
- * A stepper across the open stages — click one to move there — and the two
30
- * closing buttons. Every one of them calls the stage route, never Firestore:
31
- * the move is what an automation listens for, and the route is the only
32
- * writer that emits it. On a closed deal the stepper gives way to the
33
- * verdict — Won or Lost, when, and why — and a way back: reopening is a
34
- * move into an open stage, which the route emits as `dealStageChanged` like
35
- * any other move.
29
+ * A path across the whole pipeline, the way Salesforce draws one: every open
30
+ * stage — click one to move there — and then one closing step, so the stages
31
+ * still ahead of the deal are always in view and the path ends where every
32
+ * deal ends. The closing step reads "Closed" while the deal is open and asks
33
+ * won or lost when chosen, the same two moves as the buttons in the header;
34
+ * once the deal is closed it reads the pipeline's own name for the verdict.
35
+ * Every move calls the stage route, never Firestore: the move is what an
36
+ * automation listens for, and the route is the only writer that emits it.
37
+ * Under a closed deal's path sits the verdict — when, and why — and a way
38
+ * back: reopening is a move into an open stage, which the route emits as
39
+ * `dealStageChanged` like any other move.
36
40
  *
37
41
  * The line under the stepper reads the deal's own probability and forecast
38
42
  * category (AGL-3516) — a move gives it the new stage's, which the route
39
43
  * stamps, and the stepper says so on hover.
40
44
  */ export function DealStageCard(props) {
45
+ var _ref, _ref1;
46
+ var _closingStage, _closingStage1;
41
47
  const { deal, pipeline, api, nowMs } = props;
42
48
  const { enqueueSnackbar } = useSnackbar();
43
49
  const [busy, setBusy] = useState(false);
44
50
  const [losing, setLosing] = useState(false);
51
+ const [closeMenu, setCloseMenu] = useState(null);
45
52
  const stages = openStages(pipeline);
46
53
  const activeIndex = stages.findIndex((stage)=>stage.id === deal.stageId);
54
+ const wonName = (_ref = (_closingStage = closingStage(pipeline, 'won')) == null ? void 0 : _closingStage.name) != null ? _ref : DEAL_STATUS_LABELS.won;
55
+ const lostName = (_ref1 = (_closingStage1 = closingStage(pipeline, 'lost')) == null ? void 0 : _closingStage1.name) != null ? _ref1 : DEAL_STATUS_LABELS.lost;
47
56
  const run = useCallback(async (request, done)=>{
48
57
  setBusy(true);
49
58
  try {
@@ -70,6 +79,44 @@ import { LostReasonDialog } from "./lost-reason-dialog.js";
70
79
  const current = activeIndex === -1 ? null : stages[activeIndex];
71
80
  const probability = dealProbability(deal, current);
72
81
  const forecast = CRM_FORECAST_CATEGORY_LABELS[dealForecastCategory(deal, current)];
82
+ const path = /*#__PURE__*/ _jsxs(Stepper, {
83
+ nonLinear: true,
84
+ activeStep: closed ? stages.length : activeIndex,
85
+ alternativeLabel: true,
86
+ children: [
87
+ stages.map((stage, index)=>/*#__PURE__*/ _jsx(Step, {
88
+ completed: closed || index < activeIndex,
89
+ children: /*#__PURE__*/ _jsx(StepButton, {
90
+ title: closed ? undefined : `Move here: ${stage.probability}% likely to close`,
91
+ disabled: busy || closed || stage.id === deal.stageId,
92
+ onClick: ()=>void run(()=>api.moveToStage(deal, stage.id), `Moved to ${stage.name}`),
93
+ children: stage.name
94
+ })
95
+ }, stage.id)),
96
+ /*#__PURE__*/ _jsx(Step, {
97
+ completed: deal.status === 'won',
98
+ children: /*#__PURE__*/ _jsx(StepButton, {
99
+ "data-testid": "deal-stage-closing-step",
100
+ title: closed ? undefined : 'Close the deal as won or lost',
101
+ disabled: busy || closed,
102
+ onClick: (event)=>setCloseMenu(event.currentTarget),
103
+ icon: deal.status === 'won' ? /*#__PURE__*/ _jsx(MdiIcon, {
104
+ path: mdiTrophyOutline.path,
105
+ color: "success",
106
+ fontSize: "medium"
107
+ }) : deal.status === 'lost' ? /*#__PURE__*/ _jsx(MdiIcon, {
108
+ path: mdiThumbDownOutline.path,
109
+ color: "error",
110
+ fontSize: "medium"
111
+ }) : undefined,
112
+ children: /*#__PURE__*/ _jsx(StepLabel, {
113
+ error: deal.status === 'lost',
114
+ children: deal.status === 'won' ? wonName : deal.status === 'lost' ? lostName : 'Closed'
115
+ })
116
+ })
117
+ }, "$closed")
118
+ ]
119
+ });
73
120
  return /*#__PURE__*/ _jsxs(CardDisplay, {
74
121
  header: 'Stage',
75
122
  help: pluginDocsHelp('deals', {
@@ -117,6 +164,7 @@ import { LostReasonDialog } from "./lost-reason-dialog.js";
117
164
  }) : closed ? /*#__PURE__*/ _jsxs(Stack, {
118
165
  spacing: 1.5,
119
166
  children: [
167
+ path,
120
168
  /*#__PURE__*/ _jsxs(Stack, {
121
169
  direction: "row",
122
170
  spacing: 1,
@@ -169,20 +217,7 @@ import { LostReasonDialog } from "./lost-reason-dialog.js";
169
217
  }) : /*#__PURE__*/ _jsxs(Stack, {
170
218
  spacing: 1.5,
171
219
  children: [
172
- /*#__PURE__*/ _jsx(Stepper, {
173
- nonLinear: true,
174
- activeStep: activeIndex,
175
- alternativeLabel: true,
176
- children: stages.map((stage, index)=>/*#__PURE__*/ _jsx(Step, {
177
- completed: index < activeIndex,
178
- children: /*#__PURE__*/ _jsx(StepButton, {
179
- title: `Move here: ${stage.probability}% likely to close`,
180
- disabled: busy || stage.id === deal.stageId,
181
- onClick: ()=>void run(()=>api.moveToStage(deal, stage.id), `Moved to ${stage.name}`),
182
- children: stage.name
183
- })
184
- }, stage.id))
185
- }),
220
+ path,
186
221
  /*#__PURE__*/ _jsx(Typography, {
187
222
  variant: "body2",
188
223
  color: "text.secondary",
@@ -193,6 +228,47 @@ import { LostReasonDialog } from "./lost-reason-dialog.js";
193
228
  })
194
229
  ]
195
230
  }),
231
+ /*#__PURE__*/ _jsxs(Menu, {
232
+ anchorEl: closeMenu,
233
+ open: Boolean(closeMenu),
234
+ onClose: ()=>setCloseMenu(null),
235
+ children: [
236
+ /*#__PURE__*/ _jsxs(MenuItem, {
237
+ onClick: ()=>{
238
+ setCloseMenu(null);
239
+ void run(()=>api.markWon(deal), 'Deal won');
240
+ },
241
+ children: [
242
+ /*#__PURE__*/ _jsx(ListItemIcon, {
243
+ children: /*#__PURE__*/ _jsx(MdiIcon, {
244
+ path: mdiTrophyOutline.path,
245
+ color: "success"
246
+ })
247
+ }),
248
+ /*#__PURE__*/ _jsx(ListItemText, {
249
+ children: wonName
250
+ })
251
+ ]
252
+ }),
253
+ /*#__PURE__*/ _jsxs(MenuItem, {
254
+ onClick: ()=>{
255
+ setCloseMenu(null);
256
+ setLosing(true);
257
+ },
258
+ children: [
259
+ /*#__PURE__*/ _jsx(ListItemIcon, {
260
+ children: /*#__PURE__*/ _jsx(MdiIcon, {
261
+ path: mdiThumbDownOutline.path,
262
+ color: "error"
263
+ })
264
+ }),
265
+ /*#__PURE__*/ _jsx(ListItemText, {
266
+ children: lostName
267
+ })
268
+ ]
269
+ })
270
+ ]
271
+ }),
196
272
  /*#__PURE__*/ _jsx(LostReasonDialog, {
197
273
  open: losing,
198
274
  dealTitle: deal.title,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/deal-stage-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CRM_FORECAST_CATEGORY_LABELS,\n dealForecastCategory,\n dealProbability,\n pluginDocsHelp,\n} from '@aglyn/aglyn'\nimport { mdiThumbDownOutline, mdiTrophyOutline } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n MenuItem,\n Stack,\n Step,\n StepButton,\n Stepper,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useState } from 'react'\nimport { type DealStageApi } from '../hooks/use-deal-stage-api'\nimport {\n daysInStage,\n DEAL_STATUS_LABELS,\n type DealDoc,\n openStages,\n type PipelineDoc,\n} from '../model/deal-board-model'\nimport { LostReasonDialog } from './lost-reason-dialog'\n\nexport interface DealStageCardProps {\n deal: DealDoc\n pipeline: PipelineDoc | null\n api: DealStageApi\n nowMs: number\n}\n\n/**\n * Where the deal is, and the controls that move it (AGL-2598).\n *\n * A stepper across the open stages — click one to move there — and the two\n * closing buttons. Every one of them calls the stage route, never Firestore:\n * the move is what an automation listens for, and the route is the only\n * writer that emits it. On a closed deal the stepper gives way to the\n * verdict — Won or Lost, when, and why — and a way back: reopening is a\n * move into an open stage, which the route emits as `dealStageChanged` like\n * any other move.\n *\n * The line under the stepper reads the deal's own probability and forecast\n * category (AGL-3516) — a move gives it the new stage's, which the route\n * stamps, and the stepper says so on hover.\n */\nexport function DealStageCard(props: DealStageCardProps) {\n const { deal, pipeline, api, nowMs } = props\n const { enqueueSnackbar } = useSnackbar()\n const [busy, setBusy] = useState(false)\n const [losing, setLosing] = useState(false)\n const stages = openStages(pipeline)\n const activeIndex = stages.findIndex((stage) => stage.id === deal.stageId)\n\n const run = useCallback(\n async (request: () => Promise<unknown>, done: string) => {\n setBusy(true)\n try {\n await request()\n enqueueSnackbar(done, { variant: 'success', persist: false })\n return true\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The deal could not be moved.',\n { variant: 'warning', allowDuplicate: true },\n )\n return false\n } finally {\n setBusy(false)\n }\n },\n [enqueueSnackbar],\n )\n\n const closed = deal.status !== 'open'\n const days = daysInStage(deal, nowMs)\n const current = activeIndex === -1 ? null : stages[activeIndex]\n const probability = dealProbability(deal, current)\n const forecast = CRM_FORECAST_CATEGORY_LABELS[dealForecastCategory(deal, current)]\n\n return (\n <CardDisplay\n header={'Stage'}\n help={pluginDocsHelp('deals', { anchor: '#moving-winning-and-losing' })}\n HeaderProps={{\n action: !closed ? (\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n color=\"success\"\n variant=\"outlined\"\n disabled={busy || !pipeline}\n startIcon={<MdiIcon path={mdiTrophyOutline.path} size={0.8} />}\n onClick={() => void run(() => api.markWon(deal), 'Deal won')}\n >\n {'Won'}\n </Button>\n <Button\n size=\"small\"\n color=\"error\"\n variant=\"outlined\"\n disabled={busy || !pipeline}\n startIcon={<MdiIcon path={mdiThumbDownOutline.path} size={0.8} />}\n onClick={() => setLosing(true)}\n >\n {'Lost'}\n </Button>\n </Stack>\n ) : null,\n }}\n contentGutterX\n contentGutterY\n >\n {!pipeline ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {\"This deal's pipeline no longer exists, so it has no stages to move through.\"}\n </Typography>\n ) : closed ? (\n <Stack spacing={1.5}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip\n label={DEAL_STATUS_LABELS[deal.status]}\n color={deal.status === 'won' ? 'success' : 'default'}\n />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {typeof deal.closedAtMs === 'number'\n ? `Closed ${new Date(deal.closedAtMs).toLocaleDateString()}`\n : 'Closed'}\n </Typography>\n </Stack>\n {deal.status === 'lost' && deal.lostReason ? (\n <Typography variant=\"body2\">{deal.lostReason}</Typography>\n ) : null}\n <TextField\n select\n size=\"small\"\n label=\"Reopen into\"\n value=\"\"\n disabled={busy}\n onChange={(event) => {\n const stageId = event.target.value\n if (!stageId) return\n void run(() => api.moveToStage(deal, stageId), 'Deal reopened')\n }}\n helperText=\"Reopening puts the deal back in an open stage and tells the automations.\"\n sx={{ maxWidth: 320 }}\n >\n <MenuItem value=\"\">{'Pick a stage'}</MenuItem>\n {stages.map((stage) => (\n <MenuItem key={stage.id} value={stage.id}>\n {stage.name}\n </MenuItem>\n ))}\n </TextField>\n </Stack>\n ) : (\n <Stack spacing={1.5}>\n <Stepper nonLinear activeStep={activeIndex} alternativeLabel>\n {stages.map((stage, index) => (\n <Step key={stage.id} completed={index < activeIndex}>\n <StepButton\n title={`Move here: ${stage.probability}% likely to close`}\n disabled={busy || stage.id === deal.stageId}\n onClick={() =>\n void run(\n () => api.moveToStage(deal, stage.id),\n `Moved to ${stage.name}`,\n )\n }\n >\n {stage.name}\n </StepButton>\n </Step>\n ))}\n </Stepper>\n <Typography variant=\"body2\" color=\"text.secondary\" sx={{ textAlign: 'center' }}>\n {!current\n ? 'This deal sits in a stage the pipeline no longer has. Pick one above.'\n : `${days} ${days === 1 ? 'day' : 'days'} in ${current.name} · ` +\n `${probability ?? current.probability}% likely to close · ${forecast}`}\n </Typography>\n </Stack>\n )}\n <LostReasonDialog\n open={losing}\n dealTitle={deal.title}\n busy={busy}\n onClose={() => setLosing(false)}\n onConfirm={(reason) =>\n void run(() => api.markLost(deal, reason), 'Deal marked lost').then(\n (ok) => ok && setLosing(false),\n )\n }\n />\n </CardDisplay>\n )\n}\nDealStageCard.displayName = 'DealStageCard'\n\nexport default DealStageCard\n"],"names":["CRM_FORECAST_CATEGORY_LABELS","dealForecastCategory","dealProbability","pluginDocsHelp","mdiThumbDownOutline","mdiTrophyOutline","CardDisplay","MdiIcon","useSnackbar","Button","Chip","MenuItem","Stack","Step","StepButton","Stepper","TextField","Typography","useCallback","useState","daysInStage","DEAL_STATUS_LABELS","openStages","LostReasonDialog","DealStageCard","props","deal","pipeline","api","nowMs","enqueueSnackbar","busy","setBusy","losing","setLosing","stages","activeIndex","findIndex","stage","id","stageId","run","request","done","variant","persist","error","Error","message","allowDuplicate","closed","status","days","current","probability","forecast","header","help","anchor","HeaderProps","action","direction","spacing","size","color","disabled","startIcon","path","onClick","markWon","contentGutterX","contentGutterY","sx","alignItems","label","closedAtMs","Date","toLocaleDateString","lostReason","select","value","onChange","event","target","moveToStage","helperText","maxWidth","map","name","nonLinear","activeStep","alternativeLabel","index","completed","title","textAlign","open","dealTitle","onClose","onConfirm","reason","markLost","then","ok","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/deal-stage-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CRM_FORECAST_CATEGORY_LABELS,\n dealForecastCategory,\n dealProbability,\n pluginDocsHelp,\n} from '@aglyn/aglyn'\nimport { mdiThumbDownOutline, mdiTrophyOutline } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n ListItemIcon,\n ListItemText,\n Menu,\n MenuItem,\n Stack,\n Step,\n StepButton,\n StepLabel,\n Stepper,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useState } from 'react'\nimport { type DealStageApi } from '../hooks/use-deal-stage-api'\nimport {\n closingStage,\n daysInStage,\n DEAL_STATUS_LABELS,\n type DealDoc,\n openStages,\n type PipelineDoc,\n} from '../model/deal-board-model'\nimport { LostReasonDialog } from './lost-reason-dialog'\n\nexport interface DealStageCardProps {\n deal: DealDoc\n pipeline: PipelineDoc | null\n api: DealStageApi\n nowMs: number\n}\n\n/**\n * Where the deal is, and the controls that move it (AGL-2598).\n *\n * A path across the whole pipeline, the way Salesforce draws one: every open\n * stage — click one to move there — and then one closing step, so the stages\n * still ahead of the deal are always in view and the path ends where every\n * deal ends. The closing step reads \"Closed\" while the deal is open and asks\n * won or lost when chosen, the same two moves as the buttons in the header;\n * once the deal is closed it reads the pipeline's own name for the verdict.\n * Every move calls the stage route, never Firestore: the move is what an\n * automation listens for, and the route is the only writer that emits it.\n * Under a closed deal's path sits the verdict — when, and why — and a way\n * back: reopening is a move into an open stage, which the route emits as\n * `dealStageChanged` like any other move.\n *\n * The line under the stepper reads the deal's own probability and forecast\n * category (AGL-3516) — a move gives it the new stage's, which the route\n * stamps, and the stepper says so on hover.\n */\nexport function DealStageCard(props: DealStageCardProps) {\n const { deal, pipeline, api, nowMs } = props\n const { enqueueSnackbar } = useSnackbar()\n const [busy, setBusy] = useState(false)\n const [losing, setLosing] = useState(false)\n const [closeMenu, setCloseMenu] = useState<HTMLElement | null>(null)\n const stages = openStages(pipeline)\n const activeIndex = stages.findIndex((stage) => stage.id === deal.stageId)\n const wonName = closingStage(pipeline, 'won')?.name ?? DEAL_STATUS_LABELS.won\n const lostName = closingStage(pipeline, 'lost')?.name ?? DEAL_STATUS_LABELS.lost\n\n const run = useCallback(\n async (request: () => Promise<unknown>, done: string) => {\n setBusy(true)\n try {\n await request()\n enqueueSnackbar(done, { variant: 'success', persist: false })\n return true\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The deal could not be moved.',\n { variant: 'warning', allowDuplicate: true },\n )\n return false\n } finally {\n setBusy(false)\n }\n },\n [enqueueSnackbar],\n )\n\n const closed = deal.status !== 'open'\n const days = daysInStage(deal, nowMs)\n const current = activeIndex === -1 ? null : stages[activeIndex]\n const probability = dealProbability(deal, current)\n const forecast = CRM_FORECAST_CATEGORY_LABELS[dealForecastCategory(deal, current)]\n\n const path = (\n <Stepper nonLinear activeStep={closed ? stages.length : activeIndex} alternativeLabel>\n {stages.map((stage, index) => (\n <Step key={stage.id} completed={closed || index < activeIndex}>\n <StepButton\n title={closed ? undefined : `Move here: ${stage.probability}% likely to close`}\n disabled={busy || closed || stage.id === deal.stageId}\n onClick={() =>\n void run(() => api.moveToStage(deal, stage.id), `Moved to ${stage.name}`)\n }\n >\n {stage.name}\n </StepButton>\n </Step>\n ))}\n <Step key=\"$closed\" completed={deal.status === 'won'}>\n <StepButton\n data-testid=\"deal-stage-closing-step\"\n title={closed ? undefined : 'Close the deal as won or lost'}\n disabled={busy || closed}\n onClick={(event) => setCloseMenu(event.currentTarget)}\n icon={\n deal.status === 'won' ? (\n <MdiIcon path={mdiTrophyOutline.path} color=\"success\" fontSize=\"medium\" />\n ) : deal.status === 'lost' ? (\n <MdiIcon path={mdiThumbDownOutline.path} color=\"error\" fontSize=\"medium\" />\n ) : undefined\n }\n >\n <StepLabel error={deal.status === 'lost'}>\n {deal.status === 'won' ? wonName : deal.status === 'lost' ? lostName : 'Closed'}\n </StepLabel>\n </StepButton>\n </Step>\n </Stepper>\n )\n\n return (\n <CardDisplay\n header={'Stage'}\n help={pluginDocsHelp('deals', { anchor: '#moving-winning-and-losing' })}\n HeaderProps={{\n action: !closed ? (\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n color=\"success\"\n variant=\"outlined\"\n disabled={busy || !pipeline}\n startIcon={<MdiIcon path={mdiTrophyOutline.path} size={0.8} />}\n onClick={() => void run(() => api.markWon(deal), 'Deal won')}\n >\n {'Won'}\n </Button>\n <Button\n size=\"small\"\n color=\"error\"\n variant=\"outlined\"\n disabled={busy || !pipeline}\n startIcon={<MdiIcon path={mdiThumbDownOutline.path} size={0.8} />}\n onClick={() => setLosing(true)}\n >\n {'Lost'}\n </Button>\n </Stack>\n ) : null,\n }}\n contentGutterX\n contentGutterY\n >\n {!pipeline ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {\"This deal's pipeline no longer exists, so it has no stages to move through.\"}\n </Typography>\n ) : closed ? (\n <Stack spacing={1.5}>\n {path}\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip\n label={DEAL_STATUS_LABELS[deal.status]}\n color={deal.status === 'won' ? 'success' : 'default'}\n />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {typeof deal.closedAtMs === 'number'\n ? `Closed ${new Date(deal.closedAtMs).toLocaleDateString()}`\n : 'Closed'}\n </Typography>\n </Stack>\n {deal.status === 'lost' && deal.lostReason ? (\n <Typography variant=\"body2\">{deal.lostReason}</Typography>\n ) : null}\n <TextField\n select\n size=\"small\"\n label=\"Reopen into\"\n value=\"\"\n disabled={busy}\n onChange={(event) => {\n const stageId = event.target.value\n if (!stageId) return\n void run(() => api.moveToStage(deal, stageId), 'Deal reopened')\n }}\n helperText=\"Reopening puts the deal back in an open stage and tells the automations.\"\n sx={{ maxWidth: 320 }}\n >\n <MenuItem value=\"\">{'Pick a stage'}</MenuItem>\n {stages.map((stage) => (\n <MenuItem key={stage.id} value={stage.id}>\n {stage.name}\n </MenuItem>\n ))}\n </TextField>\n </Stack>\n ) : (\n <Stack spacing={1.5}>\n {path}\n <Typography variant=\"body2\" color=\"text.secondary\" sx={{ textAlign: 'center' }}>\n {!current\n ? 'This deal sits in a stage the pipeline no longer has. Pick one above.'\n : `${days} ${days === 1 ? 'day' : 'days'} in ${current.name} · ` +\n `${probability ?? current.probability}% likely to close · ${forecast}`}\n </Typography>\n </Stack>\n )}\n <Menu anchorEl={closeMenu} open={Boolean(closeMenu)} onClose={() => setCloseMenu(null)}>\n <MenuItem\n onClick={() => {\n setCloseMenu(null)\n void run(() => api.markWon(deal), 'Deal won')\n }}\n >\n <ListItemIcon>\n <MdiIcon path={mdiTrophyOutline.path} color=\"success\" />\n </ListItemIcon>\n <ListItemText>{wonName}</ListItemText>\n </MenuItem>\n <MenuItem\n onClick={() => {\n setCloseMenu(null)\n setLosing(true)\n }}\n >\n <ListItemIcon>\n <MdiIcon path={mdiThumbDownOutline.path} color=\"error\" />\n </ListItemIcon>\n <ListItemText>{lostName}</ListItemText>\n </MenuItem>\n </Menu>\n <LostReasonDialog\n open={losing}\n dealTitle={deal.title}\n busy={busy}\n onClose={() => setLosing(false)}\n onConfirm={(reason) =>\n void run(() => api.markLost(deal, reason), 'Deal marked lost').then(\n (ok) => ok && setLosing(false),\n )\n }\n />\n </CardDisplay>\n )\n}\nDealStageCard.displayName = 'DealStageCard'\n\nexport default DealStageCard\n"],"names":["CRM_FORECAST_CATEGORY_LABELS","dealForecastCategory","dealProbability","pluginDocsHelp","mdiThumbDownOutline","mdiTrophyOutline","CardDisplay","MdiIcon","useSnackbar","Button","Chip","ListItemIcon","ListItemText","Menu","MenuItem","Stack","Step","StepButton","StepLabel","Stepper","TextField","Typography","useCallback","useState","closingStage","daysInStage","DEAL_STATUS_LABELS","openStages","LostReasonDialog","DealStageCard","props","deal","pipeline","api","nowMs","enqueueSnackbar","busy","setBusy","losing","setLosing","closeMenu","setCloseMenu","stages","activeIndex","findIndex","stage","id","stageId","wonName","name","won","lostName","lost","run","request","done","variant","persist","error","Error","message","allowDuplicate","closed","status","days","current","probability","forecast","path","nonLinear","activeStep","length","alternativeLabel","map","index","completed","title","undefined","disabled","onClick","moveToStage","data-testid","event","currentTarget","icon","color","fontSize","header","help","anchor","HeaderProps","action","direction","spacing","size","startIcon","markWon","contentGutterX","contentGutterY","sx","alignItems","label","closedAtMs","Date","toLocaleDateString","lostReason","select","value","onChange","target","helperText","maxWidth","textAlign","anchorEl","open","Boolean","onClose","dealTitle","onConfirm","reason","markLost","then","ok","displayName"],"mappings":"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"}
@@ -169,7 +169,7 @@ const TASK_DONE_CEILING = 1000;
169
169
  header: 'Activity by teammate',
170
170
  help: Aglyn.pluginDocsHelp('crmReports', {
171
171
  anchor: '#activity-by-teammate',
172
- excerpt: 'The calls, emails, meetings and notes each teammate logged in ' + 'the period, and the tasks they completed, busiest first. ' + 'Grouped from the period’s newest thousand of each.'
172
+ excerpt: 'The calls, emails, messages, meetings and notes each teammate logged in ' + 'the period, and the tasks they completed, busiest first. ' + 'Grouped from the period’s newest thousand of each.'
173
173
  }),
174
174
  contentGutterX: true,
175
175
  contentGutterY: true,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/activity-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportExport } from './report-export'\nimport { reportFilename } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\nimport {\n activityLeaderboard,\n type ActivityLeaderboardRow,\n deltaPercent,\n} from '../../model/crm-reports'\n\n/**\n * How many activities and how many completed tasks the leaderboard is\n * grouped from.\n *\n * The tiles are server counts and never meet these bounds; the table needs\n * each activity's kind and author and each task's completer, so it reads\n * the period's newest thousand of each and says when there were more —\n * the contacts list's bound, and the one every window on this page shares.\n */\nconst ACTIVITY_CEILING = 1000\nconst TASK_DONE_CEILING = 1000\n\ntype ActivityRow = Aglyn.CrmActivityRow\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The table's columns, which are also the CSV's. */\nconst COLUMNS = [\n 'Teammate',\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]),\n 'Activities',\n 'Tasks done',\n] as const\n\nexport interface ActivityCardProps {\n report: CrmReportScope\n}\n\n/**\n * Activity by teammate (AGL-2624): the calls, emails, meetings and notes\n * each member logged in the period, and the tasks they ticked off,\n * busiest first.\n *\n * ## Two windows, two indexes, one table\n *\n * Activities are counted within the period on `atMs`; completed tasks are\n * counted the same way on `completedAtMs`, under `status == 'done'`. Each\n * count is also drawn as a window, newest first, for the leaderboard.\n *\n * ## Why that is four indexes and not one\n *\n * The counts and the windows do not share an index, and neither do the two\n * audiences.\n *\n * A COUNT here names no `orderBy`. Firestore then orders implicitly by the\n * range field, ASCENDING, so a count wants `completedAtMs` ascending — while\n * the window beside it asks for `desc` explicitly and wants the descending\n * one. A composite index serves one direction or its exact reverse, never\n * both, so declaring only the direction the window reads leaves the count\n * with nothing: `FAILED_PRECONDITION`, one column of dashes, and a card that\n * otherwise looks fine.\n *\n * Then the visibility clause doubles it. `visibleToClause` contributes\n * nothing when the reader's tokens are null, which is every organization-level\n * read, so the shape arriving at Firestore has no leading array field — a\n * different index, not a prefix of the one with it. `(visibleTo, status,\n * completedAtMs)` serves a site and `(status, completedAtMs)` the org above\n * it; one without the other is a card that works for exactly one audience.\n *\n * The two reads are independent so that a missing task index degrades ONE\n * column to a dash and a notice rather than the whole card: the activities\n * were read, and the leaderboard draws them.\n *\n * A task ordered by `completedAtMs` is a task the complete route stamped;\n * a task marked done some other way, without the stamp, is not in the\n * window and the caption says what the column is grouped from.\n *\n * ## Names\n *\n * Every activity carries the name it was signed with, for the reason\n * `CrmActivity.byName` gives — a scoped reader cannot resolve a colleague's\n * uid. The roster is read too, through the members route any member may\n * ask, and wins when it knows the uid, so a renamed teammate reads by\n * their current name; the signed name covers a uid the roster no longer\n * has, and the uid itself covers the rest.\n */\nexport function ActivityCard(props: ActivityCardProps) {\n const { report } = props\n const { scope, tokens, range, period, routes } = report\n const firestore = useFirestore()\n const roster = useOrgMemberOptions(scope[1])\n\n const activitiesBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.activities),\n ...visibleToClause(tokens),\n where('atMs', '>=', from),\n where('atMs', '<', to),\n )\n const tasksDoneBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'done'),\n where('completedAtMs', '>=', from),\n where('completedAtMs', '<', to),\n )\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n const activityCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(activitiesBetween(range.from, range.to)),\n countOf(activitiesBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:counts') },\n )\n const taskCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(tasksDoneBetween(range.from, range.to)),\n countOf(tasksDoneBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-counts') },\n )\n\n const activityWindow = useWindowRead<ActivityRow>(\n () =>\n query(\n activitiesBetween(range.from, range.to),\n orderBy('atMs', 'desc'),\n limit(ACTIVITY_CEILING + 1),\n ),\n ACTIVITY_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:window') },\n )\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n tasksDoneBetween(range.from, range.to),\n orderBy('completedAtMs', 'desc'),\n limit(TASK_DONE_CEILING + 1),\n ),\n TASK_DONE_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-window') },\n )\n\n const rows = useMemo(\n () => activityLeaderboard(activityWindow.rows, taskWindow.rows),\n [activityWindow, taskWindow],\n )\n const nameOf = (row: ActivityLeaderboardRow): string => {\n if (!row.uid) return 'No teammate'\n return Aglyn.findOrgMember(roster.options, row.uid)?.label ?? row.name ?? row.uid\n }\n const teammates = rows.filter((row) => row.uid).length\n\n const activitiesRead = activityWindow.status === 'success'\n const tasksRead = taskWindow.status === 'success'\n const settled = activityWindow.status !== 'loading' && taskWindow.status !== 'loading'\n const caption = [\n activityWindow.truncated\n ? `Grouped from the ${ACTIVITY_CEILING.toLocaleString()} most recent activities in the period`\n : null,\n taskWindow.truncated\n ? `the ${TASK_DONE_CEILING.toLocaleString()} most recently completed tasks`\n : null,\n ]\n .filter(Boolean)\n .join(' and ')\n const activityFigures = activityCounts.value\n const taskFigures = taskCounts.value\n\n return (\n <CardDisplay\n header={'Activity by teammate'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#activity-by-teammate',\n excerpt:\n 'The calls, emails, meetings and notes each teammate logged in ' +\n 'the period, and the tasks they completed, busiest first. ' +\n 'Grouped from the period’s newest thousand of each.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <ReportExport\n filename={reportFilename('activity', period)}\n columns={COLUMNS}\n rows={() =>\n rows.map((row) => [\n nameOf(row),\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => row.kinds[kind]),\n row.activities,\n tasksRead ? row.tasksDone : '',\n ])\n }\n disabled={!activitiesRead || !rows.length}\n />\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack useFlexGap direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Activities logged'}\n value={activityFigures ? activityFigures.current.toLocaleString() : null}\n deltaPct={\n activityFigures\n ? deltaPercent(activityFigures.current, activityFigures.previous)\n : null\n }\n note={'counted on the server'}\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Tasks completed'}\n value={taskFigures ? taskFigures.current.toLocaleString() : null}\n deltaPct={\n taskFigures\n ? deltaPercent(taskFigures.current, taskFigures.previous)\n : null\n }\n note={'ticked off in the period'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Teammates active'}\n value={settled ? teammates.toLocaleString() : null}\n note={'logged or completed something'}\n />\n </Stack>\n {activityCounts.status === 'error' || activityWindow.status === 'error' ? (\n <Alert severity=\"warning\">{'The activities could not be read.'}</Alert>\n ) : null}\n {taskCounts.status === 'error' || taskWindow.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The completed-task counts could not be read; the Tasks done column is left blank.'}\n </Alert>\n ) : null}\n <Section title={'Who did what'}>\n {rows.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.uid || '$nobody'}>\n <TableCell>{nameOf(row)}</TableCell>\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <TableCell key={kind} align=\"right\">\n {row.kinds[kind].toLocaleString()}\n </TableCell>\n ))}\n <TableCell align=\"right\">{row.activities.toLocaleString()}</TableCell>\n <TableCell align=\"right\">\n {tasksRead ? row.tasksDone.toLocaleString() : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled ? 'Nothing logged or completed in this period.' : 'Reading…'}\n </Typography>\n )}\n {rows.some((row) => !row.uid) ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No teammate — activities an automation logged, and tasks completed with nobody assigned.'}\n </Typography>\n ) : null}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${caption}; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nActivityCard.displayName = 'ActivityCard'\n\nexport default ActivityCard\n"],"names":["Aglyn","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","getCountFromServer","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","activityLeaderboard","deltaPercent","ACTIVITY_CEILING","TASK_DONE_CEILING","COLUMNS","CRM_ACTIVITY_KINDS","map","kind","CRM_ACTIVITY_KIND_LABELS","ActivityCard","props","report","scope","tokens","range","period","routes","firestore","roster","activitiesBetween","from","to","CRM_COLLECTIONS","activities","tasksDoneBetween","tasks","countOf","target","then","snapshot","data","count","activityCounts","Promise","all","previousFrom","previousTo","current","previous","cacheKey","taskCounts","activityWindow","taskWindow","rows","nameOf","row","uid","findOrgMember","options","label","name","teammates","filter","length","activitiesRead","status","tasksRead","settled","caption","truncated","toLocaleString","Boolean","join","activityFigures","value","taskFigures","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","filename","columns","kinds","tasksDone","disabled","spacing","useFlexGap","direction","sx","flexWrap","deltaPct","note","href","section","severity","title","size","column","index","align","variant","color","some","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,YAAYA,WAAW,eAAc;AACrC,SAASC,WAAW,QAAQ,uBAAsB;AAClD,SAASC,OAAO,QAAQ,6DAA4D;AACpF,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SACEC,KAAK,EACLC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SACEC,kBAAkB,EAClBC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,mBAAmB,QAAQ,wCAAoC;AACxE,SAASC,YAAY,QAAQ,qBAAiB;AAC9C,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SAEEC,cAAc,EACdC,gBAAgB,EAChBC,eAAe,QACV,oBAAgB;AACvB,SAASC,cAAc,QAAQ,wBAAoB;AACnD,SAASC,gBAAgB,EAAEC,aAAa,QAAQ,0BAAsB;AACtE,SACEC,mBAAmB,EAEnBC,YAAY,QACP,6BAAyB;AAEhC;;;;;;;;CAQC,GACD,MAAMC,mBAAmB;AACzB,MAAMC,oBAAoB;AAK1B,mDAAmD,GACnD,MAAMC,UAAU;IACd;OACG/B,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASlC,MAAMmC,wBAAwB,CAACD,KAAK;IAC9E;IACA;CACD;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+CC,GACD,OAAO,SAASE,aAAaC,KAAwB;IACnD,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAM,EAAEE,KAAK,EAAEC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,MAAM,EAAE,GAAGL;IACjD,MAAMM,YAAY3B;IAClB,MAAM4B,SAAS3B,oBAAoBqB,KAAK,CAAC,EAAE;IAE3C,MAAMO,oBAAoB,CAACC,MAAcC,KACvClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACC,UAAU,MAChE3B,gBAAgBiB,SACnBzB,MAAM,QAAQ,MAAMgC,OACpBhC,MAAM,QAAQ,KAAKiC;IAEvB,MAAMG,mBAAmB,CAACJ,MAAcC,KACtClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACG,KAAK,MAC3D7B,gBAAgBiB,SACnBzB,MAAM,UAAU,MAAM,SACtBA,MAAM,iBAAiB,MAAMgC,OAC7BhC,MAAM,iBAAiB,KAAKiC;IAEhC,MAAMK,UAAU,CAACC,SACf3C,mBAAmB2C,QAAQC,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,GAAGC,KAAK;IAErE,MAAMC,iBAAiBlC,iBACrB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQP,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC9CK,QAAQP,kBAAkBL,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC/D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM6B,aAAa1C,iBACjB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQF,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC7CK,QAAQF,iBAAiBV,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC9D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAM8B,iBAAiB1C,cACrB,IACEZ,MACEgC,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACtCnC,QAAQ,QAAQ,SAChBD,MAAMiB,mBAAmB,KAE7BA,kBACA;QAACe;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM+B,aAAa3C,cACjB,IACEZ,MACEqC,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACrCnC,QAAQ,iBAAiB,SACzBD,MAAMkB,oBAAoB,KAE9BA,mBACA;QAACc;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAMgC,OAAOtD,QACX,IAAMW,oBAAoByC,eAAeE,IAAI,EAAED,WAAWC,IAAI,GAC9D;QAACF;QAAgBC;KAAW;IAE9B,MAAME,SAAS,CAACC;YAEPxE;YAAAA;QADP,IAAI,CAACwE,IAAIC,GAAG,EAAE,OAAO;QACrB,QAAOzE,iBAAAA,uBAAAA,MAAM0E,aAAa,CAAC7B,OAAO8B,OAAO,EAAEH,IAAIC,GAAG,sBAA3CzE,qBAA8C4E,KAAK,oBAAIJ,IAAIK,IAAI,YAA/D7E,OAAmEwE,IAAIC,GAAG;IACnF;IACA,MAAMK,YAAYR,KAAKS,MAAM,CAAC,CAACP,MAAQA,IAAIC,GAAG,EAAEO,MAAM;IAEtD,MAAMC,iBAAiBb,eAAec,MAAM,KAAK;IACjD,MAAMC,YAAYd,WAAWa,MAAM,KAAK;IACxC,MAAME,UAAUhB,eAAec,MAAM,KAAK,aAAab,WAAWa,MAAM,KAAK;IAC7E,MAAMG,UAAU;QACdjB,eAAekB,SAAS,GACpB,CAAC,iBAAiB,EAAEzD,iBAAiB0D,cAAc,GAAG,qCAAqC,CAAC,GAC5F;QACJlB,WAAWiB,SAAS,GAChB,CAAC,IAAI,EAAExD,kBAAkByD,cAAc,GAAG,8BAA8B,CAAC,GACzE;KACL,CACER,MAAM,CAACS,SACPC,IAAI,CAAC;IACR,MAAMC,kBAAkB/B,eAAegC,KAAK;IAC5C,MAAMC,cAAczB,WAAWwB,KAAK;IAEpC,qBACE,KAAC1F;QACC4F,QAAQ;QACRC,MAAM9F,MAAM+F,cAAc,CAAC,cAAc;YACvCC,QAAQ;YACRC,SACE,mEACA,8DACA;QACJ;QACAC,cAAc;QACdC,cAAc;QACdC,aAAa;YACXC,sBACE,KAAClF;gBACCmF,UAAUlF,eAAe,YAAYsB;gBACrC6D,SAASxE;gBACTuC,MAAM,IACJA,KAAKrC,GAAG,CAAC,CAACuC,MAAQ;4BAChBD,OAAOC;+BACJxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASsC,IAAIgC,KAAK,CAACtE,KAAK;4BACzDsC,IAAItB,UAAU;4BACdiC,YAAYX,IAAIiC,SAAS,GAAG;yBAC7B;gBAEHC,UAAU,CAACzB,kBAAkB,CAACX,KAAKU,MAAM;;QAG/C;kBAEA,cAAA,MAAC3E;YAAMsG,SAAS;;8BACd,MAACtG;oBAAMuG,UAAU;oBAACC,WAAU;oBAAMF,SAAS;oBAAGG,IAAI;wBAAEC,UAAU;oBAAO;;sCACnE,KAACvF;4BACCoD,OAAO;4BACPe,OAAOD,kBAAkBA,gBAAgB1B,OAAO,CAACuB,cAAc,KAAK;4BACpEyB,UACEtB,kBACI9D,aAAa8D,gBAAgB1B,OAAO,EAAE0B,gBAAgBzB,QAAQ,IAC9D;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOC,cAAcA,YAAY5B,OAAO,CAACuB,cAAc,KAAK;4BAC5DyB,UACEpB,cACIhE,aAAagE,YAAY5B,OAAO,EAAE4B,YAAY3B,QAAQ,IACtD;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOP,UAAUN,UAAUS,cAAc,KAAK;4BAC9C0B,MAAM;;;;gBAGTtD,eAAeuB,MAAM,KAAK,WAAWd,eAAec,MAAM,KAAK,wBAC9D,KAAC9E;oBAAMgH,UAAS;8BAAW;qBACzB;gBACHjD,WAAWe,MAAM,KAAK,WAAWb,WAAWa,MAAM,KAAK,wBACtD,KAAC9E;oBAAMgH,UAAS;8BACb;qBAED;8BACJ,MAAClH;oBAAQmH,OAAO;;wBACb/C,KAAKU,MAAM,iBACV,MAAC7E;4BAAYmH,MAAK;;8CAChB,KAAC9G;8CACC,cAAA,KAACC;kDACEsB,QAAQE,GAAG,CAAC,CAACsF,QAAQC,sBACpB,KAACjH;gDAAuBkH,OAAOD,QAAQ,UAAU;0DAC9CD;+CADaA;;;8CAMtB,KAACjH;8CACEgE,KAAKrC,GAAG,CAAC,CAACuC,oBACT,MAAC/D;;8DACC,KAACF;8DAAWgE,OAAOC;;gDAClBxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,qBAC7B,KAAC3B;wDAAqBkH,OAAM;kEACzBjD,IAAIgC,KAAK,CAACtE,KAAK,CAACqD,cAAc;uDADjBrD;8DAIlB,KAAC3B;oDAAUkH,OAAM;8DAASjD,IAAItB,UAAU,CAACqC,cAAc;;8DACvD,KAAChF;oDAAUkH,OAAM;8DACdtC,YAAYX,IAAIiC,SAAS,CAAClB,cAAc,KAAK;;;2CATnCf,IAAIC,GAAG,IAAI;;;2CAgBhC,KAAC/D;4BAAWgH,SAAQ;4BAAQC,OAAM;sCAC/BvC,UAAU,gDAAgD;;wBAG9Dd,KAAKsD,IAAI,CAAC,CAACpD,MAAQ,CAACA,IAAIC,GAAG,kBAC1B,KAAC/D;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC;6BAED;wBAEHtC,wBACC,KAAC3E;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC,GAAGtC,QAAQ,sCAAsC,CAAC;6BAEnD;;;;;;AAKd;AACAjD,aAAayF,WAAW,GAAG;AAE3B,eAAezF,aAAY"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/activity-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportExport } from './report-export'\nimport { reportFilename } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\nimport {\n activityLeaderboard,\n type ActivityLeaderboardRow,\n deltaPercent,\n} from '../../model/crm-reports'\n\n/**\n * How many activities and how many completed tasks the leaderboard is\n * grouped from.\n *\n * The tiles are server counts and never meet these bounds; the table needs\n * each activity's kind and author and each task's completer, so it reads\n * the period's newest thousand of each and says when there were more —\n * the contacts list's bound, and the one every window on this page shares.\n */\nconst ACTIVITY_CEILING = 1000\nconst TASK_DONE_CEILING = 1000\n\ntype ActivityRow = Aglyn.CrmActivityRow\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The table's columns, which are also the CSV's. */\nconst COLUMNS = [\n 'Teammate',\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]),\n 'Activities',\n 'Tasks done',\n] as const\n\nexport interface ActivityCardProps {\n report: CrmReportScope\n}\n\n/**\n * Activity by teammate (AGL-2624): the calls, emails, meetings and notes\n * each member logged in the period, and the tasks they ticked off,\n * busiest first.\n *\n * ## Two windows, two indexes, one table\n *\n * Activities are counted within the period on `atMs`; completed tasks are\n * counted the same way on `completedAtMs`, under `status == 'done'`. Each\n * count is also drawn as a window, newest first, for the leaderboard.\n *\n * ## Why that is four indexes and not one\n *\n * The counts and the windows do not share an index, and neither do the two\n * audiences.\n *\n * A COUNT here names no `orderBy`. Firestore then orders implicitly by the\n * range field, ASCENDING, so a count wants `completedAtMs` ascending — while\n * the window beside it asks for `desc` explicitly and wants the descending\n * one. A composite index serves one direction or its exact reverse, never\n * both, so declaring only the direction the window reads leaves the count\n * with nothing: `FAILED_PRECONDITION`, one column of dashes, and a card that\n * otherwise looks fine.\n *\n * Then the visibility clause doubles it. `visibleToClause` contributes\n * nothing when the reader's tokens are null, which is every organization-level\n * read, so the shape arriving at Firestore has no leading array field — a\n * different index, not a prefix of the one with it. `(visibleTo, status,\n * completedAtMs)` serves a site and `(status, completedAtMs)` the org above\n * it; one without the other is a card that works for exactly one audience.\n *\n * The two reads are independent so that a missing task index degrades ONE\n * column to a dash and a notice rather than the whole card: the activities\n * were read, and the leaderboard draws them.\n *\n * A task ordered by `completedAtMs` is a task the complete route stamped;\n * a task marked done some other way, without the stamp, is not in the\n * window and the caption says what the column is grouped from.\n *\n * ## Names\n *\n * Every activity carries the name it was signed with, for the reason\n * `CrmActivity.byName` gives — a scoped reader cannot resolve a colleague's\n * uid. The roster is read too, through the members route any member may\n * ask, and wins when it knows the uid, so a renamed teammate reads by\n * their current name; the signed name covers a uid the roster no longer\n * has, and the uid itself covers the rest.\n */\nexport function ActivityCard(props: ActivityCardProps) {\n const { report } = props\n const { scope, tokens, range, period, routes } = report\n const firestore = useFirestore()\n const roster = useOrgMemberOptions(scope[1])\n\n const activitiesBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.activities),\n ...visibleToClause(tokens),\n where('atMs', '>=', from),\n where('atMs', '<', to),\n )\n const tasksDoneBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'done'),\n where('completedAtMs', '>=', from),\n where('completedAtMs', '<', to),\n )\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n const activityCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(activitiesBetween(range.from, range.to)),\n countOf(activitiesBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:counts') },\n )\n const taskCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(tasksDoneBetween(range.from, range.to)),\n countOf(tasksDoneBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-counts') },\n )\n\n const activityWindow = useWindowRead<ActivityRow>(\n () =>\n query(\n activitiesBetween(range.from, range.to),\n orderBy('atMs', 'desc'),\n limit(ACTIVITY_CEILING + 1),\n ),\n ACTIVITY_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:window') },\n )\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n tasksDoneBetween(range.from, range.to),\n orderBy('completedAtMs', 'desc'),\n limit(TASK_DONE_CEILING + 1),\n ),\n TASK_DONE_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-window') },\n )\n\n const rows = useMemo(\n () => activityLeaderboard(activityWindow.rows, taskWindow.rows),\n [activityWindow, taskWindow],\n )\n const nameOf = (row: ActivityLeaderboardRow): string => {\n if (!row.uid) return 'No teammate'\n return Aglyn.findOrgMember(roster.options, row.uid)?.label ?? row.name ?? row.uid\n }\n const teammates = rows.filter((row) => row.uid).length\n\n const activitiesRead = activityWindow.status === 'success'\n const tasksRead = taskWindow.status === 'success'\n const settled = activityWindow.status !== 'loading' && taskWindow.status !== 'loading'\n const caption = [\n activityWindow.truncated\n ? `Grouped from the ${ACTIVITY_CEILING.toLocaleString()} most recent activities in the period`\n : null,\n taskWindow.truncated\n ? `the ${TASK_DONE_CEILING.toLocaleString()} most recently completed tasks`\n : null,\n ]\n .filter(Boolean)\n .join(' and ')\n const activityFigures = activityCounts.value\n const taskFigures = taskCounts.value\n\n return (\n <CardDisplay\n header={'Activity by teammate'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#activity-by-teammate',\n excerpt:\n 'The calls, emails, messages, meetings and notes each teammate logged in ' +\n 'the period, and the tasks they completed, busiest first. ' +\n 'Grouped from the period’s newest thousand of each.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <ReportExport\n filename={reportFilename('activity', period)}\n columns={COLUMNS}\n rows={() =>\n rows.map((row) => [\n nameOf(row),\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => row.kinds[kind]),\n row.activities,\n tasksRead ? row.tasksDone : '',\n ])\n }\n disabled={!activitiesRead || !rows.length}\n />\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack useFlexGap direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Activities logged'}\n value={activityFigures ? activityFigures.current.toLocaleString() : null}\n deltaPct={\n activityFigures\n ? deltaPercent(activityFigures.current, activityFigures.previous)\n : null\n }\n note={'counted on the server'}\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Tasks completed'}\n value={taskFigures ? taskFigures.current.toLocaleString() : null}\n deltaPct={\n taskFigures\n ? deltaPercent(taskFigures.current, taskFigures.previous)\n : null\n }\n note={'ticked off in the period'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Teammates active'}\n value={settled ? teammates.toLocaleString() : null}\n note={'logged or completed something'}\n />\n </Stack>\n {activityCounts.status === 'error' || activityWindow.status === 'error' ? (\n <Alert severity=\"warning\">{'The activities could not be read.'}</Alert>\n ) : null}\n {taskCounts.status === 'error' || taskWindow.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The completed-task counts could not be read; the Tasks done column is left blank.'}\n </Alert>\n ) : null}\n <Section title={'Who did what'}>\n {rows.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.uid || '$nobody'}>\n <TableCell>{nameOf(row)}</TableCell>\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <TableCell key={kind} align=\"right\">\n {row.kinds[kind].toLocaleString()}\n </TableCell>\n ))}\n <TableCell align=\"right\">{row.activities.toLocaleString()}</TableCell>\n <TableCell align=\"right\">\n {tasksRead ? row.tasksDone.toLocaleString() : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled ? 'Nothing logged or completed in this period.' : 'Reading…'}\n </Typography>\n )}\n {rows.some((row) => !row.uid) ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No teammate — activities an automation logged, and tasks completed with nobody assigned.'}\n </Typography>\n ) : null}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${caption}; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nActivityCard.displayName = 'ActivityCard'\n\nexport default ActivityCard\n"],"names":["Aglyn","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","getCountFromServer","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","activityLeaderboard","deltaPercent","ACTIVITY_CEILING","TASK_DONE_CEILING","COLUMNS","CRM_ACTIVITY_KINDS","map","kind","CRM_ACTIVITY_KIND_LABELS","ActivityCard","props","report","scope","tokens","range","period","routes","firestore","roster","activitiesBetween","from","to","CRM_COLLECTIONS","activities","tasksDoneBetween","tasks","countOf","target","then","snapshot","data","count","activityCounts","Promise","all","previousFrom","previousTo","current","previous","cacheKey","taskCounts","activityWindow","taskWindow","rows","nameOf","row","uid","findOrgMember","options","label","name","teammates","filter","length","activitiesRead","status","tasksRead","settled","caption","truncated","toLocaleString","Boolean","join","activityFigures","value","taskFigures","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","filename","columns","kinds","tasksDone","disabled","spacing","useFlexGap","direction","sx","flexWrap","deltaPct","note","href","section","severity","title","size","column","index","align","variant","color","some","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,YAAYA,WAAW,eAAc;AACrC,SAASC,WAAW,QAAQ,uBAAsB;AAClD,SAASC,OAAO,QAAQ,6DAA4D;AACpF,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SACEC,KAAK,EACLC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SACEC,kBAAkB,EAClBC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,mBAAmB,QAAQ,wCAAoC;AACxE,SAASC,YAAY,QAAQ,qBAAiB;AAC9C,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SAEEC,cAAc,EACdC,gBAAgB,EAChBC,eAAe,QACV,oBAAgB;AACvB,SAASC,cAAc,QAAQ,wBAAoB;AACnD,SAASC,gBAAgB,EAAEC,aAAa,QAAQ,0BAAsB;AACtE,SACEC,mBAAmB,EAEnBC,YAAY,QACP,6BAAyB;AAEhC;;;;;;;;CAQC,GACD,MAAMC,mBAAmB;AACzB,MAAMC,oBAAoB;AAK1B,mDAAmD,GACnD,MAAMC,UAAU;IACd;OACG/B,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASlC,MAAMmC,wBAAwB,CAACD,KAAK;IAC9E;IACA;CACD;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+CC,GACD,OAAO,SAASE,aAAaC,KAAwB;IACnD,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAM,EAAEE,KAAK,EAAEC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,MAAM,EAAE,GAAGL;IACjD,MAAMM,YAAY3B;IAClB,MAAM4B,SAAS3B,oBAAoBqB,KAAK,CAAC,EAAE;IAE3C,MAAMO,oBAAoB,CAACC,MAAcC,KACvClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACC,UAAU,MAChE3B,gBAAgBiB,SACnBzB,MAAM,QAAQ,MAAMgC,OACpBhC,MAAM,QAAQ,KAAKiC;IAEvB,MAAMG,mBAAmB,CAACJ,MAAcC,KACtClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACG,KAAK,MAC3D7B,gBAAgBiB,SACnBzB,MAAM,UAAU,MAAM,SACtBA,MAAM,iBAAiB,MAAMgC,OAC7BhC,MAAM,iBAAiB,KAAKiC;IAEhC,MAAMK,UAAU,CAACC,SACf3C,mBAAmB2C,QAAQC,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,GAAGC,KAAK;IAErE,MAAMC,iBAAiBlC,iBACrB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQP,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC9CK,QAAQP,kBAAkBL,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC/D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM6B,aAAa1C,iBACjB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQF,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC7CK,QAAQF,iBAAiBV,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC9D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAM8B,iBAAiB1C,cACrB,IACEZ,MACEgC,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACtCnC,QAAQ,QAAQ,SAChBD,MAAMiB,mBAAmB,KAE7BA,kBACA;QAACe;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM+B,aAAa3C,cACjB,IACEZ,MACEqC,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACrCnC,QAAQ,iBAAiB,SACzBD,MAAMkB,oBAAoB,KAE9BA,mBACA;QAACc;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAMgC,OAAOtD,QACX,IAAMW,oBAAoByC,eAAeE,IAAI,EAAED,WAAWC,IAAI,GAC9D;QAACF;QAAgBC;KAAW;IAE9B,MAAME,SAAS,CAACC;YAEPxE;YAAAA;QADP,IAAI,CAACwE,IAAIC,GAAG,EAAE,OAAO;QACrB,QAAOzE,iBAAAA,uBAAAA,MAAM0E,aAAa,CAAC7B,OAAO8B,OAAO,EAAEH,IAAIC,GAAG,sBAA3CzE,qBAA8C4E,KAAK,oBAAIJ,IAAIK,IAAI,YAA/D7E,OAAmEwE,IAAIC,GAAG;IACnF;IACA,MAAMK,YAAYR,KAAKS,MAAM,CAAC,CAACP,MAAQA,IAAIC,GAAG,EAAEO,MAAM;IAEtD,MAAMC,iBAAiBb,eAAec,MAAM,KAAK;IACjD,MAAMC,YAAYd,WAAWa,MAAM,KAAK;IACxC,MAAME,UAAUhB,eAAec,MAAM,KAAK,aAAab,WAAWa,MAAM,KAAK;IAC7E,MAAMG,UAAU;QACdjB,eAAekB,SAAS,GACpB,CAAC,iBAAiB,EAAEzD,iBAAiB0D,cAAc,GAAG,qCAAqC,CAAC,GAC5F;QACJlB,WAAWiB,SAAS,GAChB,CAAC,IAAI,EAAExD,kBAAkByD,cAAc,GAAG,8BAA8B,CAAC,GACzE;KACL,CACER,MAAM,CAACS,SACPC,IAAI,CAAC;IACR,MAAMC,kBAAkB/B,eAAegC,KAAK;IAC5C,MAAMC,cAAczB,WAAWwB,KAAK;IAEpC,qBACE,KAAC1F;QACC4F,QAAQ;QACRC,MAAM9F,MAAM+F,cAAc,CAAC,cAAc;YACvCC,QAAQ;YACRC,SACE,6EACA,8DACA;QACJ;QACAC,cAAc;QACdC,cAAc;QACdC,aAAa;YACXC,sBACE,KAAClF;gBACCmF,UAAUlF,eAAe,YAAYsB;gBACrC6D,SAASxE;gBACTuC,MAAM,IACJA,KAAKrC,GAAG,CAAC,CAACuC,MAAQ;4BAChBD,OAAOC;+BACJxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASsC,IAAIgC,KAAK,CAACtE,KAAK;4BACzDsC,IAAItB,UAAU;4BACdiC,YAAYX,IAAIiC,SAAS,GAAG;yBAC7B;gBAEHC,UAAU,CAACzB,kBAAkB,CAACX,KAAKU,MAAM;;QAG/C;kBAEA,cAAA,MAAC3E;YAAMsG,SAAS;;8BACd,MAACtG;oBAAMuG,UAAU;oBAACC,WAAU;oBAAMF,SAAS;oBAAGG,IAAI;wBAAEC,UAAU;oBAAO;;sCACnE,KAACvF;4BACCoD,OAAO;4BACPe,OAAOD,kBAAkBA,gBAAgB1B,OAAO,CAACuB,cAAc,KAAK;4BACpEyB,UACEtB,kBACI9D,aAAa8D,gBAAgB1B,OAAO,EAAE0B,gBAAgBzB,QAAQ,IAC9D;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOC,cAAcA,YAAY5B,OAAO,CAACuB,cAAc,KAAK;4BAC5DyB,UACEpB,cACIhE,aAAagE,YAAY5B,OAAO,EAAE4B,YAAY3B,QAAQ,IACtD;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOP,UAAUN,UAAUS,cAAc,KAAK;4BAC9C0B,MAAM;;;;gBAGTtD,eAAeuB,MAAM,KAAK,WAAWd,eAAec,MAAM,KAAK,wBAC9D,KAAC9E;oBAAMgH,UAAS;8BAAW;qBACzB;gBACHjD,WAAWe,MAAM,KAAK,WAAWb,WAAWa,MAAM,KAAK,wBACtD,KAAC9E;oBAAMgH,UAAS;8BACb;qBAED;8BACJ,MAAClH;oBAAQmH,OAAO;;wBACb/C,KAAKU,MAAM,iBACV,MAAC7E;4BAAYmH,MAAK;;8CAChB,KAAC9G;8CACC,cAAA,KAACC;kDACEsB,QAAQE,GAAG,CAAC,CAACsF,QAAQC,sBACpB,KAACjH;gDAAuBkH,OAAOD,QAAQ,UAAU;0DAC9CD;+CADaA;;;8CAMtB,KAACjH;8CACEgE,KAAKrC,GAAG,CAAC,CAACuC,oBACT,MAAC/D;;8DACC,KAACF;8DAAWgE,OAAOC;;gDAClBxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,qBAC7B,KAAC3B;wDAAqBkH,OAAM;kEACzBjD,IAAIgC,KAAK,CAACtE,KAAK,CAACqD,cAAc;uDADjBrD;8DAIlB,KAAC3B;oDAAUkH,OAAM;8DAASjD,IAAItB,UAAU,CAACqC,cAAc;;8DACvD,KAAChF;oDAAUkH,OAAM;8DACdtC,YAAYX,IAAIiC,SAAS,CAAClB,cAAc,KAAK;;;2CATnCf,IAAIC,GAAG,IAAI;;;2CAgBhC,KAAC/D;4BAAWgH,SAAQ;4BAAQC,OAAM;sCAC/BvC,UAAU,gDAAgD;;wBAG9Dd,KAAKsD,IAAI,CAAC,CAACpD,MAAQ,CAACA,IAAIC,GAAG,kBAC1B,KAAC/D;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC;6BAED;wBAEHtC,wBACC,KAAC3E;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC,GAAGtC,QAAQ,sCAAsC,CAAC;6BAEnD;;;;;;AAKd;AACAjD,aAAayF,WAAW,GAAG;AAE3B,eAAezF,aAAY"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ export interface ScrollEdges {
18
+ /** Callback ref for the box that scrolls sideways. */
19
+ ref: (node: HTMLElement | null) => void;
20
+ /** Whether there is more to the left of what is in view. */
21
+ start: boolean;
22
+ /** Whether there is more to the right of what is in view. */
23
+ end: boolean;
24
+ /** Scrolls most of a view's width toward the start (`-1`) or the end (`1`). */
25
+ page: (direction: -1 | 1) => void;
26
+ }
27
+ /**
28
+ * Which way a sideways-scrolling box has more to show, and a way to page
29
+ * it — the facts a pair of scroll arrows needs, the way MUI's Tabs draws
30
+ * them. A scrollbar alone says nothing on a trackpad Mac, where it stays
31
+ * hidden until the reader already knows to scroll.
32
+ *
33
+ * Re-measured on scroll and whenever the box or its content changes size,
34
+ * so a column collapsing, a deal arriving or the window narrowing moves the
35
+ * arrows with it. A page is most of a view rather than all of it, so the
36
+ * column at the edge stays in sight as a landmark.
37
+ */
38
+ export declare function useScrollEdges(): ScrollEdges;