feat : add dashboard with Chart.js charts and filters

Implement the dashboard page with real data from the API:
- KPI cards (hours, active tasks, total tasks, projects)
- Charts: hours by day (line), hours by project (doughnut),
  tasks by status (doughnut), tasks by priority (bar),
  tasks by project (horizontal stacked bar)
- Filters: period (week/month), project, user
- Add chart.js and vue-chartjs dependencies
- Add dashboard sidebar icon and translations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 09:05:35 +01:00
parent 0733ac16cd
commit 25aef9b2d5
6 changed files with 776 additions and 69 deletions

View File

@@ -111,8 +111,21 @@
"hoursWorked": "Heures travaillées",
"inProgress": "En cours",
"done": "Terminé",
"filters": {
"period": "Période",
"project": "Projet",
"user": "Utilisateur",
"allProjects": "Tous les projets",
"allUsers": "Tous les utilisateurs"
},
"periods": {
"thisWeek": "Cette semaine",
"lastWeek": "Semaine dernière",
"thisMonth": "Ce mois",
"lastMonth": "Mois dernier"
},
"stats": {
"hoursThisWeek": "Heures cette semaine",
"hoursPeriod": "Heures sur la période",
"myActiveTasks": "Mes tâches actives",
"completed": "terminée(s)",
"totalTasks": "Tâches totales",
@@ -121,8 +134,8 @@
"users": "utilisateur(s)"
},
"charts": {
"hoursByDay": "Heures par jour (semaine en cours)",
"hoursByProject": "Temps par projet (semaine en cours)",
"hoursByDay": "Heures par jour",
"hoursByProject": "Temps par projet",
"tasksByStatus": "Tâches par statut",
"tasksByPriority": "Tâches par priorité",
"tasksByProject": "Tâches par projet"

View File

@@ -22,7 +22,7 @@
<nav class="flex-1 overflow-hidden" :class="ui.sidebarCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<SidebarLink
to="/"
icon="mdi:question-mark"
icon="mdi:view-dashboard-outline"
label="Tableau de bord"
:collapsed="ui.sidebarCollapsed"
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
@@ -99,8 +99,8 @@
<div class="h-full flex-1 flex flex-col min-h-0">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto bg-white px-16 pb-24">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-12 bg-white" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-16 pb-24">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-12 flex-shrink-0 bg-white" />
<slot/>
</main>
</div>

View File

@@ -12,10 +12,12 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}
},
@@ -72,6 +74,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1027,7 +1030,6 @@
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -1037,7 +1039,6 @@
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
@@ -1052,7 +1053,6 @@
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1"
},
@@ -1065,7 +1065,6 @@
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -1078,7 +1077,6 @@
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
@@ -1088,7 +1086,6 @@
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
@@ -1102,7 +1099,6 @@
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -1112,7 +1108,6 @@
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -1126,7 +1121,6 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=12.22"
},
@@ -1140,7 +1134,6 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18"
},
@@ -2118,6 +2111,12 @@
"node": ">= 12"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@kwsites/file-exists": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
@@ -2414,6 +2413,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
"license": "MIT",
"peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
@@ -2486,6 +2486,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "^3.5.27",
"defu": "^6.1.4",
@@ -3132,6 +3133,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.95.0"
},
@@ -5237,8 +5239,7 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -5250,8 +5251,7 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
@@ -5586,6 +5586,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29",
@@ -5779,6 +5780,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5818,7 +5820,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6150,6 +6151,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -6343,6 +6345,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6471,6 +6474,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6607,6 +6611,19 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -6636,6 +6653,7 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
@@ -7169,8 +7187,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -7670,7 +7687,6 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
@@ -7701,7 +7717,6 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -7714,7 +7729,6 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7727,7 +7741,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"license": "ISC",
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -7740,7 +7753,6 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -7750,7 +7762,6 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
@@ -7768,7 +7779,6 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7794,7 +7804,6 @@
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -7807,7 +7816,6 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -7908,8 +7916,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-fifo": {
"version": "1.3.2",
@@ -7937,15 +7944,13 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-npm-meta": {
"version": "1.4.0",
@@ -7990,7 +7995,6 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -8021,7 +8025,6 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -8038,7 +8041,6 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -8051,8 +8053,7 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/foreground-child": {
"version": "3.3.1",
@@ -8585,7 +8586,6 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.19"
}
@@ -8962,22 +8962,19 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
@@ -9055,7 +9052,6 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -9316,7 +9312,6 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -9401,7 +9396,6 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -9793,8 +9787,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
@@ -10030,6 +10023,7 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dxup/nuxt": "^0.3.2",
"@nuxt/cli": "^3.33.0",
@@ -10300,7 +10294,6 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -10352,6 +10345,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.112.0"
},
@@ -10435,7 +10429,6 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -10451,7 +10444,6 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -10494,7 +10486,6 @@
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -10598,6 +10589,7 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -10714,6 +10706,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11257,6 +11250,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -11307,7 +11301,6 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -11344,7 +11337,6 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -11706,6 +11698,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12488,6 +12481,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -12828,7 +12822,6 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -12896,6 +12889,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13331,7 +13325,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@@ -13356,6 +13349,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -13717,6 +13711,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -13742,6 +13737,16 @@
"ufo": "^1.6.1"
}
},
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-devtools-stub": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
@@ -13753,6 +13758,7 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0",
@@ -13774,6 +13780,7 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
@@ -13826,7 +13833,6 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13995,7 +14001,6 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},

View File

@@ -16,10 +16,12 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}
}

View File

@@ -1,7 +1,666 @@
<template>
<h1 class="text-primary-500">Tableau de bord</h1>
</template>
<script setup lang="ts">
import { Doughnut, Bar, Line } from 'vue-chartjs'
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TimeEntry } from '~/services/dto/time-entry'
import type { Project } from '~/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTimeEntryService } from '~/services/time-entries'
import { useProjectService } from '~/services/projects'
import { useUserService } from '~/services/users'
const { t } = useI18n()
const auth = useAuthStore()
useHead({ title: t('dashboard.title') })
const taskService = useTaskService()
const statusService = useTaskStatusService()
const priorityService = useTaskPriorityService()
const timeEntryService = useTimeEntryService()
const projectService = useProjectService()
const userService = useUserService()
const allTasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const priorities = ref<TaskPriority[]>([])
const allTimeEntries = ref<TimeEntry[]>([])
const projects = ref<Project[]>([])
const users = ref<UserData[]>([])
const isLoading = ref(true)
// ── Filters ──
type PeriodKey = 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
const selectedPeriod = ref<PeriodKey>('thisWeek')
const selectedProjectId = ref<number | null>(null)
const selectedUserId = ref<number | null>(null)
const periodOptions = computed(() => [
{ label: t('dashboard.periods.thisWeek'), value: 'thisWeek' },
{ label: t('dashboard.periods.lastWeek'), value: 'lastWeek' },
{ label: t('dashboard.periods.thisMonth'), value: 'thisMonth' },
{ label: t('dashboard.periods.lastMonth'), value: 'lastMonth' },
])
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const userOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
// ── Period date ranges ──
function getWeekRange(offset: number = 0) {
const now = new Date()
const day = now.getDay()
const diffToMonday = day === 0 ? -6 : 1 - day
const monday = new Date(now)
monday.setDate(now.getDate() + diffToMonday + offset * 7)
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
return { start: monday, end: sunday }
}
function getMonthRange(offset: number = 0) {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0)
end.setHours(23, 59, 59, 999)
return { start, end }
}
const dateRange = computed(() => {
switch (selectedPeriod.value) {
case 'thisWeek': return getWeekRange(0)
case 'lastWeek': return getWeekRange(-1)
case 'thisMonth': return getMonthRange(0)
case 'lastMonth': return getMonthRange(-1)
}
})
const isWeekPeriod = computed(() =>
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
)
// ── Filtered data (client-side project filter) ──
const tasks = computed(() => {
if (!selectedProjectId.value) return allTasks.value
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
})
const timeEntries = computed(() => {
if (!selectedProjectId.value) return allTimeEntries.value
return allTimeEntries.value.filter(e => e.project?.id === selectedProjectId.value)
})
// ── Data loading ──
async function loadReferenceData() {
const [s, p, proj, u] = await Promise.all([
statusService.getAll(),
priorityService.getAll(),
projectService.getAll(),
userService.getAll(),
])
statuses.value = s
priorities.value = p
projects.value = proj
users.value = u
}
async function loadTasks() {
allTasks.value = await taskService.getFiltered({ archived: false })
}
async function loadTimeEntries() {
const params: { after: string; before: string; user?: number } = {
after: dateRange.value.start.toISOString(),
before: dateRange.value.end.toISOString(),
}
if (selectedUserId.value) {
params.user = selectedUserId.value
}
allTimeEntries.value = await timeEntryService.getByDateRange(params)
}
async function loadAll() {
isLoading.value = true
try {
await Promise.all([loadReferenceData(), loadTasks(), loadTimeEntries()])
} finally {
isLoading.value = false
}
}
// Reload time entries when period or user changes (server-side filter)
watch([selectedPeriod, selectedUserId], () => {
loadTimeEntries()
})
onMounted(() => loadAll())
// ── Helpers ──
function durationHours(entry: TimeEntry): number {
const start = new Date(entry.startedAt)
const end = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
return (end.getTime() - start.getTime()) / 3_600_000
}
function formatHours(h: number): string {
const hours = Math.floor(h)
const mins = Math.round((h - hours) * 60)
return mins > 0 ? `${hours}h${String(mins).padStart(2, '0')}` : `${hours}h`
}
// ── KPI Stats ──
const totalHoursThisWeek = computed(() =>
timeEntries.value.reduce((sum, e) => sum + durationHours(e), 0)
)
const myTasks = computed(() =>
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
)
const myTasksDone = computed(() =>
myTasks.value.filter(t => t.status?.isFinal)
)
const unassignedTasks = computed(() =>
tasks.value.filter(t => !t.assignee)
)
// ── Chart: Tasks by Status (Doughnut) ──
const tasksByStatusData = computed(() => {
const sorted = [...statuses.value].sort((a, b) => a.position - b.position)
const noStatus = tasks.value.filter(t => !t.status).length
const labels = noStatus > 0 ? ['Backlog', ...sorted.map(s => s.label)] : sorted.map(s => s.label)
const data = noStatus > 0
? [noStatus, ...sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)]
: sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)
const colors = noStatus > 0
? ['#9ca3af', ...sorted.map(s => s.color)]
: sorted.map(s => s.color)
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 0,
}],
}
})
// ── Chart: Tasks by Priority (Bar) ──
const tasksByPriorityData = computed(() => {
const sorted = [...priorities.value]
const noPriority = tasks.value.filter(t => !t.priority).length
const labels = [...sorted.map(p => p.label), ...(noPriority > 0 ? [t('dashboard.noPriority')] : [])]
const data = [...sorted.map(p => tasks.value.filter(t => t.priority?.id === p.id).length), ...(noPriority > 0 ? [noPriority] : [])]
const colors = [...sorted.map(p => p.color), ...(noPriority > 0 ? ['#9ca3af'] : [])]
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 0,
borderRadius: 6,
}],
}
})
// ── Chart: Hours by Project (Doughnut) ──
const hoursByProjectData = computed(() => {
const projectHours = new Map<number, { name: string; color: string; hours: number }>()
let noProjectHours = 0
for (const entry of timeEntries.value) {
const h = durationHours(entry)
if (entry.project) {
const existing = projectHours.get(entry.project.id)
if (existing) {
existing.hours += h
} else {
projectHours.set(entry.project.id, {
name: entry.project.name,
color: entry.project.color || '#6366f1',
hours: h,
})
}
} else {
noProjectHours += h
}
}
const entries = [...projectHours.values()].sort((a, b) => b.hours - a.hours)
if (noProjectHours > 0) {
entries.push({ name: t('dashboard.noProject'), color: '#9ca3af', hours: noProjectHours })
}
return {
labels: entries.map(e => e.name),
datasets: [{
data: entries.map(e => Math.round(e.hours * 100) / 100),
backgroundColor: entries.map(e => e.color),
borderWidth: 0,
}],
}
})
// ── Chart: Hours by Day (Line) ──
const weekDayLabels = [
t('dashboard.days.mon'),
t('dashboard.days.tue'),
t('dashboard.days.wed'),
t('dashboard.days.thu'),
t('dashboard.days.fri'),
t('dashboard.days.sat'),
t('dashboard.days.sun'),
]
const hoursByDayData = computed(() => {
if (isWeekPeriod.value) {
const dayHours = new Array(7).fill(0)
for (const entry of timeEntries.value) {
const start = new Date(entry.startedAt)
const dayIndex = start.getDay() === 0 ? 6 : start.getDay() - 1
dayHours[dayIndex] += durationHours(entry)
}
return {
labels: weekDayLabels,
datasets: [{
label: t('dashboard.hoursWorked'),
data: dayHours.map(h => Math.round(h * 100) / 100),
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointBackgroundColor: '#6366f1',
pointRadius: 4,
}],
}
}
// Month view: group by week number
const { start, end } = dateRange.value
const weekMap = new Map<string, number>()
const weekLabels: string[] = []
// Build week labels for the month
const cursor = new Date(start)
while (cursor <= end) {
const weekStart = new Date(cursor)
const weekEnd = new Date(cursor)
weekEnd.setDate(weekEnd.getDate() + 6)
if (weekEnd > end) weekEnd.setTime(end.getTime())
const label = `${weekStart.getDate()}/${weekStart.getMonth() + 1} - ${weekEnd.getDate()}/${weekEnd.getMonth() + 1}`
weekLabels.push(label)
weekMap.set(label, 0)
cursor.setDate(cursor.getDate() + 7)
// Align to Monday
const d = cursor.getDay()
if (d !== 1) {
cursor.setDate(cursor.getDate() + (d === 0 ? 1 : 8 - d))
}
}
for (const entry of timeEntries.value) {
const entryDate = new Date(entry.startedAt)
for (let i = 0; i < weekLabels.length; i++) {
const parts = weekLabels[i].split(' - ')
const [sd, sm] = parts[0].split('/').map(Number)
const [ed, em] = parts[1].split('/').map(Number)
const ws = new Date(start.getFullYear(), sm - 1, sd)
const we = new Date(start.getFullYear(), em - 1, ed, 23, 59, 59)
if (entryDate >= ws && entryDate <= we) {
weekMap.set(weekLabels[i], (weekMap.get(weekLabels[i]) ?? 0) + durationHours(entry))
break
}
}
}
return {
labels: weekLabels,
datasets: [{
label: t('dashboard.hoursWorked'),
data: weekLabels.map(l => Math.round((weekMap.get(l) ?? 0) * 100) / 100),
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointBackgroundColor: '#6366f1',
pointRadius: 4,
}],
}
})
// ── Chart: Tasks by Project (Horizontal Bar) ──
const tasksByProjectData = computed(() => {
const projectTasks = new Map<number, { name: string; color: string; count: number; done: number }>()
for (const task of tasks.value) {
if (!task.project) continue
const existing = projectTasks.get(task.project.id)
const isDone = task.status?.isFinal ?? false
if (existing) {
existing.count++
if (isDone) existing.done++
} else {
projectTasks.set(task.project.id, {
name: task.project.name,
color: task.project.color || '#6366f1',
count: 1,
done: isDone ? 1 : 0,
})
}
}
const entries = [...projectTasks.values()].sort((a, b) => b.count - a.count)
return {
labels: entries.map(e => e.name),
datasets: [
{
label: t('dashboard.inProgress'),
data: entries.map(e => e.count - e.done),
backgroundColor: entries.map(e => e.color),
borderWidth: 0,
borderRadius: 6,
},
{
label: t('dashboard.done'),
data: entries.map(e => e.done),
backgroundColor: entries.map(e => e.color + '66'),
borderWidth: 0,
borderRadius: 6,
},
],
}
})
// ── Chart options ──
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom' as const,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 12 },
},
},
},
}
const barOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: '#f3f4f6' },
},
x: {
grid: { display: false },
},
},
}
const horizontalBarOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y' as const,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 12 },
},
},
},
scales: {
x: {
beginAtZero: true,
stacked: true,
ticks: { stepSize: 1 },
grid: { color: '#f3f4f6' },
},
y: {
stacked: true,
grid: { display: false },
},
},
}
const lineOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: any) => `${formatHours(ctx.raw)}`,
},
},
},
scales: {
y: {
beginAtZero: true,
grid: { color: '#f3f4f6' },
ticks: {
callback: (value: any) => `${value}h`,
},
},
x: {
grid: { display: false },
},
},
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-primary-500">{{ $t('dashboard.title') }}</h1>
<!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedPeriod"
:options="periodOptions"
:label="$t('dashboard.filters.period')"
min-width="!w-48"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
:label="$t('dashboard.filters.project')"
:empty-option-label="$t('dashboard.filters.allProjects')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedUserId"
:options="userOptions"
:label="$t('dashboard.filters.user')"
:empty-option-label="$t('dashboard.filters.allUsers')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Loading -->
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
<p class="text-neutral-400">{{ $t('common.loading') }}</p>
</div>
<template v-else>
<!-- KPI Cards -->
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.hoursPeriod') }}
</p>
<p class="mt-2 text-3xl font-bold text-neutral-900">
{{ formatHours(totalHoursThisWeek) }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.myActiveTasks') }}
</p>
<p class="mt-2 text-3xl font-bold text-neutral-900">
{{ myTasks.length - myTasksDone.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ myTasksDone.length }} {{ $t('dashboard.stats.completed') }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.totalTasks') }}
</p>
<p class="mt-2 text-3xl font-bold text-neutral-900">
{{ tasks.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ unassignedTasks.length }} {{ $t('dashboard.stats.unassigned') }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.projects') }}
</p>
<p class="mt-2 text-3xl font-bold text-neutral-900">
{{ projects.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ users.length }} {{ $t('dashboard.stats.users') }}
</p>
</div>
</div>
<!-- Charts Row 1 -->
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<!-- Hours by Day (Line) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.hoursByDay') }}
</h2>
<div class="mt-4 h-64">
<Line :data="hoursByDayData" :options="lineOptions" />
</div>
</div>
<!-- Hours by Project (Doughnut) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.hoursByProject') }}
</h2>
<div class="mt-4 h-64">
<Doughnut
v-if="hoursByProjectData.labels.length > 0"
:data="hoursByProjectData"
:options="doughnutOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Tasks by Status (Doughnut) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByStatus') }}
</h2>
<div class="mt-4 h-64">
<Doughnut
v-if="tasksByStatusData.labels.length > 0"
:data="tasksByStatusData"
:options="doughnutOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
<!-- Tasks by Priority (Bar) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByPriority') }}
</h2>
<div class="mt-4 h-64">
<Bar
v-if="tasksByPriorityData.labels.length > 0"
:data="tasksByPriorityData"
:options="barOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
<!-- Charts Row 3 -->
<div class="mt-6">
<!-- Tasks by Project (Horizontal Bar) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByProject') }}
</h2>
<div class="mt-4" :style="{ height: Math.max(200, tasksByProjectData.labels.length * 40 + 60) + 'px' }">
<Bar
v-if="tasksByProjectData.labels.length > 0"
:data="tasksByProjectData"
:options="horizontalBarOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,28 @@
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
ArcElement,
BarElement,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Filler,
} from 'chart.js'
export default defineNuxtPlugin(() => {
ChartJS.register(
Title,
Tooltip,
Legend,
ArcElement,
BarElement,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Filler,
)
})