@burdenoff/microfe-planmagnet 2026.930.22 → 2026.930.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +6 -5
- package/dist/planmagnet/PlanMagnetRoot.d.ts.map +1 -1
- package/dist/planmagnet/PlanMagnetRoot.js +154 -147
- package/dist/planmagnet/PlanMagnetRoot.js.map +1 -1
- package/dist/planmagnet/PlanMagnetRoutes.d.ts.map +1 -1
- package/dist/planmagnet/PlanMagnetRoutes.js +66 -62
- package/dist/planmagnet/PlanMagnetRoutes.js.map +1 -1
- package/dist/planmagnet/controls/FeatureControls.d.ts +11 -0
- package/dist/planmagnet/controls/FeatureControls.d.ts.map +1 -1
- package/dist/planmagnet/controls/FeatureControls.js +305 -135
- package/dist/planmagnet/controls/FeatureControls.js.map +1 -1
- package/dist/planmagnet/controls/Pickers.d.ts +18 -0
- package/dist/planmagnet/controls/Pickers.d.ts.map +1 -1
- package/dist/planmagnet/controls/Pickers.js +78 -1
- package/dist/planmagnet/controls/Pickers.js.map +1 -1
- package/dist/planmagnet/controls/PrioritizationControls.d.ts +72 -0
- package/dist/planmagnet/controls/PrioritizationControls.d.ts.map +1 -0
- package/dist/planmagnet/controls/PrioritizationControls.js +246 -0
- package/dist/planmagnet/controls/PrioritizationControls.js.map +1 -0
- package/dist/planmagnet/dependency/DependencyGraph.d.ts.map +1 -1
- package/dist/planmagnet/dependency/DependencyGraph.js +5 -2
- package/dist/planmagnet/dependency/DependencyGraph.js.map +1 -1
- package/dist/planmagnet/dependency/GanttChart.d.ts.map +1 -1
- package/dist/planmagnet/dependency/GanttChart.js +11 -5
- package/dist/planmagnet/dependency/GanttChart.js.map +1 -1
- package/dist/planmagnet/index.d.ts +1 -0
- package/dist/planmagnet/index.d.ts.map +1 -1
- package/dist/planmagnet/index.js +1 -0
- package/dist/planmagnet/pages/ProductsFeatureDetailPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProductsFeatureDetailPagePage.js +131 -125
- package/dist/planmagnet/pages/ProductsFeatureDetailPagePage.js.map +1 -1
- package/dist/planmagnet/pages/ProductsFeaturesPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProductsFeaturesPagePage.js +183 -99
- package/dist/planmagnet/pages/ProductsFeaturesPagePage.js.map +1 -1
- package/dist/planmagnet/pages/ProductsNewFeaturePagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProductsNewFeaturePagePage.js +113 -97
- package/dist/planmagnet/pages/ProductsNewFeaturePagePage.js.map +1 -1
- package/dist/planmagnet/pages/ProductsPrioritizationPagePage.d.ts +2 -0
- package/dist/planmagnet/pages/ProductsPrioritizationPagePage.d.ts.map +1 -0
- package/dist/planmagnet/pages/ProductsPrioritizationPagePage.js +363 -0
- package/dist/planmagnet/pages/ProductsPrioritizationPagePage.js.map +1 -0
- package/dist/planmagnet/pages/ProductsProductDetailPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProductsProductDetailPagePage.js +96 -79
- package/dist/planmagnet/pages/ProductsProductDetailPagePage.js.map +1 -1
- package/dist/planmagnet/pages/ProductsRoadmapDetailPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProductsRoadmapDetailPagePage.js +133 -123
- package/dist/planmagnet/pages/ProductsRoadmapDetailPagePage.js.map +1 -1
- package/dist/planmagnet/pages/analytics/StatusFlowCard.js +5 -2
- package/dist/planmagnet/pages/analytics/StatusFlowCard.js.map +1 -1
- package/dist/planmagnet/pages/analytics/WorkBreakdownCard.js +5 -2
- package/dist/planmagnet/pages/analytics/WorkBreakdownCard.js.map +1 -1
- package/dist/planmagnet/product/ProductControls.d.ts +33 -0
- package/dist/planmagnet/product/ProductControls.d.ts.map +1 -1
- package/dist/planmagnet/product/ProductControls.js +593 -268
- package/dist/planmagnet/product/ProductControls.js.map +1 -1
- package/dist/planmagnet/product/routes.d.ts +10 -0
- package/dist/planmagnet/product/routes.d.ts.map +1 -0
- package/dist/planmagnet/product/routes.js +6 -0
- package/dist/planmagnet/product/routes.js.map +1 -0
- package/dist/planmagnet/roadmaps/RoadmapControls.d.ts +10 -0
- package/dist/planmagnet/roadmaps/RoadmapControls.d.ts.map +1 -1
- package/dist/planmagnet/roadmaps/RoadmapControls.js +235 -184
- package/dist/planmagnet/roadmaps/RoadmapControls.js.map +1 -1
- package/dist/planmagnet/utils/statusColor.d.ts +8 -9
- package/dist/planmagnet/utils/statusColor.d.ts.map +1 -1
- package/dist/planmagnet/utils/statusColor.js +8 -40
- package/dist/planmagnet/utils/statusColor.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"StatusFlowCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/StatusFlowCard.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { Workflow } from 'lucide-react';\nimport { ResponsiveContainer, Sankey, Tooltip } from 'recharts';\nimport { WORK_ITEM_STATUSES, enumLabel } from '../../controls/WorkItemControls';\nimport { statusVisual } from '../../utils/statusColor';\nimport { isForbidden } from '../../utils/graphqlErrors';\n\n/**\n * ANR-24: status-flow Sankey (wspace-planmagnet-svc#65 `planmagnetStatusFlow`) — aggregated status-to-status\n * transition counts for one project, over a date window capped server-side at 366 days.\n *\n * recharts' Sankey lays its nodes out by depth, walked outward from every node with no incoming link\n * (`getNodesTree` in recharts' own source), with NO cycle guard — a real transition graph almost always has\n * cycles (e.g. IN_REVIEW -> IN_PROGRESS -> IN_REVIEW, a change request), which would recurse forever. The fix:\n * every status becomes TWO Sankey nodes, a \"from\" one and a \"to\" one, so every link only ever points from the\n * \"from\" column to the \"to\" column — a strict two-level DAG by construction, however tangled the underlying\n * status graph is. This is a display transform only; it does not change what the data means (every link is\n * still exactly one observed fromStatus -> toStatus edge, whichever status is on which side of the column).\n */\n\ntype WorkItemStatusValue = (typeof WORK_ITEM_STATUSES)[number];\n\nconst GET_STATUS_FLOW = gql`\n query GetAnalyticsStatusFlow(\n $workspaceId: ID!\n $projectId: ID!\n $from: DateTime!\n $to: DateTime!\n ) {\n planmagnetStatusFlow(workspaceId: $workspaceId, projectId: $projectId, from: $from, to: $to) {\n fromStatus\n toStatus\n count\n }\n }\n`;\n\ninterface StatusFlowEdge {\n fromStatus: WorkItemStatusValue;\n toStatus: WorkItemStatusValue;\n count: number;\n}\n\ninterface StatusFlowData {\n planmagnetStatusFlow: StatusFlowEdge[];\n}\n\ninterface FlowNodeDatum {\n name: string;\n status: WorkItemStatusValue;\n side: 'from' | 'to';\n}\n\ninterface FlowLinkDatum {\n source: number;\n target: number;\n value: number;\n}\n\ninterface FlowLinkPayload {\n source: FlowNodeDatum;\n target: FlowNodeDatum;\n value: number;\n}\n\n/** The server's own cap (see the field's doc comment in graphql/wspace.graphql) — the UI never sends a wider\n * window, and says so when the page's own range would have asked for one. */\nconst MAX_WINDOW_DAYS = 366;\nconst MAX_WINDOW_MS = MAX_WINDOW_DAYS * 86_400_000;\n\nfunction clampWindow(\n startDate: string,\n endDate: string\n): { from: string; to: string; clamped: boolean } {\n const startMs = Date.parse(startDate);\n const endMs = Date.parse(endDate);\n if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs - startMs <= MAX_WINDOW_MS) {\n return { from: startDate, to: endDate, clamped: false };\n }\n return { from: new Date(endMs - MAX_WINDOW_MS).toISOString(), to: endDate, clamped: true };\n}\n\n/** Every status that actually appears becomes a \"from\" node (left column) and/or a \"to\" node (right column), in\n * the app's canonical status order — never in whatever order the API happened to return edges. */\nfunction buildFlow(edges: StatusFlowEdge[]): { nodes: FlowNodeDatum[]; links: FlowLinkDatum[] } {\n const fromStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.fromStatus === s));\n const toStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.toStatus === s));\n const fromIndex = new Map(fromStatuses.map((s, i) => [s, i]));\n const toIndex = new Map(toStatuses.map((s, i) => [s, fromStatuses.length + i]));\n\n const nodes: FlowNodeDatum[] = [\n ...fromStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'from' as const })),\n ...toStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'to' as const })),\n ];\n\n const links: FlowLinkDatum[] = edges.flatMap((e) => {\n const source = fromIndex.get(e.fromStatus);\n const target = toIndex.get(e.toStatus);\n // Every edge's statuses come straight from fromStatuses/toStatuses above, so this can never actually miss —\n // the check just keeps the result type honest without a non-null assertion.\n return source === undefined || target === undefined ? [] : [{ source, target, value: e.count }];\n });\n\n return { nodes, links };\n}\n\ninterface FlowNodeProps {\n x: number;\n y: number;\n width: number;\n height: number;\n payload: FlowNodeDatum;\n}\n\nfunction FlowNode({ x, y, width, height, payload }: FlowNodeProps) {\n const visual = statusVisual(payload.status);\n const isFrom = payload.side === 'from';\n return (\n <g>\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={2}\n className={`${visual.fill} ${visual.stroke}`}\n strokeWidth={1}\n />\n <text\n x={isFrom ? x - 6 : x + width + 6}\n y={y + height / 2}\n dy=\"0.32em\"\n textAnchor={isFrom ? 'end' : 'start'}\n className=\"fill-foreground text-[11px]\"\n >\n {payload.name}\n </text>\n </g>\n );\n}\n\ninterface FlowLinkProps {\n sourceX: number;\n sourceY: number;\n targetX: number;\n targetY: number;\n sourceControlX: number;\n targetControlX: number;\n linkWidth: number;\n payload: FlowLinkPayload;\n}\n\nfunction FlowLink({\n sourceX,\n sourceY,\n targetX,\n targetY,\n sourceControlX,\n targetControlX,\n linkWidth,\n payload,\n}: FlowLinkProps) {\n // Colored by the transition's origin status — the same fixed status->color mapping every other visualization\n // in this app (Gantt, dependency graph) uses, read via statusVisual rather than a second color table.\n const visual = statusVisual(payload.source.status);\n return (\n <path\n d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}\n className={visual.stroke}\n strokeWidth={Math.max(linkWidth, 1)}\n strokeOpacity={0.45}\n fill=\"none\"\n />\n );\n}\n\nexport function StatusFlowCard({\n workspaceId,\n projectId,\n startDate,\n endDate,\n}: {\n workspaceId?: string | null;\n /** Status flow is always one project (`projectId: ID!` on the server) — same convention as CycleTimeCard. */\n projectId: string;\n startDate: string;\n endDate: string;\n}) {\n const { t } = useI18n();\n const clamp = useMemo(() => clampWindow(startDate, endDate), [startDate, endDate]);\n const { data, loading, error } = useQuery<StatusFlowData>(GET_STATUS_FLOW, {\n variables: { workspaceId, projectId, from: clamp.from, to: clamp.to },\n skip: !workspaceId || !projectId,\n });\n // Recomputed straight from `data` on every render rather than behind its own useMemo: at most a handful of\n // statuses and edges, and `?? []` below would hand memoization a fresh array identity every render anyway —\n // same call this page's own project/sprint default-picking makes for a similarly small list.\n const edges = data?.planmagnetStatusFlow ?? [];\n const { nodes, links } = buildFlow(edges);\n const sortedEdges = [...edges].sort((a, b) => b.count - a.count);\n\n return (\n <Card data-testid=\"status-flow-card\">\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Workflow className=\"h-4 w-4\" />\n {t('planmagnet.analytics.statusFlow.title', 'Status flow')}\n </CardTitle>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.subtitle',\n 'How work items moved between statuses in the selected range.'\n )}\n </p>\n {clamp.clamped && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.statusFlow.clamped',\n 'Status flow is capped at a {{days}}-day window — showing the most recent {{days}} days of the selected range (from {{from}}).',\n { days: MAX_WINDOW_DAYS, from: clamp.from.slice(0, 10) }\n )}\n </p>\n )}\n </CardHeader>\n <CardContent>\n {!projectId ? (\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.noProject',\n 'Select a project to see its status flow.'\n )}\n </p>\n ) : loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.statusFlow.forbidden',\n 'You do not have access to status flow for this project.'\n )\n : `${t('planmagnet.analytics.statusFlow.error', 'Status flow could not be loaded')}: ${error.message}`}\n </p>\n ) : edges.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.statusFlow.empty',\n 'No status changes recorded for this project in the selected range.'\n )}\n </p>\n ) : (\n <div className=\"space-y-4\">\n <div data-testid=\"viz-status-flow\">\n <ResponsiveContainer width=\"100%\" height={Math.max(260, nodes.length * 24)}>\n <Sankey\n data={{ nodes, links }}\n node={FlowNode}\n link={FlowLink}\n nodeWidth={10}\n nodePadding={16}\n linkCurvature={0.5}\n margin={{ top: 8, right: 90, bottom: 8, left: 90 }}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // recharts types a Sankey tooltip payload's inner `.payload` as `any` — it nests our own\n // node/link datum one level below recharts' own node/link render-props wrapper. Narrowed\n // here, once, rather than threading `any` through the render below.\n const el = payload[0]?.payload as\n | { payload?: FlowNodeDatum | FlowLinkPayload }\n | undefined;\n const inner = el?.payload;\n if (!inner) return null;\n if ('source' in inner) {\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">\n {inner.source.name} → {inner.target.name}\n </p>\n <p className=\"text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.tooltipCount',\n '{{count}} item(s)',\n {\n count: inner.value,\n }\n )}\n </p>\n </div>\n );\n }\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{inner.name}</p>\n </div>\n );\n }}\n />\n </Sankey>\n </ResponsiveContainer>\n </div>\n\n {/* Accessible table alternative — every edge the Sankey draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-status-flow-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t('planmagnet.analytics.statusFlow.tableCaption', 'Status transitions')}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.from', 'From')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.to', 'To')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.statusFlow.count', 'Count')}\n </th>\n </tr>\n </thead>\n <tbody>\n {sortedEdges.map((e) => (\n <tr key={`${e.fromStatus}-${e.toStatus}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{enumLabel(e.fromStatus)}</td>\n <td className=\"py-1.5 pr-2\">{enumLabel(e.toStatus)}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{e.count}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"StatusFlowCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/StatusFlowCard.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { Workflow } from 'lucide-react';\nimport { ResponsiveContainer, Sankey, Tooltip } from 'recharts';\nimport { WORK_ITEM_STATUSES, enumLabel } from '../../controls/WorkItemControls';\nimport { statusVisual } from '../../utils/statusColor';\nimport { isForbidden } from '../../utils/graphqlErrors';\n\n/**\n * ANR-24: status-flow Sankey (wspace-planmagnet-svc#65 `planmagnetStatusFlow`) — aggregated status-to-status\n * transition counts for one project, over a date window capped server-side at 366 days.\n *\n * recharts' Sankey lays its nodes out by depth, walked outward from every node with no incoming link\n * (`getNodesTree` in recharts' own source), with NO cycle guard — a real transition graph almost always has\n * cycles (e.g. IN_REVIEW -> IN_PROGRESS -> IN_REVIEW, a change request), which would recurse forever. The fix:\n * every status becomes TWO Sankey nodes, a \"from\" one and a \"to\" one, so every link only ever points from the\n * \"from\" column to the \"to\" column — a strict two-level DAG by construction, however tangled the underlying\n * status graph is. This is a display transform only; it does not change what the data means (every link is\n * still exactly one observed fromStatus -> toStatus edge, whichever status is on which side of the column).\n */\n\ntype WorkItemStatusValue = (typeof WORK_ITEM_STATUSES)[number];\n\nconst GET_STATUS_FLOW = gql`\n query GetAnalyticsStatusFlow(\n $workspaceId: ID!\n $projectId: ID!\n $from: DateTime!\n $to: DateTime!\n ) {\n planmagnetStatusFlow(workspaceId: $workspaceId, projectId: $projectId, from: $from, to: $to) {\n fromStatus\n toStatus\n count\n }\n }\n`;\n\ninterface StatusFlowEdge {\n fromStatus: WorkItemStatusValue;\n toStatus: WorkItemStatusValue;\n count: number;\n}\n\ninterface StatusFlowData {\n planmagnetStatusFlow: StatusFlowEdge[];\n}\n\ninterface FlowNodeDatum {\n name: string;\n status: WorkItemStatusValue;\n side: 'from' | 'to';\n}\n\ninterface FlowLinkDatum {\n source: number;\n target: number;\n value: number;\n}\n\ninterface FlowLinkPayload {\n source: FlowNodeDatum;\n target: FlowNodeDatum;\n value: number;\n}\n\n/** The server's own cap (see the field's doc comment in graphql/wspace.graphql) — the UI never sends a wider\n * window, and says so when the page's own range would have asked for one. */\nconst MAX_WINDOW_DAYS = 366;\nconst MAX_WINDOW_MS = MAX_WINDOW_DAYS * 86_400_000;\n\nfunction clampWindow(\n startDate: string,\n endDate: string\n): { from: string; to: string; clamped: boolean } {\n const startMs = Date.parse(startDate);\n const endMs = Date.parse(endDate);\n if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs - startMs <= MAX_WINDOW_MS) {\n return { from: startDate, to: endDate, clamped: false };\n }\n return { from: new Date(endMs - MAX_WINDOW_MS).toISOString(), to: endDate, clamped: true };\n}\n\n/** Every status that actually appears becomes a \"from\" node (left column) and/or a \"to\" node (right column), in\n * the app's canonical status order — never in whatever order the API happened to return edges. */\nfunction buildFlow(edges: StatusFlowEdge[]): { nodes: FlowNodeDatum[]; links: FlowLinkDatum[] } {\n const fromStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.fromStatus === s));\n const toStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.toStatus === s));\n const fromIndex = new Map(fromStatuses.map((s, i) => [s, i]));\n const toIndex = new Map(toStatuses.map((s, i) => [s, fromStatuses.length + i]));\n\n const nodes: FlowNodeDatum[] = [\n ...fromStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'from' as const })),\n ...toStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'to' as const })),\n ];\n\n const links: FlowLinkDatum[] = edges.flatMap((e) => {\n const source = fromIndex.get(e.fromStatus);\n const target = toIndex.get(e.toStatus);\n // Every edge's statuses come straight from fromStatuses/toStatuses above, so this can never actually miss —\n // the check just keeps the result type honest without a non-null assertion.\n return source === undefined || target === undefined ? [] : [{ source, target, value: e.count }];\n });\n\n return { nodes, links };\n}\n\ninterface FlowNodeProps {\n x: number;\n y: number;\n width: number;\n height: number;\n payload: FlowNodeDatum;\n}\n\nfunction FlowNode({ x, y, width, height, payload }: FlowNodeProps) {\n const visual = statusVisual(payload.status);\n const isFrom = payload.side === 'from';\n return (\n <g>\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={2}\n style={{ fill: visual.color, stroke: visual.color }}\n strokeWidth={1}\n />\n <text\n x={isFrom ? x - 6 : x + width + 6}\n y={y + height / 2}\n dy=\"0.32em\"\n textAnchor={isFrom ? 'end' : 'start'}\n className=\"fill-foreground text-[11px]\"\n >\n {payload.name}\n </text>\n </g>\n );\n}\n\ninterface FlowLinkProps {\n sourceX: number;\n sourceY: number;\n targetX: number;\n targetY: number;\n sourceControlX: number;\n targetControlX: number;\n linkWidth: number;\n payload: FlowLinkPayload;\n}\n\nfunction FlowLink({\n sourceX,\n sourceY,\n targetX,\n targetY,\n sourceControlX,\n targetControlX,\n linkWidth,\n payload,\n}: FlowLinkProps) {\n // Colored by the transition's origin status — the same fixed status->color mapping every other visualization\n // in this app (Gantt, dependency graph) uses, read via statusVisual rather than a second color table.\n const visual = statusVisual(payload.source.status);\n return (\n <path\n d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}\n style={{ stroke: visual.color }}\n strokeWidth={Math.max(linkWidth, 1)}\n strokeOpacity={0.45}\n fill=\"none\"\n />\n );\n}\n\nexport function StatusFlowCard({\n workspaceId,\n projectId,\n startDate,\n endDate,\n}: {\n workspaceId?: string | null;\n /** Status flow is always one project (`projectId: ID!` on the server) — same convention as CycleTimeCard. */\n projectId: string;\n startDate: string;\n endDate: string;\n}) {\n const { t } = useI18n();\n const clamp = useMemo(() => clampWindow(startDate, endDate), [startDate, endDate]);\n const { data, loading, error } = useQuery<StatusFlowData>(GET_STATUS_FLOW, {\n variables: { workspaceId, projectId, from: clamp.from, to: clamp.to },\n skip: !workspaceId || !projectId,\n });\n // Recomputed straight from `data` on every render rather than behind its own useMemo: at most a handful of\n // statuses and edges, and `?? []` below would hand memoization a fresh array identity every render anyway —\n // same call this page's own project/sprint default-picking makes for a similarly small list.\n const edges = data?.planmagnetStatusFlow ?? [];\n const { nodes, links } = buildFlow(edges);\n const sortedEdges = [...edges].sort((a, b) => b.count - a.count);\n\n return (\n <Card data-testid=\"status-flow-card\">\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Workflow className=\"h-4 w-4\" />\n {t('planmagnet.analytics.statusFlow.title', 'Status flow')}\n </CardTitle>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.subtitle',\n 'How work items moved between statuses in the selected range.'\n )}\n </p>\n {clamp.clamped && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.statusFlow.clamped',\n 'Status flow is capped at a {{days}}-day window — showing the most recent {{days}} days of the selected range (from {{from}}).',\n { days: MAX_WINDOW_DAYS, from: clamp.from.slice(0, 10) }\n )}\n </p>\n )}\n </CardHeader>\n <CardContent>\n {!projectId ? (\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.noProject',\n 'Select a project to see its status flow.'\n )}\n </p>\n ) : loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.statusFlow.forbidden',\n 'You do not have access to status flow for this project.'\n )\n : `${t('planmagnet.analytics.statusFlow.error', 'Status flow could not be loaded')}: ${error.message}`}\n </p>\n ) : edges.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.statusFlow.empty',\n 'No status changes recorded for this project in the selected range.'\n )}\n </p>\n ) : (\n <div className=\"space-y-4\">\n <div data-testid=\"viz-status-flow\">\n <ResponsiveContainer width=\"100%\" height={Math.max(260, nodes.length * 24)}>\n <Sankey\n data={{ nodes, links }}\n node={FlowNode}\n link={FlowLink}\n nodeWidth={10}\n nodePadding={16}\n linkCurvature={0.5}\n margin={{ top: 8, right: 90, bottom: 8, left: 90 }}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // recharts types a Sankey tooltip payload's inner `.payload` as `any` — it nests our own\n // node/link datum one level below recharts' own node/link render-props wrapper. Narrowed\n // here, once, rather than threading `any` through the render below.\n const el = payload[0]?.payload as\n | { payload?: FlowNodeDatum | FlowLinkPayload }\n | undefined;\n const inner = el?.payload;\n if (!inner) return null;\n if ('source' in inner) {\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">\n {inner.source.name} → {inner.target.name}\n </p>\n <p className=\"text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.tooltipCount',\n '{{count}} item(s)',\n {\n count: inner.value,\n }\n )}\n </p>\n </div>\n );\n }\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{inner.name}</p>\n </div>\n );\n }}\n />\n </Sankey>\n </ResponsiveContainer>\n </div>\n\n {/* Accessible table alternative — every edge the Sankey draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-status-flow-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t('planmagnet.analytics.statusFlow.tableCaption', 'Status transitions')}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.from', 'From')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.to', 'To')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.statusFlow.count', 'Count')}\n </th>\n </tr>\n </thead>\n <tbody>\n {sortedEdges.map((e) => (\n <tr key={`${e.fromStatus}-${e.toStatus}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{enumLabel(e.fromStatus)}</td>\n <td className=\"py-1.5 pr-2\">{enumLabel(e.toStatus)}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{e.count}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
|
|
@@ -142,8 +142,11 @@ function T(e) {
|
|
|
142
142
|
y: n,
|
|
143
143
|
width: r,
|
|
144
144
|
height: i,
|
|
145
|
-
className: "
|
|
146
|
-
style: {
|
|
145
|
+
className: "stroke-background",
|
|
146
|
+
style: {
|
|
147
|
+
fill: "var(--color-chart-2)",
|
|
148
|
+
fillOpacity: w(m)
|
|
149
|
+
},
|
|
147
150
|
strokeWidth: 2
|
|
148
151
|
}), x = v && /* @__PURE__ */ d("text", {
|
|
149
152
|
x: t + 4,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WorkBreakdownCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/WorkBreakdownCard.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { LayoutGrid } from 'lucide-react';\nimport { ResponsiveContainer, Tooltip, Treemap } from 'recharts';\nimport { usePlanMagnetNavigate } from '../../providers/PlanMagnetProvider';\nimport { rowLinkProps } from '../../utils/rowNavigation';\nimport { isForbidden } from '../../utils/graphqlErrors';\nimport { truncate } from '../../dependency/DependencyGraph';\n\n/**\n * ANR-24: work breakdown Treemap (wspace-planmagnet-svc#65 `planmagnetWorkBreakdown`) — live (not date-ranged;\n * see the field's own doc comment) open/done counts grouped project -> epic, with a \"No epic\" bucket per\n * project for items an epic rollup doesn't reach (see PlanmagnetNoEpicWorkBreakdown's doc comment: direct\n * children only, same as PlanmagnetEpicWorkBreakdown — a sub-task nested two levels under an epic falls here\n * too, not under its grandparent epic).\n *\n * recharts' Treemap computes a node's own size as the SUM of its children's sizes when `node.children` is set\n * (see `computeNode` in recharts' source) — so each project's own cell size, and its done-ratio color, both\n * come from summing this card's own visualized epic + \"No epic\" leaves, not from the server's separate\n * project-level totals (which include every live item in the project, not only the ones a leaf here\n * represents — see PlanmagnetProjectWorkBreakdown's doc comment). What the treemap draws and what the table\n * below it lists are therefore always the same numbers.\n */\n\ntype BreakdownMetric = 'items' | 'points';\n\nconst GET_WORK_BREAKDOWN = gql`\n query GetAnalyticsWorkBreakdown($workspaceId: ID!, $projectIds: [ID!]) {\n planmagnetWorkBreakdown(workspaceId: $workspaceId, projectIds: $projectIds) {\n truncatedProjects\n projects {\n projectId\n projectName\n truncatedEpics\n epics {\n epicId\n epicTitle\n openCount\n doneCount\n openPoints\n donePoints\n }\n noEpic {\n openCount\n doneCount\n openPoints\n donePoints\n }\n }\n }\n }\n`;\n\ninterface EpicBreakdown {\n epicId: string;\n epicTitle: string;\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface NoEpicBreakdown {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface ProjectBreakdown {\n projectId: string;\n projectName: string;\n truncatedEpics: boolean;\n epics: EpicBreakdown[];\n noEpic: NoEpicBreakdown;\n}\n\ninterface WorkBreakdownData {\n planmagnetWorkBreakdown: {\n truncatedProjects: boolean;\n projects: ProjectBreakdown[];\n };\n}\n\ninterface CountsLike {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\nfunction metricTotals(c: CountsLike, metric: BreakdownMetric): { size: number; done: number } {\n return metric === 'items'\n ? { size: c.openCount + c.doneCount, done: c.doneCount }\n : { size: c.openPoints + c.donePoints, done: c.donePoints };\n}\n\nfunction doneRatioOf(c: CountsLike, metric: BreakdownMetric): number | null {\n const { size, done } = metricTotals(c, metric);\n return size > 0 ? done / size : null;\n}\n\ninterface BreakdownLeafDatum extends CountsLike {\n name: string;\n size: number;\n doneRatio: number | null;\n /** null = not an individually openable work item (the project-group frame, or the \"No epic\" bucket). */\n epicId: string | null;\n isNoEpic: boolean;\n isHeader: boolean;\n projectId: string;\n projectName: string;\n}\n\ninterface BreakdownGroupDatum {\n name: string;\n projectId: string;\n children: BreakdownLeafDatum[];\n}\n\n/** A label cell reserves this share of each project's own area (never less than HEADER_MIN_SIZE). Small relative\n * to its real epic siblings so it barely affects the epics' own visual weight, but consistently sized so every\n * project's header reads the same size. */\nconst HEADER_FRACTION = 0.14;\nconst HEADER_MIN_SIZE = 1;\n\nfunction buildBreakdownGroups(\n projects: ProjectBreakdown[],\n metric: BreakdownMetric,\n noEpicLabel: string\n): BreakdownGroupDatum[] {\n const groups: BreakdownGroupDatum[] = [];\n\n for (const project of projects) {\n const epicLeaves: BreakdownLeafDatum[] = project.epics\n .map((epic) => ({\n name: epic.epicTitle,\n size: metricTotals(epic, metric).size,\n doneRatio: doneRatioOf(epic, metric),\n epicId: epic.epicId,\n isNoEpic: false,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: epic.openCount,\n doneCount: epic.doneCount,\n openPoints: epic.openPoints,\n donePoints: epic.donePoints,\n }))\n .filter((leaf) => leaf.size > 0);\n\n const noEpicSize = metricTotals(project.noEpic, metric).size;\n if (noEpicSize > 0) {\n epicLeaves.push({\n name: noEpicLabel,\n size: noEpicSize,\n doneRatio: doneRatioOf(project.noEpic, metric),\n epicId: null,\n isNoEpic: true,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: project.noEpic.openCount,\n doneCount: project.noEpic.doneCount,\n openPoints: project.noEpic.openPoints,\n donePoints: project.noEpic.donePoints,\n });\n }\n\n const totalSize = epicLeaves.reduce((sum, leaf) => sum + leaf.size, 0);\n if (totalSize <= 0) continue;\n\n // A small leading child in a list of much-larger siblings reliably becomes its own single-item row under\n // squarify's row-packing (recharts' Treemap layout): adding the next, much bigger sibling to that same row\n // would immediately worsen the row's aspect ratio, so squarify closes the row after just this one item —\n // which a single-item row always stretches to the full width (or height) of its parent rect. That makes\n // this synthetic \"header\" leaf a reliable label strip across the top (or side) of each project's own area,\n // without needing a second, hand-rolled layout pass. See WorkBreakdownCell's `isHeader` branch for the\n // render side of this.\n const headerLeaf: BreakdownLeafDatum = {\n name: project.projectName,\n size: Math.max(Math.round(totalSize * HEADER_FRACTION), HEADER_MIN_SIZE),\n doneRatio: null,\n epicId: null,\n isNoEpic: false,\n isHeader: true,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: 0,\n doneCount: 0,\n openPoints: 0,\n donePoints: 0,\n };\n\n groups.push({\n name: project.projectName,\n projectId: project.projectId,\n children: [headerLeaf, ...epicLeaves],\n });\n }\n\n return groups;\n}\n\nfunction ratioToFillOpacity(doneRatio: number | null): number {\n // Sequential, single-hue encoding on DONE's own chart color (the same hue statusVisual.ts assigns to the DONE\n // status everywhere else in this app): lighter = mostly open, darker = mostly done. Never fully transparent,\n // so a 0%-done cell still reads as a populated cell rather than empty space — and the exact ratio is always\n // available in the tooltip and the table below, so this fill is a supplementary channel, never the only way\n // to read it.\n if (doneRatio === null) return 0.12;\n return 0.18 + doneRatio * 0.72;\n}\n\ninterface WorkBreakdownCellProps {\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n depth?: number;\n name?: string;\n isHeader?: boolean;\n isNoEpic?: boolean;\n epicId?: string | null;\n doneRatio?: number | null;\n onOpenEpic: (epicId: string) => void;\n noEpicLabel: string;\n}\n\nfunction WorkBreakdownCell(props: WorkBreakdownCellProps) {\n const {\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n depth = 0,\n name = '',\n isHeader = false,\n isNoEpic = false,\n epicId = null,\n doneRatio = null,\n onOpenEpic,\n noEpicLabel,\n } = props;\n\n if (width <= 0 || height <= 0) return null;\n\n if (depth === 0) {\n // recharts wraps the ENTIRE `data` array (every project combined) in one synthetic root at depth 0\n // (see `computeNode` / `getDerivedStateFromProps` in recharts' own Treemap source) — this fires exactly\n // once for the whole chart area, never once per project. It has no boundary of its own to draw (a rect\n // here would be one border around every project combined, not a per-project frame), so render nothing.\n return null;\n }\n\n if (depth === 1) {\n // The real per-project frame: recharts assigns depth 1 to each top-level entry of `data` — i.e. one\n // `BreakdownGroupDatum` per project — so this branch fires once per project, at that project's own\n // bounds. Drawn first (recharts renders a non-leaf node's own content, then its children, in that\n // order), so the header + epic leaves at depth 2 paint on top of it within the same bounds.\n // Never filled: fill is reserved for done-ratio below, so the frame reads purely as a boundary.\n return (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={4}\n className=\"fill-none stroke-border\"\n strokeWidth={1}\n />\n );\n }\n\n const label = isNoEpic ? noEpicLabel : name;\n const showLabel = width > 28 && height > 14;\n const fitChars = Math.max(1, Math.floor((width - 8) / 6));\n const displayLabel = truncate(label, fitChars);\n\n if (isHeader) {\n return (\n <g>\n <rect x={x} y={y} width={width} height={height} className=\"fill-muted\" />\n {showLabel && (\n <text\n x={x + 4}\n y={y + height / 2}\n dy=\"0.32em\"\n className=\"fill-foreground text-[11px] font-medium\"\n >\n {displayLabel}\n </text>\n )}\n </g>\n );\n }\n\n const cellRect = (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n className=\"fill-chart-2 stroke-background\"\n style={{ fillOpacity: ratioToFillOpacity(doneRatio) }}\n strokeWidth={2}\n />\n );\n const cellLabel = showLabel && (\n <text x={x + 4} y={y + height / 2} dy=\"0.32em\" className=\"fill-foreground text-[11px]\">\n {displayLabel}\n </text>\n );\n\n if (!epicId) {\n // \"No epic\" bucket — an aggregate of several work items, not one item, so there is nothing to navigate to.\n return (\n <g>\n {cellRect}\n {cellLabel}\n </g>\n );\n }\n\n const accessibleLabel = `${name} — ${Math.round((doneRatio ?? 0) * 100)}% done`;\n return (\n <g\n {...rowLinkProps<SVGGElement>(() => onOpenEpic(epicId), accessibleLabel)}\n className=\"cursor-pointer focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2\"\n >\n <title>{accessibleLabel}</title>\n {cellRect}\n {cellLabel}\n </g>\n );\n}\n\nexport function WorkBreakdownCard({\n workspaceId,\n projectId,\n}: {\n workspaceId?: string | null;\n /** ANR-09 project scope. Omitted/undefined = every project (up to 50 — see truncatedProjects below). */\n projectId?: string;\n}) {\n const { t } = useI18n();\n const navigate = usePlanMagnetNavigate();\n const [metric, setMetric] = useState<BreakdownMetric>('items');\n const { data, loading, error } = useQuery<WorkBreakdownData>(GET_WORK_BREAKDOWN, {\n variables: { workspaceId, projectIds: projectId ? [projectId] : undefined },\n skip: !workspaceId,\n });\n\n const noEpicLabel = t('planmagnet.analytics.workBreakdown.noEpic', 'No epic');\n const breakdown = data?.planmagnetWorkBreakdown;\n const projects = breakdown?.projects ?? [];\n const groups = buildBreakdownGroups(projects, metric, noEpicLabel);\n const truncatedProjectNames = projects.filter((p) => p.truncatedEpics).map((p) => p.projectName);\n\n function openEpic(epicId: string) {\n navigate(`/tasks/task-detail-page?taskId=${encodeURIComponent(epicId)}`);\n }\n\n return (\n <Card data-testid=\"work-breakdown-card\">\n <CardHeader>\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <CardTitle className=\"flex items-center gap-2\">\n <LayoutGrid className=\"h-4 w-4\" />\n {t('planmagnet.analytics.workBreakdown.title', 'Work breakdown')}\n </CardTitle>\n <div\n className=\"flex items-center gap-1 rounded-md border p-0.5\"\n role=\"group\"\n aria-label={t('planmagnet.analytics.workBreakdown.metricLabel', 'Size by')}\n data-testid=\"viz-breakdown-metric\"\n >\n {(['items', 'points'] as const).map((m) => (\n <button\n key={m}\n type=\"button\"\n aria-pressed={metric === m}\n onClick={() => setMetric(m)}\n className={`px-2 py-1 text-xs rounded ${\n metric === m\n ? 'bg-primary text-primary-foreground'\n : 'text-muted-foreground hover:bg-muted'\n }`}\n >\n {m === 'items'\n ? t('planmagnet.analytics.workBreakdown.items', 'Items')\n : t('planmagnet.analytics.workBreakdown.points', 'Story points')}\n </button>\n ))}\n </div>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.subtitle',\n 'Live open/done work, by project and epic — current state, not limited by the date range above.'\n )}\n </p>\n </CardHeader>\n <CardContent>\n {loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.workBreakdown.forbidden',\n 'You do not have access to the work breakdown for this scope.'\n )\n : `${t('planmagnet.analytics.workBreakdown.error', 'Work breakdown could not be loaded')}: ${error.message}`}\n </p>\n ) : groups.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.workBreakdown.empty',\n 'No live work items found for this scope.'\n )}\n </p>\n ) : (\n <div className=\"space-y-3\">\n {breakdown?.truncatedProjects && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedProjects',\n 'Showing the first 50 projects with live work. Narrow by project above to see a specific one.'\n )}\n </p>\n )}\n {truncatedProjectNames.length > 0 && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedEpics',\n 'Showing at most 200 epics for: {{projects}}.',\n { projects: truncatedProjectNames.join(', ') }\n )}\n </p>\n )}\n\n <div data-testid=\"viz-work-breakdown\">\n <ResponsiveContainer width=\"100%\" height={360}>\n <Treemap\n data={groups}\n dataKey=\"size\"\n type=\"flat\"\n aspectRatio={4 / 3}\n content={<WorkBreakdownCell onOpenEpic={openEpic} noEpicLabel={noEpicLabel} />}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // Treemap hands back our own leaf datum directly as `payload.payload` (recharts types it\n // as `any`); narrowed here, once.\n const node = payload[0]?.payload as BreakdownLeafDatum | undefined;\n if (!node || node.isHeader) return null;\n const label = node.isNoEpic ? noEpicLabel : node.name;\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{label}</p>\n {!node.isNoEpic && (\n <p className=\"text-muted-foreground\">{node.projectName}</p>\n )}\n <p className=\"mt-1\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}:{' '}\n {node.openCount} ({node.openPoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')}) ·{' '}\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}:{' '}\n {node.doneCount} ({node.donePoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')})\n </p>\n <p>\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}:{' '}\n {node.doneRatio === null ? '—' : `${Math.round(node.doneRatio * 100)}%`}\n </p>\n </div>\n );\n }}\n />\n </Treemap>\n </ResponsiveContainer>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.legend',\n 'Shade = share done (lighter → more open, darker → more done). Click an epic to open it.'\n )}\n </p>\n\n {/* Accessible table alternative — every group/epic the treemap draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-work-breakdown-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.tableCaption',\n 'Work breakdown by project and epic'\n )}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.project', 'Project')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.epic', 'Epic')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}\n </th>\n </tr>\n </thead>\n <tbody>\n {groups.flatMap((g) =>\n g.children\n .filter((leaf) => !leaf.isHeader)\n .map((leaf) => {\n const epicId = leaf.epicId;\n const open = metric === 'items' ? leaf.openCount : leaf.openPoints;\n const done = metric === 'items' ? leaf.doneCount : leaf.donePoints;\n return (\n <tr key={`${g.projectId}-${epicId ?? 'no-epic'}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{g.name}</td>\n <td className=\"py-1.5 pr-2\">\n {epicId ? (\n <button\n type=\"button\"\n className=\"text-left text-primary hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary\"\n onClick={() => openEpic(epicId)}\n >\n {leaf.name}\n </button>\n ) : (\n noEpicLabel\n )}\n </td>\n <td className=\"py-1.5 text-right tabular-nums\">{open}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{done}</td>\n <td className=\"py-1.5 text-right tabular-nums\">\n {leaf.doneRatio === null ? '—' : `${Math.round(leaf.doneRatio * 100)}%`}\n </td>\n </tr>\n );\n })\n )}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"WorkBreakdownCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/WorkBreakdownCard.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { LayoutGrid } from 'lucide-react';\nimport { ResponsiveContainer, Tooltip, Treemap } from 'recharts';\nimport { usePlanMagnetNavigate } from '../../providers/PlanMagnetProvider';\nimport { rowLinkProps } from '../../utils/rowNavigation';\nimport { isForbidden } from '../../utils/graphqlErrors';\nimport { truncate } from '../../dependency/DependencyGraph';\n\n/**\n * ANR-24: work breakdown Treemap (wspace-planmagnet-svc#65 `planmagnetWorkBreakdown`) — live (not date-ranged;\n * see the field's own doc comment) open/done counts grouped project -> epic, with a \"No epic\" bucket per\n * project for items an epic rollup doesn't reach (see PlanmagnetNoEpicWorkBreakdown's doc comment: direct\n * children only, same as PlanmagnetEpicWorkBreakdown — a sub-task nested two levels under an epic falls here\n * too, not under its grandparent epic).\n *\n * recharts' Treemap computes a node's own size as the SUM of its children's sizes when `node.children` is set\n * (see `computeNode` in recharts' source) — so each project's own cell size, and its done-ratio color, both\n * come from summing this card's own visualized epic + \"No epic\" leaves, not from the server's separate\n * project-level totals (which include every live item in the project, not only the ones a leaf here\n * represents — see PlanmagnetProjectWorkBreakdown's doc comment). What the treemap draws and what the table\n * below it lists are therefore always the same numbers.\n */\n\ntype BreakdownMetric = 'items' | 'points';\n\nconst GET_WORK_BREAKDOWN = gql`\n query GetAnalyticsWorkBreakdown($workspaceId: ID!, $projectIds: [ID!]) {\n planmagnetWorkBreakdown(workspaceId: $workspaceId, projectIds: $projectIds) {\n truncatedProjects\n projects {\n projectId\n projectName\n truncatedEpics\n epics {\n epicId\n epicTitle\n openCount\n doneCount\n openPoints\n donePoints\n }\n noEpic {\n openCount\n doneCount\n openPoints\n donePoints\n }\n }\n }\n }\n`;\n\ninterface EpicBreakdown {\n epicId: string;\n epicTitle: string;\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface NoEpicBreakdown {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface ProjectBreakdown {\n projectId: string;\n projectName: string;\n truncatedEpics: boolean;\n epics: EpicBreakdown[];\n noEpic: NoEpicBreakdown;\n}\n\ninterface WorkBreakdownData {\n planmagnetWorkBreakdown: {\n truncatedProjects: boolean;\n projects: ProjectBreakdown[];\n };\n}\n\ninterface CountsLike {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\nfunction metricTotals(c: CountsLike, metric: BreakdownMetric): { size: number; done: number } {\n return metric === 'items'\n ? { size: c.openCount + c.doneCount, done: c.doneCount }\n : { size: c.openPoints + c.donePoints, done: c.donePoints };\n}\n\nfunction doneRatioOf(c: CountsLike, metric: BreakdownMetric): number | null {\n const { size, done } = metricTotals(c, metric);\n return size > 0 ? done / size : null;\n}\n\ninterface BreakdownLeafDatum extends CountsLike {\n name: string;\n size: number;\n doneRatio: number | null;\n /** null = not an individually openable work item (the project-group frame, or the \"No epic\" bucket). */\n epicId: string | null;\n isNoEpic: boolean;\n isHeader: boolean;\n projectId: string;\n projectName: string;\n}\n\ninterface BreakdownGroupDatum {\n name: string;\n projectId: string;\n children: BreakdownLeafDatum[];\n}\n\n/** A label cell reserves this share of each project's own area (never less than HEADER_MIN_SIZE). Small relative\n * to its real epic siblings so it barely affects the epics' own visual weight, but consistently sized so every\n * project's header reads the same size. */\nconst HEADER_FRACTION = 0.14;\nconst HEADER_MIN_SIZE = 1;\n\nfunction buildBreakdownGroups(\n projects: ProjectBreakdown[],\n metric: BreakdownMetric,\n noEpicLabel: string\n): BreakdownGroupDatum[] {\n const groups: BreakdownGroupDatum[] = [];\n\n for (const project of projects) {\n const epicLeaves: BreakdownLeafDatum[] = project.epics\n .map((epic) => ({\n name: epic.epicTitle,\n size: metricTotals(epic, metric).size,\n doneRatio: doneRatioOf(epic, metric),\n epicId: epic.epicId,\n isNoEpic: false,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: epic.openCount,\n doneCount: epic.doneCount,\n openPoints: epic.openPoints,\n donePoints: epic.donePoints,\n }))\n .filter((leaf) => leaf.size > 0);\n\n const noEpicSize = metricTotals(project.noEpic, metric).size;\n if (noEpicSize > 0) {\n epicLeaves.push({\n name: noEpicLabel,\n size: noEpicSize,\n doneRatio: doneRatioOf(project.noEpic, metric),\n epicId: null,\n isNoEpic: true,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: project.noEpic.openCount,\n doneCount: project.noEpic.doneCount,\n openPoints: project.noEpic.openPoints,\n donePoints: project.noEpic.donePoints,\n });\n }\n\n const totalSize = epicLeaves.reduce((sum, leaf) => sum + leaf.size, 0);\n if (totalSize <= 0) continue;\n\n // A small leading child in a list of much-larger siblings reliably becomes its own single-item row under\n // squarify's row-packing (recharts' Treemap layout): adding the next, much bigger sibling to that same row\n // would immediately worsen the row's aspect ratio, so squarify closes the row after just this one item —\n // which a single-item row always stretches to the full width (or height) of its parent rect. That makes\n // this synthetic \"header\" leaf a reliable label strip across the top (or side) of each project's own area,\n // without needing a second, hand-rolled layout pass. See WorkBreakdownCell's `isHeader` branch for the\n // render side of this.\n const headerLeaf: BreakdownLeafDatum = {\n name: project.projectName,\n size: Math.max(Math.round(totalSize * HEADER_FRACTION), HEADER_MIN_SIZE),\n doneRatio: null,\n epicId: null,\n isNoEpic: false,\n isHeader: true,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: 0,\n doneCount: 0,\n openPoints: 0,\n donePoints: 0,\n };\n\n groups.push({\n name: project.projectName,\n projectId: project.projectId,\n children: [headerLeaf, ...epicLeaves],\n });\n }\n\n return groups;\n}\n\nfunction ratioToFillOpacity(doneRatio: number | null): number {\n // Sequential, single-hue encoding on DONE's own chart color (the same hue statusVisual.ts assigns to the DONE\n // status everywhere else in this app): lighter = mostly open, darker = mostly done. Never fully transparent,\n // so a 0%-done cell still reads as a populated cell rather than empty space — and the exact ratio is always\n // available in the tooltip and the table below, so this fill is a supplementary channel, never the only way\n // to read it.\n if (doneRatio === null) return 0.12;\n return 0.18 + doneRatio * 0.72;\n}\n\ninterface WorkBreakdownCellProps {\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n depth?: number;\n name?: string;\n isHeader?: boolean;\n isNoEpic?: boolean;\n epicId?: string | null;\n doneRatio?: number | null;\n onOpenEpic: (epicId: string) => void;\n noEpicLabel: string;\n}\n\nfunction WorkBreakdownCell(props: WorkBreakdownCellProps) {\n const {\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n depth = 0,\n name = '',\n isHeader = false,\n isNoEpic = false,\n epicId = null,\n doneRatio = null,\n onOpenEpic,\n noEpicLabel,\n } = props;\n\n if (width <= 0 || height <= 0) return null;\n\n if (depth === 0) {\n // recharts wraps the ENTIRE `data` array (every project combined) in one synthetic root at depth 0\n // (see `computeNode` / `getDerivedStateFromProps` in recharts' own Treemap source) — this fires exactly\n // once for the whole chart area, never once per project. It has no boundary of its own to draw (a rect\n // here would be one border around every project combined, not a per-project frame), so render nothing.\n return null;\n }\n\n if (depth === 1) {\n // The real per-project frame: recharts assigns depth 1 to each top-level entry of `data` — i.e. one\n // `BreakdownGroupDatum` per project — so this branch fires once per project, at that project's own\n // bounds. Drawn first (recharts renders a non-leaf node's own content, then its children, in that\n // order), so the header + epic leaves at depth 2 paint on top of it within the same bounds.\n // Never filled: fill is reserved for done-ratio below, so the frame reads purely as a boundary.\n return (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={4}\n className=\"fill-none stroke-border\"\n strokeWidth={1}\n />\n );\n }\n\n const label = isNoEpic ? noEpicLabel : name;\n const showLabel = width > 28 && height > 14;\n const fitChars = Math.max(1, Math.floor((width - 8) / 6));\n const displayLabel = truncate(label, fitChars);\n\n if (isHeader) {\n return (\n <g>\n <rect x={x} y={y} width={width} height={height} className=\"fill-muted\" />\n {showLabel && (\n <text\n x={x + 4}\n y={y + height / 2}\n dy=\"0.32em\"\n className=\"fill-foreground text-[11px] font-medium\"\n >\n {displayLabel}\n </text>\n )}\n </g>\n );\n }\n\n const cellRect = (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n className=\"stroke-background\"\n style={{ fill: 'var(--color-chart-2)', fillOpacity: ratioToFillOpacity(doneRatio) }}\n strokeWidth={2}\n />\n );\n const cellLabel = showLabel && (\n <text x={x + 4} y={y + height / 2} dy=\"0.32em\" className=\"fill-foreground text-[11px]\">\n {displayLabel}\n </text>\n );\n\n if (!epicId) {\n // \"No epic\" bucket — an aggregate of several work items, not one item, so there is nothing to navigate to.\n return (\n <g>\n {cellRect}\n {cellLabel}\n </g>\n );\n }\n\n const accessibleLabel = `${name} — ${Math.round((doneRatio ?? 0) * 100)}% done`;\n return (\n <g\n {...rowLinkProps<SVGGElement>(() => onOpenEpic(epicId), accessibleLabel)}\n className=\"cursor-pointer focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2\"\n >\n <title>{accessibleLabel}</title>\n {cellRect}\n {cellLabel}\n </g>\n );\n}\n\nexport function WorkBreakdownCard({\n workspaceId,\n projectId,\n}: {\n workspaceId?: string | null;\n /** ANR-09 project scope. Omitted/undefined = every project (up to 50 — see truncatedProjects below). */\n projectId?: string;\n}) {\n const { t } = useI18n();\n const navigate = usePlanMagnetNavigate();\n const [metric, setMetric] = useState<BreakdownMetric>('items');\n const { data, loading, error } = useQuery<WorkBreakdownData>(GET_WORK_BREAKDOWN, {\n variables: { workspaceId, projectIds: projectId ? [projectId] : undefined },\n skip: !workspaceId,\n });\n\n const noEpicLabel = t('planmagnet.analytics.workBreakdown.noEpic', 'No epic');\n const breakdown = data?.planmagnetWorkBreakdown;\n const projects = breakdown?.projects ?? [];\n const groups = buildBreakdownGroups(projects, metric, noEpicLabel);\n const truncatedProjectNames = projects.filter((p) => p.truncatedEpics).map((p) => p.projectName);\n\n function openEpic(epicId: string) {\n navigate(`/tasks/task-detail-page?taskId=${encodeURIComponent(epicId)}`);\n }\n\n return (\n <Card data-testid=\"work-breakdown-card\">\n <CardHeader>\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <CardTitle className=\"flex items-center gap-2\">\n <LayoutGrid className=\"h-4 w-4\" />\n {t('planmagnet.analytics.workBreakdown.title', 'Work breakdown')}\n </CardTitle>\n <div\n className=\"flex items-center gap-1 rounded-md border p-0.5\"\n role=\"group\"\n aria-label={t('planmagnet.analytics.workBreakdown.metricLabel', 'Size by')}\n data-testid=\"viz-breakdown-metric\"\n >\n {(['items', 'points'] as const).map((m) => (\n <button\n key={m}\n type=\"button\"\n aria-pressed={metric === m}\n onClick={() => setMetric(m)}\n className={`px-2 py-1 text-xs rounded ${\n metric === m\n ? 'bg-primary text-primary-foreground'\n : 'text-muted-foreground hover:bg-muted'\n }`}\n >\n {m === 'items'\n ? t('planmagnet.analytics.workBreakdown.items', 'Items')\n : t('planmagnet.analytics.workBreakdown.points', 'Story points')}\n </button>\n ))}\n </div>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.subtitle',\n 'Live open/done work, by project and epic — current state, not limited by the date range above.'\n )}\n </p>\n </CardHeader>\n <CardContent>\n {loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.workBreakdown.forbidden',\n 'You do not have access to the work breakdown for this scope.'\n )\n : `${t('planmagnet.analytics.workBreakdown.error', 'Work breakdown could not be loaded')}: ${error.message}`}\n </p>\n ) : groups.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.workBreakdown.empty',\n 'No live work items found for this scope.'\n )}\n </p>\n ) : (\n <div className=\"space-y-3\">\n {breakdown?.truncatedProjects && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedProjects',\n 'Showing the first 50 projects with live work. Narrow by project above to see a specific one.'\n )}\n </p>\n )}\n {truncatedProjectNames.length > 0 && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedEpics',\n 'Showing at most 200 epics for: {{projects}}.',\n { projects: truncatedProjectNames.join(', ') }\n )}\n </p>\n )}\n\n <div data-testid=\"viz-work-breakdown\">\n <ResponsiveContainer width=\"100%\" height={360}>\n <Treemap\n data={groups}\n dataKey=\"size\"\n type=\"flat\"\n aspectRatio={4 / 3}\n content={<WorkBreakdownCell onOpenEpic={openEpic} noEpicLabel={noEpicLabel} />}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // Treemap hands back our own leaf datum directly as `payload.payload` (recharts types it\n // as `any`); narrowed here, once.\n const node = payload[0]?.payload as BreakdownLeafDatum | undefined;\n if (!node || node.isHeader) return null;\n const label = node.isNoEpic ? noEpicLabel : node.name;\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{label}</p>\n {!node.isNoEpic && (\n <p className=\"text-muted-foreground\">{node.projectName}</p>\n )}\n <p className=\"mt-1\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}:{' '}\n {node.openCount} ({node.openPoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')}) ·{' '}\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}:{' '}\n {node.doneCount} ({node.donePoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')})\n </p>\n <p>\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}:{' '}\n {node.doneRatio === null ? '—' : `${Math.round(node.doneRatio * 100)}%`}\n </p>\n </div>\n );\n }}\n />\n </Treemap>\n </ResponsiveContainer>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.legend',\n 'Shade = share done (lighter → more open, darker → more done). Click an epic to open it.'\n )}\n </p>\n\n {/* Accessible table alternative — every group/epic the treemap draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-work-breakdown-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.tableCaption',\n 'Work breakdown by project and epic'\n )}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.project', 'Project')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.epic', 'Epic')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}\n </th>\n </tr>\n </thead>\n <tbody>\n {groups.flatMap((g) =>\n g.children\n .filter((leaf) => !leaf.isHeader)\n .map((leaf) => {\n const epicId = leaf.epicId;\n const open = metric === 'items' ? leaf.openCount : leaf.openPoints;\n const done = metric === 'items' ? leaf.doneCount : leaf.donePoints;\n return (\n <tr key={`${g.projectId}-${epicId ?? 'no-epic'}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{g.name}</td>\n <td className=\"py-1.5 pr-2\">\n {epicId ? (\n <button\n type=\"button\"\n className=\"text-left text-primary hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary\"\n onClick={() => openEpic(epicId)}\n >\n {leaf.name}\n </button>\n ) : (\n noEpicLabel\n )}\n </td>\n <td className=\"py-1.5 text-right tabular-nums\">{open}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{done}</td>\n <td className=\"py-1.5 text-right tabular-nums\">\n {leaf.doneRatio === null ? '—' : `${Math.round(leaf.doneRatio * 100)}%`}\n </td>\n </tr>\n );\n })\n )}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
|
|
@@ -26,6 +26,12 @@ export interface ProductObjectiveRef {
|
|
|
26
26
|
title: string;
|
|
27
27
|
status: string;
|
|
28
28
|
}
|
|
29
|
+
export interface ProductCompetitorRef {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
description: string | null;
|
|
33
|
+
website: string | null;
|
|
34
|
+
}
|
|
29
35
|
/** Every query that lists products or shows a product's detail/links. */
|
|
30
36
|
export declare const PRODUCT_QUERIES: string[];
|
|
31
37
|
export declare function ProductHeaderActions({ productId, name, archivedAt, }: {
|
|
@@ -49,4 +55,31 @@ export declare function ProductObjectivesPanel({ productId, objectives, }: {
|
|
|
49
55
|
productId: string;
|
|
50
56
|
objectives: ProductObjectiveRef[];
|
|
51
57
|
}): import("react/jsx-runtime").JSX.Element;
|
|
58
|
+
export interface ProductAboutValues {
|
|
59
|
+
id: string;
|
|
60
|
+
mission: string | null;
|
|
61
|
+
valueProposition: string | null;
|
|
62
|
+
businessModel: string | null;
|
|
63
|
+
targetSegments: string[];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The product detail page's "About this product" section (PRM-02): mission, value proposition, business model
|
|
67
|
+
* and target segments, shown read-only with an inline "Edit" toggle for the four fields. Mirrors
|
|
68
|
+
* `FeaturePrioritizationCard`'s own stays-mounted-across-save shape (not a dialog) so a validation refusal from
|
|
69
|
+
* the server surfaces right where the fields are, and the saved values show immediately without navigating.
|
|
70
|
+
*/
|
|
71
|
+
export declare function ProductAboutPanel({ product }: {
|
|
72
|
+
product: ProductAboutValues;
|
|
73
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
74
|
+
/**
|
|
75
|
+
* The product detail page's "Competitive landscape" panel (PRM-02): the competitors linked to this product, a
|
|
76
|
+
* picker to link another from the workspace directory (`CompetitorSearchSelect`, shared with the feature
|
|
77
|
+
* request detail page's own competitor panel), and an unlink confirm per row — unlike the feature request
|
|
78
|
+
* panel's plain X, PRM-02 asks for a confirm step here since a product's competitive landscape is reviewed
|
|
79
|
+
* material, not a quick per-request note.
|
|
80
|
+
*/
|
|
81
|
+
export declare function ProductCompetitorsPanel({ productId, competitors, }: {
|
|
82
|
+
productId: string;
|
|
83
|
+
competitors: ProductCompetitorRef[];
|
|
84
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
52
85
|
//# sourceMappingURL=ProductControls.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProductControls.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/product/ProductControls.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ProductControls.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/product/ProductControls.tsx"],"names":[],"mappings":"AA6BA;;;;;;;GAOG;AAIH,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACd;AACD,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,yEAAyE;AACzE,eAAO,MAAM,eAAe,UAA2D,CAAC;AA0BxF,wBAAgB,oBAAoB,CAAC,EACnC,SAAS,EACT,IAAI,EACJ,UAAU,GACX,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,2CAyEA;AA8BD,wBAAgB,oBAAoB,CAAC,EACnC,SAAS,EACT,QAAQ,GACT,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B,2CA+DA;AA0JD,wBAAgB,oBAAoB,CAAC,EACnC,SAAS,EACT,QAAQ,GACT,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B,2CAqEA;AAkKD,wBAAgB,2BAA2B,CAAC,EAC1C,SAAS,EACT,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,wBAAwB,EAAE,CAAC;CAC7C,2CAuEA;AAyJD,wBAAgB,sBAAsB,CAAC,EACrC,SAAS,EACT,UAAU,GACX,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,mBAAmB,EAAE,CAAC;CACnC,2CAuEA;AA6ID,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AA4FD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,kBAAkB,CAAA;CAAE,2CA0J7E;AAiBD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,EACtC,SAAS,EACT,WAAW,GACZ,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,oBAAoB,EAAE,CAAC;CACrC,2CA4FA"}
|