init
This commit is contained in:
222
package-lock.json
generated
222
package-lock.json
generated
@ -9,6 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"recharts": "^3.8.1"
|
||||
@ -23,6 +25,15 @@
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@ -794,6 +805,19 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -814,6 +838,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@ -870,6 +901,15 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@ -883,6 +923,26 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@ -904,6 +964,27 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@ -1057,6 +1138,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -1146,6 +1237,17 @@
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@ -1164,6 +1266,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@ -1319,6 +1427,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
@ -1338,6 +1459,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@ -1348,6 +1475,23 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@ -1668,6 +1812,19 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -1726,6 +1883,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
@ -1822,12 +1989,29 @@
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
@ -1878,6 +2062,26 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||
@ -1899,6 +2103,15 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@ -1953,6 +2166,15 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"recharts": "^3.8.1"
|
||||
|
||||
123
src/App.tsx
123
src/App.tsx
@ -1,9 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { StoryGroup } from './types/jira'
|
||||
import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient'
|
||||
import { groupSubtasksUnderStories } from './lib/groupIssues'
|
||||
import { statusToPhase } from './lib/statusPhase'
|
||||
import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
|
||||
import { isIssueDone } from './lib/statusBuckets'
|
||||
import { computeLandingEstimate } from './lib/executiveLanding'
|
||||
import {
|
||||
computeVerdict,
|
||||
criticalMilestoneImpactMessages,
|
||||
deadlineHealthScore,
|
||||
latestMilestoneDateIso,
|
||||
resourceHealthScore,
|
||||
scopeCompletionPercent,
|
||||
} from './lib/executiveHealth'
|
||||
import { countSubtasksByPhase } from './lib/phaseAggregate'
|
||||
import {
|
||||
loadDashboardConfig,
|
||||
saveDashboardConfig,
|
||||
@ -18,10 +28,18 @@ import { BoardView } from './components/BoardView'
|
||||
import { DashboardSkeleton } from './components/DashboardSkeleton'
|
||||
import { MilestonesTimeline } from './components/MilestonesTimeline'
|
||||
import { DashboardSettingsModal } from './components/DashboardSettingsModal'
|
||||
import { ManagementOverview } from './components/ManagementOverview'
|
||||
import { PhaseDistributionChart } from './components/PhaseDistributionChart'
|
||||
import { ExportDashboardButton } from './components/ExportDashboardButton'
|
||||
import { StatusBucketProvider } from './context/StatusBucketContext'
|
||||
import { LaneLabelsProvider } from './context/LaneLabelsContext'
|
||||
import { PipelineOverview } from './components/PipelineOverview'
|
||||
import { LaneTicketsListView } from './components/LaneTicketsListView'
|
||||
|
||||
type ViewMode = 'list' | 'board'
|
||||
|
||||
export default function App() {
|
||||
const dashboardRef = useRef<HTMLDivElement>(null)
|
||||
const [groups, setGroups] = useState<StoryGroup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@ -31,6 +49,9 @@ export default function App() {
|
||||
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
const statusBucketsRef = useRef(dashboardCfg.statusBuckets)
|
||||
statusBucketsRef.current = dashboardCfg.statusBuckets
|
||||
|
||||
const myViewActive = Boolean(dashboardCfg.myViewActive)
|
||||
|
||||
const displayGroups = useMemo(() => {
|
||||
@ -40,6 +61,67 @@ export default function App() {
|
||||
)
|
||||
}, [groups, myViewActive, dashboardCfg])
|
||||
|
||||
const landing = useMemo(
|
||||
() =>
|
||||
computeLandingEstimate(
|
||||
groups,
|
||||
burnupData,
|
||||
dashboardCfg.teamCapacity,
|
||||
dashboardCfg.baselineCapacity,
|
||||
dashboardCfg.statusBuckets,
|
||||
),
|
||||
[
|
||||
groups,
|
||||
burnupData,
|
||||
dashboardCfg.teamCapacity,
|
||||
dashboardCfg.baselineCapacity,
|
||||
dashboardCfg.statusBuckets,
|
||||
],
|
||||
)
|
||||
|
||||
const finalMilestoneIso = useMemo(
|
||||
() => latestMilestoneDateIso(dashboardCfg.milestones),
|
||||
[dashboardCfg.milestones],
|
||||
)
|
||||
|
||||
const health = useMemo(() => {
|
||||
const deadlineScore = deadlineHealthScore(landing.estimatedLanding, finalMilestoneIso)
|
||||
const scopeScore = scopeCompletionPercent(groups, dashboardCfg.statusBuckets)
|
||||
const resourceScore = resourceHealthScore(
|
||||
landing.remainingSubtasks,
|
||||
dashboardCfg.teamCapacity,
|
||||
dashboardCfg.wipSlotsPerDev,
|
||||
)
|
||||
const verdict = computeVerdict(deadlineScore, scopeScore, resourceScore)
|
||||
return { deadlineScore, scopeScore, resourceScore, verdict }
|
||||
}, [
|
||||
landing.estimatedLanding,
|
||||
landing.remainingSubtasks,
|
||||
finalMilestoneIso,
|
||||
groups,
|
||||
dashboardCfg.teamCapacity,
|
||||
dashboardCfg.wipSlotsPerDev,
|
||||
dashboardCfg.statusBuckets,
|
||||
])
|
||||
|
||||
const impactMessages = useMemo(
|
||||
() =>
|
||||
criticalMilestoneImpactMessages(
|
||||
dashboardCfg.milestones,
|
||||
groups,
|
||||
landing.effectiveVelocityPerDay,
|
||||
dashboardCfg.statusBuckets,
|
||||
),
|
||||
[
|
||||
dashboardCfg.milestones,
|
||||
dashboardCfg.statusBuckets,
|
||||
groups,
|
||||
landing.effectiveVelocityPerDay,
|
||||
],
|
||||
)
|
||||
|
||||
const phaseCounts = useMemo(() => countSubtasksByPhase(groups), [groups])
|
||||
|
||||
const toggleMyView = () => {
|
||||
const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
|
||||
setDashboardCfg(next)
|
||||
@ -61,10 +143,9 @@ export default function App() {
|
||||
setUpdatedAt(new Date())
|
||||
|
||||
const totalSubs = grouped.reduce((acc, g) => acc + g.subtasks.length, 0)
|
||||
const cfg = statusBucketsRef.current
|
||||
const doneSubs = grouped.reduce(
|
||||
(acc, g) =>
|
||||
acc +
|
||||
g.subtasks.filter((s) => statusToPhase(s.fields.status.name) === 'done').length,
|
||||
(acc, g) => acc + g.subtasks.filter((s) => isIssueDone(s, cfg)).length,
|
||||
0,
|
||||
)
|
||||
setBurnupData(appendBurnupSnapshot(doneSubs, totalSubs))
|
||||
@ -99,6 +180,8 @@ export default function App() {
|
||||
const baseOk = import.meta.env.DEV ? true : Boolean(jiraClient.defaults.baseURL)
|
||||
|
||||
return (
|
||||
<StatusBucketProvider value={dashboardCfg.statusBuckets}>
|
||||
<LaneLabelsProvider value={dashboardCfg.laneLabels}>
|
||||
<div className="min-h-screen px-4 pb-20 pt-8 sm:px-6 md:px-8 md:pt-10">
|
||||
<header className="mx-auto mb-6 flex max-w-7xl flex-col gap-4 lg:mb-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
@ -162,6 +245,9 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!loading && groups.length > 0 && (
|
||||
<ExportDashboardButton targetRef={dashboardRef} />
|
||||
)}
|
||||
{updatedAt && !loading && (
|
||||
<span className="text-xs text-slate-500">
|
||||
Mis à jour {updatedAt.toLocaleTimeString('fr-FR')}
|
||||
@ -206,20 +292,37 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{!loading && !error && groups.length > 0 && (
|
||||
<>
|
||||
<div ref={dashboardRef} className="space-y-10">
|
||||
<MilestonesTimeline
|
||||
milestones={dashboardCfg.milestones}
|
||||
groups={groups}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
impactMessages={impactMessages}
|
||||
/>
|
||||
|
||||
<ManagementOverview
|
||||
deadlineScore={health.deadlineScore}
|
||||
scopeScore={health.scopeScore}
|
||||
resourceScore={health.resourceScore}
|
||||
verdict={health.verdict}
|
||||
landing={landing}
|
||||
finalMilestoneDate={finalMilestoneIso}
|
||||
/>
|
||||
|
||||
<ExecutiveSummary groups={displayGroups} />
|
||||
|
||||
<section className="mb-10">
|
||||
<PipelineOverview groups={displayGroups} />
|
||||
|
||||
<LaneTicketsListView groups={displayGroups} />
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Burnup
|
||||
Reporting
|
||||
</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<BurnupChart data={burnupData} />
|
||||
<PhaseDistributionChart counts={phaseCounts} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@ -248,7 +351,7 @@ export default function App() {
|
||||
<BoardView groups={displayGroups} />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@ -259,5 +362,7 @@ export default function App() {
|
||||
onSave={saveSettings}
|
||||
/>
|
||||
</div>
|
||||
</LaneLabelsProvider>
|
||||
</StatusBucketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -84,6 +84,7 @@ export async function fetchAllIssuesByJql(
|
||||
'priority',
|
||||
'assignee',
|
||||
'timetracking',
|
||||
'labels',
|
||||
storyPointsField,
|
||||
] as const
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
@ -13,6 +14,16 @@ type Props = {
|
||||
data: BurnupPoint[]
|
||||
}
|
||||
|
||||
function withIdealLine(points: BurnupPoint[]): (BurnupPoint & { ideal: number })[] {
|
||||
if (points.length === 0) return []
|
||||
const target = points[points.length - 1]!.total
|
||||
const n = points.length
|
||||
return points.map((p, i) => ({
|
||||
...p,
|
||||
ideal: n <= 1 ? target : (i / (n - 1)) * target,
|
||||
}))
|
||||
}
|
||||
|
||||
export function BurnupChart({ data }: Props) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@ -22,18 +33,21 @@ export function BurnupChart({ data }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = withIdealLine(data)
|
||||
|
||||
return (
|
||||
<div className="h-[280px] w-full rounded-2xl border border-white/10 bg-slate-950/30 p-3 backdrop-blur-md sm:p-4">
|
||||
<p className="mb-2 text-center text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Burnup — terminés vs périmètre
|
||||
<div className="flex h-[280px] w-full min-h-[280px] min-w-0 flex-col rounded-2xl border border-white/10 bg-slate-950/30 p-3 backdrop-blur-md sm:p-4">
|
||||
<p className="mb-2 shrink-0 text-center text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Burn-up — objectif vs réalisé
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(148,163,184,0.12)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||
tickFormatter={(d) => d.slice(5)}
|
||||
tickFormatter={(d) => String(d).slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 10 }} allowDecimals={false} width={28} />
|
||||
<Tooltip
|
||||
@ -45,24 +59,35 @@ export function BurnupChart({ data }: Props) {
|
||||
}}
|
||||
labelFormatter={(l) => `Date : ${l}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
name="Périmètre (sous-tâches)"
|
||||
stroke="#64748b"
|
||||
name="Objectif (périmètre)"
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ideal"
|
||||
name="Objectif linéaire"
|
||||
stroke="#eab308"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="6 4"
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="done"
|
||||
name="Terminées"
|
||||
name="Réalisé (terminées)"
|
||||
stroke="#34d399"
|
||||
strokeWidth={2}
|
||||
strokeWidth={2.5}
|
||||
dot={{ r: 3, fill: '#34d399' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,9 +3,57 @@ import {
|
||||
exportConfigJson,
|
||||
mergeImportedConfig,
|
||||
type DashboardConfig,
|
||||
type LaneLabelsConfig,
|
||||
type Milestone,
|
||||
type StatusBucketConfig,
|
||||
} from '../lib/dashboardConfig'
|
||||
|
||||
function parseBucketLines(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const BUCKET_FIELD_DEFS: {
|
||||
key: keyof StatusBucketConfig
|
||||
title: string
|
||||
hint: string
|
||||
}[] = [
|
||||
{ key: 'todo', title: 'À faire', hint: 'Ex. À faire, Open, Backlog' },
|
||||
{ key: 'in_progress', title: 'En cours', hint: 'Ex. In Progress, Code Review, Recette' },
|
||||
{ key: 'blocked', title: 'Bloqué', hint: 'Ex. Bloqué, Blocked' },
|
||||
{ key: 'done', title: 'Terminé', hint: 'Ex. Done, Terminé, Closed, Livré' },
|
||||
{ key: 'cancel', title: 'Annulé', hint: 'Ex. Annulé, Cancelled, Won\'t fix' },
|
||||
]
|
||||
|
||||
const LANE_LABEL_FIELD_DEFS: {
|
||||
key: keyof LaneLabelsConfig
|
||||
title: string
|
||||
hint: string
|
||||
}[] = [
|
||||
{
|
||||
key: 'analyse',
|
||||
title: 'Piste Analyse',
|
||||
hint: 'Étiquettes Jira comptées comme Analyse (ex. analyse). Une par ligne ou virgules.',
|
||||
},
|
||||
{
|
||||
key: 'design',
|
||||
title: 'Piste Design',
|
||||
hint: 'Ex. design, maquette (si vous utilisez ces étiquettes en Jira).',
|
||||
},
|
||||
{
|
||||
key: 'integration',
|
||||
title: 'Piste Intégration',
|
||||
hint: 'Ex. integration — ou tout libellé d’étiquette utilisé côté dev / recette.',
|
||||
},
|
||||
{
|
||||
key: 'blocked',
|
||||
title: 'Marquage bloqué (étiquettes)',
|
||||
hint: 'Ex. blocked : les tickets portant l’une de ces étiquettes sont signalés comme bloqués par étiquette (liste et tooltips).',
|
||||
},
|
||||
]
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
config: DashboardConfig
|
||||
@ -19,6 +67,7 @@ function newMilestone(): Milestone {
|
||||
title: '',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
linkedStoryKeys: [],
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,6 +145,66 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 px-5 py-4">
|
||||
<div className="rounded-xl border border-amber-500/25 bg-amber-500/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-amber-200/90">
|
||||
Capacité & reporting
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase text-slate-500">Effectif actif</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.25}
|
||||
step={0.25}
|
||||
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
|
||||
value={draft.teamCapacity}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
teamCapacity: Math.max(0.25, Number(e.target.value) || 0.25),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] uppercase text-slate-500">Baseline vélocité</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.25}
|
||||
step={0.25}
|
||||
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
|
||||
value={draft.baselineCapacity}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
baselineCapacity: Math.max(0.25, Number(e.target.value) || 0.25),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] uppercase text-slate-500">WIP / pers.</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
|
||||
value={draft.wipSlotsPerDev}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
wipSlotsPerDev: Math.max(1, Math.floor(Number(e.target.value) || 1)),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-slate-500">
|
||||
La date d’atterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux
|
||||
nominaux pour la jauge « Ressources ».
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Mon accountId Jira (recommandé pour « Ma vue »)
|
||||
@ -120,6 +229,75 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-violet-500/25 bg-violet-500/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-violet-200/90">
|
||||
Cartographie des statuts Jira
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
|
||||
Libellés exacts des statuts dans Jira (un par ligne ou séparés par des virgules). La
|
||||
comparaison ignore casse et accents. Si un statut n’est dans aucune liste, la catégorie
|
||||
Jira (nouveau, en cours, terminé) sert de secours. Les listes vides au prochain
|
||||
chargement reprennent les valeurs par défaut.
|
||||
</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{BUCKET_FIELD_DEFS.map(({ key, title, hint }) => (
|
||||
<div key={key}>
|
||||
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{title}
|
||||
</label>
|
||||
<p className="text-[10px] text-slate-600">{hint}</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="mt-1 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
|
||||
value={draft.statusBuckets[key].join('\n')}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusBuckets: { ...d.statusBuckets, [key]: parseBucketLines(e.target.value) },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-cyan-500/25 bg-cyan-500/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200/90">
|
||||
Étiquettes Jira (pistes & bloqué)
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
|
||||
Les sous-tâches sont classées en Analyse / Design / Intégration si elles portent au moins
|
||||
une des étiquettes listées (ordre de priorité : Analyse, puis Design, puis Intégration).
|
||||
Sinon, l’ancienne détection par mots dans le résumé et le statut s’applique. Les listes
|
||||
vides au prochain chargement reprennent les valeurs par défaut (analyse, design,
|
||||
integration, blocked).
|
||||
</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{LANE_LABEL_FIELD_DEFS.map(({ key, title, hint }) => (
|
||||
<div key={key}>
|
||||
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{title}
|
||||
</label>
|
||||
<p className="text-[10px] text-slate-600">{hint}</p>
|
||||
<textarea
|
||||
rows={2}
|
||||
spellCheck={false}
|
||||
className="mt-1 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
|
||||
value={draft.laneLabels[key].join('\n')}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
laneLabels: { ...d.laneLabels, [key]: parseBucketLines(e.target.value) },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
@ -145,7 +323,16 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
onChange={(e) => updateMilestone(m.id, { title: e.target.value })}
|
||||
placeholder="ex. Fin design"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-[11px] text-amber-100/90">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(m.critical)}
|
||||
onChange={(e) => updateMilestone(m.id, { critical: e.target.checked })}
|
||||
className="rounded border-amber-400/50"
|
||||
/>
|
||||
Jalon critique (alerte d’impact si retard)
|
||||
</label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="rounded border border-white/10 bg-transparent px-2 py-1 text-xs"
|
||||
|
||||
@ -8,6 +8,8 @@ import {
|
||||
goldenCarbonSubtasks,
|
||||
maquetteRelatedSubtasks,
|
||||
} from '../lib/executiveKpis'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import { useLaneLabels } from '../context/LaneLabelsContext'
|
||||
|
||||
type Props = {
|
||||
groups: StoryGroup[]
|
||||
@ -23,8 +25,8 @@ function DonutGlobal({ pct }: { pct: number }) {
|
||||
{ name: 'Reste', value: rest },
|
||||
]
|
||||
return (
|
||||
<div className="relative mx-auto h-[140px] w-[140px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<div className="relative mx-auto h-[140px] w-[140px] min-h-[140px] min-w-[140px] shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
@ -60,10 +62,12 @@ function DonutGlobal({ pct }: { pct: number }) {
|
||||
}
|
||||
|
||||
export function ExecutiveSummary({ groups }: Props) {
|
||||
const total = globalProgressPercent(groups)
|
||||
const design = designHealthPercent(groups)
|
||||
const golden = goldenCarbonHealthPercent(groups)
|
||||
const blockers = blockingTicketsCount(groups)
|
||||
const cfg = useStatusBuckets()
|
||||
const laneCfg = useLaneLabels()
|
||||
const total = globalProgressPercent(groups, cfg)
|
||||
const design = designHealthPercent(groups, cfg)
|
||||
const golden = goldenCarbonHealthPercent(groups, cfg)
|
||||
const blockers = blockingTicketsCount(groups, cfg, laneCfg)
|
||||
|
||||
const maquetteCount = maquetteRelatedSubtasks(groups).length
|
||||
const gcCount = goldenCarbonSubtasks(groups).length
|
||||
@ -124,7 +128,8 @@ export function ExecutiveSummary({ groups }: Props) {
|
||||
</p>
|
||||
<p className="mt-4 text-4xl font-semibold tabular-nums text-white">{blockers}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-slate-400">
|
||||
Tickets en statut « Bloqué », « Recette KO » ou assimilés (stories + sous-tâches).
|
||||
Seau « Bloqué » (statuts), étiquettes bloquées (réglages), ou libellés type Recette KO
|
||||
(stories + sous-tâches).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
80
src/components/ExportDashboardButton.tsx
Normal file
80
src/components/ExportDashboardButton.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useState, type RefObject } from 'react'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
|
||||
type Props = {
|
||||
targetRef: RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function ExportDashboardButton({ targetRef }: Props) {
|
||||
const [busy, setBusy] = useState<'png' | 'pdf' | null>(null)
|
||||
|
||||
const runExport = async (mode: 'png' | 'pdf') => {
|
||||
const el = targetRef.current
|
||||
if (!el) return
|
||||
setBusy(mode)
|
||||
try {
|
||||
const canvas = await html2canvas(el, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: '#020617',
|
||||
})
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
if (mode === 'png') {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `dashboard-copil-${stamp}.png`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, 'image/png')
|
||||
} else {
|
||||
const imgData = canvas.toDataURL('image/png')
|
||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' })
|
||||
const pageW = pdf.internal.pageSize.getWidth()
|
||||
const pageH = pdf.internal.pageSize.getHeight()
|
||||
const img = new Image()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error('image'))
|
||||
img.src = imgData
|
||||
})
|
||||
const ratio = Math.min(pageW / img.width, pageH / img.height)
|
||||
const w = img.width * ratio
|
||||
const h = img.height * ratio
|
||||
const x = (pageW - w) / 2
|
||||
const y = (pageH - h) / 2
|
||||
pdf.addImage(imgData, 'PNG', x, y, w, h)
|
||||
pdf.save(`dashboard-copil-${stamp}.pdf`)
|
||||
}
|
||||
} catch {
|
||||
alert('Export impossible (vérifiez les bloqueurs ou réessayez).')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void runExport('png')}
|
||||
className="rounded-lg border border-violet-500/50 bg-violet-500/15 px-3 py-2 text-xs font-semibold text-violet-100 transition hover:bg-violet-500/25 disabled:opacity-50"
|
||||
>
|
||||
{busy === 'png' ? 'Export…' : 'Exporter PNG (Copil)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void runExport('pdf')}
|
||||
className="rounded-lg border border-sky-500/50 bg-sky-500/15 px-3 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-500/25 disabled:opacity-50"
|
||||
>
|
||||
{busy === 'pdf' ? 'Export…' : 'Exporter PDF (Copil)'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
310
src/components/LaneTicketsListView.tsx
Normal file
310
src/components/LaneTicketsListView.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { JiraIssue, StoryGroup } from '../types/jira'
|
||||
import { useLaneLabels } from '../context/LaneLabelsContext'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import { detectWorkLane, type WorkLane } from '../lib/laneDetection'
|
||||
import {
|
||||
resolveWorkBucketFromIssue,
|
||||
type StatusBucketConfig,
|
||||
type WorkStatusBucket,
|
||||
} from '../lib/statusBuckets'
|
||||
import { getRemainingEstimateUnits } from '../lib/jiraFieldExtractors'
|
||||
import { jiraBrowseIssueUrl } from '../lib/jiraLinks'
|
||||
|
||||
type Props = {
|
||||
groups: StoryGroup[]
|
||||
}
|
||||
|
||||
type Row = {
|
||||
issue: JiraIssue
|
||||
}
|
||||
|
||||
const LANES: { id: WorkLane; title: string }[] = [
|
||||
{ id: 'analyse', title: 'Analyse' },
|
||||
{ id: 'design', title: 'Design' },
|
||||
{ id: 'integration', title: 'Intégration' },
|
||||
]
|
||||
|
||||
const BUCKET_FR: Record<WorkStatusBucket, string> = {
|
||||
todo: 'À faire',
|
||||
in_progress: 'En cours',
|
||||
blocked: 'Bloqué',
|
||||
done: 'Terminé',
|
||||
cancel: 'Annulé',
|
||||
}
|
||||
|
||||
const BUCKET_OPTIONS: { value: 'all' | WorkStatusBucket; label: string }[] = [
|
||||
{ value: 'all', label: 'Tous suivi' },
|
||||
{ value: 'todo', label: 'À faire' },
|
||||
{ value: 'in_progress', label: 'En cours' },
|
||||
{ value: 'blocked', label: 'Bloqué' },
|
||||
{ value: 'done', label: 'Terminé' },
|
||||
{ value: 'cancel', label: 'Annulé' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS: { value: 'key' | 'summary' | 'status' | 'remaining' | 'bucket'; label: string }[] = [
|
||||
{ value: 'key', label: 'Clé' },
|
||||
{ value: 'summary', label: 'Résumé' },
|
||||
{ value: 'status', label: 'Statut Jira' },
|
||||
{ value: 'remaining', label: 'Reste (u.)' },
|
||||
{ value: 'bucket', label: 'Suivi' },
|
||||
]
|
||||
|
||||
function IssueKeyCell({ issueKey }: { issueKey: string }) {
|
||||
const href = jiraBrowseIssueUrl(issueKey)
|
||||
const cls =
|
||||
'font-mono text-[11px] text-cyan-300/95 decoration-cyan-400/40 hover:text-cyan-200 hover:underline'
|
||||
if (!href) return <span className={cls}>{issueKey}</span>
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={`${cls} underline-offset-2`}>
|
||||
{issueKey}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function rowMatchesQuery(issue: JiraIssue, q: string): boolean {
|
||||
if (!q.trim()) return true
|
||||
const n = q.trim().toLowerCase()
|
||||
const labels = (issue.fields.labels ?? []).join(' ').toLowerCase()
|
||||
return (
|
||||
issue.key.toLowerCase().includes(n) ||
|
||||
issue.fields.summary.toLowerCase().includes(n) ||
|
||||
issue.fields.status.name.toLowerCase().includes(n) ||
|
||||
labels.includes(n)
|
||||
)
|
||||
}
|
||||
|
||||
function LaneSection({
|
||||
title,
|
||||
rows,
|
||||
bucketCfg,
|
||||
}: {
|
||||
title: string
|
||||
rows: Row[]
|
||||
bucketCfg: StatusBucketConfig
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [bucketFilter, setBucketFilter] = useState<'all' | WorkStatusBucket>('all')
|
||||
const [sort, setSort] = useState<'key' | 'summary' | 'status' | 'remaining' | 'bucket'>('key')
|
||||
const [asc, setAsc] = useState(true)
|
||||
|
||||
const processed = useMemo(() => {
|
||||
let list = rows.filter(({ issue }) => {
|
||||
if (!rowMatchesQuery(issue, q)) return false
|
||||
if (bucketFilter === 'all') return true
|
||||
return resolveWorkBucketFromIssue(issue, bucketCfg) === bucketFilter
|
||||
})
|
||||
const dir = asc ? 1 : -1
|
||||
list = [...list].sort((a, b) => {
|
||||
const A = a.issue
|
||||
const B = b.issue
|
||||
switch (sort) {
|
||||
case 'key':
|
||||
return A.key.localeCompare(B.key) * dir
|
||||
case 'summary':
|
||||
return A.fields.summary.localeCompare(B.fields.summary, 'fr') * dir
|
||||
case 'status':
|
||||
return A.fields.status.name.localeCompare(B.fields.status.name, 'fr') * dir
|
||||
case 'remaining': {
|
||||
const ra = getRemainingEstimateUnits(A)
|
||||
const rb = getRemainingEstimateUnits(B)
|
||||
return ra === rb ? A.key.localeCompare(B.key) : (ra - rb) * dir
|
||||
}
|
||||
case 'bucket': {
|
||||
const ba = BUCKET_FR[resolveWorkBucketFromIssue(A, bucketCfg)]
|
||||
const bb = BUCKET_FR[resolveWorkBucketFromIssue(B, bucketCfg)]
|
||||
const c = ba.localeCompare(bb, 'fr')
|
||||
return c !== 0 ? c * dir : A.key.localeCompare(B.key) * dir
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return list
|
||||
}, [rows, q, bucketFilter, sort, asc, bucketCfg])
|
||||
|
||||
const totalRemaining = useMemo(
|
||||
() => rows.reduce((acc, { issue }) => acc + getRemainingEstimateUnits(issue), 0),
|
||||
[rows],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-white">
|
||||
{title}
|
||||
<span className="font-mono text-xs font-normal text-slate-500">
|
||||
{rows.length} ticket{rows.length !== 1 ? 's' : ''}
|
||||
<span className="mx-1.5 text-slate-600">·</span>
|
||||
Σ reste <span className="text-slate-400">{totalRemaining.toFixed(2)} u.</span>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">Aucune sous-tâche sur cette piste.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap items-end gap-2 rounded-lg border border-white/[0.06] bg-black/20 p-2">
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
|
||||
Recherche
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Clé, résumé, statut, étiquette…"
|
||||
className="mt-0.5 w-full rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
|
||||
Suivi
|
||||
</label>
|
||||
<select
|
||||
value={bucketFilter}
|
||||
onChange={(e) =>
|
||||
setBucketFilter(e.target.value as 'all' | WorkStatusBucket)
|
||||
}
|
||||
className="mt-0.5 block rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none"
|
||||
>
|
||||
{BUCKET_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
|
||||
Trier par
|
||||
</label>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) =>
|
||||
setSort(e.target.value as 'key' | 'summary' | 'status' | 'remaining' | 'bucket')
|
||||
}
|
||||
className="mt-0.5 block rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none"
|
||||
>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-medium uppercase tracking-wide text-slate-500">
|
||||
Ordre
|
||||
</label>
|
||||
<select
|
||||
value={asc ? 'asc' : 'desc'}
|
||||
onChange={(e) => setAsc(e.target.value === 'asc')}
|
||||
className="mt-0.5 block rounded border border-white/10 bg-slate-950/60 px-2 py-1 text-[11px] text-slate-200 outline-none"
|
||||
>
|
||||
<option value="asc">Croissant</option>
|
||||
<option value="desc">Décroissant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-[10px] text-slate-600">
|
||||
{processed.length} ligne{processed.length !== 1 ? 's' : ''} affichée
|
||||
{processed.length !== rows.length ? ` sur ${rows.length}` : ''}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-black/20">
|
||||
<table className="w-full min-w-[480px] border-collapse text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.08] bg-slate-900/80 text-[10px] uppercase tracking-wide text-slate-500">
|
||||
<th className="px-3 py-2 font-medium">Clé</th>
|
||||
<th className="px-3 py-2 font-medium">Résumé</th>
|
||||
<th className="px-3 py-2 font-medium">Statut Jira</th>
|
||||
<th className="px-3 py-2 font-medium">Suivi</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Reste (u.)</th>
|
||||
<th className="px-3 py-2 font-medium">Étiquettes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processed.map(({ issue }) => {
|
||||
const bucket = resolveWorkBucketFromIssue(issue, bucketCfg)
|
||||
const labels = issue.fields.labels ?? []
|
||||
const rem = getRemainingEstimateUnits(issue)
|
||||
return (
|
||||
<tr
|
||||
key={issue.key}
|
||||
className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.03]"
|
||||
>
|
||||
<td className="whitespace-nowrap px-3 py-2 align-top">
|
||||
<IssueKeyCell issueKey={issue.key} />
|
||||
</td>
|
||||
<td className="max-w-[240px] px-3 py-2 align-top text-slate-200">
|
||||
<span className="line-clamp-2" title={issue.fields.summary}>
|
||||
{issue.fields.summary}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
|
||||
{issue.fields.status.name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-300">
|
||||
{BUCKET_FR[bucket]}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 align-top text-right font-mono text-slate-400">
|
||||
{rem.toFixed(2)}
|
||||
</td>
|
||||
<td className="max-w-[180px] px-3 py-2 align-top text-[10px] text-slate-500">
|
||||
{labels.length === 0 ? (
|
||||
'—'
|
||||
) : (
|
||||
<span className="line-clamp-2" title={labels.join(', ')}>
|
||||
{labels.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LaneTicketsListView({ groups }: Props) {
|
||||
const laneCfg = useLaneLabels()
|
||||
const bucketCfg = useStatusBuckets()
|
||||
|
||||
const rowsByLane = useMemo(() => {
|
||||
const map: Record<WorkLane, Row[]> = { analyse: [], design: [], integration: [] }
|
||||
for (const g of groups) {
|
||||
for (const st of g.subtasks) {
|
||||
const lane = detectWorkLane(st, laneCfg)
|
||||
map[lane].push({ issue: st })
|
||||
}
|
||||
}
|
||||
for (const lane of Object.keys(map) as WorkLane[]) {
|
||||
map[lane].sort((a, b) => a.issue.key.localeCompare(b.issue.key))
|
||||
}
|
||||
return map
|
||||
}, [groups, laneCfg])
|
||||
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/35 p-4 backdrop-blur-xl sm:p-5">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Liste par piste (sous-tâches)
|
||||
</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500">
|
||||
Filtres et tri indépendants par piste. Reste (u.) = remainingEstimate Jira ÷ 27 000 (comme
|
||||
ailleurs sur le dashboard). Les étiquettes incluent le marquage bloqué si vous l’utilisez.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-10">
|
||||
{LANES.map(({ id, title }) => (
|
||||
<LaneSection key={id} title={title} rows={rowsByLane[id]} bucketCfg={bucketCfg} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
192
src/components/ManagementOverview.tsx
Normal file
192
src/components/ManagementOverview.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
|
||||
import type { LandingEstimate } from '../lib/executiveLanding'
|
||||
import type { Verdict } from '../lib/executiveHealth'
|
||||
|
||||
type Props = {
|
||||
deadlineScore: number
|
||||
scopeScore: number
|
||||
resourceScore: number
|
||||
verdict: Verdict
|
||||
landing: LandingEstimate | null
|
||||
finalMilestoneDate: string | null
|
||||
}
|
||||
|
||||
function GaugeDonut({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
accent,
|
||||
}: {
|
||||
label: string
|
||||
hint: string
|
||||
value: number
|
||||
accent: string
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(100, value))
|
||||
const rest = 100 - v
|
||||
const data = [
|
||||
{ name: 'score', value: v },
|
||||
{ name: 'rest', value: rest },
|
||||
]
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center">
|
||||
<p className="mb-1 text-center text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
{label}
|
||||
</p>
|
||||
<div className="relative h-[120px] w-[120px] min-h-[120px] min-w-[120px] shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={38}
|
||||
outerRadius={52}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
stroke="none"
|
||||
>
|
||||
<Cell fill={accent} />
|
||||
<Cell fill="rgb(15 23 42 / 0.9)" />
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(val) => [`${Math.round(Number(val ?? 0))}%`, '']}
|
||||
contentStyle={{
|
||||
background: 'rgba(15,23,42,0.96)',
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-xl font-bold tabular-nums text-white">{Math.round(v)}</span>
|
||||
<span className="text-[9px] text-slate-500">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 max-w-[140px] text-center text-[10px] leading-snug text-slate-500">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function verdictBlock(verdict: Verdict): { text: string; className: string } {
|
||||
switch (verdict) {
|
||||
case 'on_track':
|
||||
return {
|
||||
text: 'DANS LES TEMPS',
|
||||
className:
|
||||
'border-emerald-400/70 bg-emerald-500/15 text-emerald-100 shadow-[0_0_32px_rgba(16,185,129,0.35)]',
|
||||
}
|
||||
case 'at_risk':
|
||||
return {
|
||||
text: 'EN RISQUE',
|
||||
className:
|
||||
'border-amber-400/80 bg-amber-500/20 text-amber-50 shadow-[0_0_36px_rgba(251,191,36,0.45)]',
|
||||
}
|
||||
case 'critical':
|
||||
return {
|
||||
text: 'CRITIQUE',
|
||||
className:
|
||||
'border-rose-500/90 bg-rose-600/25 text-rose-50 shadow-[0_0_40px_rgba(244,63,94,0.55)]',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatFrDate(d: Date): string {
|
||||
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function ManagementOverview({
|
||||
deadlineScore,
|
||||
scopeScore,
|
||||
resourceScore,
|
||||
verdict,
|
||||
landing,
|
||||
finalMilestoneDate,
|
||||
}: Props) {
|
||||
const v = verdictBlock(verdict)
|
||||
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/50 px-4 py-5 backdrop-blur-xl sm:px-6">
|
||||
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||
Management overview
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Santé du projet : délais vs atterrissage, complétion des stories, charge vs capacité
|
||||
configurée.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-2xl border-2 px-6 py-4 text-center transition ${v.className}`}
|
||||
>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.35em] text-white/80">Verdict</p>
|
||||
<p className="mt-2 text-2xl font-black tracking-tight sm:text-3xl">{v.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid min-h-0 min-w-0 grid-cols-1 gap-6 border-b border-white/[0.06] pb-6 sm:grid-cols-3">
|
||||
<GaugeDonut
|
||||
label="Délais"
|
||||
hint="Marge entre date d’atterrissage estimée et dernier jalon."
|
||||
value={deadlineScore}
|
||||
accent="rgb(56 189 248)"
|
||||
/>
|
||||
<GaugeDonut
|
||||
label="Scope"
|
||||
hint="Stories entièrement terminées (sous-tâches incluses)."
|
||||
value={scopeScore}
|
||||
accent="rgb(167 139 250)"
|
||||
/>
|
||||
<GaugeDonut
|
||||
label="Ressources"
|
||||
hint="Sous-tâches ouvertes vs capacité (effectif × créneaux / pers.)."
|
||||
value={resourceScore}
|
||||
accent="rgb(52 211 153)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm text-slate-300 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/10 bg-black/25 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Atterrissage estimé
|
||||
</p>
|
||||
{landing?.estimatedLanding ? (
|
||||
<p className="mt-1 font-mono text-lg text-white">
|
||||
{formatFrDate(landing.estimatedLanding)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-amber-200/90">Indisponible (vélocité nulle ou pas d’historique).</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Reste <span className="font-mono text-slate-300">{landing?.remainingSubtasks ?? '—'}</span>{' '}
|
||||
sous-tâche(s) · vélocité ajustée{' '}
|
||||
<span className="font-mono text-slate-300">
|
||||
{(landing?.effectiveVelocityPerDay ?? 0).toFixed(2)}
|
||||
</span>{' '}
|
||||
/ jour
|
||||
{finalMilestoneDate && (
|
||||
<>
|
||||
{' '}
|
||||
· jalon cible <span className="font-mono text-slate-400">{finalMilestoneDate}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/25 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Méthode
|
||||
</p>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 text-xs text-slate-500">
|
||||
<li>Vélocité = moyenne des terminées / jour sur 14 jours (historique burnup).</li>
|
||||
<li>Projection en jours ouvrés (lun–ven) à partir d’aujourd’hui.</li>
|
||||
<li>Capacité : voir Réglages (effectif × baseline).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,11 +1,22 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { Milestone } from '../lib/dashboardConfig'
|
||||
import { isMilestoneLate } from '../lib/milestoneStatus'
|
||||
import type { StatusBucketConfig } from '../lib/statusBuckets'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import {
|
||||
isMilestoneLate,
|
||||
milestoneAverageCompletionPercent,
|
||||
milestoneCalendarDaysUntil,
|
||||
milestoneLinkedGroups,
|
||||
milestoneOpenRemainingUnits,
|
||||
} from '../lib/milestoneStatus'
|
||||
import { ProjectRoadmapBar } from './ProjectRoadmapBar'
|
||||
|
||||
type Props = {
|
||||
milestones: Milestone[]
|
||||
groups: StoryGroup[]
|
||||
onOpenSettings: () => void
|
||||
/** Alertes d’impact (ex. jalons critiques en retard). */
|
||||
impactMessages?: string[]
|
||||
}
|
||||
|
||||
function formatFr(iso: string): string {
|
||||
@ -20,12 +31,52 @@ function formatFr(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props) {
|
||||
function delaySummary(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
): { text: string; className: string } {
|
||||
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
|
||||
if (pct >= 100) {
|
||||
return { text: 'Terminé', className: 'text-emerald-400' }
|
||||
}
|
||||
if (isMilestoneLate(m, groups, cfg)) {
|
||||
return { text: 'Retard', className: 'text-rose-400' }
|
||||
}
|
||||
const d = milestoneCalendarDaysUntil(m)
|
||||
if (d > 1) return { text: `Dans ${d} j`, className: d <= 7 ? 'text-amber-200' : 'text-slate-400' }
|
||||
if (d === 1) return { text: 'Demain', className: 'text-amber-200' }
|
||||
if (d === 0) return { text: "Aujourd'hui", className: 'text-amber-300' }
|
||||
return { text: '—', className: 'text-slate-500' }
|
||||
}
|
||||
|
||||
export function MilestonesTimeline({
|
||||
milestones,
|
||||
groups,
|
||||
onOpenSettings,
|
||||
impactMessages = [],
|
||||
}: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const sorted = [...milestones].sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
{impactMessages.length > 0 && (
|
||||
<div className="mb-4 space-y-2 rounded-xl border-2 border-rose-500/80 bg-rose-600/20 px-4 py-3 text-sm text-rose-50 shadow-[0_0_28px_rgba(244,63,94,0.35)]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-amber-200">
|
||||
Impact dépendances
|
||||
</p>
|
||||
<ul className="list-inside list-disc space-y-1 text-[13px] leading-relaxed">
|
||||
{impactMessages.map((msg, i) => (
|
||||
<li key={i}>{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProjectRoadmapBar milestones={milestones} />
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Timeline de jalons
|
||||
</h2>
|
||||
@ -38,19 +89,30 @@ export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-xs leading-relaxed text-slate-500">
|
||||
Chaque jalon regarde un périmètre : les stories saisies dans « Stories liées », ou toutes les
|
||||
stories chargées si ce champ est vide. L’avancement est la moyenne des pourcentages de
|
||||
sous-tâches terminées (même règle que le retard). Le RAF est la somme du temps restant Jira
|
||||
(unités ÷ 27 000) sur les sous-tâches encore actives de ce périmètre.
|
||||
</p>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Aucun jalon défini — ouvrez la configuration pour ajouter des dates clés (ex. fin design,
|
||||
recette, mise en ligne).
|
||||
Aucun jalon défini — ajoutez des jalons avec une date et, si besoin, un sous-ensemble de
|
||||
stories pour éviter qu’une échéance globale ne mélange tout le périmètre DCC.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative pt-2 pb-6">
|
||||
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-slate-600 to-transparent" />
|
||||
<ul className="relative flex flex-wrap gap-6 sm:flex-nowrap sm:justify-between">
|
||||
{sorted.map((m) => {
|
||||
const late = isMilestoneLate(m, groups)
|
||||
const late = isMilestoneLate(m, groups, cfg)
|
||||
return (
|
||||
<li key={m.id} className="flex min-w-[100px] flex-1 flex-col items-center sm:flex-none">
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex min-w-[100px] flex-1 flex-col items-center sm:flex-none"
|
||||
>
|
||||
<span
|
||||
className={`relative z-10 mb-2 flex h-4 w-4 rounded-full ring-4 ring-slate-950 ${
|
||||
late
|
||||
@ -59,12 +121,18 @@ export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props
|
||||
}`}
|
||||
title={
|
||||
late
|
||||
? 'Retard : date dépassée et stories liées non terminées (sous-tâches).'
|
||||
? 'Retard : date dépassée et périmètre non à 100 % (sous-tâches).'
|
||||
: 'À jour ou échéance future.'
|
||||
}
|
||||
/>
|
||||
<span className="max-w-[140px] text-center text-xs font-medium text-white">
|
||||
{m.title}
|
||||
{m.critical && (
|
||||
<span className="ml-1 text-[9px] font-bold uppercase text-amber-300">
|
||||
{' '}
|
||||
(critique)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">{formatFr(m.date)}</span>
|
||||
{late && (
|
||||
@ -77,6 +145,70 @@ export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Synthèse par jalon
|
||||
</h3>
|
||||
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-black/20">
|
||||
<table className="w-full min-w-[720px] border-collapse text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.08] bg-slate-900/80 text-[10px] uppercase tracking-wide text-slate-500">
|
||||
<th className="px-3 py-2 font-medium">Jalon</th>
|
||||
<th className="px-3 py-2 font-medium">Date</th>
|
||||
<th className="px-3 py-2 font-medium">Périmètre</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Avancement</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RAF (u.)</th>
|
||||
<th className="px-3 py-2 font-medium">Échéance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((m) => {
|
||||
const linked = milestoneLinkedGroups(m, groups)
|
||||
const nStories = linked.length
|
||||
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
|
||||
const raf = milestoneOpenRemainingUnits(m, groups, cfg)
|
||||
const del = delaySummary(m, groups, cfg)
|
||||
const scopeHint =
|
||||
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
|
||||
? `${nStories} story(s) liée(s)`
|
||||
: `Toutes (${nStories})`
|
||||
return (
|
||||
<tr
|
||||
key={m.id}
|
||||
className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.03]"
|
||||
>
|
||||
<td className="px-3 py-2 align-top text-slate-200">
|
||||
{m.title}
|
||||
{m.critical && (
|
||||
<span className="ml-1 text-[9px] font-bold uppercase text-amber-400">
|
||||
critique
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
|
||||
{formatFr(m.date)}
|
||||
</td>
|
||||
<td className="max-w-[200px] px-3 py-2 align-top text-slate-500" title={scopeHint}>
|
||||
{scopeHint}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-right font-mono text-slate-300">
|
||||
{pct}%
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-right font-mono text-slate-400">
|
||||
{raf.toFixed(2)}
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-3 py-2 align-top font-medium ${del.className}`}>
|
||||
{del.text}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
76
src/components/PhaseDistributionChart.tsx
Normal file
76
src/components/PhaseDistributionChart.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import type { PhaseId } from '../types/jira'
|
||||
|
||||
type Props = {
|
||||
counts: Record<PhaseId, number>
|
||||
}
|
||||
|
||||
const PHASE_COLORS: Record<PhaseId, string> = {
|
||||
analyse: '#a78bfa',
|
||||
design: '#fbbf24',
|
||||
integration: '#38bdf8',
|
||||
done: '#34d399',
|
||||
}
|
||||
|
||||
const LABELS: Record<PhaseId, string> = {
|
||||
analyse: 'Analyse',
|
||||
design: 'Design',
|
||||
integration: 'Intégration',
|
||||
done: 'Terminé',
|
||||
}
|
||||
|
||||
export function PhaseDistributionChart({ counts }: Props) {
|
||||
const data = [
|
||||
{
|
||||
name: 'Sous-tâches',
|
||||
[LABELS.analyse]: counts.analyse,
|
||||
[LABELS.design]: counts.design,
|
||||
[LABELS.integration]: counts.integration,
|
||||
[LABELS.done]: counts.done,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex h-[300px] w-full min-h-[300px] min-w-0 flex-col rounded-2xl border border-white/10 bg-slate-950/30 p-3 backdrop-blur-md sm:p-4">
|
||||
<p className="mb-2 shrink-0 text-center text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Répartition par phase (volume global)
|
||||
</p>
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(148,163,184,0.12)" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fill: '#94a3b8', fontSize: 10 }} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" width={100} tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'rgba(15,23,42,0.96)',
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
||||
<Bar dataKey={LABELS.analyse} stackId="a" fill={PHASE_COLORS.analyse} name={LABELS.analyse} />
|
||||
<Bar dataKey={LABELS.design} stackId="a" fill={PHASE_COLORS.design} name={LABELS.design} />
|
||||
<Bar
|
||||
dataKey={LABELS.integration}
|
||||
stackId="a"
|
||||
fill={PHASE_COLORS.integration}
|
||||
name={LABELS.integration}
|
||||
/>
|
||||
<Bar dataKey={LABELS.done} stackId="a" fill={PHASE_COLORS.done} name={LABELS.done} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import type { JiraIssue } from '../types/jira'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import { useLaneLabels } from '../context/LaneLabelsContext'
|
||||
import { laneAggregateState, type WorkLane } from '../lib/laneDetection'
|
||||
import { isIssueDone } from '../lib/statusBuckets'
|
||||
|
||||
const LANES: { lane: WorkLane; label: string; hint: string }[] = [
|
||||
{ lane: 'analyse', label: 'A', hint: 'Piste Analyse' },
|
||||
@ -25,10 +28,16 @@ type Props = {
|
||||
}
|
||||
|
||||
export function PhaseLaneIcons({ subtasks }: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const laneCfg = useLaneLabels()
|
||||
const isDone = (st: JiraIssue) => isIssueDone(st, cfg)
|
||||
return (
|
||||
<div className="flex items-center gap-2" title="État par piste (sous-tâches détectées par libellé)">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
title="État par piste (étiquettes Jira des réglages, puis repli résumé / statut)"
|
||||
>
|
||||
{LANES.map(({ lane, label, hint }) => {
|
||||
const st = laneAggregateState(subtasks, lane)
|
||||
const st = laneAggregateState(subtasks, lane, isDone, laneCfg)
|
||||
return (
|
||||
<span
|
||||
key={lane}
|
||||
|
||||
138
src/components/PipelineOverview.tsx
Normal file
138
src/components/PipelineOverview.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import { useLaneLabels } from '../context/LaneLabelsContext'
|
||||
import { detectWorkLane, type WorkLane } from '../lib/laneDetection'
|
||||
import { getRemainingEstimateUnits } from '../lib/jiraFieldExtractors'
|
||||
import {
|
||||
resolveWorkBucketFromIssue,
|
||||
type WorkStatusBucket,
|
||||
} from '../lib/statusBuckets'
|
||||
|
||||
type Props = {
|
||||
groups: StoryGroup[]
|
||||
}
|
||||
|
||||
const LANES: { id: WorkLane; title: string; subtitle: string }[] = [
|
||||
{ id: 'analyse', title: 'Analyse', subtitle: 'Spécification, cadrage' },
|
||||
{ id: 'design', title: 'Design', subtitle: 'Maquettes, UI/UX' },
|
||||
{ id: 'integration', title: 'Intégration', subtitle: 'Dev, recette, déploiement' },
|
||||
]
|
||||
|
||||
const BUCKET_SEGMENTS: { bucket: WorkStatusBucket; label: string; barClass: string }[] = [
|
||||
{ bucket: 'todo', label: 'À faire', barClass: 'bg-slate-500' },
|
||||
{ bucket: 'in_progress', label: 'En cours', barClass: 'bg-sky-500' },
|
||||
{ bucket: 'blocked', label: 'Bloqué', barClass: 'bg-rose-500' },
|
||||
{ bucket: 'done', label: 'Terminé', barClass: 'bg-emerald-500' },
|
||||
{ bucket: 'cancel', label: 'Annulé', barClass: 'bg-slate-700' },
|
||||
]
|
||||
|
||||
function emptyBuckets(): Record<WorkStatusBucket, number> {
|
||||
return { todo: 0, in_progress: 0, blocked: 0, done: 0, cancel: 0 }
|
||||
}
|
||||
|
||||
export function PipelineOverview({ groups }: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const laneCfg = useLaneLabels()
|
||||
const subs = groups.flatMap((g) => g.subtasks)
|
||||
|
||||
const laneRows = LANES.map(({ id, title, subtitle }) => {
|
||||
const inLane = subs.filter((s) => detectWorkLane(s, laneCfg) === id)
|
||||
const byBucket = emptyBuckets()
|
||||
for (const s of inLane) {
|
||||
byBucket[resolveWorkBucketFromIssue(s, cfg)] += 1
|
||||
}
|
||||
const total = inLane.length
|
||||
let rafUnits = 0
|
||||
for (const s of inLane) {
|
||||
const b = resolveWorkBucketFromIssue(s, cfg)
|
||||
if (b !== 'done' && b !== 'cancel') rafUnits += getRemainingEstimateUnits(s)
|
||||
}
|
||||
return { id, title, subtitle, byBucket, total, rafUnits }
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/35 p-4 backdrop-blur-xl sm:p-5">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Pipeline Analyse / Design / Intégration
|
||||
</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500">
|
||||
Répartition des sous-tâches par piste : étiquettes Jira (réglages), puis repli sur résumé /
|
||||
statut. Catégories de suivi (À faire, En cours, …) selon la cartographie des statuts. RAF =
|
||||
somme du reste Jira (remainingEstimate ÷ 27 000) sur les sous-tâches hors terminé et hors
|
||||
annulé.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-3">
|
||||
{laneRows.map(({ id, title, subtitle, byBucket, total, rafUnits }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3 shadow-inner"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{title}</p>
|
||||
<p className="text-[11px] text-slate-500">{subtitle}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-right font-mono text-xs text-slate-400">
|
||||
<span className="text-slate-300">{total}</span> st
|
||||
<span className="mx-1.5 text-slate-600">·</span>
|
||||
<span
|
||||
title="Reste à faire en unités Jira (Σ remainingEstimate / 27 000), sous-tâches hors terminé et hors annulé"
|
||||
>
|
||||
RAF{' '}
|
||||
<span className="text-slate-300">{rafUnits.toFixed(2)}</span>
|
||||
<span className="text-slate-600"> u.</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="mt-4 text-xs text-slate-500">Aucune sous-tâche sur cette piste.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex h-3 w-full overflow-hidden rounded-full bg-slate-800/90 ring-1 ring-inset ring-white/[0.06]">
|
||||
{BUCKET_SEGMENTS.map(({ bucket, barClass }) => {
|
||||
const n = byBucket[bucket]
|
||||
if (n === 0) return null
|
||||
const pct = (n / total) * 100
|
||||
return (
|
||||
<div
|
||||
key={bucket}
|
||||
className={`${barClass} min-w-0 transition-[width]`}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={`${BUCKET_SEGMENTS.find((b) => b.bucket === bucket)?.label}: ${n}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1 text-[11px] text-slate-400">
|
||||
{BUCKET_SEGMENTS.map(({ bucket, label, barClass }) => {
|
||||
const n = byBucket[bucket]
|
||||
if (n === 0) return null
|
||||
return (
|
||||
<li key={bucket} className="flex items-center justify-between gap-2 tabular-nums">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-sm ${barClass}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-300">
|
||||
{n}{' '}
|
||||
<span className="text-slate-500">
|
||||
({Math.round((n / total) * 100)}%)
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
39
src/components/ProjectRoadmapBar.tsx
Normal file
39
src/components/ProjectRoadmapBar.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import type { Milestone } from '../lib/dashboardConfig'
|
||||
|
||||
type Props = {
|
||||
milestones: Milestone[]
|
||||
}
|
||||
|
||||
export function ProjectRoadmapBar({ milestones }: Props) {
|
||||
const sorted = [...milestones].sort((a, b) => a.date.localeCompare(b.date))
|
||||
if (sorted.length === 0) return null
|
||||
|
||||
const first = sorted[0]!.date
|
||||
const last = sorted[sorted.length - 1]!.date
|
||||
const t0 = new Date(first + 'T00:00:00').getTime()
|
||||
const t1 = new Date(last + 'T23:59:59').getTime()
|
||||
const span = Math.max(1, t1 - t0)
|
||||
const now = Date.now()
|
||||
const pct = Math.max(0, Math.min(100, ((now - t0) / span) * 100))
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border border-white/[0.06] bg-black/20 px-4 py-3">
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">
|
||||
Avancement calendrier (jalons)
|
||||
</p>
|
||||
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500 shadow-[0_0_20px_rgba(34,211,238,0.35)] transition-[width] duration-700"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap justify-between gap-x-2 gap-y-1 text-[10px] text-slate-400">
|
||||
{sorted.map((m) => (
|
||||
<span key={m.id} className="max-w-[120px] truncate text-center" title={m.title}>
|
||||
{m.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -2,10 +2,12 @@ import { useState } from 'react'
|
||||
import type { PhaseId, StoryGroup } from '../types/jira'
|
||||
import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase'
|
||||
import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel'
|
||||
import { storyProgressPercent, stepperStates } from '../lib/storyMetrics'
|
||||
import { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics'
|
||||
import { PhaseStepper } from './PhaseStepper'
|
||||
import { PhaseLaneIcons } from './PhaseLaneIcons'
|
||||
import { blockingSummaryForTooltip } from '../lib/executiveKpis'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import { useLaneLabels } from '../context/LaneLabelsContext'
|
||||
import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors'
|
||||
import { jiraBrowseIssueUrl } from '../lib/jiraLinks'
|
||||
|
||||
@ -55,13 +57,15 @@ function phaseChipClass(phase: PhaseId): string {
|
||||
}
|
||||
|
||||
export function StoryCard({ group, variant = 'default' }: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const laneCfg = useLaneLabels()
|
||||
const { story, subtasks } = group
|
||||
const [subsOpen, setSubsOpen] = useState(true)
|
||||
const progress = storyProgressPercent(subtasks)
|
||||
const steps = stepperStates(subtasks)
|
||||
const progress = subtaskDoneRatioPercent(subtasks, cfg)
|
||||
const steps = stepperStates(subtasks, cfg)
|
||||
const band = priorityBand(story)
|
||||
const assignee = story.fields.assignee?.displayName
|
||||
const blockerHint = blockingSummaryForTooltip(group)
|
||||
const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg)
|
||||
const isBoard = variant === 'board'
|
||||
const spStory = getStoryPoints(story)
|
||||
const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0)
|
||||
@ -133,7 +137,7 @@ export function StoryCard({ group, variant = 'default' }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Progression (sous-tâches)</span>
|
||||
<span>Terminées / sous-tâches</span>
|
||||
<span className="font-semibold tabular-nums text-emerald-300">{progress}%</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
19
src/context/LaneLabelsContext.tsx
Normal file
19
src/context/LaneLabelsContext.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { LaneLabelsConfig } from '../lib/laneDetection'
|
||||
import { defaultLaneLabelsConfig } from '../lib/laneDetection'
|
||||
|
||||
const LaneLabelsContext = createContext<LaneLabelsConfig>(defaultLaneLabelsConfig())
|
||||
|
||||
export function LaneLabelsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: LaneLabelsConfig
|
||||
children: ReactNode
|
||||
}) {
|
||||
return <LaneLabelsContext.Provider value={value}>{children}</LaneLabelsContext.Provider>
|
||||
}
|
||||
|
||||
export function useLaneLabels(): LaneLabelsConfig {
|
||||
return useContext(LaneLabelsContext)
|
||||
}
|
||||
19
src/context/StatusBucketContext.tsx
Normal file
19
src/context/StatusBucketContext.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { StatusBucketConfig } from '../lib/statusBuckets'
|
||||
import { defaultStatusBucketConfig } from '../lib/statusBuckets'
|
||||
|
||||
const StatusBucketContext = createContext<StatusBucketConfig>(defaultStatusBucketConfig())
|
||||
|
||||
export function StatusBucketProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: StatusBucketConfig
|
||||
children: ReactNode
|
||||
}) {
|
||||
return <StatusBucketContext.Provider value={value}>{children}</StatusBucketContext.Provider>
|
||||
}
|
||||
|
||||
export function useStatusBuckets(): StatusBucketConfig {
|
||||
return useContext(StatusBucketContext)
|
||||
}
|
||||
@ -5,11 +5,31 @@ export type Milestone = {
|
||||
date: string
|
||||
/** Clés de stories à contrôler pour le statut « retard » ; vide = toutes les stories chargées */
|
||||
linkedStoryKeys?: string[]
|
||||
/** Jalon critique : alerte d’impact si retard après la date. */
|
||||
critical?: boolean
|
||||
}
|
||||
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { defaultStatusBucketConfig, mergeStatusBucketConfig } from './statusBuckets'
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import { defaultLaneLabelsConfig, mergeLaneLabelsConfig } from './laneDetection'
|
||||
|
||||
export type { StatusBucketConfig } from './statusBuckets'
|
||||
export type { LaneLabelsConfig } from './laneDetection'
|
||||
|
||||
export type DashboardConfig = {
|
||||
version: 1
|
||||
milestones: Milestone[]
|
||||
/** Cartographie statuts Jira → To do / In progress / Blocked / Done / Cancel. */
|
||||
statusBuckets: StatusBucketConfig
|
||||
/** Étiquettes Jira par piste (Analyse / Design / Intégration) et pour le marquage bloqué. */
|
||||
laneLabels: LaneLabelsConfig
|
||||
/** Effectif pris en compte pour la projection (développeurs actifs). */
|
||||
teamCapacity: number
|
||||
/** Effectif de référence pour l’échelle de vélocité (ex. 3). */
|
||||
baselineCapacity: number
|
||||
/** Sous-tâches ouvertes « nominale » par personne pour la jauge charge vs capacité. */
|
||||
wipSlotsPerDev: number
|
||||
myJiraAccountId?: string
|
||||
myJiraEmail?: string
|
||||
/** Filtre « Ma vue » (sous-tâches me concernant). */
|
||||
@ -21,6 +41,11 @@ const STORAGE_KEY = 'dcc-dashboard-config-v1'
|
||||
export const defaultDashboardConfig = (): DashboardConfig => ({
|
||||
version: 1,
|
||||
milestones: [],
|
||||
statusBuckets: defaultStatusBucketConfig(),
|
||||
laneLabels: defaultLaneLabelsConfig(),
|
||||
teamCapacity: 3,
|
||||
baselineCapacity: 3,
|
||||
wipSlotsPerDev: 5,
|
||||
})
|
||||
|
||||
export function loadDashboardConfig(): DashboardConfig {
|
||||
@ -32,6 +57,28 @@ export function loadDashboardConfig(): DashboardConfig {
|
||||
return {
|
||||
version: 1,
|
||||
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
|
||||
statusBuckets: mergeStatusBucketConfig(
|
||||
parsed.statusBuckets && typeof parsed.statusBuckets === 'object'
|
||||
? (parsed.statusBuckets as Partial<StatusBucketConfig>)
|
||||
: undefined,
|
||||
),
|
||||
laneLabels: mergeLaneLabelsConfig(
|
||||
parsed.laneLabels && typeof parsed.laneLabels === 'object'
|
||||
? (parsed.laneLabels as Partial<LaneLabelsConfig>)
|
||||
: undefined,
|
||||
),
|
||||
teamCapacity:
|
||||
typeof parsed.teamCapacity === 'number' && Number.isFinite(parsed.teamCapacity)
|
||||
? Math.max(0.25, parsed.teamCapacity)
|
||||
: 3,
|
||||
baselineCapacity:
|
||||
typeof parsed.baselineCapacity === 'number' && Number.isFinite(parsed.baselineCapacity)
|
||||
? Math.max(0.25, parsed.baselineCapacity)
|
||||
: 3,
|
||||
wipSlotsPerDev:
|
||||
typeof parsed.wipSlotsPerDev === 'number' && Number.isFinite(parsed.wipSlotsPerDev)
|
||||
? Math.max(1, Math.floor(parsed.wipSlotsPerDev))
|
||||
: 5,
|
||||
myJiraAccountId: parsed.myJiraAccountId,
|
||||
myJiraEmail: parsed.myJiraEmail,
|
||||
myViewActive: parsed.myViewActive,
|
||||
@ -65,6 +112,26 @@ export function mergeImportedConfig(
|
||||
return {
|
||||
version: 1,
|
||||
milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones,
|
||||
statusBuckets:
|
||||
o.statusBuckets && typeof o.statusBuckets === 'object'
|
||||
? mergeStatusBucketConfig(o.statusBuckets as Partial<StatusBucketConfig>)
|
||||
: current.statusBuckets,
|
||||
laneLabels:
|
||||
o.laneLabels && typeof o.laneLabels === 'object'
|
||||
? mergeLaneLabelsConfig(o.laneLabels as Partial<LaneLabelsConfig>)
|
||||
: current.laneLabels,
|
||||
teamCapacity:
|
||||
typeof o.teamCapacity === 'number' && Number.isFinite(o.teamCapacity)
|
||||
? Math.max(0.25, o.teamCapacity)
|
||||
: current.teamCapacity,
|
||||
baselineCapacity:
|
||||
typeof o.baselineCapacity === 'number' && Number.isFinite(o.baselineCapacity)
|
||||
? Math.max(0.25, o.baselineCapacity)
|
||||
: current.baselineCapacity,
|
||||
wipSlotsPerDev:
|
||||
typeof o.wipSlotsPerDev === 'number' && Number.isFinite(o.wipSlotsPerDev)
|
||||
? Math.max(1, Math.floor(o.wipSlotsPerDev))
|
||||
: current.wipSlotsPerDev,
|
||||
myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId,
|
||||
myJiraEmail: o.myJiraEmail ?? current.myJiraEmail,
|
||||
myViewActive: o.myViewActive ?? current.myViewActive,
|
||||
|
||||
100
src/lib/executiveHealth.ts
Normal file
100
src/lib/executiveHealth.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { Milestone } from './dashboardConfig'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueDone, resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
|
||||
export type Verdict = 'on_track' | 'at_risk' | 'critical'
|
||||
|
||||
export function isStoryDoneForReporting(g: StoryGroup, cfg: StatusBucketConfig): boolean {
|
||||
if (g.subtasks.length === 0) return isIssueDone(g.story, cfg)
|
||||
return g.subtasks.every((s) => isIssueDone(s, cfg))
|
||||
}
|
||||
|
||||
export function scopeCompletionPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
|
||||
if (groups.length === 0) return 0
|
||||
const done = groups.filter((g) => isStoryDoneForReporting(g, cfg)).length
|
||||
return Math.round((done / groups.length) * 100)
|
||||
}
|
||||
|
||||
/** Dernière échéance parmi les jalons (ISO yyyy-mm-dd). */
|
||||
export function latestMilestoneDateIso(milestones: Milestone[]): string | null {
|
||||
if (milestones.length === 0) return null
|
||||
return [...milestones].sort((a, b) => b.date.localeCompare(a.date))[0]!.date
|
||||
}
|
||||
|
||||
/**
|
||||
* Score 0–100 : marge calendaire entre atterrissage estimé et jalon final
|
||||
* (100 = atterrissage avant ou le jour du jalon, baisse si dépassement).
|
||||
*/
|
||||
export function deadlineHealthScore(estimatedLanding: Date | null, finalIso: string | null): number {
|
||||
if (!estimatedLanding || !finalIso) return 55
|
||||
const end = new Date(finalIso + 'T23:59:59')
|
||||
const diffMs = end.getTime() - estimatedLanding.getTime()
|
||||
const diffDays = diffMs / 86400000
|
||||
if (diffDays >= 5) return 100
|
||||
if (diffDays >= 0) return Math.round(70 + (diffDays / 5) * 30)
|
||||
if (diffDays >= -5) return Math.round(Math.max(0, 70 + diffDays * 14))
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Score 0–100 : marge de charge (100 = peu de sous-tâches ouvertes vs capacité nominale).
|
||||
*/
|
||||
export function resourceHealthScore(
|
||||
openSubtasks: number,
|
||||
teamCapacity: number,
|
||||
wipSlotsPerDev: number,
|
||||
): number {
|
||||
const cap = Math.max(1, teamCapacity * Math.max(1, wipSlotsPerDev))
|
||||
const util = openSubtasks / cap
|
||||
return Math.max(0, Math.min(100, Math.round(100 - util * 100)))
|
||||
}
|
||||
|
||||
export function computeVerdict(
|
||||
deadlineScore: number,
|
||||
scopePct: number,
|
||||
resourceScore: number,
|
||||
): Verdict {
|
||||
if (deadlineScore < 22 || scopePct < 28 || resourceScore < 18) return 'critical'
|
||||
if (deadlineScore < 52 || scopePct < 50 || resourceScore < 40) return 'at_risk'
|
||||
return 'on_track'
|
||||
}
|
||||
|
||||
/** Messages d’impact si un jalon critique est en retard. */
|
||||
export function criticalMilestoneImpactMessages(
|
||||
milestones: Milestone[],
|
||||
groups: StoryGroup[],
|
||||
velocityPerBusinessDay: number,
|
||||
cfg: StatusBucketConfig,
|
||||
): string[] {
|
||||
const msgs: string[] = []
|
||||
const v = velocityPerBusinessDay > 0.001 ? velocityPerBusinessDay : null
|
||||
for (const m of milestones) {
|
||||
if (!m.critical) continue
|
||||
const deadline = new Date(m.date + 'T23:59:59')
|
||||
if (new Date() <= deadline) continue
|
||||
const keys = m.linkedStoryKeys?.length ? m.linkedStoryKeys : groups.map((g) => g.story.key)
|
||||
const linked = groups.filter((g) => keys.includes(g.story.key))
|
||||
const open = linked.reduce(
|
||||
(acc, g) =>
|
||||
acc +
|
||||
g.subtasks.filter((s) => {
|
||||
const b = resolveWorkBucketFromIssue(s, cfg)
|
||||
return b !== 'done' && b !== 'cancel'
|
||||
}).length,
|
||||
0,
|
||||
)
|
||||
if (open === 0) continue
|
||||
if (v == null) {
|
||||
msgs.push(
|
||||
`« ${m.title} » (critique) : échéance dépassée — ${open} sous-tâche(s) encore ouvertes. Vélocité insuffisante pour quantifier le glissement.`,
|
||||
)
|
||||
} else {
|
||||
const slip = Math.ceil(open / v)
|
||||
msgs.push(
|
||||
`« ${m.title} » (critique) : retard — ~${slip} jour(s) ouvrés de charge restante à la vélocité actuelle, impact probable sur la date finale.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import type { JiraIssue, StoryGroup } from '../types/jira'
|
||||
import { statusToPhase } from './statusPhase'
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import { issueHasBlockedLaneLabel } from './laneDetection'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueBlocked, isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
|
||||
function norm(s: string): string {
|
||||
return s
|
||||
@ -34,12 +37,14 @@ export function goldenCarbonSubtasks(groups: StoryGroup[]): JiraIssue[] {
|
||||
)
|
||||
}
|
||||
|
||||
/** Progression globale : sous-tâches terminées / sous-tâches totales. */
|
||||
export function globalProgressPercent(groups: StoryGroup[]): number {
|
||||
/** Progression globale : sous-tâches Done / (toutes sauf Cancel). */
|
||||
export function globalProgressPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
|
||||
const subs = allSubtasks(groups)
|
||||
if (subs.length === 0) return 0
|
||||
const done = subs.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
|
||||
return Math.round((done / subs.length) * 100)
|
||||
const active = subs.filter((s) => !isIssueCanceled(s, cfg))
|
||||
if (active.length === 0) return 0
|
||||
const done = active.filter((s) => isIssueDone(s, cfg)).length
|
||||
return Math.round((done / active.length) * 100)
|
||||
}
|
||||
|
||||
/** Sous-tâches considérées comme « maquette » (libellé à ajuster selon votre vocabulaire Jira). */
|
||||
@ -49,11 +54,13 @@ export function isMaquetteRelated(st: JiraIssue): boolean {
|
||||
}
|
||||
|
||||
/** % de maquettes validées parmi les sous-tâches identifiées comme maquettes. */
|
||||
export function designHealthPercent(groups: StoryGroup[]): number {
|
||||
export function designHealthPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
|
||||
const candidates = maquetteRelatedSubtasks(groups)
|
||||
if (candidates.length === 0) return 0
|
||||
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
|
||||
return Math.round((ok / candidates.length) * 100)
|
||||
const active = candidates.filter((s) => !isIssueCanceled(s, cfg))
|
||||
if (active.length === 0) return 0
|
||||
const ok = active.filter((s) => isIssueDone(s, cfg)).length
|
||||
return Math.round((ok / active.length) * 100)
|
||||
}
|
||||
|
||||
function textWithComponents(st: JiraIssue, story: JiraIssue): string {
|
||||
@ -68,29 +75,55 @@ function isGoldenCarbonRelated(st: JiraIssue, story: JiraIssue): boolean {
|
||||
return /golden\s*carbon|goldencarbon/i.test(textWithComponents(st, story))
|
||||
}
|
||||
|
||||
export function goldenCarbonHealthPercent(groups: StoryGroup[]): number {
|
||||
export function goldenCarbonHealthPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
|
||||
const candidates = goldenCarbonSubtasks(groups)
|
||||
if (candidates.length === 0) return 0
|
||||
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
|
||||
return Math.round((ok / candidates.length) * 100)
|
||||
const active = candidates.filter((s) => !isIssueCanceled(s, cfg))
|
||||
if (active.length === 0) return 0
|
||||
const ok = active.filter((s) => isIssueDone(s, cfg)).length
|
||||
return Math.round((ok / active.length) * 100)
|
||||
}
|
||||
|
||||
export function blockingTicketsCount(groups: StoryGroup[]): number {
|
||||
function isBlockingIssue(
|
||||
i: JiraIssue,
|
||||
statusCfg: StatusBucketConfig,
|
||||
laneCfg: LaneLabelsConfig,
|
||||
): boolean {
|
||||
return (
|
||||
isIssueBlocked(i, statusCfg) ||
|
||||
isBlockingStatus(i.fields.status.name) ||
|
||||
issueHasBlockedLaneLabel(i, laneCfg)
|
||||
)
|
||||
}
|
||||
|
||||
export function blockingTicketsCount(
|
||||
groups: StoryGroup[],
|
||||
statusCfg: StatusBucketConfig,
|
||||
laneCfg: LaneLabelsConfig,
|
||||
): number {
|
||||
const issues: JiraIssue[] = [
|
||||
...groups.map((g) => g.story),
|
||||
...allSubtasks(groups),
|
||||
]
|
||||
return issues.filter((i) => isBlockingStatus(i.fields.status.name)).length
|
||||
return issues.filter((i) => isBlockingIssue(i, statusCfg, laneCfg)).length
|
||||
}
|
||||
|
||||
export function blockingIssuesInGroup(group: StoryGroup): JiraIssue[] {
|
||||
export function blockingIssuesInGroup(
|
||||
group: StoryGroup,
|
||||
statusCfg: StatusBucketConfig,
|
||||
laneCfg: LaneLabelsConfig,
|
||||
): JiraIssue[] {
|
||||
return [group.story, ...group.subtasks].filter((i) =>
|
||||
isBlockingStatus(i.fields.status.name),
|
||||
isBlockingIssue(i, statusCfg, laneCfg),
|
||||
)
|
||||
}
|
||||
|
||||
export function blockingSummaryForTooltip(group: StoryGroup): string {
|
||||
const list = blockingIssuesInGroup(group)
|
||||
export function blockingSummaryForTooltip(
|
||||
group: StoryGroup,
|
||||
statusCfg: StatusBucketConfig,
|
||||
laneCfg: LaneLabelsConfig,
|
||||
): string {
|
||||
const list = blockingIssuesInGroup(group, statusCfg, laneCfg)
|
||||
if (list.length === 0) return 'Aucun ticket bloquant sur cette story.'
|
||||
return list.map((i) => `${i.key} — ${i.fields.status.name}: ${i.fields.summary}`).join('\n')
|
||||
}
|
||||
|
||||
110
src/lib/executiveLanding.ts
Normal file
110
src/lib/executiveLanding.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { BurnupPoint } from './burnupHistory'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
|
||||
function daySpanInclusive(a: string, b: string): number {
|
||||
const t0 = new Date(a + 'T12:00:00').getTime()
|
||||
const t1 = new Date(b + 'T12:00:00').getTime()
|
||||
return Math.max(1, Math.round((t1 - t0) / 86400000))
|
||||
}
|
||||
|
||||
/**
|
||||
* Vélocité moyenne : sous-tâches **terminées par jour calendaire** sur les `windowDays` derniers jours,
|
||||
* à partir des deltas de l’historique burnup (un point par date).
|
||||
*/
|
||||
export function velocitySubtasksDonePerDay(
|
||||
points: BurnupPoint[],
|
||||
windowDays = 14,
|
||||
): number {
|
||||
if (points.length === 0) return 0
|
||||
const sorted = [...points].sort((a, b) => a.date.localeCompare(b.date))
|
||||
const last = sorted[sorted.length - 1]!
|
||||
const end = new Date(last.date + 'T12:00:00')
|
||||
const start = new Date(end)
|
||||
start.setDate(start.getDate() - windowDays)
|
||||
const startStr = start.toISOString().slice(0, 10)
|
||||
const slice = sorted.filter((p) => p.date >= startStr)
|
||||
if (slice.length < 2) {
|
||||
const first = sorted[0]!
|
||||
const span = daySpanInclusive(first.date, last.date)
|
||||
return Math.max(0, last.done - first.done) / span
|
||||
}
|
||||
let sum = 0
|
||||
let n = 0
|
||||
for (let i = 1; i < slice.length; i++) {
|
||||
const prev = slice[i - 1]!
|
||||
const cur = slice[i]!
|
||||
const delta = Math.max(0, cur.done - prev.done)
|
||||
const span = daySpanInclusive(prev.date, cur.date)
|
||||
sum += delta / span
|
||||
n += 1
|
||||
}
|
||||
return n > 0 ? sum / n : 0
|
||||
}
|
||||
|
||||
/** Sous-tâches encore à traiter (hors Done et hors Cancel). */
|
||||
export function countOpenSubtasks(groups: StoryGroup[], cfg: StatusBucketConfig): number {
|
||||
return groups.reduce(
|
||||
(acc, g) =>
|
||||
acc +
|
||||
g.subtasks.filter((s) => {
|
||||
const b = resolveWorkBucketFromIssue(s, cfg)
|
||||
return b !== 'done' && b !== 'cancel'
|
||||
}).length,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Avance le calendrier en **jours ouvrés** (lun–ven), sans compter week-ends. */
|
||||
export function addBusinessDays(from: Date, businessDays: number): Date {
|
||||
const d = new Date(from.getFullYear(), from.getMonth(), from.getDate())
|
||||
let left = Math.max(0, Math.ceil(businessDays))
|
||||
while (left > 0) {
|
||||
d.setDate(d.getDate() + 1)
|
||||
const w = d.getDay()
|
||||
if (w !== 0 && w !== 6) left -= 1
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
export type LandingEstimate = {
|
||||
/** Sous-tâches restantes (non terminées). */
|
||||
remainingSubtasks: number
|
||||
/** Moyenne issue de l’historique burnup (terminées / jour calendaire). */
|
||||
rawVelocityPerDay: number
|
||||
/** Après prise en compte de la capacité vs baseline. */
|
||||
effectiveVelocityPerDay: number
|
||||
/** Jours ouvrés estimés pour finir (null si vélocité nulle). */
|
||||
businessDaysToFinish: number | null
|
||||
/** Date d’atterrissage projetée (jours ouvrés). */
|
||||
estimatedLanding: Date | null
|
||||
}
|
||||
|
||||
export function computeLandingEstimate(
|
||||
groups: StoryGroup[],
|
||||
burnup: BurnupPoint[],
|
||||
teamCapacity: number,
|
||||
baselineCapacity: number,
|
||||
cfg: StatusBucketConfig,
|
||||
windowDays = 14,
|
||||
): LandingEstimate {
|
||||
const remainingSubtasks = countOpenSubtasks(groups, cfg)
|
||||
const rawVelocityPerDay = velocitySubtasksDonePerDay(burnup, windowDays)
|
||||
const base = Math.max(1, baselineCapacity)
|
||||
const cap = Math.max(0.25, teamCapacity)
|
||||
const effectiveVelocityPerDay = rawVelocityPerDay * (cap / base)
|
||||
const businessDaysToFinish =
|
||||
effectiveVelocityPerDay > 0.001
|
||||
? Math.ceil(remainingSubtasks / effectiveVelocityPerDay)
|
||||
: null
|
||||
const estimatedLanding =
|
||||
businessDaysToFinish != null ? addBusinessDays(new Date(), businessDaysToFinish) : null
|
||||
return {
|
||||
remainingSubtasks,
|
||||
rawVelocityPerDay,
|
||||
effectiveVelocityPerDay,
|
||||
businessDaysToFinish,
|
||||
estimatedLanding,
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,76 @@ import type { JiraIssue, JiraStatus } from '../types/jira'
|
||||
|
||||
export type WorkLane = 'analyse' | 'design' | 'integration'
|
||||
|
||||
/** Regroupe les sous-tâches par « piste » Analyse / Design / Intégration (mots-clés + repli sur le statut). */
|
||||
export function detectWorkLane(subtask: JiraIssue): WorkLane {
|
||||
/** Étiquettes Jira (comparaison insensible à la casse et aux accents) par piste et pour le marquage bloqué. */
|
||||
export type LaneLabelsConfig = {
|
||||
analyse: string[]
|
||||
design: string[]
|
||||
integration: string[]
|
||||
blocked: string[]
|
||||
}
|
||||
|
||||
function normLabel(s: string): string {
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
}
|
||||
|
||||
function issueNormalizedLabels(issue: JiraIssue): Set<string> {
|
||||
const raw = issue.fields.labels ?? []
|
||||
return new Set(raw.map((l) => normLabel(String(l))))
|
||||
}
|
||||
|
||||
function matchesConfiguredLabels(issue: JiraIssue, configured: string[]): boolean {
|
||||
if (configured.length === 0) return false
|
||||
const set = issueNormalizedLabels(issue)
|
||||
return configured.some((c) => set.has(normLabel(c)))
|
||||
}
|
||||
|
||||
export function defaultLaneLabelsConfig(): LaneLabelsConfig {
|
||||
return {
|
||||
analyse: ['analyse'],
|
||||
design: ['design'],
|
||||
integration: ['integration'],
|
||||
blocked: ['blocked'],
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeLaneLabelsConfig(partial?: Partial<LaneLabelsConfig>): LaneLabelsConfig {
|
||||
const d = defaultLaneLabelsConfig()
|
||||
if (!partial) return d
|
||||
const pick = (key: keyof LaneLabelsConfig): string[] => {
|
||||
const v = partial[key]
|
||||
return Array.isArray(v) && v.length > 0 ? [...v] : d[key]
|
||||
}
|
||||
return {
|
||||
analyse: pick('analyse'),
|
||||
design: pick('design'),
|
||||
integration: pick('integration'),
|
||||
blocked: pick('blocked'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Piste déduite des étiquettes uniquement (ordre : analyse → design → intégration).
|
||||
* `null` si aucune étiquette configurée ne correspond.
|
||||
*/
|
||||
export function workLaneFromLabels(issue: JiraIssue, cfg: LaneLabelsConfig): WorkLane | null {
|
||||
if (matchesConfiguredLabels(issue, cfg.analyse)) return 'analyse'
|
||||
if (matchesConfiguredLabels(issue, cfg.design)) return 'design'
|
||||
if (matchesConfiguredLabels(issue, cfg.integration)) return 'integration'
|
||||
return null
|
||||
}
|
||||
|
||||
export function issueHasBlockedLaneLabel(issue: JiraIssue, cfg: LaneLabelsConfig): boolean {
|
||||
return matchesConfiguredLabels(issue, cfg.blocked)
|
||||
}
|
||||
|
||||
/** Repli historique : résumé + nom de statut (si pas d’étiquette de piste reconnue). */
|
||||
export function detectWorkLaneHeuristic(subtask: JiraIssue): WorkLane {
|
||||
const s = subtask.fields.summary.toLowerCase()
|
||||
if (
|
||||
/\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s)
|
||||
) {
|
||||
if (/\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s)) {
|
||||
return 'analyse'
|
||||
}
|
||||
if (/\bdesign\b|maquette|figma|wireframe|zoning|ui\b|ux\b/i.test(s)) {
|
||||
@ -24,6 +88,13 @@ export function detectWorkLane(subtask: JiraIssue): WorkLane {
|
||||
return 'integration'
|
||||
}
|
||||
|
||||
/** Piste de travail : étiquettes Jira (réglages) puis heuristique résumé / statut. */
|
||||
export function detectWorkLane(subtask: JiraIssue, laneLabels: LaneLabelsConfig): WorkLane {
|
||||
const fromLabels = workLaneFromLabels(subtask, laneLabels)
|
||||
if (fromLabels) return fromLabels
|
||||
return detectWorkLaneHeuristic(subtask)
|
||||
}
|
||||
|
||||
export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' | 'done' | 'unknown' {
|
||||
const k = status.statusCategory?.key
|
||||
if (k === 'new') return 'new'
|
||||
@ -32,17 +103,20 @@ export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' |
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** Couleur logique pour une piste : vert = tout terminé, bleu = en cours, gris = à faire / inconnu. */
|
||||
/**
|
||||
* Couleur logique pour une piste : vert = tout terminé (via cartographie statuts),
|
||||
* bleu = mix, gris = à faire.
|
||||
*/
|
||||
export function laneAggregateState(
|
||||
subtasks: JiraIssue[],
|
||||
lane: WorkLane,
|
||||
isDone: (st: JiraIssue) => boolean,
|
||||
laneLabels: LaneLabelsConfig,
|
||||
): 'empty' | 'grey' | 'blue' | 'green' {
|
||||
const inLane = subtasks.filter((st) => detectWorkLane(st) === lane)
|
||||
const inLane = subtasks.filter((st) => detectWorkLane(st, laneLabels) === lane)
|
||||
if (inLane.length === 0) return 'empty'
|
||||
const cats = inLane.map((st) => statusCategoryKey(st.fields.status))
|
||||
if (cats.every((c) => c === 'done')) return 'green'
|
||||
if (cats.some((c) => c === 'indeterminate')) return 'blue'
|
||||
if (cats.some((c) => c === 'done') && cats.some((c) => c !== 'done')) return 'blue'
|
||||
if (inLane.every(isDone)) return 'green'
|
||||
if (inLane.some(isDone) && inLane.some((st) => !isDone(st))) return 'blue'
|
||||
const names = inLane.map((st) => st.fields.status.name.toLowerCase()).join(' ')
|
||||
if (
|
||||
/en cours|in progress|review|recette|test|qa|intégration|integration|development/i.test(names)
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { Milestone } from './dashboardConfig'
|
||||
import { storyProgressPercent } from './storyMetrics'
|
||||
import { getRemainingEstimateUnits } from './jiraFieldExtractors'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
import { subtaskDoneRatioPercent } from './storyMetrics'
|
||||
|
||||
/** Pour les jalons : story sans sous-tâche = considérée comme « livrée » côté sous-tâches. */
|
||||
function storyCompletionForMilestone(g: StoryGroup): number {
|
||||
function storyCompletionForMilestone(g: StoryGroup, cfg: StatusBucketConfig): number {
|
||||
if (g.subtasks.length === 0) return 100
|
||||
return storyProgressPercent(g.subtasks)
|
||||
return subtaskDoneRatioPercent(g.subtasks, cfg)
|
||||
}
|
||||
|
||||
function endOfDay(isoDate: string): Date {
|
||||
@ -14,19 +17,61 @@ function endOfDay(isoDate: string): Date {
|
||||
return d
|
||||
}
|
||||
|
||||
/** Jalon en retard : date dépassée et au moins une story concernée n’est pas à 100 %. */
|
||||
export function isMilestoneLate(m: Milestone, groups: StoryGroup[]): boolean {
|
||||
if (groups.length === 0) return false
|
||||
const deadline = endOfDay(m.date)
|
||||
if (new Date() <= deadline) return false
|
||||
/** Stories du périmètre du jalon (clés liées si renseignées, sinon tout le chargement). */
|
||||
export function milestoneLinkedGroups(m: Milestone, groups: StoryGroup[]): StoryGroup[] {
|
||||
const keys =
|
||||
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
|
||||
? m.linkedStoryKeys
|
||||
: groups.map((g) => g.story.key)
|
||||
for (const key of keys) {
|
||||
const g = groups.find((x) => x.story.key === key)
|
||||
if (!g) continue
|
||||
if (storyCompletionForMilestone(g) < 100) return true
|
||||
const set = new Set(keys)
|
||||
return groups.filter((g) => set.has(g.story.key))
|
||||
}
|
||||
|
||||
/** Moyenne des % sous-tâches terminées sur les stories du périmètre (0–100). */
|
||||
export function milestoneAverageCompletionPercent(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
): number {
|
||||
const linked = milestoneLinkedGroups(m, groups)
|
||||
if (linked.length === 0) return 100
|
||||
const sum = linked.reduce((acc, g) => acc + storyCompletionForMilestone(g, cfg), 0)
|
||||
return Math.round(sum / linked.length)
|
||||
}
|
||||
|
||||
/** Somme du reste à faire (unités Jira) sur sous-tâches non terminées / non annulées du périmètre. */
|
||||
export function milestoneOpenRemainingUnits(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
): number {
|
||||
let u = 0
|
||||
for (const g of milestoneLinkedGroups(m, groups)) {
|
||||
for (const s of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(s, cfg)
|
||||
if (b !== 'done' && b !== 'cancel') u += getRemainingEstimateUnits(s)
|
||||
}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
/** Jours calendaires jusqu’à la fin du jour du jalon (négatif = retard). */
|
||||
export function milestoneCalendarDaysUntil(m: Milestone): number {
|
||||
const end = endOfDay(m.date)
|
||||
return Math.ceil((end.getTime() - Date.now()) / 86400000)
|
||||
}
|
||||
|
||||
/** Jalon en retard : date dépassée et au moins une story concernée n’est pas à 100 %. */
|
||||
export function isMilestoneLate(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
): boolean {
|
||||
if (groups.length === 0) return false
|
||||
const deadline = endOfDay(m.date)
|
||||
if (new Date() <= deadline) return false
|
||||
for (const g of milestoneLinkedGroups(m, groups)) {
|
||||
if (storyCompletionForMilestone(g, cfg) < 100) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
13
src/lib/phaseAggregate.ts
Normal file
13
src/lib/phaseAggregate.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { PhaseId, StoryGroup } from '../types/jira'
|
||||
import { statusToPhase } from './statusPhase'
|
||||
|
||||
export function countSubtasksByPhase(groups: StoryGroup[]): Record<PhaseId, number> {
|
||||
const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 }
|
||||
for (const g of groups) {
|
||||
for (const s of g.subtasks) {
|
||||
const p = statusToPhase(s.fields.status.name)
|
||||
acc[p] += 1
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
125
src/lib/statusBuckets.ts
Normal file
125
src/lib/statusBuckets.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import type { JiraIssue } from '../types/jira'
|
||||
|
||||
/** Catégories de suivi (paramétrables selon vos libellés Jira). */
|
||||
export type WorkStatusBucket = 'todo' | 'in_progress' | 'blocked' | 'done' | 'cancel'
|
||||
|
||||
export type StatusBucketConfig = {
|
||||
todo: string[]
|
||||
in_progress: string[]
|
||||
blocked: string[]
|
||||
done: string[]
|
||||
cancel: string[]
|
||||
}
|
||||
|
||||
export function normalizeStatusLabel(s: string): string {
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
}
|
||||
|
||||
function matchesList(statusName: string, list: string[]): boolean {
|
||||
const n = normalizeStatusLabel(statusName)
|
||||
return list.some((entry) => normalizeStatusLabel(entry) === n)
|
||||
}
|
||||
|
||||
/** Libellés Jira par défaut (insensible à la casse / accents au match). */
|
||||
export function defaultStatusBucketConfig(): StatusBucketConfig {
|
||||
return {
|
||||
todo: [
|
||||
'To Do',
|
||||
'Todo',
|
||||
'À faire',
|
||||
'A faire',
|
||||
'Open',
|
||||
'Ouvert',
|
||||
'0-OUVERT',
|
||||
'0-Ouvert',
|
||||
'New',
|
||||
'Nouveau',
|
||||
'Backlog',
|
||||
'Sélectionné',
|
||||
'Selectionne',
|
||||
],
|
||||
in_progress: [
|
||||
'In Progress',
|
||||
'En cours',
|
||||
'In development',
|
||||
'Code Review',
|
||||
'Review',
|
||||
'Recette',
|
||||
'Test',
|
||||
'QA',
|
||||
'Intégration',
|
||||
'Integration',
|
||||
'Prêt pour développement',
|
||||
'Pret pour developpement',
|
||||
],
|
||||
blocked: ['Blocked', 'Bloqué', 'Bloque'],
|
||||
done: [
|
||||
'Done',
|
||||
'Terminé',
|
||||
'Termine',
|
||||
'Closed',
|
||||
'Resolved',
|
||||
'Résolu',
|
||||
'Resolu',
|
||||
'Livré',
|
||||
'Livre',
|
||||
'Fermé',
|
||||
'Ferme',
|
||||
],
|
||||
cancel: ['Cancelled', 'Canceled', 'Annulé', 'Annule', "Won't fix", 'Wontfix'],
|
||||
}
|
||||
}
|
||||
|
||||
/** Fusionne la config sauvegardée avec les défauts (liste vide sur un seau → défaut). */
|
||||
export function mergeStatusBucketConfig(partial?: Partial<StatusBucketConfig>): StatusBucketConfig {
|
||||
const d = defaultStatusBucketConfig()
|
||||
if (!partial) return d
|
||||
const pick = (key: keyof StatusBucketConfig): string[] => {
|
||||
const v = partial[key]
|
||||
return Array.isArray(v) && v.length > 0 ? [...v] : d[key]
|
||||
}
|
||||
return {
|
||||
todo: pick('todo'),
|
||||
in_progress: pick('in_progress'),
|
||||
blocked: pick('blocked'),
|
||||
done: pick('done'),
|
||||
cancel: pick('cancel'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le seau d’un ticket : listes explicites puis repli sur `statusCategory` Jira.
|
||||
*/
|
||||
export function resolveWorkBucketFromIssue(
|
||||
issue: JiraIssue,
|
||||
cfg: StatusBucketConfig,
|
||||
): WorkStatusBucket {
|
||||
const name = issue.fields.status?.name ?? ''
|
||||
if (matchesList(name, cfg.blocked)) return 'blocked'
|
||||
if (matchesList(name, cfg.cancel)) return 'cancel'
|
||||
if (matchesList(name, cfg.done)) return 'done'
|
||||
if (matchesList(name, cfg.in_progress)) return 'in_progress'
|
||||
if (matchesList(name, cfg.todo)) return 'todo'
|
||||
|
||||
const cat = issue.fields.status?.statusCategory?.key
|
||||
if (cat === 'done') return 'done'
|
||||
if (cat === 'new') return 'todo'
|
||||
if (cat === 'indeterminate') return 'in_progress'
|
||||
return 'todo'
|
||||
}
|
||||
|
||||
export function isIssueDone(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
|
||||
return resolveWorkBucketFromIssue(issue, cfg) === 'done'
|
||||
}
|
||||
|
||||
export function isIssueCanceled(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
|
||||
return resolveWorkBucketFromIssue(issue, cfg) === 'cancel'
|
||||
}
|
||||
|
||||
export function isIssueBlocked(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
|
||||
return resolveWorkBucketFromIssue(issue, cfg) === 'blocked'
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import type { JiraIssue, PhaseId } from '../types/jira'
|
||||
import { PHASE_ORDER, statusToPhase } from './statusPhase'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
|
||||
function phaseRank(p: PhaseId): number {
|
||||
const i = PHASE_ORDER.indexOf(p)
|
||||
@ -31,11 +33,28 @@ export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boole
|
||||
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
|
||||
}
|
||||
|
||||
export function stepperStates(subtasks: JiraIssue[]): Record<PhaseId, boolean> {
|
||||
/**
|
||||
* % de sous-tâches **Done** parmi les tickets encore pertinents (hors **Cancel**).
|
||||
* Aligné sur la cartographie des statuts (Réglages).
|
||||
*/
|
||||
export function subtaskDoneRatioPercent(
|
||||
subtasks: JiraIssue[],
|
||||
cfg: StatusBucketConfig,
|
||||
): number {
|
||||
const active = subtasks.filter((s) => !isIssueCanceled(s, cfg))
|
||||
if (active.length === 0) return 0
|
||||
const done = active.filter((s) => isIssueDone(s, cfg)).length
|
||||
return Math.round((done / active.length) * 100)
|
||||
}
|
||||
|
||||
export function stepperStates(
|
||||
subtasks: JiraIssue[],
|
||||
cfg: StatusBucketConfig,
|
||||
): Record<PhaseId, boolean> {
|
||||
return {
|
||||
analyse: isStepComplete(subtasks, 'analyse'),
|
||||
design: isStepComplete(subtasks, 'design'),
|
||||
integration: isStepComplete(subtasks, 'integration'),
|
||||
done: subtasks.length > 0 && subtasks.every((st) => statusToPhase(st.fields.status.name) === 'done'),
|
||||
done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,8 @@ export type JiraIssueFields = {
|
||||
priority?: JiraPriority | null
|
||||
assignee?: JiraUser | null
|
||||
timetracking?: JiraTimeTracking
|
||||
/** Étiquettes Jira (`labels`) si demandé dans `fields` de la recherche. */
|
||||
labels?: string[]
|
||||
/** Si demandé dans `fields` : sous-tâches sous le parent (à fusionner dans le lot si absentes en racine). */
|
||||
subtasks?: JiraEmbeddedChildIssue[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user