Compare commits
206 Commits
feat/tfsSy
...
af41531597
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af41531597 | ||
|
|
d9e7a05f14 | ||
|
|
4e4c347250 | ||
|
|
8bdd3a8253 | ||
|
|
31f9855a3c | ||
|
|
b8256a18b6 | ||
|
|
f404f06d14 | ||
|
|
deb3097047 | ||
|
|
a9a2988293 | ||
|
|
72cd76c77b | ||
|
|
6cad6a333d | ||
|
|
f0b9f75817 | ||
|
|
8340008839 | ||
|
|
f7c9926348 | ||
|
|
c7c47039b4 | ||
|
|
2d4c161e1d | ||
|
|
9fc355abad | ||
|
|
08f3fb6e85 | ||
|
|
e4e49df60b | ||
|
|
5d1239c4de | ||
|
|
48e649cf75 | ||
|
|
76394375ea | ||
|
|
0bf9802e71 | ||
|
|
cd391506ce | ||
|
|
3e19121cb2 | ||
|
|
fd46ed180f | ||
|
|
f7f77a49dc | ||
|
|
b60e74b1ff | ||
|
|
87acb3709d | ||
|
|
2b9205007f | ||
|
|
3b7a6c3972 | ||
|
|
a4188b09e5 | ||
|
|
7952459b42 | ||
|
|
583efaa8c5 | ||
|
|
5dcfa19b0c | ||
|
|
67515441fb | ||
|
|
75f27c69ee | ||
|
|
8cb0dcf3af | ||
|
|
6bfcd1f100 | ||
|
|
6748799a90 | ||
|
|
e7cbd56e89 | ||
|
|
52d8332f0c | ||
|
|
7811453e02 | ||
|
|
ab4a7b3b3e | ||
|
|
0b17934ca1 | ||
|
|
7d4ab33fca | ||
|
|
1c28d6b782 | ||
|
|
d6538356a1 | ||
|
|
65e1a3c2d0 | ||
|
|
ae22535dd0 | ||
|
|
0ffcec7ffc | ||
|
|
d9cf9a2655 | ||
|
|
f8100ae3e9 | ||
|
|
6c86ce44f1 | ||
|
|
1fe59f26e4 | ||
|
|
17dade54e6 | ||
|
|
f98247c142 | ||
|
|
1499394438 | ||
|
|
8bb5495e13 | ||
|
|
cd35d67306 | ||
|
|
714f8ccd5e | ||
|
|
7490c38d55 | ||
|
|
b2a8c961a8 | ||
|
|
ffd3eb998a | ||
|
|
ad0b723e00 | ||
|
|
89af1fc597 | ||
|
|
052b2c2c66 | ||
|
|
34f1a62435 | ||
|
|
35bda37599 | ||
|
|
94145c1ffd | ||
|
|
eac9e9a0bb | ||
|
|
c7ad1c0416 | ||
|
|
e14b428e12 | ||
|
|
0658b8ff93 | ||
|
|
9fb374fb23 | ||
|
|
48e3822696 | ||
|
|
aae35aa811 | ||
|
|
943d14cfc1 | ||
|
|
c84ee86ed4 | ||
|
|
7900ba3b73 | ||
|
|
1a670cb392 | ||
|
|
1dfb8f8ac1 | ||
|
|
735070dd6f | ||
|
|
2137da2ac2 | ||
|
|
c1de8cd064 | ||
|
|
a1f82a4c9b | ||
|
|
f4c6b1181f | ||
|
|
39936f5d06 | ||
|
|
775788fdb5 | ||
|
|
10c1f811ce | ||
|
|
99377ee38d | ||
|
|
fbb9311f9e | ||
|
|
9094aca1ff | ||
|
|
d4e8dc144b | ||
|
|
46c1c5e9a1 | ||
|
|
2e3e8bb222 | ||
|
|
63ef861360 | ||
|
|
e0b5afb437 | ||
|
|
7e79dbe49c | ||
|
|
ead02e0aaa | ||
|
|
133a09f995 | ||
|
|
e73e46893f | ||
|
|
988ffbf774 | ||
|
|
0d20d602cb | ||
|
|
a034e265fd | ||
|
|
c104fc0e11 | ||
|
|
e2527ca88a | ||
|
|
014b0269dc | ||
|
|
5b3f705689 | ||
|
|
f13ed5b8d9 | ||
|
|
352a65af47 | ||
|
|
7ebf7d491b | ||
|
|
4885871657 | ||
|
|
8519ec094f | ||
|
|
d8ca4ef00b | ||
|
|
307b3a8a14 | ||
|
|
703145a791 | ||
|
|
785dc91159 | ||
|
|
7aa9d6dd6b | ||
|
|
30aaca4877 | ||
|
|
17b86b6087 | ||
|
|
43c141d3cd | ||
|
|
f145bed97d | ||
|
|
884139f8f7 | ||
|
|
dc7b7c7616 | ||
|
|
9d63d31064 | ||
|
|
270a2bd4d0 | ||
|
|
d1d65cdca1 | ||
|
|
df7d2a9afa | ||
|
|
f50f4baaa9 | ||
|
|
f0d14e29f8 | ||
|
|
6ef52bec85 | ||
|
|
c647725536 | ||
|
|
1d7c2b5e1a | ||
|
|
dc46232dd7 | ||
|
|
bff4f394ac | ||
|
|
ec6c51f9ec | ||
|
|
74c658b3e7 | ||
|
|
32f9d1d5de | ||
|
|
749f69680b | ||
|
|
c1a14f9196 | ||
|
|
6c0c353a4e | ||
|
|
3fcada65f6 | ||
|
|
d45a04d347 | ||
|
|
41fdd0c5b5 | ||
|
|
8d657872c0 | ||
|
|
641a009b34 | ||
|
|
687d02ff3a | ||
|
|
5a3d825b8e | ||
|
|
0fcd4d68c1 | ||
|
|
bdf8ab9fb4 | ||
|
|
0e2eaf1052 | ||
|
|
9ef23dbddc | ||
|
|
7acb2d7e4e | ||
|
|
aa348a0f82 | ||
|
|
b5d6967fcd | ||
|
|
97770917c1 | ||
|
|
58353a0dec | ||
|
|
986f1732ea | ||
|
|
b9f801c110 | ||
|
|
6fccf20581 | ||
|
|
7de060566f | ||
|
|
bd7ede412e | ||
|
|
350dbe6479 | ||
|
|
b87fa64d4d | ||
|
|
a01c0d83d0 | ||
|
|
31541a11d4 | ||
|
|
908f39bc5f | ||
|
|
0253555fa4 | ||
|
|
2e9cc4e667 | ||
|
|
a5199a8302 | ||
|
|
c224c644b1 | ||
|
|
65a307c8ac | ||
|
|
a3a5be96a2 | ||
|
|
026a175681 | ||
|
|
4e9d06896d | ||
|
|
6ca24b9509 | ||
|
|
b0e7a60308 | ||
|
|
f2b18e4527 | ||
|
|
cd71824cc8 | ||
|
|
551279efcb | ||
|
|
a870f7f3dc | ||
|
|
0f22ae7019 | ||
|
|
9ec775acbf | ||
|
|
cff9ad10f0 | ||
|
|
6db5e2ef00 | ||
|
|
167f90369b | ||
|
|
75aa60cb83 | ||
|
|
ea21df13c7 | ||
|
|
9c8d19fb09 | ||
|
|
7ebc0af3c7 | ||
|
|
11ebe5cd00 | ||
|
|
21e1f68921 | ||
|
|
8a227aec36 | ||
|
|
7ac961f6c7 | ||
|
|
34b9aff6e7 | ||
|
|
fd3827214f | ||
|
|
336b5c1006 | ||
|
|
db8ff88a4c | ||
|
|
f9c92f9efd | ||
|
|
bbb4e543c4 | ||
|
|
88ab8c9334 | ||
|
|
f5417040fd | ||
|
|
b8e0307f03 | ||
|
|
ed16e2bb80 | ||
|
|
f88954bf81 |
167
.cursor/rules/css-variables-theme.mdc
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: CSS Variables theme system best practices
|
||||||
|
---
|
||||||
|
|
||||||
|
# CSS Variables Theme System
|
||||||
|
|
||||||
|
## Core Principle: Pure CSS Variables for Theming
|
||||||
|
|
||||||
|
This project uses **CSS Variables exclusively** for theming. No Tailwind `dark:` classes or conditional CSS classes.
|
||||||
|
|
||||||
|
## ✅ Architecture Pattern
|
||||||
|
|
||||||
|
### CSS Structure
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Light theme (default values) */
|
||||||
|
--background: #f1f5f9;
|
||||||
|
--foreground: #0f172a;
|
||||||
|
--primary: #0891b2;
|
||||||
|
--success: #059669;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--accent: #d97706;
|
||||||
|
--purple: #8b5cf6;
|
||||||
|
--yellow: #eab308;
|
||||||
|
--green: #059669;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--gray: #6b7280;
|
||||||
|
--gray-light: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark theme (override values) */
|
||||||
|
--background: #1e293b;
|
||||||
|
--foreground: #f1f5f9;
|
||||||
|
--primary: #06b6d4;
|
||||||
|
--success: #10b981;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--accent: #f59e0b;
|
||||||
|
--purple: #8b5cf6;
|
||||||
|
--yellow: #eab308;
|
||||||
|
--green: #10b981;
|
||||||
|
--blue: #3b82f6;
|
||||||
|
--gray: #9ca3af;
|
||||||
|
--gray-light: #374151;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme Application
|
||||||
|
- **Single source of truth**: [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) applies theme class to `document.documentElement`
|
||||||
|
- **No duplication**: Theme is applied only once, not in multiple places
|
||||||
|
- **SSR safe**: Initial theme from server-side preferences
|
||||||
|
|
||||||
|
## ✅ Component Usage Patterns
|
||||||
|
|
||||||
|
### Correct: Using CSS Variables
|
||||||
|
```tsx
|
||||||
|
// ✅ GOOD: CSS Variables in className
|
||||||
|
<div className="bg-[var(--card)] text-[var(--foreground)] border-[var(--border)]">
|
||||||
|
|
||||||
|
// ✅ GOOD: CSS Variables in style prop
|
||||||
|
<div style={{ color: 'var(--primary)', backgroundColor: 'var(--card)' }}>
|
||||||
|
|
||||||
|
// ✅ GOOD: CSS Variables with color-mix for transparency
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--primary) 10%, transparent)',
|
||||||
|
borderColor: 'color-mix(in srgb, var(--primary) 20%, var(--border))'
|
||||||
|
}}>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Forbidden: Tailwind Dark Mode Classes
|
||||||
|
```tsx
|
||||||
|
// ❌ BAD: Tailwind dark: classes
|
||||||
|
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
|
||||||
|
|
||||||
|
// ❌ BAD: Conditional classes
|
||||||
|
<div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
|
||||||
|
|
||||||
|
// ❌ BAD: Hardcoded colors
|
||||||
|
<div className="bg-red-500 text-blue-600">
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Color System
|
||||||
|
|
||||||
|
### Semantic Color Tokens
|
||||||
|
- `--background`: Main background color
|
||||||
|
- `--foreground`: Main text color
|
||||||
|
- `--card`: Card/panel background
|
||||||
|
- `--card-hover`: Card hover state
|
||||||
|
- `--card-column`: Column background (darker than cards)
|
||||||
|
- `--border`: Border color
|
||||||
|
- `--input`: Input field background
|
||||||
|
- `--primary`: Primary brand color
|
||||||
|
- `--primary-foreground`: Text on primary background
|
||||||
|
- `--muted`: Muted text color
|
||||||
|
- `--muted-foreground`: Secondary text color
|
||||||
|
- `--accent`: Accent color (orange/amber)
|
||||||
|
- `--destructive`: Error/danger color (red)
|
||||||
|
- `--success`: Success color (green)
|
||||||
|
- `--purple`: Purple accent
|
||||||
|
- `--yellow`: Yellow accent
|
||||||
|
- `--green`: Green accent
|
||||||
|
- `--blue`: Blue accent
|
||||||
|
- `--gray`: Gray color
|
||||||
|
- `--gray-light`: Light gray background
|
||||||
|
|
||||||
|
### Color Mixing Patterns
|
||||||
|
```css
|
||||||
|
/* Background with transparency */
|
||||||
|
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
|
||||||
|
/* Border with transparency */
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
|
||||||
|
|
||||||
|
/* Text with opacity */
|
||||||
|
color: color-mix(in srgb, var(--destructive) 80%, transparent);
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Theme Context Usage
|
||||||
|
|
||||||
|
### ThemeProvider Setup
|
||||||
|
```tsx
|
||||||
|
// In layout.tsx
|
||||||
|
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Usage
|
||||||
|
```tsx
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { theme, toggleTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={toggleTheme}>
|
||||||
|
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Future Extensibility
|
||||||
|
|
||||||
|
This system is designed to support:
|
||||||
|
- **Custom color themes**: Easy to add new color variables
|
||||||
|
- **User preferences**: Colors can be dynamically changed
|
||||||
|
- **Theme presets**: Multiple predefined themes
|
||||||
|
- **Accessibility**: High contrast modes
|
||||||
|
|
||||||
|
## 🚨 Anti-patterns to Avoid
|
||||||
|
|
||||||
|
1. **Don't mix approaches**: Never use both CSS variables and Tailwind dark: classes
|
||||||
|
2. **Don't duplicate theme application**: Theme should be applied only in ThemeContext
|
||||||
|
3. **Don't hardcode colors**: Always use semantic color tokens
|
||||||
|
4. **Don't use conditional classes**: Use CSS variables instead
|
||||||
|
5. **Don't forget transparency**: Use `color-mix()` for semi-transparent colors
|
||||||
|
|
||||||
|
## 📁 Key Files
|
||||||
|
|
||||||
|
- [globals.css](mdc:src/app/globals.css) - CSS Variables definitions
|
||||||
|
- [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) - Theme management
|
||||||
|
- [UserPreferencesContext.tsx](mdc:src/contexts/UserPreferencesContext.tsx) - Preferences sync
|
||||||
|
- [layout.tsx](mdc:src/app/layout.tsx) - Theme provider setup
|
||||||
|
|
||||||
|
Remember: **CSS Variables are the single source of truth for theming. Keep it pure and consistent.**
|
||||||
32
.husky/post-commit
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Auto-version hook: incrémente la version après certains commits
|
||||||
|
|
||||||
|
# Récupérer le dernier message de commit
|
||||||
|
commit_msg=$(git log -1 --pretty=%B)
|
||||||
|
|
||||||
|
# Ignorer si le commit contient [skip version] ou si c'est un commit de version
|
||||||
|
if echo "$commit_msg" | grep -qE "\[skip version\]|chore: bump version"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérifier si le commit devrait déclencher une mise à jour de version
|
||||||
|
# Types pris en charge:
|
||||||
|
# - feat: → minor bump
|
||||||
|
# - fix:, perf:, security:, patch:, refactor: → patch bump
|
||||||
|
# - feat!:, refactor!:, etc. (avec !) → major bump
|
||||||
|
# - breaking change ou BREAKING CHANGE → major bump
|
||||||
|
# Ignorés: chore:, docs:, style:, test:, ci:, build:
|
||||||
|
if echo "$commit_msg" | grep -qiE "^feat:|^fix:|^perf:|^security:|^patch:|^refactor:|^[a-z]+!:|breaking change"; then
|
||||||
|
# Lancer le script en mode hook (silent + ajout auto au staging)
|
||||||
|
pnpm tsx scripts/auto-version.ts --silent --hook
|
||||||
|
|
||||||
|
# Vérifier si package.json a changé
|
||||||
|
if ! git diff --quiet package.json; then
|
||||||
|
echo ""
|
||||||
|
echo "📦 Version mise à jour automatiquement"
|
||||||
|
echo "💡 Pour commit: git add package.json && git commit -m 'chore: bump version'"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|
||||||
2
.husky/pre-commit
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
lint-staged
|
||||||
33
AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Responsibilities
|
||||||
|
|
||||||
|
Source lives in `src/`, with Next.js routes under `src/app` (UI) and API handlers in `src/app/api`—API files orchestrate services only. Domain logic, database queries, and external integrations reside in `src/services` (always use `database.ts`). HTTP clients belong in `src/clients`, React hooks in `src/hooks`, reusable UI in `src/components`, and shared utilities or types in `src/lib`. Operational helpers live in `scripts/`, Prisma schema and migrations in `prisma/`, and static assets in `public/`.
|
||||||
|
|
||||||
|
## Build, Test & Operational Commands
|
||||||
|
|
||||||
|
Start local development with `pnpm run dev` (Turbopack). Build production artifacts using `pnpm run build` and serve them via `pnpm run start`. Lint and type-check with `pnpm run lint`; run formatting verification through `pnpm run prettier:check` or fix issues via `pnpm run prettier:format`. Operational validations include `pnpm run backup:list`, `pnpm run backup:verify`, and cache utilities such as `pnpm run cache:stats`.
|
||||||
|
|
||||||
|
## Coding Style & Naming
|
||||||
|
|
||||||
|
Prettier and ESLint (`next/core-web-vitals`) enforce a 2-space, TypeScript-first style. Components and hooks use PascalCase, utilities use camelCase, and types live in `src/types` or `src/lib`. Co-locate component-specific assets near their implementation. Never bypass lint-staged; rely on Husky to run formatting before commits.
|
||||||
|
|
||||||
|
## Architecture & Data Flow Rules
|
||||||
|
|
||||||
|
Keep business logic out of the frontend: components, hooks, and clients may orchestrate UI state but must call services for domain decisions. Services are the only place for PostgreSQL queries and must expose typed interfaces with transaction handling. API routes validate input, call services, and return typed JSON—no raw SQL or business rules inline. Prefer Server Actions for straightforward mutations that require fast UI feedback; keep complex workflows, public endpoints, and integrations in API routes. Clients wrap HTTP calls, reuse the base HTTP client, and return typed responses.
|
||||||
|
|
||||||
|
## Styling & Theme System
|
||||||
|
|
||||||
|
All theming goes through CSS variables defined in `src/app/globals.css` and applied by `ThemeContext`. Do not use Tailwind `dark:` toggles or hard-coded colors; prefer `var(--token)` (and `color-mix` when translucency is needed). Keep semantic naming (`--card`, `--primary`, `--muted`) and extend the palette by adding variables instead of branching logic in components.
|
||||||
|
|
||||||
|
## Testing & Verification
|
||||||
|
|
||||||
|
Today's automated coverage relies on linting plus targeted scripts (e.g., `pnpm run test:story-points`, `pnpm run test:jira-fields`). When adding data flows or schedulers, contribute new headless scripts under `scripts/` and document manual QA steps in PRs. Name fixtures after the feature they back (`backlog-config.json`) and ensure linting passes before review.
|
||||||
|
|
||||||
|
## TODO Tracking & Workflow
|
||||||
|
|
||||||
|
If you complete an item in `TODO.md`, immediately flip its checkbox to checked without altering wording; add timestamps for major milestones. Mirror progress across sub-tasks and note blockers to keep the list trustworthy.
|
||||||
|
|
||||||
|
## Commit & PR Expectations
|
||||||
|
|
||||||
|
Follow the conventional prefixes visible in history (`feat:`, `refactor:`, `chore:`) with concise, action-oriented subjects. Group related changes per commit, documenting architectural context in the body when touching shared layers. Pull requests must summarize behavior changes, link issues, and attach screenshots for UI updates or schema diffs for Prisma changes. Confirm migrations, linting, formatting, and relevant scripts succeed before requesting review.
|
||||||
82
BACKUP.md
@@ -40,16 +40,16 @@ TowerControl dispose d'un système de sauvegarde automatique et manuel complet p
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Voir la configuration actuelle
|
# Voir la configuration actuelle
|
||||||
npm run backup:config
|
pnpm run backup:config
|
||||||
|
|
||||||
# Modifier la fréquence
|
# Modifier la fréquence
|
||||||
tsx scripts/backup-manager.ts config-set interval=daily
|
pnpm tsx scripts/backup-manager.ts config-set interval=daily
|
||||||
|
|
||||||
# Modifier le nombre max de sauvegardes
|
# Modifier le nombre max de sauvegardes
|
||||||
tsx scripts/backup-manager.ts config-set maxBackups=10
|
pnpm tsx scripts/backup-manager.ts config-set maxBackups=10
|
||||||
|
|
||||||
# Activer/désactiver la compression
|
# Activer/désactiver la compression
|
||||||
tsx scripts/backup-manager.ts config-set compression=true
|
pnpm tsx scripts/backup-manager.ts config-set compression=true
|
||||||
```
|
```
|
||||||
|
|
||||||
### Personnalisation du dossier de sauvegarde
|
### Personnalisation du dossier de sauvegarde
|
||||||
@@ -59,10 +59,10 @@ tsx scripts/backup-manager.ts config-set compression=true
|
|||||||
BACKUP_STORAGE_PATH="./custom-backups"
|
BACKUP_STORAGE_PATH="./custom-backups"
|
||||||
|
|
||||||
# Via variable temporaire (une seule fois)
|
# Via variable temporaire (une seule fois)
|
||||||
BACKUP_STORAGE_PATH="./my-backups" npm run backup:create
|
BACKUP_STORAGE_PATH="./my-backups" pnpm run backup:create
|
||||||
|
|
||||||
# Exemple avec un chemin absolu
|
# Exemple avec un chemin absolu
|
||||||
BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
BACKUP_STORAGE_PATH="/var/backups/towercontrol" pnpm run backup:create
|
||||||
```
|
```
|
||||||
|
|
||||||
## Utilisation
|
## Utilisation
|
||||||
@@ -70,12 +70,14 @@ BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
|||||||
### Interface graphique
|
### Interface graphique
|
||||||
|
|
||||||
#### Paramètres Avancés
|
#### Paramètres Avancés
|
||||||
|
|
||||||
- **Visualisation** du statut en temps réel
|
- **Visualisation** du statut en temps réel
|
||||||
- **Création manuelle** de sauvegardes
|
- **Création manuelle** de sauvegardes
|
||||||
- **Vérification** de l'intégrité
|
- **Vérification** de l'intégrité
|
||||||
- **Lien** vers la gestion complète
|
- **Lien** vers la gestion complète
|
||||||
|
|
||||||
#### Page de gestion complète
|
#### Page de gestion complète
|
||||||
|
|
||||||
- **Configuration** détaillée du système
|
- **Configuration** détaillée du système
|
||||||
- **Liste** de toutes les sauvegardes
|
- **Liste** de toutes les sauvegardes
|
||||||
- **Actions** (supprimer, restaurer)
|
- **Actions** (supprimer, restaurer)
|
||||||
@@ -85,29 +87,29 @@ BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Créer une sauvegarde immédiate
|
# Créer une sauvegarde immédiate
|
||||||
npm run backup:create
|
pnpm run backup:create
|
||||||
|
|
||||||
# Lister toutes les sauvegardes
|
# Lister toutes les sauvegardes
|
||||||
npm run backup:list
|
pnpm run backup:list
|
||||||
|
|
||||||
# Vérifier l'intégrité de la base
|
# Vérifier l'intégrité de la base
|
||||||
npm run backup:verify
|
pnpm run backup:verify
|
||||||
|
|
||||||
# Voir la configuration
|
# Voir la configuration
|
||||||
npm run backup:config
|
pnpm run backup:config
|
||||||
|
|
||||||
# Démarrer le planificateur
|
# Démarrer le planificateur
|
||||||
npm run backup:start
|
pnpm run backup:start
|
||||||
|
|
||||||
# Arrêter le planificateur
|
# Arrêter le planificateur
|
||||||
npm run backup:stop
|
pnpm run backup:stop
|
||||||
|
|
||||||
# Statut du planificateur
|
# Statut du planificateur
|
||||||
npm run backup:status
|
pnpm run backup:status
|
||||||
|
|
||||||
# Commandes avancées (tsx requis)
|
# Commandes avancées (pnpm tsx requis)
|
||||||
tsx scripts/backup-manager.ts delete <filename>
|
pnpm tsx scripts/backup-manager.ts delete <filename>
|
||||||
tsx scripts/backup-manager.ts restore <filename> --force
|
pnpm tsx scripts/backup-manager.ts restore <filename> --force
|
||||||
```
|
```
|
||||||
|
|
||||||
## Planificateur automatique
|
## Planificateur automatique
|
||||||
@@ -128,13 +130,13 @@ En production, le planificateur démarre automatiquement 30 secondes après le l
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Démarrer manuellement
|
# Démarrer manuellement
|
||||||
npm run backup:start
|
pnpm run backup:start
|
||||||
|
|
||||||
# Arrêter
|
# Arrêter
|
||||||
npm run backup:stop
|
pnpm run backup:stop
|
||||||
|
|
||||||
# Voir le statut
|
# Voir le statut
|
||||||
npm run backup:status
|
pnpm run backup:status
|
||||||
```
|
```
|
||||||
|
|
||||||
## Fichiers de sauvegarde
|
## Fichiers de sauvegarde
|
||||||
@@ -153,6 +155,7 @@ Par défaut : `./backups/` (relatif au dossier du projet)
|
|||||||
### Métadonnées
|
### Métadonnées
|
||||||
|
|
||||||
Chaque sauvegarde contient :
|
Chaque sauvegarde contient :
|
||||||
|
|
||||||
- **Horodatage** précis de création
|
- **Horodatage** précis de création
|
||||||
- **Taille** du fichier
|
- **Taille** du fichier
|
||||||
- **Type** (manuelle ou automatique)
|
- **Type** (manuelle ou automatique)
|
||||||
@@ -172,17 +175,19 @@ Chaque sauvegarde contient :
|
|||||||
### Procédure
|
### Procédure
|
||||||
|
|
||||||
#### Via interface (développement uniquement)
|
#### Via interface (développement uniquement)
|
||||||
|
|
||||||
1. Aller dans la gestion des sauvegardes
|
1. Aller dans la gestion des sauvegardes
|
||||||
2. Cliquer sur **"Restaurer"** à côté du fichier souhaité
|
2. Cliquer sur **"Restaurer"** à côté du fichier souhaité
|
||||||
3. Confirmer l'action
|
3. Confirmer l'action
|
||||||
|
|
||||||
#### Via CLI
|
#### Via CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Restaurer avec confirmation
|
# Restaurer avec confirmation
|
||||||
tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
pnpm tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||||
|
|
||||||
# Restaurer en forçant (sans confirmation)
|
# Restaurer en forçant (sans confirmation)
|
||||||
tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
pnpm tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
||||||
```
|
```
|
||||||
|
|
||||||
## Vérification d'intégrité
|
## Vérification d'intégrité
|
||||||
@@ -197,11 +202,11 @@ tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.g
|
|||||||
### Commandes
|
### Commandes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Via npm script
|
# Via pnpm script
|
||||||
npm run backup:verify
|
pnpm run backup:verify
|
||||||
|
|
||||||
# Via CLI complet
|
# Via CLI complet
|
||||||
tsx scripts/backup-manager.ts verify
|
pnpm tsx scripts/backup-manager.ts verify
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vérifications effectuées
|
### Vérifications effectuées
|
||||||
@@ -221,10 +226,10 @@ Le système supprime automatiquement les anciennes sauvegardes selon `maxBackups
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Supprimer une sauvegarde spécifique
|
# Supprimer une sauvegarde spécifique
|
||||||
tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
pnpm tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||||
|
|
||||||
# Forcer la suppression
|
# Forcer la suppression
|
||||||
tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
pnpm tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
||||||
```
|
```
|
||||||
|
|
||||||
### Surveillance des logs
|
### Surveillance des logs
|
||||||
@@ -236,6 +241,7 @@ Les opérations de sauvegarde sont loggées dans la console de l'application.
|
|||||||
### Problèmes courants
|
### Problèmes courants
|
||||||
|
|
||||||
#### Erreur "sqlite3 command not found"
|
#### Erreur "sqlite3 command not found"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sur macOS
|
# Sur macOS
|
||||||
brew install sqlite
|
brew install sqlite
|
||||||
@@ -245,6 +251,7 @@ sudo apt-get install sqlite3
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Permissions insuffisantes
|
#### Permissions insuffisantes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Vérifier les permissions du dossier de sauvegarde
|
# Vérifier les permissions du dossier de sauvegarde
|
||||||
ls -la backups/
|
ls -la backups/
|
||||||
@@ -254,13 +261,14 @@ chmod 755 backups/
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Espace disque insuffisant
|
#### Espace disque insuffisant
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Vérifier l'espace disponible
|
# Vérifier l'espace disponible
|
||||||
df -h
|
df -h
|
||||||
|
|
||||||
# Supprimer d'anciennes sauvegardes
|
# Supprimer d'anciennes sauvegardes
|
||||||
tsx scripts/backup-manager.ts list
|
pnpm tsx scripts/backup-manager.ts list
|
||||||
tsx scripts/backup-manager.ts delete <filename>
|
pnpm tsx scripts/backup-manager.ts delete <filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logs de debug
|
### Logs de debug
|
||||||
@@ -268,9 +276,11 @@ tsx scripts/backup-manager.ts delete <filename>
|
|||||||
Pour activer le debug détaillé, modifier `services/database.ts` :
|
Pour activer le debug détaillé, modifier `services/database.ts` :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const prisma = globalThis.__prisma || new PrismaClient({
|
export const prisma =
|
||||||
|
globalThis.__prisma ||
|
||||||
|
new PrismaClient({
|
||||||
log: ['query', 'info', 'warn', 'error'], // Debug activé
|
log: ['query', 'info', 'warn', 'error'], // Debug activé
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
@@ -298,14 +308,15 @@ En environnement Docker, tout est centralisé dans le dossier `data/` :
|
|||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# docker-compose.yml
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "file:./data/prod.db" # Base de données Prisma
|
DATABASE_URL: 'file:./data/prod.db' # Base de données Prisma
|
||||||
BACKUP_DATABASE_PATH: "./data/prod.db" # Base à sauvegarder
|
BACKUP_DATABASE_PATH: './data/prod.db' # Base à sauvegarder
|
||||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
BACKUP_STORAGE_PATH: './data/backups' # Dossier des sauvegardes
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data # Bind mount vers dossier local
|
- ./data:/app/data # Bind mount vers dossier local
|
||||||
```
|
```
|
||||||
|
|
||||||
**Structure des dossiers :**
|
**Structure des dossiers :**
|
||||||
|
|
||||||
```
|
```
|
||||||
./data/ # Dossier local mappé
|
./data/ # Dossier local mappé
|
||||||
├── prod.db # Base de données production
|
├── prod.db # Base de données production
|
||||||
@@ -333,7 +344,7 @@ POST /api/backups/[filename] # Restaurer (dev seulement)
|
|||||||
const response = await fetch('/api/backups', {
|
const response = await fetch('/api/backups', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'create' })
|
body: JSON.stringify({ action: 'create' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lister les sauvegardes
|
// Lister les sauvegardes
|
||||||
@@ -366,15 +377,16 @@ scripts/
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Version actuelle ✅
|
### Version actuelle ✅
|
||||||
|
|
||||||
- Sauvegardes automatiques et manuelles
|
- Sauvegardes automatiques et manuelles
|
||||||
- Interface graphique complète
|
- Interface graphique complète
|
||||||
- CLI d'administration
|
- CLI d'administration
|
||||||
- Compression et rétention
|
- Compression et rétention
|
||||||
|
|
||||||
### Améliorations futures 🚧
|
### Améliorations futures 🚧
|
||||||
|
|
||||||
- Sauvegarde vers cloud (S3, Google Drive)
|
- Sauvegarde vers cloud (S3, Google Drive)
|
||||||
- Chiffrement des sauvegardes
|
- Chiffrement des sauvegardes
|
||||||
- Notifications par email
|
- Notifications par email
|
||||||
- Métriques de performance
|
- Métriques de performance
|
||||||
- Sauvegarde incrémentale
|
- Sauvegarde incrémentale
|
||||||
|
|
||||||
|
|||||||
30
DOCKER.md
@@ -5,6 +5,7 @@ Guide d'utilisation de TowerControl avec Docker.
|
|||||||
## 🚀 Démarrage rapide
|
## 🚀 Démarrage rapide
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Démarrer le service de production
|
# Démarrer le service de production
|
||||||
docker-compose up -d towercontrol
|
docker-compose up -d towercontrol
|
||||||
@@ -14,6 +15,7 @@ open http://localhost:3006
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Développement
|
### Développement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Démarrer le service de développement avec live reload
|
# Démarrer le service de développement avec live reload
|
||||||
docker-compose --profile dev up towercontrol-dev
|
docker-compose --profile dev up towercontrol-dev
|
||||||
@@ -25,6 +27,7 @@ open http://localhost:3005
|
|||||||
## 📋 Services disponibles
|
## 📋 Services disponibles
|
||||||
|
|
||||||
### 🚀 `towercontrol` (Production)
|
### 🚀 `towercontrol` (Production)
|
||||||
|
|
||||||
- **Port** : 3006
|
- **Port** : 3006
|
||||||
- **Base de données** : `./data/prod.db`
|
- **Base de données** : `./data/prod.db`
|
||||||
- **Sauvegardes** : `./data/backups/`
|
- **Sauvegardes** : `./data/backups/`
|
||||||
@@ -32,6 +35,7 @@ open http://localhost:3005
|
|||||||
- **Restart** : Automatique
|
- **Restart** : Automatique
|
||||||
|
|
||||||
### 🛠️ `towercontrol-dev` (Développement)
|
### 🛠️ `towercontrol-dev` (Développement)
|
||||||
|
|
||||||
- **Port** : 3005
|
- **Port** : 3005
|
||||||
- **Base de données** : `./data/dev.db`
|
- **Base de données** : `./data/dev.db`
|
||||||
- **Sauvegardes** : `./data/backups/` (partagées)
|
- **Sauvegardes** : `./data/backups/` (partagées)
|
||||||
@@ -55,7 +59,7 @@ open http://localhost:3005
|
|||||||
### Variables d'environnement
|
### Variables d'environnement
|
||||||
|
|
||||||
| Variable | Production | Développement | Description |
|
| Variable | Production | Développement | Description |
|
||||||
|----------|------------|---------------|-------------|
|
| ---------------------- | --------------------- | -------------------- | ---------------- |
|
||||||
| `NODE_ENV` | `production` | `development` | Mode d'exécution |
|
| `NODE_ENV` | `production` | `development` | Mode d'exécution |
|
||||||
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
|
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
|
||||||
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
|
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
|
||||||
@@ -70,6 +74,7 @@ open http://localhost:3005
|
|||||||
## 📚 Commandes utiles
|
## 📚 Commandes utiles
|
||||||
|
|
||||||
### Gestion des conteneurs
|
### Gestion des conteneurs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Voir les logs
|
# Voir les logs
|
||||||
docker-compose logs -f towercontrol
|
docker-compose logs -f towercontrol
|
||||||
@@ -86,32 +91,35 @@ docker-compose down -v --rmi all
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Gestion des données
|
### Gestion des données
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sauvegarder les données
|
# Sauvegarder les données
|
||||||
docker-compose exec towercontrol npm run backup:create
|
docker-compose exec towercontrol pnpm run backup:create
|
||||||
|
|
||||||
# Lister les sauvegardes
|
# Lister les sauvegardes
|
||||||
docker-compose exec towercontrol npm run backup:list
|
docker-compose exec towercontrol pnpm run backup:list
|
||||||
|
|
||||||
# Accéder au shell du conteneur
|
# Accéder au shell du conteneur
|
||||||
docker-compose exec towercontrol sh
|
docker-compose exec towercontrol sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Base de données
|
### Base de données
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Migrations Prisma
|
# Migrations Prisma
|
||||||
docker-compose exec towercontrol npx prisma migrate deploy
|
docker-compose exec towercontrol pnpm prisma migrate deploy
|
||||||
|
|
||||||
# Reset de la base (dev uniquement)
|
# Reset de la base (dev uniquement)
|
||||||
docker-compose exec towercontrol-dev npx prisma migrate reset
|
docker-compose exec towercontrol-dev pnpm prisma migrate reset
|
||||||
|
|
||||||
# Studio Prisma (dev)
|
# Studio Prisma (dev)
|
||||||
docker-compose exec towercontrol-dev npx prisma studio
|
docker-compose exec towercontrol-dev pnpm prisma studio
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔍 Debugging
|
## 🔍 Debugging
|
||||||
|
|
||||||
### Vérifier la santé
|
### Vérifier la santé
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Health check
|
# Health check
|
||||||
curl http://localhost:3006/api/health
|
curl http://localhost:3006/api/health
|
||||||
@@ -122,6 +130,7 @@ docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Logs détaillés
|
### Logs détaillés
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Logs avec timestamps
|
# Logs avec timestamps
|
||||||
docker-compose logs -f -t towercontrol
|
docker-compose logs -f -t towercontrol
|
||||||
@@ -135,6 +144,7 @@ docker-compose logs --tail=100 towercontrol
|
|||||||
### Problèmes courants
|
### Problèmes courants
|
||||||
|
|
||||||
**Port déjà utilisé**
|
**Port déjà utilisé**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Trouver le processus qui utilise le port
|
# Trouver le processus qui utilise le port
|
||||||
lsof -i :3006
|
lsof -i :3006
|
||||||
@@ -142,12 +152,14 @@ kill -9 <PID>
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Base de données corrompue**
|
**Base de données corrompue**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Restaurer depuis une sauvegarde
|
# Restaurer depuis une sauvegarde
|
||||||
docker-compose exec towercontrol npm run backup:restore filename.db.gz
|
docker-compose exec towercontrol pnpm run backup:restore filename.db.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
**Permissions**
|
**Permissions**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Corriger les permissions du dossier data
|
# Corriger les permissions du dossier data
|
||||||
sudo chown -R $USER:$USER ./data
|
sudo chown -R $USER:$USER ./data
|
||||||
@@ -156,6 +168,7 @@ sudo chown -R $USER:$USER ./data
|
|||||||
## 📊 Monitoring
|
## 📊 Monitoring
|
||||||
|
|
||||||
### Espace disque
|
### Espace disque
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Taille du dossier data
|
# Taille du dossier data
|
||||||
du -sh ./data
|
du -sh ./data
|
||||||
@@ -165,6 +178,7 @@ df -h .
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stats des conteneurs
|
# Stats des conteneurs
|
||||||
docker stats
|
docker stats
|
||||||
@@ -176,6 +190,7 @@ docker-compose exec towercontrol free -h
|
|||||||
## 🔒 Production
|
## 🔒 Production
|
||||||
|
|
||||||
### Recommandations
|
### Recommandations
|
||||||
|
|
||||||
- Utiliser un reverse proxy (nginx, traefik)
|
- Utiliser un reverse proxy (nginx, traefik)
|
||||||
- Configurer HTTPS
|
- Configurer HTTPS
|
||||||
- Sauvegarder régulièrement `./data/`
|
- Sauvegarder régulièrement `./data/`
|
||||||
@@ -183,6 +198,7 @@ docker-compose exec towercontrol free -h
|
|||||||
- Logs centralisés
|
- Logs centralisés
|
||||||
|
|
||||||
### Exemple nginx
|
### Exemple nginx
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
|||||||
44
Dockerfile
@@ -1,6 +1,11 @@
|
|||||||
# Multi-stage Dockerfile for Next.js with Prisma
|
# Multi-stage Dockerfile for Next.js with Prisma
|
||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
@@ -8,9 +13,13 @@ RUN apk add --no-cache libc6-compat
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
# Copy Prisma schema for postinstall script
|
||||||
|
COPY prisma ./prisma
|
||||||
|
# Set dummy DATABASE_URL for Prisma client generation during postinstall
|
||||||
|
ENV DATABASE_URL="file:/tmp/build.db"
|
||||||
RUN \
|
RUN \
|
||||||
if [ -f package-lock.json ]; then npm install; \
|
if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -23,20 +32,17 @@ COPY . .
|
|||||||
# Set a dummy DATABASE_URL for build time (Prisma needs it to generate client)
|
# Set a dummy DATABASE_URL for build time (Prisma needs it to generate client)
|
||||||
ENV DATABASE_URL="file:/tmp/build.db"
|
ENV DATABASE_URL="file:/tmp/build.db"
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client (no DB needed at build time)
|
||||||
RUN npx prisma generate
|
RUN pnpm prisma generate
|
||||||
|
|
||||||
# Initialize the database schema for build time
|
|
||||||
RUN npx prisma migrate deploy || npx prisma db push
|
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
# Set timezone to Europe/Paris and install sqlite3 for backups
|
# Set timezone to Europe/Paris and install sqlite3 for backups
|
||||||
RUN apk add --no-cache tzdata sqlite
|
RUN apk add --no-cache tzdata sqlite su-exec
|
||||||
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -60,21 +66,25 @@ RUN chown nextjs:nodejs .next
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
# Copy Prisma files
|
# Copy Prisma schema and migrations
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
|
||||||
|
|
||||||
# Create data directory for SQLite and backups
|
# Copy pnpm node_modules (includes .pnpm store with Prisma client)
|
||||||
RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Create data directory for SQLite and backups (will be overridden by volume mount but ensures it exists)
|
||||||
|
RUN mkdir -p /app/data/backups && chmod -R 777 /app/data
|
||||||
|
|
||||||
# Set all ENV vars before switching user
|
# Set all ENV vars before switching user
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
ENV TZ=Europe/Paris
|
ENV TZ=Europe/Paris
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start the application with database migration
|
# Start the application with Prisma migrations
|
||||||
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]
|
# Fix permissions for data directory (volume mount may have wrong ownership)
|
||||||
|
# Then switch to nextjs user and run migrations
|
||||||
|
# For fresh DBs: use db push to apply schema, then mark migrations as applied
|
||||||
|
# For existing DBs: use migrate deploy to apply incremental migrations
|
||||||
|
CMD ["sh", "-c", "mkdir -p /app/data/backups && chown -R nextjs:nodejs /app || chmod -R 755 /app || true; chmod -R 777 /app/data && chown -R nextjs:nodejs /app/data || true; exec su-exec nextjs sh -c 'set +e; if ! pnpm prisma migrate deploy; then echo \"Migration failed, using db push for fresh database...\"; pnpm prisma db push --accept-data-loss --skip-generate; for migration in prisma/migrations/*/; do if [ -d \"$migration\" ] && [ -f \"$migration/migration.sql\" ]; then migration_name=$(basename \"$migration\"); pnpm prisma migrate resolve --applied \"$migration_name\" 2>/dev/null || true; fi; done; fi; set -e; exec node server.js'"]
|
||||||
|
|||||||
157
PNPM_MIGRATION.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Migration vers pnpm
|
||||||
|
|
||||||
|
## ✅ Changements effectués
|
||||||
|
|
||||||
|
### 1. Nettoyage npm
|
||||||
|
|
||||||
|
- ✅ Suppression de `node_modules/`
|
||||||
|
- ✅ Suppression de `package-lock.json`
|
||||||
|
|
||||||
|
### 2. Installation pnpm
|
||||||
|
|
||||||
|
- ✅ Installation des dépendances avec `pnpm install`
|
||||||
|
- ✅ Création de `pnpm-lock.yaml`
|
||||||
|
- ✅ Ajout de `tailwindcss` comme dépendance dev explicite (requis par pnpm)
|
||||||
|
|
||||||
|
### 3. Configuration
|
||||||
|
|
||||||
|
- ✅ Création de `.npmrc` avec :
|
||||||
|
- `enable-pre-post-scripts=true`
|
||||||
|
- `auto-install-peers=true`
|
||||||
|
- ✅ Rebuild des packages critiques : `@prisma/client`, `prisma`, `esbuild`, `sharp`, `@tailwindcss/oxide`
|
||||||
|
|
||||||
|
### 4. Scripts package.json
|
||||||
|
|
||||||
|
- ✅ Remplacement de tous les `npx` par `pnpm` dans les scripts
|
||||||
|
- Scripts modifiés :
|
||||||
|
- `backup:*` (create, list, verify, config, start, stop, status)
|
||||||
|
- `cache:*` (monitor, stats, cleanup, clear)
|
||||||
|
- `test:*` (story-points, jira-fields)
|
||||||
|
|
||||||
|
### 5. Dockerfile
|
||||||
|
|
||||||
|
- ✅ Installation de pnpm via `corepack enable`
|
||||||
|
- ✅ Variables d'environnement `PNPM_HOME` et `PATH`
|
||||||
|
- ✅ Remplacement de `package-lock.json` par `pnpm-lock.yaml`
|
||||||
|
- ✅ Remplacement de `npm install` par `pnpm install --frozen-lockfile`
|
||||||
|
- ✅ Remplacement de tous les `npx`/`npm` par `pnpm`
|
||||||
|
|
||||||
|
### 6. docker-compose.yml
|
||||||
|
|
||||||
|
- ✅ Mise à jour du service `towercontrol-dev` pour utiliser pnpm
|
||||||
|
|
||||||
|
### 7. Documentation
|
||||||
|
|
||||||
|
- ✅ Mise à jour de `README.md` :
|
||||||
|
- Prérequis : `pnpm 9+` au lieu de `npm` ou `yarn`
|
||||||
|
- Toutes les commandes d'installation et d'utilisation
|
||||||
|
- Scripts disponibles
|
||||||
|
|
||||||
|
## 🧪 Tests effectués
|
||||||
|
|
||||||
|
- ✅ `pnpm install` - Installation réussie
|
||||||
|
- ✅ `pnpm prisma generate` - Génération du client Prisma OK
|
||||||
|
- ✅ `pnpm run lint` - Linting réussi
|
||||||
|
- ✅ `pnpm run build` - Build de production réussi
|
||||||
|
|
||||||
|
## 📦 Nouvelles dépendances ajoutées
|
||||||
|
|
||||||
|
- `tailwindcss` (devDependencies) - Requis explicitement pour l'import CSS avec pnpm
|
||||||
|
|
||||||
|
## ⚠️ Points d'attention
|
||||||
|
|
||||||
|
### Warning workspace root
|
||||||
|
|
||||||
|
Un warning apparaît lors du build :
|
||||||
|
|
||||||
|
```
|
||||||
|
Warning: Next.js inferred your workspace root, but it may not be correct.
|
||||||
|
We detected multiple lockfiles and selected the directory of /Users/julien.froidefond/package-lock.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : Un `package-lock.json` existe dans `/Users/julien.froidefond/`
|
||||||
|
|
||||||
|
**Solutions** :
|
||||||
|
|
||||||
|
1. Supprimer le lockfile parent si inutilisé
|
||||||
|
2. Ou ajouter dans `next.config.ts` :
|
||||||
|
```typescript
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Peer dependency warning
|
||||||
|
|
||||||
|
```
|
||||||
|
@emoji-mart/react 1.1.1 requires peer react@"^16.8 || ^17 || ^18" but found 19.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact** : Aucun pour le moment, le projet fonctionne avec React 19
|
||||||
|
**Action** : À surveiller lors des mises à jour de `@emoji-mart/react`
|
||||||
|
|
||||||
|
## 🚀 Commandes usuelles
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # Installer les dépendances
|
||||||
|
pnpm run dev # Mode développement
|
||||||
|
pnpm run build # Build de production
|
||||||
|
pnpm run start # Démarrer en production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Base de données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm prisma studio # Interface graphique
|
||||||
|
pnpm prisma generate # Regénérer le client
|
||||||
|
pnpm prisma db push # Appliquer le schéma
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qualité
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run lint # ESLint
|
||||||
|
pnpm run prettier:format # Formatter
|
||||||
|
pnpm run prettier:check # Vérifier le formatage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Production (port 3006)
|
||||||
|
docker compose --profile dev up -d # Développement (port 3005)
|
||||||
|
docker compose down # Arrêter
|
||||||
|
docker compose build --no-cache # Rebuild complet
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Avantages de pnpm
|
||||||
|
|
||||||
|
1. **Performance** : Installation plus rapide (liens symboliques)
|
||||||
|
2. **Espace disque** : Économie grâce au store global
|
||||||
|
3. **Sécurité** : Structure node_modules stricte (pas d'accès aux dépendances non déclarées)
|
||||||
|
4. **Monorepo** : Support natif des workspaces
|
||||||
|
5. **Déterminisme** : Lockfile plus fiable
|
||||||
|
|
||||||
|
## 🔄 Rollback vers npm
|
||||||
|
|
||||||
|
Si besoin de revenir à npm :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Supprimer pnpm
|
||||||
|
rm -rf node_modules pnpm-lock.yaml .npmrc
|
||||||
|
|
||||||
|
# Restaurer les anciens scripts dans package.json
|
||||||
|
# Restaurer l'ancien Dockerfile
|
||||||
|
# Restaurer l'ancien docker-compose.yml
|
||||||
|
|
||||||
|
# Réinstaller avec npm
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Date de migration** : 16 octobre 2025
|
||||||
|
**Version pnpm** : 10.15.1
|
||||||
|
**Version Node** : 20
|
||||||
264
README.md
@@ -20,6 +20,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
|||||||
## ✨ Fonctionnalités principales
|
## ✨ Fonctionnalités principales
|
||||||
|
|
||||||
### 🏗️ Kanban moderne
|
### 🏗️ Kanban moderne
|
||||||
|
|
||||||
- **Drag & drop fluide** avec @dnd-kit (optimistic updates)
|
- **Drag & drop fluide** avec @dnd-kit (optimistic updates)
|
||||||
- **Colonnes configurables** : backlog, todo, in_progress, done, cancelled, freeze, archived
|
- **Colonnes configurables** : backlog, todo, in_progress, done, cancelled, freeze, archived
|
||||||
- **Vues multiples** : Kanban classique + swimlanes par priorité
|
- **Vues multiples** : Kanban classique + swimlanes par priorité
|
||||||
@@ -27,18 +28,21 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
|||||||
- **Création rapide** : Ajout inline dans chaque colonne
|
- **Création rapide** : Ajout inline dans chaque colonne
|
||||||
|
|
||||||
### 🏷️ Système de tags avancé
|
### 🏷️ Système de tags avancé
|
||||||
|
|
||||||
- **Tags colorés** avec sélecteur de couleur
|
- **Tags colorés** avec sélecteur de couleur
|
||||||
- **Autocomplete intelligent** lors de la saisie
|
- **Autocomplete intelligent** lors de la saisie
|
||||||
- **Filtrage en temps réel** par tags
|
- **Filtrage en temps réel** par tags
|
||||||
- **Gestion complète** avec page dédiée `/tags`
|
- **Gestion complète** avec page dédiée `/tags`
|
||||||
|
|
||||||
### 📊 Filtrage et recherche
|
### 📊 Filtrage et recherche
|
||||||
|
|
||||||
- **Recherche temps réel** dans les titres et descriptions
|
- **Recherche temps réel** dans les titres et descriptions
|
||||||
- **Filtres combinables** : statut, priorité, tags, source
|
- **Filtres combinables** : statut, priorité, tags, source
|
||||||
- **Tri flexible** : date, priorité, alphabétique
|
- **Tri flexible** : date, priorité, alphabétique
|
||||||
- **Interface intuitive** avec dropdowns et toggles
|
- **Interface intuitive** avec dropdowns et toggles
|
||||||
|
|
||||||
### 📝 Daily Notes
|
### 📝 Daily Notes
|
||||||
|
|
||||||
- **Checkboxes quotidiennes** avec sections "Hier" / "Aujourd'hui"
|
- **Checkboxes quotidiennes** avec sections "Hier" / "Aujourd'hui"
|
||||||
- **Navigation par date** (précédent/suivant)
|
- **Navigation par date** (précédent/suivant)
|
||||||
- **Liaison optionnelle** avec les tâches existantes
|
- **Liaison optionnelle** avec les tâches existantes
|
||||||
@@ -46,6 +50,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
|||||||
- **Historique calendaire** des dailies
|
- **Historique calendaire** des dailies
|
||||||
|
|
||||||
### 🔗 Intégration Jira Cloud
|
### 🔗 Intégration Jira Cloud
|
||||||
|
|
||||||
- **Synchronisation unidirectionnelle** (Jira → local)
|
- **Synchronisation unidirectionnelle** (Jira → local)
|
||||||
- **Authentification sécurisée** (email + API token)
|
- **Authentification sécurisée** (email + API token)
|
||||||
- **Mapping intelligent** des statuts Jira
|
- **Mapping intelligent** des statuts Jira
|
||||||
@@ -54,6 +59,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
|||||||
- **Interface de configuration** complète
|
- **Interface de configuration** complète
|
||||||
|
|
||||||
### 🎨 Interface & UX
|
### 🎨 Interface & UX
|
||||||
|
|
||||||
- **Thème adaptatif** : dark/light + détection système
|
- **Thème adaptatif** : dark/light + détection système
|
||||||
- **Design cohérent** : palette cyberpunk/tech avec Tailwind CSS
|
- **Design cohérent** : palette cyberpunk/tech avec Tailwind CSS
|
||||||
- **Composants modulaires** : Button, Input, Card, Modal, Badge
|
- **Composants modulaires** : Button, Input, Card, Modal, Badge
|
||||||
@@ -61,6 +67,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
|||||||
- **Responsive design** pour tous les écrans
|
- **Responsive design** pour tous les écrans
|
||||||
|
|
||||||
### ⚡ Performance & Architecture
|
### ⚡ Performance & Architecture
|
||||||
|
|
||||||
- **Server Actions** pour les mutations rapides (vs API routes)
|
- **Server Actions** pour les mutations rapides (vs API routes)
|
||||||
- **Architecture SSR** avec hydratation optimisée
|
- **Architecture SSR** avec hydratation optimisée
|
||||||
- **Base de données SQLite** ultra-rapide
|
- **Base de données SQLite** ultra-rapide
|
||||||
@@ -72,8 +79,9 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
|||||||
## 🛠️ Installation
|
## 🛠️ Installation
|
||||||
|
|
||||||
### Prérequis
|
### Prérequis
|
||||||
|
|
||||||
- **Node.js** 18+
|
- **Node.js** 18+
|
||||||
- **npm** ou **yarn**
|
- **pnpm** 9+
|
||||||
|
|
||||||
### Installation locale
|
### Installation locale
|
||||||
|
|
||||||
@@ -83,17 +91,17 @@ git clone https://github.com/votre-repo/towercontrol.git
|
|||||||
cd towercontrol
|
cd towercontrol
|
||||||
|
|
||||||
# Installer les dépendances
|
# Installer les dépendances
|
||||||
npm install
|
pnpm install
|
||||||
|
|
||||||
# Configurer la base de données
|
# Configurer la base de données
|
||||||
npx prisma generate
|
pnpm prisma generate
|
||||||
npx prisma db push
|
pnpm prisma db push
|
||||||
|
|
||||||
# (Optionnel) Ajouter des données de test
|
# (Optionnel) Ajouter des données de test
|
||||||
npm run seed
|
pnpm run seed
|
||||||
|
|
||||||
# Démarrer en développement
|
# Démarrer en développement
|
||||||
npm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
L'application sera accessible sur **http://localhost:3000**
|
L'application sera accessible sur **http://localhost:3000**
|
||||||
@@ -115,10 +123,12 @@ docker compose --profile dev up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Accès :**
|
**Accès :**
|
||||||
|
|
||||||
- **Production** : http://localhost:3006
|
- **Production** : http://localhost:3006
|
||||||
- **Développement** : http://localhost:3005
|
- **Développement** : http://localhost:3005
|
||||||
|
|
||||||
**Gestion des données :**
|
**Gestion des données :**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Utiliser votre base locale existante (décommentez dans docker-compose.yml)
|
# Utiliser votre base locale existante (décommentez dans docker-compose.yml)
|
||||||
# - ./prisma/dev.db:/app/data/prod.db
|
# - ./prisma/dev.db:/app/data/prod.db
|
||||||
@@ -134,6 +144,7 @@ docker compose down -v
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Avantages Docker :**
|
**Avantages Docker :**
|
||||||
|
|
||||||
- ✅ **Isolation complète** - Pas de pollution de l'environnement local
|
- ✅ **Isolation complète** - Pas de pollution de l'environnement local
|
||||||
- ✅ **Base persistante** - Volumes Docker pour SQLite
|
- ✅ **Base persistante** - Volumes Docker pour SQLite
|
||||||
- ✅ **Prêt pour prod** - Configuration optimisée
|
- ✅ **Prêt pour prod** - Configuration optimisée
|
||||||
@@ -182,31 +193,204 @@ JIRA_API_TOKEN="votre_token_api"
|
|||||||
```
|
```
|
||||||
towercontrol/
|
towercontrol/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # Pages Next.js 15 (App Router)
|
│ ├── app/ # Next.js 15 App Router (pages & routes)
|
||||||
│ │ ├── api/ # API Routes (endpoints complexes)
|
│ │ ├── api/ # API Routes (endpoints complexes)
|
||||||
│ │ ├── daily/ # Page daily notes
|
│ │ │ ├── analytics/ # Endpoints d'analytics
|
||||||
│ │ ├── tags/ # Page gestion tags
|
│ │ │ ├── auth/ # Authentification (NextAuth)
|
||||||
│ │ └── settings/ # Page configuration
|
│ │ │ ├── backups/ # Gestion des sauvegardes
|
||||||
|
│ │ │ ├── daily/ # API daily notes
|
||||||
|
│ │ │ ├── jira/ # API intégration Jira
|
||||||
|
│ │ │ ├── notes/ # API notes markdown
|
||||||
|
│ │ │ ├── tags/ # API gestion tags
|
||||||
|
│ │ │ ├── tasks/ # API tâches
|
||||||
|
│ │ │ ├── tfs/ # API intégration TFS
|
||||||
|
│ │ │ └── user-preferences/ # API préférences utilisateur
|
||||||
|
│ │ ├── daily/ # Page daily notes (/daily)
|
||||||
|
│ │ ├── jira-dashboard/ # Dashboard Jira (/jira-dashboard)
|
||||||
|
│ │ ├── kanban/ # Page Kanban (/kanban)
|
||||||
|
│ │ ├── manager/ # Page manager
|
||||||
|
│ │ ├── notes/ # Page notes (/notes)
|
||||||
|
│ │ ├── profile/ # Page profil utilisateur
|
||||||
|
│ │ ├── settings/ # Page configuration (/settings)
|
||||||
|
│ │ │ ├── advanced/ # Paramètres avancés
|
||||||
|
│ │ │ ├── backup/ # Gestion backups
|
||||||
|
│ │ │ ├── general/ # Paramètres généraux
|
||||||
|
│ │ │ └── integrations/ # Config intégrations
|
||||||
|
│ │ ├── weekly-manager/ # Page weekly manager
|
||||||
|
│ │ ├── layout.tsx # Layout principal
|
||||||
|
│ │ ├── page.tsx # Page d'accueil (/)
|
||||||
|
│ │ └── globals.css # Styles globaux + variables CSS
|
||||||
|
│ │
|
||||||
│ ├── actions/ # Server Actions (mutations rapides)
|
│ ├── actions/ # Server Actions (mutations rapides)
|
||||||
│ └── contexts/ # Contexts React globaux
|
│ │ ├── backup.ts # Actions sauvegardes
|
||||||
├── components/
|
│ │ ├── daily.ts # Actions daily notes
|
||||||
│ ├── ui/ # Composants UI de base
|
│ │ ├── jira-analytics.ts # Actions analytics Jira
|
||||||
│ ├── kanban/ # Composants Kanban
|
│ │ ├── preferences.ts # Actions préférences
|
||||||
│ ├── daily/ # Composants Daily notes
|
│ │ ├── tags.ts # Actions tags
|
||||||
│ └── forms/ # Formulaires réutilisables
|
│ │ ├── tasks.ts # Actions tâches
|
||||||
├── services/ # Services backend (logique métier)
|
│ │ └── tfs.ts # Actions TFS
|
||||||
│ ├── database.ts # Pool Prisma
|
│ │
|
||||||
│ ├── tasks.ts # CRUD tâches
|
│ ├── components/ # Composants React (UI uniquement)
|
||||||
│ ├── tags.ts # CRUD tags
|
│ │ ├── ui/ # Composants UI de base réutilisables
|
||||||
│ ├── daily.ts # Daily notes
|
│ │ │ ├── Button.tsx # Boutons
|
||||||
│ ├── jira.ts # Intégration Jira
|
│ │ │ ├── Input.tsx # Inputs
|
||||||
│ └── user-preferences.ts # Préférences utilisateur
|
│ │ │ ├── Modal.tsx # Modales
|
||||||
├── clients/ # Clients HTTP frontend
|
│ │ │ ├── Badge.tsx # Badges
|
||||||
├── hooks/ # Hooks React personnalisés
|
│ │ │ ├── Card.tsx # Cartes
|
||||||
├── lib/ # Utilitaires et types
|
│ │ │ └── ... # Autres composants UI
|
||||||
└── prisma/ # Schéma et migrations DB
|
│ │ ├── kanban/ # Composants Kanban spécifiques
|
||||||
|
│ │ │ ├── Board.tsx # Board principal
|
||||||
|
│ │ │ ├── Column.tsx # Colonne Kanban
|
||||||
|
│ │ │ ├── TaskCard.tsx # Carte de tâche
|
||||||
|
│ │ │ ├── filters/ # Composants de filtrage
|
||||||
|
│ │ │ └── ... # Autres composants Kanban
|
||||||
|
│ │ ├── daily/ # Composants Daily notes
|
||||||
|
│ │ ├── dashboard/ # Composants dashboard
|
||||||
|
│ │ ├── forms/ # Formulaires réutilisables
|
||||||
|
│ │ ├── jira/ # Composants intégration Jira
|
||||||
|
│ │ ├── settings/ # Composants paramètres
|
||||||
|
│ │ └── charts/ # Composants graphiques
|
||||||
|
│ │
|
||||||
|
│ ├── services/ # Services backend (logique métier)
|
||||||
|
│ │ ├── core/ # Services core
|
||||||
|
│ │ │ ├── database.ts # Pool Prisma (unique point d'accès DB)
|
||||||
|
│ │ │ ├── system-info.ts # Infos système
|
||||||
|
│ │ │ └── user-preferences.ts # Préférences utilisateur
|
||||||
|
│ │ ├── task-management/ # Gestion des tâches
|
||||||
|
│ │ │ ├── tasks.ts # CRUD tâches
|
||||||
|
│ │ │ ├── tags.ts # CRUD tags
|
||||||
|
│ │ │ └── daily.ts # Daily notes
|
||||||
|
│ │ ├── integrations/ # Intégrations externes
|
||||||
|
│ │ │ ├── jira/ # Intégration Jira
|
||||||
|
│ │ │ │ ├── jira.ts # Client Jira API
|
||||||
|
│ │ │ │ ├── analytics.ts # Analytics Jira
|
||||||
|
│ │ │ │ ├── scheduler.ts # Planification sync
|
||||||
|
│ │ │ │ └── ... # Autres services Jira
|
||||||
|
│ │ │ └── tfs/ # Intégration TFS
|
||||||
|
│ │ ├── analytics/ # Services d'analytics
|
||||||
|
│ │ │ ├── analytics.ts # Analytics générales
|
||||||
|
│ │ │ ├── metrics.ts # Métriques
|
||||||
|
│ │ │ └── ... # Autres analytics
|
||||||
|
│ │ └── data-management/ # Gestion des données
|
||||||
|
│ │ ├── backup.ts # Sauvegardes
|
||||||
|
│ │ └── backup-scheduler.ts # Planification backups
|
||||||
|
│ │
|
||||||
|
│ ├── clients/ # Clients HTTP frontend
|
||||||
|
│ │ ├── base/ # Client HTTP de base
|
||||||
|
│ │ │ └── http-client.ts # Client HTTP réutilisable
|
||||||
|
│ │ ├── tasks-client.ts # Client API tâches
|
||||||
|
│ │ ├── tags-client.ts # Client API tags
|
||||||
|
│ │ ├── daily-client.ts # Client API daily
|
||||||
|
│ │ ├── jira-client.ts # Client API Jira
|
||||||
|
│ │ └── backup-client.ts # Client API backups
|
||||||
|
│ │
|
||||||
|
│ ├── hooks/ # Hooks React personnalisés
|
||||||
|
│ │ ├── useTasks.ts # Hook gestion tâches
|
||||||
|
│ │ ├── useTags.ts # Hook gestion tags
|
||||||
|
│ │ ├── useDaily.ts # Hook daily notes
|
||||||
|
│ │ ├── useDragAndDrop.ts # Hook drag & drop
|
||||||
|
│ │ └── ... # Autres hooks
|
||||||
|
│ │
|
||||||
|
│ ├── contexts/ # Contexts React globaux
|
||||||
|
│ │ ├── ThemeContext.tsx # Gestion thème dark/light
|
||||||
|
│ │ ├── TasksContext.tsx # Context tâches
|
||||||
|
│ │ ├── UserPreferencesContext.tsx # Préférences utilisateur
|
||||||
|
│ │ └── ... # Autres contexts
|
||||||
|
│ │
|
||||||
|
│ ├── lib/ # Utilitaires et configuration
|
||||||
|
│ │ ├── types.ts # Types TypeScript partagés
|
||||||
|
│ │ ├── utils.ts # Fonctions utilitaires
|
||||||
|
│ │ ├── config.ts # Configuration app
|
||||||
|
│ │ ├── status-config.ts # Configuration statuts Kanban
|
||||||
|
│ │ ├── tag-colors.ts # Configuration couleurs tags
|
||||||
|
│ │ └── ... # Autres utilitaires
|
||||||
|
│ │
|
||||||
|
│ ├── types/ # Types TypeScript spécifiques
|
||||||
|
│ │ └── next-auth.d.ts # Types NextAuth
|
||||||
|
│ │
|
||||||
|
│ └── middleware.ts # Middleware Next.js (auth, etc.)
|
||||||
|
│
|
||||||
|
├── prisma/ # Prisma ORM
|
||||||
|
│ ├── schema.prisma # Schéma de base de données
|
||||||
|
│ └── migrations/ # Migrations SQL
|
||||||
|
│
|
||||||
|
├── scripts/ # Scripts utilitaires
|
||||||
|
│ ├── backup-manager.ts # Gestion backups
|
||||||
|
│ ├── seed-data.ts # Données de test
|
||||||
|
│ └── ... # Autres scripts
|
||||||
|
│
|
||||||
|
├── public/ # Assets statiques
|
||||||
|
│ └── icons/ # Icônes
|
||||||
|
│
|
||||||
|
├── data/ # Données locales
|
||||||
|
│ ├── dev.db # Base SQLite développement
|
||||||
|
│ ├── prod.db # Base SQLite production
|
||||||
|
│ └── backups/ # Sauvegardes automatiques
|
||||||
|
│
|
||||||
|
└── [fichiers racine] # Config projet (package.json, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Explication détaillée des dossiers
|
||||||
|
|
||||||
|
#### 📁 `src/app/` - Pages et routes Next.js
|
||||||
|
|
||||||
|
- **Pages publiques** : Routes Next.js qui génèrent les pages (`page.tsx`)
|
||||||
|
- **API Routes** : Endpoints HTTP dans `/api` pour les opérations complexes
|
||||||
|
- **Client Components** : Composants client séparés (`*PageClient.tsx`) pour l'hydratation
|
||||||
|
- **Layout** : Layout global avec providers (Theme, Auth, etc.)
|
||||||
|
|
||||||
|
#### 📁 `src/actions/` - Server Actions
|
||||||
|
|
||||||
|
- **Mutations rapides** : Actions serveur pour les mutations simples (CRUD)
|
||||||
|
- **Cache intelligent** : Révalidation automatique avec `revalidatePath()`
|
||||||
|
- **UX optimisée** : Utilisation avec `useTransition` pour les états de chargement
|
||||||
|
|
||||||
|
#### 📁 `src/components/` - Composants React (UI uniquement)
|
||||||
|
|
||||||
|
- **Règle stricte** : AUCUNE logique métier, uniquement présentation
|
||||||
|
- **Organisation par domaine** : `kanban/`, `daily/`, `jira/`, etc.
|
||||||
|
- **Composants UI réutilisables** : Dans `ui/` pour la cohérence visuelle
|
||||||
|
- **Formulaires** : Dans `forms/` pour la réutilisation
|
||||||
|
|
||||||
|
#### 📁 `src/services/` - Logique métier backend
|
||||||
|
|
||||||
|
- **Point unique d'accès DB** : `core/database.ts` (Pool Prisma)
|
||||||
|
- **Séparation par domaine** : `task-management/`, `integrations/`, `analytics/`
|
||||||
|
- **Règle stricte** : TOUTE la logique métier ici, jamais dans les composants
|
||||||
|
- **Services métier** : CRUD, calculs, validations, intégrations externes
|
||||||
|
|
||||||
|
#### 📁 `src/clients/` - Clients HTTP frontend
|
||||||
|
|
||||||
|
- **Client HTTP de base** : `base/http-client.ts` avec gestion erreurs/tokens
|
||||||
|
- **Clients par domaine** : Un client par API (tasks, tags, jira, etc.)
|
||||||
|
- **Règle stricte** : Uniquement requêtes HTTP, pas de logique métier
|
||||||
|
|
||||||
|
#### 📁 `src/hooks/` - Hooks React personnalisés
|
||||||
|
|
||||||
|
- **Orchestration UI** : Gestion état React, appels API via clients
|
||||||
|
- **Logique UI uniquement** : Pas de logique métier, uniquement coordination
|
||||||
|
|
||||||
|
#### 📁 `src/contexts/` - Contexts React globaux
|
||||||
|
|
||||||
|
- **État global** : Thème, préférences, tâches, etc.
|
||||||
|
- **Providers** : Utilisés dans le layout principal
|
||||||
|
|
||||||
|
#### 📁 `src/lib/` - Utilitaires et configuration
|
||||||
|
|
||||||
|
- **Types partagés** : `types.ts` pour la cohérence TypeScript
|
||||||
|
- **Configurations** : Statuts Kanban, couleurs tags, etc.
|
||||||
|
- **Helpers** : Fonctions utilitaires (dates, formatting, etc.)
|
||||||
|
|
||||||
|
#### 📁 `prisma/` - Base de données
|
||||||
|
|
||||||
|
- **Schéma** : Définition des modèles (`schema.prisma`)
|
||||||
|
- **Migrations** : Historique des changements de schéma
|
||||||
|
|
||||||
|
#### 📁 `scripts/` - Scripts utilitaires
|
||||||
|
|
||||||
|
- **Opérations** : Backups, seeding, maintenance
|
||||||
|
- **Exécution** : Via `pnpm run <script-name>`
|
||||||
|
|
||||||
### Stack technique
|
### Stack technique
|
||||||
|
|
||||||
- **Frontend** : Next.js 15, React 19, TypeScript, Tailwind CSS
|
- **Frontend** : Next.js 15, React 19, TypeScript, Tailwind CSS
|
||||||
@@ -262,22 +446,22 @@ towercontrol/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Développement
|
# Développement
|
||||||
npm run dev # Démarrer en mode dev avec Turbopack
|
pnpm run dev # Démarrer en mode dev avec Turbopack
|
||||||
npm run build # Build de production
|
pnpm run build # Build de production
|
||||||
npm run start # Démarrer en production
|
pnpm run start # Démarrer en production
|
||||||
|
|
||||||
# Base de données
|
# Base de données
|
||||||
npx prisma studio # Interface graphique BDD
|
pnpm prisma studio # Interface graphique BDD
|
||||||
npx prisma generate # Regénérer le client Prisma
|
pnpm prisma generate # Regénérer le client Prisma
|
||||||
npx prisma db push # Appliquer le schema à la BDD
|
pnpm prisma db push # Appliquer le schema à la BDD
|
||||||
npx prisma migrate dev # Créer une migration
|
pnpm prisma migrate dev # Créer une migration
|
||||||
|
|
||||||
# Qualité de code
|
# Qualité de code
|
||||||
npm run lint # ESLint + Prettier
|
pnpm run lint # ESLint + Prettier
|
||||||
npx tsc --noEmit # Vérification TypeScript
|
pnpm tsc --noEmit # Vérification TypeScript
|
||||||
|
|
||||||
# Scripts utilitaires
|
# Scripts utilitaires
|
||||||
npm run seed # Ajouter des données de test
|
pnpm run seed # Ajouter des données de test
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -292,7 +476,7 @@ export const UI_CONFIG = {
|
|||||||
theme: 'system', // 'light' | 'dark' | 'system'
|
theme: 'system', // 'light' | 'dark' | 'system'
|
||||||
itemsPerPage: 50, // Pagination
|
itemsPerPage: 50, // Pagination
|
||||||
enableDragAndDrop: true, // Drag & drop
|
enableDragAndDrop: true, // Drag & drop
|
||||||
autoSave: true // Sauvegarde auto
|
autoSave: true, // Sauvegarde auto
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -322,6 +506,7 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
|
|||||||
## 🚧 Roadmap
|
## 🚧 Roadmap
|
||||||
|
|
||||||
### ✅ Version 2.0 (Actuelle)
|
### ✅ Version 2.0 (Actuelle)
|
||||||
|
|
||||||
- Interface Kanban moderne avec drag & drop
|
- Interface Kanban moderne avec drag & drop
|
||||||
- Système de tags avancé
|
- Système de tags avancé
|
||||||
- Daily notes avec navigation
|
- Daily notes avec navigation
|
||||||
@@ -330,12 +515,14 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
|
|||||||
- Server Actions pour les performances
|
- Server Actions pour les performances
|
||||||
|
|
||||||
### 🔄 Version 2.1 (En cours)
|
### 🔄 Version 2.1 (En cours)
|
||||||
|
|
||||||
- [ ] Page dashboard avec analytics
|
- [ ] Page dashboard avec analytics
|
||||||
- [ ] Système de sauvegarde automatique (configurable)
|
- [ ] Système de sauvegarde automatique (configurable)
|
||||||
- [ ] Métriques de productivité et graphiques
|
- [ ] Métriques de productivité et graphiques
|
||||||
- [ ] Actions en lot (sélection multiple)
|
- [ ] Actions en lot (sélection multiple)
|
||||||
|
|
||||||
### 🎯 Version 2.2 (Futur)
|
### 🎯 Version 2.2 (Futur)
|
||||||
|
|
||||||
- [ ] Sous-tâches et hiérarchie
|
- [ ] Sous-tâches et hiérarchie
|
||||||
- [ ] Dates d'échéance et rappels
|
- [ ] Dates d'échéance et rappels
|
||||||
- [ ] Collaboration et assignation
|
- [ ] Collaboration et assignation
|
||||||
@@ -343,6 +530,7 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
|
|||||||
- [ ] Mode PWA et offline
|
- [ ] Mode PWA et offline
|
||||||
|
|
||||||
### 🚀 Version 3.0 (Vision)
|
### 🚀 Version 3.0 (Vision)
|
||||||
|
|
||||||
- [ ] Analytics d'équipe avancées
|
- [ ] Analytics d'équipe avancées
|
||||||
- [ ] Intégrations multiples (GitHub, Linear, etc.)
|
- [ ] Intégrations multiples (GitHub, Linear, etc.)
|
||||||
- [ ] API publique et webhooks
|
- [ ] API publique et webhooks
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
|
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
|
||||||
|
|
||||||
## 🎯 Objectif
|
## 🎯 Objectif
|
||||||
|
|
||||||
Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique.
|
Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique.
|
||||||
|
|
||||||
## ⚡ Changements apportés
|
## ⚡ Changements apportés
|
||||||
@@ -8,17 +9,20 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
|||||||
### 1. Service TFS (`src/services/tfs.ts`)
|
### 1. Service TFS (`src/services/tfs.ts`)
|
||||||
|
|
||||||
#### Nouvelles méthodes ajoutées :
|
#### Nouvelles méthodes ajoutées :
|
||||||
|
|
||||||
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
|
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
|
||||||
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
|
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
|
||||||
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
|
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
|
||||||
- **`filterPullRequests()`** : Applique les filtres de configuration
|
- **`filterPullRequests()`** : Applique les filtres de configuration
|
||||||
|
|
||||||
#### Méthode syncTasks refactorisée :
|
#### Méthode syncTasks refactorisée :
|
||||||
|
|
||||||
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
|
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
|
||||||
- Plus efficace et centrée sur l'utilisateur
|
- Plus efficace et centrée sur l'utilisateur
|
||||||
- Récupération directe via l'API Azure DevOps avec critères `@me`
|
- Récupération directe via l'API Azure DevOps avec critères `@me`
|
||||||
|
|
||||||
#### Configuration mise à jour :
|
#### Configuration mise à jour :
|
||||||
|
|
||||||
- **`projectName`** devient **optionnel**
|
- **`projectName`** devient **optionnel**
|
||||||
- Validation assouplie dans les factories
|
- Validation assouplie dans les factories
|
||||||
- Comportement adaptatif : projet spécifique OU toute l'organisation
|
- Comportement adaptatif : projet spécifique OU toute l'organisation
|
||||||
@@ -26,12 +30,14 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
|||||||
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
|
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
|
||||||
|
|
||||||
#### Modifications du formulaire :
|
#### Modifications du formulaire :
|
||||||
|
|
||||||
- Champ "Nom du projet" marqué comme **optionnel**
|
- Champ "Nom du projet" marqué comme **optionnel**
|
||||||
- Validation `required` supprimée
|
- Validation `required` supprimée
|
||||||
- Placeholder mis à jour : *"laisser vide pour toute l'organisation"*
|
- Placeholder mis à jour : _"laisser vide pour toute l'organisation"_
|
||||||
- Affichage du statut : *"Toute l'organisation"* si pas de projet
|
- Affichage du statut : _"Toute l'organisation"_ si pas de projet
|
||||||
|
|
||||||
#### Instructions mises à jour :
|
#### Instructions mises à jour :
|
||||||
|
|
||||||
- Explique le nouveau comportement **synchronisation intelligente**
|
- Explique le nouveau comportement **synchronisation intelligente**
|
||||||
- Précise que les PRs sont récupérées automatiquement selon l'assignation
|
- Précise que les PRs sont récupérées automatiquement selon l'assignation
|
||||||
- Note sur la portée projet vs organisation
|
- Note sur la portée projet vs organisation
|
||||||
@@ -39,17 +45,20 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
|||||||
### 3. Endpoints API
|
### 3. Endpoints API
|
||||||
|
|
||||||
#### `/api/tfs/test/route.ts`
|
#### `/api/tfs/test/route.ts`
|
||||||
|
|
||||||
- Validation mise à jour (projectName optionnel)
|
- Validation mise à jour (projectName optionnel)
|
||||||
- Message de réponse enrichi avec portée (projet/organisation)
|
- Message de réponse enrichi avec portée (projet/organisation)
|
||||||
- Retour détaillé du scope de synchronisation
|
- Retour détaillé du scope de synchronisation
|
||||||
|
|
||||||
#### `/api/tfs/sync/route.ts`
|
#### `/api/tfs/sync/route.ts`
|
||||||
|
|
||||||
- Validation assouplie pour les deux méthodes GET/POST
|
- Validation assouplie pour les deux méthodes GET/POST
|
||||||
- Configuration adaptative selon la présence du projectName
|
- Configuration adaptative selon la présence du projectName
|
||||||
|
|
||||||
## 🔧 API Azure DevOps utilisées
|
## 🔧 API Azure DevOps utilisées
|
||||||
|
|
||||||
### Nouvelles requêtes :
|
### Nouvelles requêtes :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// PRs créées par l'utilisateur
|
// PRs créées par l'utilisateur
|
||||||
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
|
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
|
||||||
@@ -59,6 +68,7 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Comportement intelligent :
|
### Comportement intelligent :
|
||||||
|
|
||||||
- **Fusion automatique** des deux types de PRs
|
- **Fusion automatique** des deux types de PRs
|
||||||
- **Déduplication** basée sur `pullRequestId`
|
- **Déduplication** basée sur `pullRequestId`
|
||||||
- **Filtrage** selon la configuration (repositories, branches, projet)
|
- **Filtrage** selon la configuration (repositories, branches, projet)
|
||||||
@@ -74,11 +84,13 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
|||||||
## 🎨 Interface utilisateur
|
## 🎨 Interface utilisateur
|
||||||
|
|
||||||
### Avant :
|
### Avant :
|
||||||
|
|
||||||
- Champ projet **obligatoire**
|
- Champ projet **obligatoire**
|
||||||
- Synchronisation limitée à UN projet
|
- Synchronisation limitée à UN projet
|
||||||
- Configuration rigide
|
- Configuration rigide
|
||||||
|
|
||||||
### Après :
|
### Après :
|
||||||
|
|
||||||
- Champ projet **optionnel**
|
- Champ projet **optionnel**
|
||||||
- Synchronisation intelligente de TOUTES les PRs assignées
|
- Synchronisation intelligente de TOUTES les PRs assignées
|
||||||
- Configuration flexible et adaptative
|
- Configuration flexible et adaptative
|
||||||
@@ -94,10 +106,11 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
|||||||
## 🚀 Déploiement
|
## 🚀 Déploiement
|
||||||
|
|
||||||
La migration est **transparente** :
|
La migration est **transparente** :
|
||||||
|
|
||||||
- Les configurations existantes continuent à fonctionner
|
- Les configurations existantes continuent à fonctionner
|
||||||
- Possibilité de supprimer le `projectName` pour étendre la portée
|
- Possibilité de supprimer le `projectName` pour étendre la portée
|
||||||
- Pas de rupture de compatibilité
|
- Pas de rupture de compatibilité
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps.* 🎯
|
_Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps._ 🎯
|
||||||
|
|||||||
323
TODO.md
@@ -1,68 +1,64 @@
|
|||||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||||
|
|
||||||
## Autre Todos
|
## Fix
|
||||||
- [x] Désactiver le hover sur les taskCard
|
|
||||||
|
|
||||||
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
- [ ] Calendrier n'a plus le bouton calendrier d'ouverture du calendrier visuel dans les inputs datetime
|
||||||
|
- [ ] Un raccourci pour chercher dans la page de Kanban
|
||||||
|
- [ ] Bouton cloner une tache dans la modale d'edition
|
||||||
|
|
||||||
### 6.1 Gestion avancée des tâches
|
## Idées à developper
|
||||||
- [ ] Actions en lot (sélection multiple)
|
|
||||||
- [ ] Sous-tâches et hiérarchie
|
|
||||||
- [ ] Dates d'échéance et rappels
|
|
||||||
- [ ] Assignation et collaboration
|
|
||||||
- [ ] Templates de tâches
|
|
||||||
|
|
||||||
### 6.2 Personnalisation et thèmes
|
- [ ] Optimisations Perf : requetes DB
|
||||||
- [ ] Mode sombre/clair
|
|
||||||
- [ ] Personnalisation des couleurs
|
|
||||||
- [ ] Configuration des colonnes Kanban
|
|
||||||
- [ ] Préférences utilisateur
|
|
||||||
|
|
||||||
## 🚀 Phase 7: Intégrations futures (Priorité 7)
|
|
||||||
|
|
||||||
### 7.1 Intégrations externes (optionnel)
|
|
||||||
- [ ] Import/Export depuis d'autres outils
|
|
||||||
- [ ] API webhooks pour intégrations
|
|
||||||
- [ ] Synchronisation cloud (optionnel)
|
|
||||||
- [ ] Notifications push
|
|
||||||
|
|
||||||
### 7.2 Optimisations et performance
|
|
||||||
- [ ] Optimisation des requêtes DB
|
|
||||||
- [ ] Pagination et virtualisation
|
|
||||||
- [ ] Cache côté client
|
|
||||||
- [ ] PWA et mode offline
|
- [ ] PWA et mode offline
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🐛 Problèmes relevés en réunion - Corrections UI/UX
|
||||||
|
|
||||||
|
### 🎨 Design et Interface
|
||||||
|
|
||||||
|
- [x] **Homepage cards** : toute en variant glass
|
||||||
|
- [x] **Icône Kanban homepage** - Changer icône sur la page d'accueil, pas lisible (utiliser une lib)
|
||||||
|
- [x] **Lisibilité label graph par tag** - Améliorer la lisibilité des labels dans les graphiques par tag <!-- Amélioré marges, légendes, tailles de police, retiré emojis -->
|
||||||
|
- [x] **Tag homepage** - Problème d'affichage des graphs de tags sur la homepage côté lisibilité, certaines icones ne sont pas entièrement visible, et la légende est trop proche du graphe. <!-- Amélioré hauteur, marges, responsive -->
|
||||||
|
- [x] **Tâches récentes** - Revoir l'affichage et la logique des tâches récentes <!-- Logique améliorée (tâches terminées récentes), responsive, icône claire -->
|
||||||
|
- [x] **Header dépasse en tablet** - Corriger le débordement du header sur tablette <!-- Responsive amélioré, taille réglée, navigation adaptative -->
|
||||||
|
- [x] **Icônes agenda et filtres** - Améliorer les icônes de l'agenda et des filtres dans desktop controls (utiliser une lib) <!-- Clock pour échéance, Settings pour filtres, Search visuelle -->
|
||||||
|
- [x] **Réunion/tâche design** - Revoir le design des bouton dans dailySectrion : les toggles avoir un compposant ui
|
||||||
|
- [x] **Légende calendrier et padding** - Corriger l'espacement et la légende du calendrier dans daily
|
||||||
|
- [x] **EditModal task couleur calendrier** - Problème de couleur en ajout de taches dans tous les icones calendriers; colmler au thème
|
||||||
|
- [x] **Weekly deux boutons actualiser** - Corriger la duplication des boutons d'actualisation
|
||||||
|
- [x] **Solarized ne doit pas être un soleil** - Corriger l'icône du thème Solarized
|
||||||
|
- [x] **Emoji interdit dans UI** - Vérifier et supprimer toutes les emojis dans l'interface, remplacer par lucide-react
|
||||||
|
- [ ] **Settings intégration : icônes** - Problème avec les icônes "Arrêté" et "Actif" : doivent etre les memes
|
||||||
|
- [ ] **Settings backup UI** - Revoir l'UI pour coller au style des intégrations
|
||||||
|
- [ ] **AlertBanner : hover et bug** - Corriger les problèmes de hover et bugs
|
||||||
|
- [ ] **Deux modales** - Problème de duplication de modales
|
||||||
|
- [ ] **Control panel et select** - Problème avec les contrôles et sélecteurs
|
||||||
|
- [ ] **TaskCard et Kanban transparence** - Appliquer la transparence sur le background et non sur la card
|
||||||
|
- [x] **Recherche Kanban desktop controls** - Ajouter icône et label : "rechercher" pour rapetir
|
||||||
|
- [ ] **Largeur page Kanban** - Réduire légèrement la largeur et revoir toutes les autres pages
|
||||||
|
- [x] **Icône thème à gauche du profil** - Repositionner l'icône de thème dans le header
|
||||||
|
- [ ] **Déconnexion trop petit et couleur** - Améliorer le bouton de déconnexion
|
||||||
|
- [ ] **Fond modal trop opaque** - Réduire l'opacité du fond des modales
|
||||||
|
- [ ] **Couleurs thème clair et TFS Jira Kanban** - Harmoniser les couleurs du thème clair
|
||||||
|
- [x] **États sélectionnés desktop control** - Revoir les couleurs des états sélectionnés pour avoir le joli bleu du dropdown partout
|
||||||
|
- [ ] **Dépasse 1000 caractères en edit modal task** - Corriger la limite (pas de limite) et revoir la quickcard description
|
||||||
|
- [ ] **UI si échéance et trop de labels dans le footer de card** - Améliorer l'affichage en mode détaillé TaskCard; certains boutons sont sur deux lignes ce qui casse l'affichage
|
||||||
|
- [ ] **Gravatar** - Implémenter l'affichage des avatars Gravatar
|
||||||
|
|
||||||
|
### 🔧 Fonctionnalités et Intégrations
|
||||||
|
|
||||||
|
- [ ] **Synchro Jira et TFS shortcuts** - Ajouter des raccourcis et bouton dans Kanban
|
||||||
|
- [x] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban. <!-- COMPLET: 1) JQL inclut resolved >= -30d pour récupérer tâches terminées, 2) syncSingleTask met à jour status + completedAt, 3) cleanupUnassignedTasks/cleanupInactivePullRequests préservent tâches done/archived -->
|
||||||
|
- [ ] **Log d'activité** - Implémenter un système de log d'activité (feature potentielle)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Nouvelles idées & fonctionnalités futures
|
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||||
|
|
||||||
### 🔄 Intégration TFS/Azure DevOps
|
|
||||||
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
|
|
||||||
- [x] PR arrivent en backlog avec filtrage par team project
|
|
||||||
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
|
||||||
- [x] Filtrage par team project, repository, auteur
|
|
||||||
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
|
|
||||||
- [x] Refactoriser pour interfaces génériques d'intégration
|
|
||||||
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
|
||||||
- [x] UI générique de configuration des intégrations
|
|
||||||
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
|
||||||
|
|
||||||
### 📋 Daily - Gestion des tâches non cochées
|
|
||||||
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
|
||||||
- [x] Liste de toutes les todos non cochées (historique complet)
|
|
||||||
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
|
||||||
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
|
|
||||||
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
|
|
||||||
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
|
|
||||||
- [x] Indicateurs visuels d'ancienneté (couleurs vert→rouge)
|
|
||||||
- [x] Actions par tâche : Cocher, Archiver, Supprimer
|
|
||||||
- [x] **Statut "Archivé" basique** <!-- Implémenté le 21/09/2025 -->
|
|
||||||
- [x] Marquage textuel [ARCHIVÉ] dans le texte de la tâche
|
|
||||||
- [x] Interface pour voir les tâches archivées (visuellement distinctes)
|
|
||||||
- [ ] Possibilité de désarchiver une tâche
|
|
||||||
- [ ] Champ dédié en base de données (actuellement via texte)
|
|
||||||
|
|
||||||
### 🎯 Jira - Suivi des demandes en attente
|
### 🎯 Jira - Suivi des demandes en attente
|
||||||
|
|
||||||
- [ ] **Page "Jiras en attente"**
|
- [ ] **Page "Jiras en attente"**
|
||||||
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
|
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
|
||||||
- [ ] Suivi des demandes formulées à d'autres équipes
|
- [ ] Suivi des demandes formulées à d'autres équipes
|
||||||
@@ -72,54 +68,98 @@
|
|||||||
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
|
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
|
||||||
- [ ] Notifications quand une demande change de statut
|
- [ ] Notifications quand une demande change de statut
|
||||||
|
|
||||||
### 🏗️ Architecture & technique
|
|
||||||
- [ ] **Système d'intégrations modulaire**
|
|
||||||
- [ ] Interface `IntegrationProvider` standardisée
|
|
||||||
- [ ] Configuration dynamique des intégrations
|
|
||||||
- [ ] Gestion des credentials par intégration
|
|
||||||
- [ ] **Modèles de données étendus**
|
|
||||||
- [ ] `PullRequest` pour TFS/GitHub
|
|
||||||
- [ ] `PendingRequest` pour les demandes Jira
|
|
||||||
- [ ] `ArchivedTask` pour les daily archivées
|
|
||||||
- [ ] **UI générique**
|
|
||||||
- [ ] Composants réutilisables pour toutes les intégrations
|
|
||||||
- [ ] Configuration unifiée des filtres et synchronisations
|
|
||||||
- [ ] Dashboard multi-intégrations
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||||
|
|
||||||
#### **Architecture actuelle → Multi-tenant**
|
#### **Architecture actuelle → Multi-tenant**
|
||||||
|
|
||||||
- **Problème** : App mono-utilisateur avec données globales
|
- **Problème** : App mono-utilisateur avec données globales
|
||||||
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données
|
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données + système de rôles
|
||||||
|
|
||||||
#### **Plan de migration**
|
#### **Plan de migration**
|
||||||
|
|
||||||
- [ ] **Phase 1: Authentification**
|
- [ ] **Phase 1: Authentification**
|
||||||
- [ ] Système de login/mot de passe (NextAuth.js ou custom)
|
- [ ] Système de login/mot de passe (NextAuth.js)
|
||||||
- [ ] Gestion des sessions sécurisées
|
- [ ] Gestion des sessions sécurisées
|
||||||
- [ ] Pages de connexion/inscription/mot de passe oublié
|
- [ ] Pages de connexion/inscription/mot de passe oublié
|
||||||
- [ ] Middleware de protection des routes
|
- [ ] Middleware de protection des routes
|
||||||
|
|
||||||
- [ ] **Phase 2: Modèle de données multi-tenant**
|
- [ ] **Phase 2: Modèle de données multi-tenant + Rôles**
|
||||||
|
- [ ] **Modèle User complet**
|
||||||
|
- [ ] Table `users` (id, email, password, name, role, createdAt, updatedAt)
|
||||||
|
- [ ] Enum `UserRole` : `ADMIN`, `MANAGER`, `USER`
|
||||||
|
- [ ] Champs optionnels : avatar, timezone, language
|
||||||
|
- [ ] **Relations hiérarchiques**
|
||||||
|
- [ ] Table `user_teams` pour les relations manager → users
|
||||||
|
- [ ] Champ `managerId` dans users (optionnel, référence vers un manager)
|
||||||
|
- [ ] Support des équipes multiples par utilisateur
|
||||||
|
- [ ] **Migration des données existantes**
|
||||||
|
- [ ] Créer un utilisateur admin par défaut avec toutes les données actuelles
|
||||||
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
|
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
|
||||||
- [ ] Migration des données existantes vers un utilisateur par défaut
|
|
||||||
- [ ] Contraintes de base de données pour l'isolation
|
- [ ] Contraintes de base de données pour l'isolation
|
||||||
- [ ] Index sur `userId` pour les performances
|
- [ ] Index sur `userId` pour les performances
|
||||||
|
|
||||||
- [ ] **Phase 3: Services et API**
|
- [ ] **Phase 3: Système de rôles et permissions**
|
||||||
- [ ] Modifier tous les services pour filtrer par `userId`
|
- [ ] **Rôle ADMIN**
|
||||||
- [ ] Middleware d'injection automatique du `userId` dans les requêtes
|
- [ ] Gestion complète des utilisateurs (CRUD)
|
||||||
- [ ] Validation que chaque utilisateur ne voit que ses données
|
- [ ] Assignation/modification des rôles
|
||||||
- [ ] API d'administration (optionnel)
|
- [ ] Accès à toutes les données système (analytics globales)
|
||||||
|
- [ ] Configuration système (intégrations Jira/TFS globales)
|
||||||
|
- [ ] Gestion des équipes et hiérarchies
|
||||||
|
- [ ] **Rôle MANAGER**
|
||||||
|
- [ ] Vue sur les tâches/daily de ses équipiers
|
||||||
|
- [ ] Assignation de tâches à ses équipiers
|
||||||
|
- [ ] Analytics d'équipe (métriques, deadlines, performance)
|
||||||
|
- [ ] Création de tâches pour son équipe
|
||||||
|
- [ ] Accès aux rapports de son équipe
|
||||||
|
- [ ] **Rôle USER**
|
||||||
|
- [ ] Accès uniquement à ses propres données
|
||||||
|
- [ ] Réception de tâches assignées par son manager
|
||||||
|
- [ ] Gestion de son daily/kanban personnel
|
||||||
|
- [ ] **Middleware de permissions**
|
||||||
|
- [ ] Validation des droits d'accès par route
|
||||||
|
- [ ] Helper functions `canAccess()`, `canManage()`, `isAdmin()`
|
||||||
|
- [ ] Protection automatique des API routes
|
||||||
|
|
||||||
- [ ] **Phase 4: UI et UX**
|
- [ ] **Phase 4: Services et API avec rôles**
|
||||||
- [ ] Header avec profil utilisateur et déconnexion
|
- [ ] **Services utilisateurs**
|
||||||
- [ ] Onboarding pour nouveaux utilisateurs
|
- [ ] `user-management.ts` : CRUD utilisateurs (admin only)
|
||||||
- [ ] Gestion du profil utilisateur
|
- [ ] `team-management.ts` : Gestion des équipes (admin/manager)
|
||||||
- [ ] Partage optionnel entre utilisateurs (équipes)
|
- [ ] `role-permissions.ts` : Logique des permissions
|
||||||
|
- [ ] **Modification des services existants**
|
||||||
|
- [ ] Tous les services filtrent par `userId` OU permissions manager
|
||||||
|
- [ ] Middleware d'injection automatique du `userId` + `userRole`
|
||||||
|
- [ ] Services analytics étendus pour les managers
|
||||||
|
- [ ] Validation que chaque utilisateur ne voit que ses données autorisées
|
||||||
|
|
||||||
|
- [ ] **Phase 5: UI et UX multi-rôles**
|
||||||
|
- [ ] **Interface Admin**
|
||||||
|
- [ ] Page de gestion des utilisateurs (/admin/users)
|
||||||
|
- [ ] Création/modification/suppression d'utilisateurs
|
||||||
|
- [ ] Assignation des rôles et équipes
|
||||||
|
- [ ] Dashboard admin avec métriques globales
|
||||||
|
- [ ] **Interface Manager**
|
||||||
|
- [ ] Vue équipe avec tâches de tous les équipiers
|
||||||
|
- [ ] Assignation de tâches à l'équipe
|
||||||
|
- [ ] Dashboard manager avec analytics d'équipe
|
||||||
|
- [ ] Gestion des deadlines et priorités d'équipe
|
||||||
|
- [ ] **Interface commune**
|
||||||
|
- [ ] Header avec profil utilisateur, rôle et déconnexion
|
||||||
|
- [ ] Onboarding différencié par rôle
|
||||||
|
- [ ] Navigation adaptée aux permissions
|
||||||
|
- [ ] Indicateurs visuels du rôle actuel
|
||||||
|
|
||||||
|
- [ ] **Phase 6: Fonctionnalités collaboratives**
|
||||||
|
- [ ] **Assignation de tâches**
|
||||||
|
- [ ] Managers peuvent créer et assigner des tâches
|
||||||
|
- [ ] Notifications de nouvelles tâches assignées
|
||||||
|
- [ ] Suivi du statut des tâches assignées
|
||||||
|
- [ ] **Partage et visibilité**
|
||||||
|
- [ ] Tâches partagées entre équipiers
|
||||||
|
- [ ] Commentaires et collaboration sur les tâches
|
||||||
|
- [ ] Historique des modifications par utilisateur
|
||||||
|
|
||||||
#### **Considérations techniques**
|
#### **Considérations techniques**
|
||||||
|
|
||||||
- **Base de données** : Ajouter `userId` partout + contraintes
|
- **Base de données** : Ajouter `userId` partout + contraintes
|
||||||
- **Sécurité** : Validation côté serveur de l'isolation des données
|
- **Sécurité** : Validation côté serveur de l'isolation des données
|
||||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||||
@@ -127,4 +167,119 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*
|
## 🤖 Intégration IA avec Mistral (Phase IA)
|
||||||
|
|
||||||
|
### **Socle technique**
|
||||||
|
|
||||||
|
- [ ] **Phase 1: Infrastructure Mistral**
|
||||||
|
- [ ] Configuration du client Mistral local
|
||||||
|
- [ ] Service `mistral-client.ts` avec connexion au modèle local
|
||||||
|
- [ ] Configuration des endpoints et paramètres (température, tokens, etc.)
|
||||||
|
- [ ] Gestion des erreurs et timeouts
|
||||||
|
- [ ] Cache des réponses pour éviter les appels répétés
|
||||||
|
- [ ] **Système de prompts**
|
||||||
|
- [ ] Template engine pour les prompts structurés
|
||||||
|
- [ ] Prompts spécialisés par fonctionnalité (analyse, génération, classification)
|
||||||
|
- [ ] Versioning des prompts pour A/B testing
|
||||||
|
- [ ] Logging des interactions pour amélioration continue
|
||||||
|
- [ ] **Sécurité et performance**
|
||||||
|
- [ ] Rate limiting pour éviter la surcharge du modèle local
|
||||||
|
- [ ] Validation des inputs avant envoi au modèle
|
||||||
|
- [ ] Sanitization des réponses IA
|
||||||
|
- [ ] Monitoring des performances (latence, tokens utilisés)
|
||||||
|
|
||||||
|
- [ ] **Phase 2: Services IA développés avec les features**
|
||||||
|
- [ ] Services créés au fur et à mesure des besoins des fonctionnalités
|
||||||
|
- [ ] Pas de développement anticipé - implémentation juste-à-temps
|
||||||
|
- [ ] Architecture modulaire pour faciliter l'ajout de nouveaux services
|
||||||
|
|
||||||
|
- [ ] **Phase 3: Configuration et gestion de l'assistant**
|
||||||
|
- [ ] **Page de configuration IA (/settings/ai-assistant)**
|
||||||
|
- [ ] Configuration du modèle Mistral (endpoint, température, max tokens)
|
||||||
|
- [ ] Activation/désactivation des fonctionnalités IA par catégorie
|
||||||
|
- [ ] Paramètres de personnalisation (style de réponses, niveau d'agressivité)
|
||||||
|
- [ ] Configuration des seuils (confiance minimale, fréquence des suggestions)
|
||||||
|
- [ ] **Gestion des prompts personnalisés**
|
||||||
|
- [ ] Interface pour modifier les prompts par fonctionnalité
|
||||||
|
- [ ] Aperçu en temps réel des modifications
|
||||||
|
- [ ] Sauvegarde/restauration des configurations
|
||||||
|
- [ ] Templates de prompts prédéfinis
|
||||||
|
- [ ] **Monitoring et analytics IA**
|
||||||
|
- [ ] Dashboard des performances IA (latence, tokens utilisés, coût)
|
||||||
|
- [ ] Historique des interactions et taux de succès
|
||||||
|
- [ ] Métriques d'utilisation par fonctionnalité
|
||||||
|
- [ ] Logs des erreurs et suggestions d'amélioration
|
||||||
|
- [ ] **Système de feedback**
|
||||||
|
- [ ] Boutons "👍/👎" sur chaque suggestion IA
|
||||||
|
- [ ] Collecte des retours utilisateur pour amélioration
|
||||||
|
- [ ] A/B testing des différents prompts
|
||||||
|
- [ ] Apprentissage des préférences utilisateur
|
||||||
|
|
||||||
|
### **Fonctionnalités IA concrètes**
|
||||||
|
|
||||||
|
#### 🎯 **Smart Task Creation**
|
||||||
|
|
||||||
|
- [ ] **Bouton "Créer avec IA" dans le Kanban**
|
||||||
|
- [ ] Input libre : "Préparer présentation client pour vendredi"
|
||||||
|
- [ ] IA génère : titre, description, estimation durée, sous-tâches
|
||||||
|
- [ ] **Mapping prioritaire avec tags existants** : IA propose uniquement des tags déjà utilisés
|
||||||
|
- [ ] Validation/modification avant création
|
||||||
|
|
||||||
|
#### 🧠 **Daily Assistant**
|
||||||
|
|
||||||
|
- [ ] **Bouton "Smart Daily" dans la page Daily**
|
||||||
|
- [ ] Input libre : "Réunion client 14h, finir le rapport, appeler le fournisseur"
|
||||||
|
- [ ] IA génère une liste de checkboxes structurées
|
||||||
|
- [ ] Validation/modification avant ajout au Daily
|
||||||
|
- [ ] Pas de génération automatique - uniquement sur demande utilisateur
|
||||||
|
- [ ] **Smart Checkbox Suggestions**
|
||||||
|
- [ ] Pendant la saisie, IA propose des checkboxes similaires
|
||||||
|
|
||||||
|
#### 🎨 **Smart Tagging**
|
||||||
|
|
||||||
|
- [ ] **Auto-tagging des nouvelles tâches**
|
||||||
|
- [ ] IA analyse le titre/description
|
||||||
|
- [ ] Propose automatiquement 2-3 tags **existants** pertinents
|
||||||
|
- [ ] Apprentissage des tags utilisés par l'utilisateur
|
||||||
|
- [ ] **Suggestions de tags pendant la saisie**
|
||||||
|
- [ ] Dropdown intelligent avec **tags existants** probables uniquement
|
||||||
|
- [ ] Tri par fréquence d'usage et pertinence
|
||||||
|
|
||||||
|
#### 💬 **Chat Assistant**
|
||||||
|
|
||||||
|
- [ ] **Widget chat en bas à droite**
|
||||||
|
- [ ] "Quelles sont mes tâches urgentes cette semaine ?"
|
||||||
|
- [ ] "Comment optimiser mon planning demain ?"
|
||||||
|
- [ ] "Résume-moi mes performances de ce mois"
|
||||||
|
- [ ] **Recherche sémantique**
|
||||||
|
- [ ] "Tâches liées au projet X" même sans tag exact
|
||||||
|
- [ ] "Tâches que j'ai faites la semaine dernière"
|
||||||
|
- [ ] Recherche par contexte, pas juste mots-clés
|
||||||
|
|
||||||
|
#### 📈 **Smart Reports**
|
||||||
|
|
||||||
|
- [ ] **Génération automatique de rapports**
|
||||||
|
- [ ] Bouton "Générer rapport IA" dans analytics
|
||||||
|
- [ ] IA analyse les données et génère un résumé textuel
|
||||||
|
- [ ] Insights personnalisés ("Tu es plus productif le matin")
|
||||||
|
- [ ] **Alertes intelligentes**
|
||||||
|
- [ ] "Attention : tu as 3 tâches urgentes non démarrées"
|
||||||
|
- [ ] "Suggestion : regrouper les tâches similaires"
|
||||||
|
- [ ] Notifications contextuelles et actionables
|
||||||
|
|
||||||
|
#### ⚡ **Quick Actions**
|
||||||
|
|
||||||
|
- [ ] **Bouton "Optimiser" sur une tâche**
|
||||||
|
- [ ] IA suggère des améliorations (titre, description)
|
||||||
|
- [ ] Propose des **tags existants** pertinents
|
||||||
|
- [ ] Propose des sous-tâches manquantes
|
||||||
|
- [ ] Estimation de durée plus précise
|
||||||
|
- [ ] **Smart Duplicate Detection**
|
||||||
|
- [ ] "Cette tâche ressemble à une tâche existante"
|
||||||
|
- [ ] Suggestions de fusion ou différenciation
|
||||||
|
- [ ] Évite la duplication accidentelle
|
||||||
|
- [ ] **Exclusion des tâches avec tag "objectif principal"** : IA ignore ces tâches dans les comparaisons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète._
|
||||||
|
|||||||
226
TODO_ARCHIVE.md
@@ -3,6 +3,7 @@
|
|||||||
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
||||||
|
|
||||||
### 1.1 Configuration projet Next.js
|
### 1.1 Configuration projet Next.js
|
||||||
|
|
||||||
- [x] Initialiser Next.js avec TypeScript
|
- [x] Initialiser Next.js avec TypeScript
|
||||||
- [x] Configurer ESLint, Prettier
|
- [x] Configurer ESLint, Prettier
|
||||||
- [x] Setup structure de dossiers selon les règles du workspace
|
- [x] Setup structure de dossiers selon les règles du workspace
|
||||||
@@ -10,12 +11,14 @@
|
|||||||
- [x] Setup Prisma ORM
|
- [x] Setup Prisma ORM
|
||||||
|
|
||||||
### 1.2 Architecture backend standalone
|
### 1.2 Architecture backend standalone
|
||||||
|
|
||||||
- [x] Créer `services/database.ts` - Pool de connexion DB
|
- [x] Créer `services/database.ts` - Pool de connexion DB
|
||||||
- [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
|
- [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
|
||||||
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
||||||
- [x] Nettoyer l'ancien code de synchronisation
|
- [x] Nettoyer l'ancien code de synchronisation
|
||||||
|
|
||||||
### 1.3 API moderne et propre
|
### 1.3 API moderne et propre
|
||||||
|
|
||||||
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
||||||
- [x] Supprimer les routes de synchronisation obsolètes
|
- [x] Supprimer les routes de synchronisation obsolètes
|
||||||
- [x] Configuration moderne dans `lib/config.ts`
|
- [x] Configuration moderne dans `lib/config.ts`
|
||||||
@@ -25,12 +28,14 @@
|
|||||||
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
||||||
|
|
||||||
### 2.1 Système de design et composants UI
|
### 2.1 Système de design et composants UI
|
||||||
|
|
||||||
- [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge)
|
- [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge)
|
||||||
- [x] Implémenter le système de design tech dark (couleurs, typographie, spacing)
|
- [x] Implémenter le système de design tech dark (couleurs, typographie, spacing)
|
||||||
- [x] Setup Tailwind CSS avec classes utilitaires personnalisées
|
- [x] Setup Tailwind CSS avec classes utilitaires personnalisées
|
||||||
- [x] Créer une palette de couleurs tech/cyberpunk
|
- [x] Créer une palette de couleurs tech/cyberpunk
|
||||||
|
|
||||||
### 2.2 Composants Kanban existants (à améliorer)
|
### 2.2 Composants Kanban existants (à améliorer)
|
||||||
|
|
||||||
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
||||||
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
||||||
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
||||||
@@ -38,6 +43,7 @@
|
|||||||
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
||||||
|
|
||||||
### 2.3 Gestion des tâches (CRUD)
|
### 2.3 Gestion des tâches (CRUD)
|
||||||
|
|
||||||
- [x] Formulaire de création de tâche (Modal + Form)
|
- [x] Formulaire de création de tâche (Modal + Form)
|
||||||
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
||||||
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
||||||
@@ -47,6 +53,7 @@
|
|||||||
- [x] Validation des formulaires et gestion d'erreurs
|
- [x] Validation des formulaires et gestion d'erreurs
|
||||||
|
|
||||||
### 2.4 Gestion des tags
|
### 2.4 Gestion des tags
|
||||||
|
|
||||||
- [x] Créer/éditer des tags avec sélecteur de couleur
|
- [x] Créer/éditer des tags avec sélecteur de couleur
|
||||||
- [x] Autocomplete pour les tags existants
|
- [x] Autocomplete pour les tags existants
|
||||||
- [x] Suppression de tags (avec vérification des dépendances)
|
- [x] Suppression de tags (avec vérification des dépendances)
|
||||||
@@ -66,6 +73,7 @@
|
|||||||
- [x] Intégration des filtres dans KanbanBoard
|
- [x] Intégration des filtres dans KanbanBoard
|
||||||
|
|
||||||
### 2.5 Clients HTTP et hooks
|
### 2.5 Clients HTTP et hooks
|
||||||
|
|
||||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
||||||
- [x] `clients/tags-client.ts` - Client pour les tags
|
- [x] `clients/tags-client.ts` - Client pour les tags
|
||||||
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
||||||
@@ -76,6 +84,7 @@
|
|||||||
- [x] Architecture SSR + hydratation client optimisée
|
- [x] Architecture SSR + hydratation client optimisée
|
||||||
|
|
||||||
### 2.6 Fonctionnalités Kanban avancées
|
### 2.6 Fonctionnalités Kanban avancées
|
||||||
|
|
||||||
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
||||||
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
||||||
- [x] Filtrage par statut/priorité/assigné
|
- [x] Filtrage par statut/priorité/assigné
|
||||||
@@ -85,6 +94,7 @@
|
|||||||
- [x] Tri des tâches (date, priorité, alphabétique)
|
- [x] Tri des tâches (date, priorité, alphabétique)
|
||||||
|
|
||||||
### 2.7 Système de thèmes (clair/sombre)
|
### 2.7 Système de thèmes (clair/sombre)
|
||||||
|
|
||||||
- [x] Créer le contexte de thème (ThemeContext + ThemeProvider)
|
- [x] Créer le contexte de thème (ThemeContext + ThemeProvider)
|
||||||
- [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune)
|
- [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune)
|
||||||
- [x] Définir les variables CSS pour le thème clair
|
- [x] Définir les variables CSS pour le thème clair
|
||||||
@@ -99,6 +109,7 @@
|
|||||||
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
||||||
|
|
||||||
### 3.1 Gestion du Daily
|
### 3.1 Gestion du Daily
|
||||||
|
|
||||||
- [x] Créer `services/daily.ts` - Service de gestion des daily notes
|
- [x] Créer `services/daily.ts` - Service de gestion des daily notes
|
||||||
- [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui)
|
- [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui)
|
||||||
- [x] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
- [x] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
||||||
@@ -111,6 +122,7 @@
|
|||||||
- [x] Vue calendar/historique des dailies
|
- [x] Vue calendar/historique des dailies
|
||||||
|
|
||||||
### 3.2 Intégration Jira Cloud
|
### 3.2 Intégration Jira Cloud
|
||||||
|
|
||||||
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
|
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
|
||||||
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
|
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
|
||||||
- [x] Authentification Basic Auth (email + API token)
|
- [x] Authentification Basic Auth (email + API token)
|
||||||
@@ -127,6 +139,7 @@
|
|||||||
- [x] Gestion des erreurs et timeouts API
|
- [x] Gestion des erreurs et timeouts API
|
||||||
|
|
||||||
### 3.3 Page d'accueil/dashboard
|
### 3.3 Page d'accueil/dashboard
|
||||||
|
|
||||||
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
||||||
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
||||||
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
||||||
@@ -137,6 +150,7 @@
|
|||||||
- [x] Intégration des analytics dans le dashboard
|
- [x] Intégration des analytics dans le dashboard
|
||||||
|
|
||||||
### 3.4 Analytics et métriques
|
### 3.4 Analytics et métriques
|
||||||
|
|
||||||
- [x] `services/analytics.ts` - Calculs statistiques
|
- [x] `services/analytics.ts` - Calculs statistiques
|
||||||
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||||
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
||||||
@@ -144,6 +158,7 @@
|
|||||||
- [x] Insights automatiques et métriques visuelles
|
- [x] Insights automatiques et métriques visuelles
|
||||||
|
|
||||||
## Autre Todo
|
## Autre Todo
|
||||||
|
|
||||||
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
|
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
|
||||||
- [x] Refactorer les couleurs des priorités dans un seul endroit
|
- [x] Refactorer les couleurs des priorités dans un seul endroit
|
||||||
- [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
- [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
||||||
@@ -161,13 +176,14 @@
|
|||||||
- [x] Vérification d'intégrité et restauration sécurisée
|
- [x] Vérification d'intégrité et restauration sécurisée
|
||||||
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
||||||
|
|
||||||
|
|
||||||
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
||||||
|
|
||||||
### 4.1 Migration vers Server Actions - Actions rapides
|
### 4.1 Migration vers Server Actions - Actions rapides
|
||||||
|
|
||||||
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
||||||
|
|
||||||
#### Actions TaskCard (Priorité 1)
|
#### Actions TaskCard (Priorité 1)
|
||||||
|
|
||||||
- [x] Créer `actions/tasks.ts` avec server actions de base
|
- [x] Créer `actions/tasks.ts` avec server actions de base
|
||||||
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
||||||
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
||||||
@@ -181,6 +197,7 @@
|
|||||||
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
||||||
|
|
||||||
#### Actions Daily (Priorité 2)
|
#### Actions Daily (Priorité 2)
|
||||||
|
|
||||||
- [x] Créer `actions/daily.ts` pour les checkboxes
|
- [x] Créer `actions/daily.ts` pour les checkboxes
|
||||||
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
||||||
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
||||||
@@ -193,6 +210,7 @@
|
|||||||
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
||||||
|
|
||||||
#### Actions User Preferences (Priorité 3)
|
#### Actions User Preferences (Priorité 3)
|
||||||
|
|
||||||
- [x] Créer `actions/preferences.ts` pour les toggles
|
- [x] Créer `actions/preferences.ts` pour les toggles
|
||||||
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
||||||
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
||||||
@@ -204,6 +222,7 @@
|
|||||||
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
||||||
|
|
||||||
#### Actions Tags (Priorité 4)
|
#### Actions Tags (Priorité 4)
|
||||||
|
|
||||||
- [x] Créer `actions/tags.ts` pour la gestion tags
|
- [x] Créer `actions/tags.ts` pour la gestion tags
|
||||||
- [x] `createTag(name, color)` - Création tag
|
- [x] `createTag(name, color)` - Création tag
|
||||||
- [x] `updateTag(tagId, data)` - Modification tag
|
- [x] `updateTag(tagId, data)` - Modification tag
|
||||||
@@ -214,29 +233,35 @@
|
|||||||
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
||||||
|
|
||||||
#### Migration progressive avec nettoyage immédiat
|
#### Migration progressive avec nettoyage immédiat
|
||||||
|
|
||||||
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
||||||
|
|
||||||
### 4.2 Conservation API Routes - Endpoints complexes
|
### 4.2 Conservation API Routes - Endpoints complexes
|
||||||
|
|
||||||
**À GARDER en API routes** (pas de migration)
|
**À GARDER en API routes** (pas de migration)
|
||||||
|
|
||||||
#### Endpoints de fetching initial
|
#### Endpoints de fetching initial
|
||||||
|
|
||||||
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
||||||
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
||||||
- ✅ `GET /api/tags` - Liste tags avec recherche
|
- ✅ `GET /api/tags` - Liste tags avec recherche
|
||||||
- ✅ `GET /api/user-preferences` - Préférences initiales
|
- ✅ `GET /api/user-preferences` - Préférences initiales
|
||||||
|
|
||||||
#### Endpoints d'intégration externe
|
#### Endpoints d'intégration externe
|
||||||
|
|
||||||
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
||||||
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
||||||
- ✅ Configuration Jira (formulaires complexes)
|
- ✅ Configuration Jira (formulaires complexes)
|
||||||
|
|
||||||
#### Raisons de conservation
|
#### Raisons de conservation
|
||||||
|
|
||||||
- **API publique** : Réutilisable depuis mobile/externe
|
- **API publique** : Réutilisable depuis mobile/externe
|
||||||
- **Logique complexe** : Synchronisation, analytics, rapports
|
- **Logique complexe** : Synchronisation, analytics, rapports
|
||||||
- **Monitoring** : Besoin de logs HTTP séparés
|
- **Monitoring** : Besoin de logs HTTP séparés
|
||||||
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
||||||
|
|
||||||
### 4.3 Architecture hybride cible
|
### 4.3 Architecture hybride cible
|
||||||
|
|
||||||
```
|
```
|
||||||
Actions rapides → Server Actions directes
|
Actions rapides → Server Actions directes
|
||||||
├── TaskCard actions (status, title, delete)
|
├── TaskCard actions (status, title, delete)
|
||||||
@@ -252,6 +277,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 Avantages attendus
|
### 4.4 Avantages attendus
|
||||||
|
|
||||||
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
||||||
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
||||||
- **📦 Bundle reduction** : Moins de code client HTTP
|
- **📦 Bundle reduction** : Moins de code client HTTP
|
||||||
@@ -261,6 +287,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
||||||
|
|
||||||
### 5.1 Configuration projet Jira
|
### 5.1 Configuration projet Jira
|
||||||
|
|
||||||
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
||||||
- [x] Interface pour sélectionner le projet à surveiller
|
- [x] Interface pour sélectionner le projet à surveiller
|
||||||
- [x] Validation de l'existence du projet via API Jira
|
- [x] Validation de l'existence du projet via API Jira
|
||||||
@@ -268,6 +295,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] Test de connexion spécifique au projet configuré
|
- [x] Test de connexion spécifique au projet configuré
|
||||||
|
|
||||||
### 5.2 Service d'analytics Jira
|
### 5.2 Service d'analytics Jira
|
||||||
|
|
||||||
- [x] Créer `services/jira-analytics.ts` - Métriques avancées
|
- [x] Créer `services/jira-analytics.ts` - Métriques avancées
|
||||||
- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés)
|
- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés)
|
||||||
- [x] Calculs de vélocité d'équipe (story points par sprint)
|
- [x] Calculs de vélocité d'équipe (story points par sprint)
|
||||||
@@ -278,6 +306,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] Cache intelligent des métriques (éviter API rate limits)
|
- [x] Cache intelligent des métriques (éviter API rate limits)
|
||||||
|
|
||||||
### 5.3 Page de surveillance `/jira-dashboard`
|
### 5.3 Page de surveillance `/jira-dashboard`
|
||||||
|
|
||||||
- [x] Créer page dédiée avec navigation depuis settings Jira
|
- [x] Créer page dédiée avec navigation depuis settings Jira
|
||||||
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
||||||
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
||||||
@@ -287,6 +316,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
||||||
|
|
||||||
### 5.4 Métriques et graphiques avancés
|
### 5.4 Métriques et graphiques avancés
|
||||||
|
|
||||||
- [x] **Vélocité** : Story points complétés par sprint
|
- [x] **Vélocité** : Story points complétés par sprint
|
||||||
- [x] **Burndown chart** : Progression vs planifié
|
- [x] **Burndown chart** : Progression vs planifié
|
||||||
- [x] **Cycle time** : Temps moyen par type de ticket
|
- [x] **Cycle time** : Temps moyen par type de ticket
|
||||||
@@ -297,6 +327,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
||||||
|
|
||||||
### 5.5 Fonctionnalités de surveillance
|
### 5.5 Fonctionnalités de surveillance
|
||||||
|
|
||||||
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
|
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
|
||||||
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
|
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
|
||||||
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||||
@@ -308,11 +339,13 @@ Endpoints complexes → API Routes conservées
|
|||||||
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
||||||
|
|
||||||
#### **Problème actuel**
|
#### **Problème actuel**
|
||||||
|
|
||||||
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
||||||
- Alias TypeScript incohérents dans `tsconfig.json`
|
- Alias TypeScript incohérents dans `tsconfig.json`
|
||||||
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
||||||
|
|
||||||
#### **Plan de migration**
|
#### **Plan de migration**
|
||||||
|
|
||||||
- [x] **Phase 1: Migration des dossiers**
|
- [x] **Phase 1: Migration des dossiers**
|
||||||
- [x] `mv components/ src/components/`
|
- [x] `mv components/ src/components/`
|
||||||
- [x] `mv lib/ src/lib/`
|
- [x] `mv lib/ src/lib/`
|
||||||
@@ -321,6 +354,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] `mv services/ src/services/`
|
- [x] `mv services/ src/services/`
|
||||||
|
|
||||||
- [x] **Phase 2: Mise à jour tsconfig.json**
|
- [x] **Phase 2: Mise à jour tsconfig.json**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
@@ -350,6 +384,7 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] Tester les fonctionnalités principales
|
- [x] Tester les fonctionnalités principales
|
||||||
|
|
||||||
#### **Structure finale attendue**
|
#### **Structure finale attendue**
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/ # Pages Next.js (déjà OK)
|
├── app/ # Pages Next.js (déjà OK)
|
||||||
@@ -369,3 +404,192 @@ src/
|
|||||||
- [x] split de certains gros composants.
|
- [x] split de certains gros composants.
|
||||||
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
||||||
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
||||||
|
- [x] Désactiver le hover sur les taskCard
|
||||||
|
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut
|
||||||
|
- [x] Personnalisation : couleurs <!-- Image de fond personnalisée implémentée -->
|
||||||
|
|
||||||
|
|
||||||
|
## 🔄 Refactoring Services par Domaine
|
||||||
|
|
||||||
|
### Organisation cible des services:
|
||||||
|
```
|
||||||
|
|
||||||
|
src/services/
|
||||||
|
├── core/ # Services fondamentaux
|
||||||
|
├── analytics/ # Analytics et métriques
|
||||||
|
├── data-management/# Backup, système, base
|
||||||
|
├── integrations/ # Services externes
|
||||||
|
├── task-management/# Gestion des tâches
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 1: Services Core (infrastructure) ✅
|
||||||
|
- [x] **Déplacer `database.ts`** → `core/database.ts`
|
||||||
|
- [x] Corriger tous les imports internes des services
|
||||||
|
- [x] Corriger import dans scripts/reset-database.ts
|
||||||
|
- [x] **Déplacer `system-info.ts`** → `core/system-info.ts`
|
||||||
|
- [x] Corriger imports dans actions/system
|
||||||
|
- [x] Corriger import dynamique de backup
|
||||||
|
- [x] **Déplacer `user-preferences.ts`** → `core/user-preferences.ts`
|
||||||
|
- [x] Corriger 13 imports externes (actions, API routes, pages)
|
||||||
|
- [x] Corriger 3 imports internes entre services
|
||||||
|
|
||||||
|
### Phase 2: Analytics & Métriques ✅
|
||||||
|
- [x] **Déplacer `analytics.ts`** → `analytics/analytics.ts`
|
||||||
|
- [x] Corriger 2 imports externes (actions, components)
|
||||||
|
- [x] **Déplacer `metrics.ts`** → `analytics/metrics.ts`
|
||||||
|
- [x] Corriger 7 imports externes (actions, hooks, components)
|
||||||
|
- [x] **Déplacer `manager-summary.ts`** → `analytics/manager-summary.ts`
|
||||||
|
- [x] Corriger 3 imports externes (components, pages)
|
||||||
|
- [x] Corriger imports database vers ../core/database
|
||||||
|
|
||||||
|
### Phase 3: Data Management ✅
|
||||||
|
- [x] **Déplacer `backup.ts`** → `data-management/backup.ts`
|
||||||
|
- [x] Corriger 6 imports externes (clients, components, pages, API)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/ et ../../lib/
|
||||||
|
- [x] **Déplacer `backup-scheduler.ts`** → `data-management/backup-scheduler.ts`
|
||||||
|
- [x] Corriger import dans script backup-manager.ts
|
||||||
|
- [x] Corriger imports relatifs entre services
|
||||||
|
|
||||||
|
### Phase 4: Task Management ✅
|
||||||
|
- [x] **Déplacer `tasks.ts`** → `task-management/tasks.ts`
|
||||||
|
- [x] Corriger 7 imports externes (pages, API routes, actions)
|
||||||
|
- [x] Corriger import dans script seed-data.ts
|
||||||
|
- [x] **Déplacer `tags.ts`** → `task-management/tags.ts`
|
||||||
|
- [x] Corriger 8 imports externes (pages, API routes, actions)
|
||||||
|
- [x] Corriger import dans script seed-tags.ts
|
||||||
|
- [x] **Déplacer `daily.ts`** → `task-management/daily.ts`
|
||||||
|
- [x] Corriger 6 imports externes (pages, API routes, actions)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/database
|
||||||
|
|
||||||
|
### Phase 5: Intégrations ✅
|
||||||
|
- [x] **Déplacer `tfs.ts`** → `integrations/tfs.ts`
|
||||||
|
- [x] Corriger 10 imports externes (actions, API routes, components, types)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/
|
||||||
|
- [x] **Déplacer services Jira** → `integrations/jira/`
|
||||||
|
- [x] `jira.ts` → `integrations/jira/jira.ts`
|
||||||
|
- [x] `jira-scheduler.ts` → `integrations/jira/scheduler.ts`
|
||||||
|
- [x] `jira-analytics.ts` → `integrations/jira/analytics.ts`
|
||||||
|
- [x] `jira-analytics-cache.ts` → `integrations/jira/analytics-cache.ts`
|
||||||
|
- [x] `jira-advanced-filters.ts` → `integrations/jira/advanced-filters.ts`
|
||||||
|
- [x] `jira-anomaly-detection.ts` → `integrations/jira/anomaly-detection.ts`
|
||||||
|
- [x] Corriger 18 imports externes (actions, API routes, hooks, components)
|
||||||
|
- [x] Corriger imports relatifs entre services Jira
|
||||||
|
|
||||||
|
## Phase 6: Cleaning
|
||||||
|
- [x] **Uniformiser les imports absolus** dans tous les services
|
||||||
|
- [x] Remplacer tous les imports relatifs `../` par `@/services/...`
|
||||||
|
- [x] Corriger l'import dynamique dans system-info.ts
|
||||||
|
- [x] 12 imports relatifs → imports absolus cohérents
|
||||||
|
|
||||||
|
### Points d'attention pour chaque service:
|
||||||
|
1. **Identifier tous les imports du service** (grep)
|
||||||
|
2. **Déplacer le fichier** vers le nouveau dossier
|
||||||
|
3. **Corriger les imports externes** (actions, API, hooks, components)
|
||||||
|
4. **Corriger les imports internes** entre services
|
||||||
|
5. **Tester** que l'app fonctionne toujours
|
||||||
|
6. **Commit** le déplacement d'un service à la fois
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔄 Intégration TFS/Azure DevOps
|
||||||
|
|
||||||
|
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
|
||||||
|
- [x] PR arrivent en backlog avec filtrage par team project
|
||||||
|
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||||
|
- [x] Filtrage par team project, repository, auteur
|
||||||
|
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
|
||||||
|
- [x] Refactoriser pour interfaces génériques d'intégration
|
||||||
|
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||||
|
- [x] UI générique de configuration des intégrations
|
||||||
|
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
||||||
|
|
||||||
|
### 📋 Daily - Gestion des tâches non cochées
|
||||||
|
|
||||||
|
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
||||||
|
- [x] Liste de toutes les todos non cochées (historique complet)
|
||||||
|
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
||||||
|
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||||
|
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
|
||||||
|
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
|
||||||
|
- [x] Indicateurs visuels d'ancienneté (couleurs vert→rouge)
|
||||||
|
- [x] Actions par tâche : Cocher, Archiver, Supprimer
|
||||||
|
- [x] **Statut "Archivé" basique** <!-- Implémenté le 21/09/2025 -->
|
||||||
|
- [x] Marquage textuel [ARCHIVÉ] dans le texte de la tâche
|
||||||
|
- [x] Interface pour voir les tâches archivées (visuellement distinctes)
|
||||||
|
- [ ] Possibilité de désarchiver une tâche
|
||||||
|
- [ ] Champ dédié en base de données (actuellement via texte)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖼️ **IMAGE DE FOND PERSONNALISÉE** ✅ TERMINÉ
|
||||||
|
|
||||||
|
### **Fonctionnalités implémentées :**
|
||||||
|
|
||||||
|
- [x] **Sélecteur d'images de fond** dans les paramètres généraux
|
||||||
|
- [x] **Images prédéfinies** : dégradés bleu, violet, coucher de soleil, océan, forêt
|
||||||
|
- [x] **URL personnalisée** : possibilité d'ajouter une image via URL
|
||||||
|
- [x] **Aperçu en temps réel** de l'image sélectionnée
|
||||||
|
- [x] **Application globale** : l'image s'applique sur toutes les pages
|
||||||
|
- [x] **Optimisation visuelle** : effet de flou et transparence pour la lisibilité
|
||||||
|
- [x] **Sauvegarde persistante** : préférence sauvegardée en base de données
|
||||||
|
- [x] **Interface intuitive** : sélection facile avec aperçus visuels
|
||||||
|
|
||||||
|
### **Architecture technique :**
|
||||||
|
|
||||||
|
- **Types** : `backgroundImage` ajouté à `ViewPreferences`
|
||||||
|
- **Service** : `userPreferencesService` mis à jour
|
||||||
|
- **Actions** : `setBackgroundImage` server action créée
|
||||||
|
- **Composant** : `BackgroundImageSelector` avec presets et URL personnalisée
|
||||||
|
- **Contexte** : `BackgroundContext` pour l'application globale
|
||||||
|
- **Styles** : CSS optimisé pour la lisibilité avec images de fond
|
||||||
|
|
||||||
|
## 🔄 **SCHEDULER TFS** ✅ TERMINÉ
|
||||||
|
|
||||||
|
### **Fonctionnalités implémentées :**
|
||||||
|
|
||||||
|
- [x] **Scheduler TFS automatique** basé sur le modèle Jira
|
||||||
|
- [x] **Configuration dans UserPreferences** : `tfsAutoSync` et `tfsSyncInterval`
|
||||||
|
- [x] **Intervalles configurables** : hourly, daily, weekly
|
||||||
|
- [x] **Auto-start du scheduler** au démarrage de l'application
|
||||||
|
- [x] **Migration douce** des champs scheduler en base de données
|
||||||
|
- [x] **Gestion des erreurs** et validation de configuration
|
||||||
|
- [x] **Status et monitoring** du scheduler
|
||||||
|
|
||||||
|
### **Architecture technique :**
|
||||||
|
|
||||||
|
- **Service** : `TfsScheduler` dans `src/services/integrations/tfs/scheduler.ts`
|
||||||
|
- **Configuration** : Champs `tfsAutoSync` et `tfsSyncInterval` dans `UserPreferences`
|
||||||
|
- **Migration** : Méthode `ensureTfsSchedulerFields()` pour compatibilité
|
||||||
|
- **Types** : Interface `TfsSchedulerConfig` avec validation
|
||||||
|
- **Singleton** : Instance globale `tfsScheduler` avec auto-start
|
||||||
|
- **Logs** : Console logs détaillés pour monitoring
|
||||||
|
|
||||||
|
### **Différences avec Jira :**
|
||||||
|
|
||||||
|
- **Pas de board d'équipe** : TFS se concentre sur les Pull Requests individuelles
|
||||||
|
- **Configuration simplifiée** : Pas de `ignoredProjects`, mais `ignoredRepositories`
|
||||||
|
- **Focus utilisateur** : Synchronisation basée sur les PRs assignées à l'utilisateur
|
||||||
|
|
||||||
|
### **Interface utilisateur :**
|
||||||
|
|
||||||
|
- **TfsSchedulerConfig** : Configuration du scheduler automatique avec statut et contrôles
|
||||||
|
- **TfsSync** : Interface de synchronisation manuelle avec détails et statistiques
|
||||||
|
- **API Routes** : `/api/tfs/scheduler-config` et `/api/tfs/scheduler-status` pour la gestion
|
||||||
|
- **Même format que Jira** : Interface identique avec badges de statut, contrôles et informations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS**
|
||||||
|
|
||||||
|
### **Phase 1: Nettoyage Architecture Thème**
|
||||||
|
|
||||||
|
- [x] **Décider de la stratégie** : CSS Variables vs Tailwind Dark Mode vs Hybride <!-- CSS Variables choisi -->
|
||||||
|
- [x] **Configurer tailwind.config.js** avec `darkMode: 'class'` si nécessaire <!-- Annulé : CSS Variables pur -->
|
||||||
|
- [x] **Supprimer la double application** du thème (layout.tsx + ThemeContext + UserPreferencesContext) <!-- ThemeContext est maintenant la source unique -->
|
||||||
|
- [x] **Refactorer les CSS variables** : `:root` pour défaut, `.dark/.light` pour override <!-- Architecture CSS propre avec :root neutre -->
|
||||||
|
- [x] **Nettoyer les composants** : supprimer classes `dark:` hardcodées, utiliser uniquement CSS variables <!-- TERMINÉ : toutes les occurrences supprimées -->
|
||||||
|
- [ ] **Corriger les problèmes d'hydration** mismatch et flashs de thème
|
||||||
|
- [ ] **Créer un système de design cohérent** avec tokens de couleur
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
125
UI_COMPONENTS_GUIDE.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Guide des Composants UI
|
||||||
|
|
||||||
|
## 🎯 Principe
|
||||||
|
|
||||||
|
**Les composants métier ne doivent JAMAIS utiliser directement les variables CSS.** Ils doivent utiliser les composants UI abstraits.
|
||||||
|
|
||||||
|
## ❌ MAUVAIS
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Composant métier avec variables CSS directes
|
||||||
|
function TaskCard({ task }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] p-4 rounded-lg">
|
||||||
|
<button className="bg-[var(--primary)] text-[var(--primary-foreground)] px-4 py-2 rounded">
|
||||||
|
{task.title}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ BON
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Composant métier utilisant les composants UI
|
||||||
|
import { Card, CardContent, Button } from '@/components/ui';
|
||||||
|
|
||||||
|
function TaskCard({ task }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="primary">{task.title}</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Composants UI Disponibles
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="primary" size="md">Action</Button>
|
||||||
|
<Button variant="secondary">Secondaire</Button>
|
||||||
|
<Button variant="destructive">Supprimer</Button>
|
||||||
|
<Button variant="ghost">Ghost</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="primary">Tag</Badge>
|
||||||
|
<Badge variant="success">Succès</Badge>
|
||||||
|
<Badge variant="destructive">Erreur</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alert
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert variant="success">
|
||||||
|
<AlertTitle>Succès</AlertTitle>
|
||||||
|
<AlertDescription>Opération réussie</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Input placeholder="Saisir..." />
|
||||||
|
<Input variant="error" placeholder="Erreur" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### StyledCard
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StyledCard variant="outline" color="primary">
|
||||||
|
Contenu avec style coloré
|
||||||
|
</StyledCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avatar
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Avatar avec URL personnalisée
|
||||||
|
<Avatar url="https://example.com/photo.jpg" email="user@example.com" name="John Doe" size={64} />
|
||||||
|
|
||||||
|
// Avatar Gravatar automatique (si pas d'URL fournie)
|
||||||
|
<Avatar email="user@gravatar.com" name="Jane Doe" size={48} />
|
||||||
|
|
||||||
|
// Avatar avec fallback
|
||||||
|
<Avatar email="unknown@example.com" name="Unknown User" size={32} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Migration
|
||||||
|
|
||||||
|
### Étape 1: Identifier les patterns
|
||||||
|
|
||||||
|
- Rechercher `var(--` dans les composants métier
|
||||||
|
- Identifier les patterns répétés (boutons, cartes, badges)
|
||||||
|
|
||||||
|
### Étape 2: Créer des composants UI
|
||||||
|
|
||||||
|
- Encapsuler les styles dans des composants UI
|
||||||
|
- Utiliser des variants pour les variations
|
||||||
|
|
||||||
|
### Étape 3: Remplacer dans les composants métier
|
||||||
|
|
||||||
|
- Importer les composants UI
|
||||||
|
- Remplacer les éléments HTML par les composants UI
|
||||||
|
|
||||||
|
## 🎨 Avantages
|
||||||
|
|
||||||
|
1. **Consistance** - Même apparence partout
|
||||||
|
2. **Maintenance** - Changements centralisés
|
||||||
|
3. **Réutilisabilité** - Composants réutilisables
|
||||||
|
4. **Type Safety** - Props typées
|
||||||
|
5. **Performance** - Styles optimisés
|
||||||
|
|
||||||
|
## 📝 Règles
|
||||||
|
|
||||||
|
1. **JAMAIS** de variables CSS dans les composants métier
|
||||||
|
2. **TOUJOURS** utiliser les composants UI
|
||||||
|
3. **CRÉER** de nouveaux composants UI si nécessaire
|
||||||
|
4. **DOCUMENTER** les nouveaux composants UI
|
||||||
12
data/README.md
Normal file → Executable file
@@ -18,11 +18,13 @@ data/
|
|||||||
## 🎯 Utilisation
|
## 🎯 Utilisation
|
||||||
|
|
||||||
### En développement local
|
### En développement local
|
||||||
|
|
||||||
- La base de données principale est dans `prisma/dev.db`
|
- La base de données principale est dans `prisma/dev.db`
|
||||||
- Ce dossier `data/` est utilisé uniquement par Docker
|
- Ce dossier `data/` est utilisé uniquement par Docker
|
||||||
- Les sauvegardes locales sont dans `backups/` (racine du projet)
|
- Les sauvegardes locales sont dans `backups/` (racine du projet)
|
||||||
|
|
||||||
### En production Docker
|
### En production Docker
|
||||||
|
|
||||||
- Base de données : `data/prod.db` ou `data/dev.db`
|
- Base de données : `data/prod.db` ou `data/dev.db`
|
||||||
- Sauvegardes : `data/backups/`
|
- Sauvegardes : `data/backups/`
|
||||||
- Tout ce dossier est mappé vers `/app/data` dans le conteneur
|
- Tout ce dossier est mappé vers `/app/data` dans le conteneur
|
||||||
@@ -45,12 +47,14 @@ BACKUP_STORAGE_PATH="./data/backups"
|
|||||||
## 🗂️ Fichiers
|
## 🗂️ Fichiers
|
||||||
|
|
||||||
### Bases de données SQLite
|
### Bases de données SQLite
|
||||||
|
|
||||||
- **prod.db** : Base de données de production
|
- **prod.db** : Base de données de production
|
||||||
- **dev.db** : Base de données de développement Docker
|
- **dev.db** : Base de données de développement Docker
|
||||||
- Format : SQLite 3
|
- Format : SQLite 3
|
||||||
- Contient : Tasks, Tags, User Preferences, Sync Logs, etc.
|
- Contient : Tasks, Tags, User Preferences, Sync Logs, etc.
|
||||||
|
|
||||||
### Sauvegardes
|
### Sauvegardes
|
||||||
|
|
||||||
- **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz`
|
- **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz`
|
||||||
- **Compression** : gzip
|
- **Compression** : gzip
|
||||||
- **Rétention** : Configurable (défaut: 5 sauvegardes)
|
- **Rétention** : Configurable (défaut: 5 sauvegardes)
|
||||||
@@ -60,16 +64,16 @@ BACKUP_STORAGE_PATH="./data/backups"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Créer une sauvegarde manuelle
|
# Créer une sauvegarde manuelle
|
||||||
npm run backup:create
|
pnpm run backup:create
|
||||||
|
|
||||||
# Lister les sauvegardes
|
# Lister les sauvegardes
|
||||||
npm run backup:list
|
pnpm run backup:list
|
||||||
|
|
||||||
# Voir la configuration
|
# Voir la configuration
|
||||||
npm run backup:config
|
pnpm run backup:config
|
||||||
|
|
||||||
# Restaurer une sauvegarde (dev uniquement)
|
# Restaurer une sauvegarde (dev uniquement)
|
||||||
npm run backup:restore filename.db.gz
|
pnpm run backup:restore filename.db.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Important
|
## ⚠️ Important
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: runner
|
target: runner
|
||||||
ports:
|
ports:
|
||||||
- "3006:3000"
|
- '${PORT:-3007}:3000'
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
DATABASE_URL: ${DATABASE_URL:-file:/app/data/dev.db}
|
||||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
BACKUP_DATABASE_PATH: ${BACKUP_DATABASE_PATH:-./data/dev.db}
|
||||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
BACKUP_STORAGE_PATH: ${BACKUP_STORAGE_PATH:-./data/backups}
|
||||||
TZ: Europe/Paris
|
TZ: ${TZ:-Europe/Paris}
|
||||||
|
# NextAuth.js
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw=}
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3006}
|
||||||
|
# Jira (optionnel)
|
||||||
|
JIRA_BASE_URL: ${JIRA_BASE_URL:-}
|
||||||
|
JIRA_EMAIL: ${JIRA_EMAIL:-}
|
||||||
|
JIRA_API_TOKEN: ${JIRA_API_TOKEN:-}
|
||||||
|
# Debug
|
||||||
|
VERBOSE_LOGGING: ${VERBOSE_LOGGING:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -28,31 +37,40 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: base
|
target: base
|
||||||
ports:
|
ports:
|
||||||
- "3005:3000"
|
- '${PORT_DEV:-3005}:3000'
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: ${NODE_ENV:-development}
|
||||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
DATABASE_URL: ${DATABASE_URL:-file:/app/data/dev.db}
|
||||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
BACKUP_DATABASE_PATH: ${BACKUP_DATABASE_PATH:-./data/dev.db}
|
||||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
BACKUP_STORAGE_PATH: ${BACKUP_STORAGE_PATH:-./data/backups}
|
||||||
TZ: Europe/Paris
|
TZ: ${TZ:-Europe/Paris}
|
||||||
|
# NextAuth.js
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw=}
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3005}
|
||||||
|
# Jira (optionnel)
|
||||||
|
JIRA_BASE_URL: ${JIRA_BASE_URL:-}
|
||||||
|
JIRA_EMAIL: ${JIRA_EMAIL:-}
|
||||||
|
JIRA_API_TOKEN: ${JIRA_API_TOKEN:-}
|
||||||
|
# Debug
|
||||||
|
VERBOSE_LOGGING: ${VERBOSE_LOGGING:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app # code en live
|
- .:/app # code en live
|
||||||
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
||||||
- /app/.next
|
- /app/.next
|
||||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||||
command: >
|
command: >
|
||||||
sh -c "npm install &&
|
sh -c "pnpm install &&
|
||||||
npx prisma generate &&
|
pnpm prisma generate &&
|
||||||
npx prisma migrate deploy &&
|
(pnpm prisma migrate deploy || (echo 'Migration failed, using db push for fresh database...' && pnpm prisma db push --accept-data-loss --skip-generate && for migration in prisma/migrations/*/; do if [ -d \"\$migration\" ] && [ -f \"\$migration/migration.sql\" ]; then migration_name=\$(basename \"\$migration\"); pnpm prisma migrate resolve --applied \"\$migration_name\" 2>/dev/null || true; fi; done)) &&
|
||||||
npm run dev"
|
pnpm run dev"
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
# 📁 Structure des données :
|
# 📁 Structure des données :
|
||||||
# ./data/ -> /app/data (bind mount)
|
# ./data/ -> /app/data (bind mount)
|
||||||
# ├── prod.db -> Base de données production
|
# ├── prod.db -> Base de données production
|
||||||
# ├── dev.db -> Base de données développement
|
# ├── dev.db -> Base de données développement
|
||||||
# └── backups/ -> Sauvegardes automatiques
|
# └── backups/ -> Sauvegardes automatiques
|
||||||
#
|
#
|
||||||
# 🔧 Configuration via .env.docker
|
# 🔧 Configuration via variables d'environnement (.env ou .env.local)
|
||||||
# 📚 Documentation : ./data/README.md
|
# Les variables utilisent la syntaxe ${VAR:-default} pour les fallbacks
|
||||||
|
# 📚 Documentation : ./data/README.md et env.example
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ JIRA_BASE_URL="" # https://votre-domaine.atlassian.net
|
|||||||
JIRA_EMAIL="" # votre.email@domaine.com
|
JIRA_EMAIL="" # votre.email@domaine.com
|
||||||
JIRA_API_TOKEN="" # Token API Jira
|
JIRA_API_TOKEN="" # Token API Jira
|
||||||
|
|
||||||
|
# NextAuth (requis)
|
||||||
|
NEXTAUTH_URL="http://localhost:3000" # URL de votre application
|
||||||
|
NEXTAUTH_SECRET="your-secret-key-here" # Clé secrète pour signer les tokens
|
||||||
|
|
||||||
# Debug (optionnel)
|
# Debug (optionnel)
|
||||||
VERBOSE_LOGGING="false" # Logs détaillés en développement
|
VERBOSE_LOGGING="false" # Logs détaillés en développement
|
||||||
NODE_ENV="development" # development | production
|
NODE_ENV="development" # development | production
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { dirname } from "path";
|
import { dirname } from 'path';
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from 'url';
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -10,14 +10,16 @@ const compat = new FlatCompat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"node_modules/**",
|
'node_modules/**',
|
||||||
".next/**",
|
'.next/**',
|
||||||
"out/**",
|
'out/**',
|
||||||
"build/**",
|
'build/**',
|
||||||
"next-env.d.ts",
|
'next-env.d.ts',
|
||||||
|
'scripts/test-runner.js', // Script Node.js qui utilise require() légitimement
|
||||||
|
'scripts/generate-icons-from-jpg.ts', // Script utilitaire avec require()
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'media.licdn.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'avatars.githubusercontent.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'lh3.googleusercontent.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'cdn.discordapp.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'images.unsplash.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'via.placeholder.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
rules: {
|
rules: {
|
||||||
'*.sql': ['raw'],
|
'*.sql': ['raw'],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
7990
package-lock.json
generated
64
package.json
@@ -1,44 +1,86 @@
|
|||||||
{
|
{
|
||||||
"name": "towercontrol",
|
"name": "towercontrol",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "prisma generate && next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"backup:create": "npx tsx scripts/backup-manager.ts create",
|
"backup:create": "pnpm tsx scripts/backup-manager.ts create",
|
||||||
"backup:list": "npx tsx scripts/backup-manager.ts list",
|
"backup:list": "pnpm tsx scripts/backup-manager.ts list",
|
||||||
"backup:verify": "npx tsx scripts/backup-manager.ts verify",
|
"backup:verify": "pnpm tsx scripts/backup-manager.ts verify",
|
||||||
"backup:config": "npx tsx scripts/backup-manager.ts config",
|
"backup:config": "pnpm tsx scripts/backup-manager.ts config",
|
||||||
"backup:start": "npx tsx scripts/backup-manager.ts scheduler-start",
|
"backup:start": "pnpm tsx scripts/backup-manager.ts scheduler-start",
|
||||||
"backup:stop": "npx tsx scripts/backup-manager.ts scheduler-stop",
|
"backup:stop": "pnpm tsx scripts/backup-manager.ts scheduler-stop",
|
||||||
"backup:status": "npx tsx scripts/backup-manager.ts scheduler-status"
|
"backup:status": "pnpm tsx scripts/backup-manager.ts scheduler-status",
|
||||||
|
"cache:monitor": "pnpm tsx scripts/cache-monitor.ts",
|
||||||
|
"cache:stats": "pnpm tsx scripts/cache-monitor.ts stats",
|
||||||
|
"cache:cleanup": "pnpm tsx scripts/cache-monitor.ts cleanup",
|
||||||
|
"cache:clear": "pnpm tsx scripts/cache-monitor.ts clear",
|
||||||
|
"test": "node scripts/test-runner.js",
|
||||||
|
"test:watch": "vitest --watch --reporter=verbose",
|
||||||
|
"test:coverage": "vitest --coverage --reporter=verbose",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:story-points": "pnpm tsx scripts/test-story-points.ts",
|
||||||
|
"test:jira-fields": "pnpm tsx scripts/test-jira-fields.ts",
|
||||||
|
"prettier:format": "prettier --write .",
|
||||||
|
"prettier:check": "prettier --check .",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@prisma/client": "^6.16.1",
|
"@prisma/client": "^6.16.1",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
|
"emoji-regex": "^10.5.0",
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
|
"mermaid": "^11.12.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
|
"prism-react-renderer": "^2.4.1",
|
||||||
"prisma": "^6.16.1",
|
"prisma": "^6.16.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"tailwind-merge": "^3.3.1"
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-toc": "^9.0.0",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"twemoji": "^14.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^15.5.3",
|
"eslint-config-next": "^15.5.3",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"knip": "^5.64.0",
|
"knip": "^5.64.0",
|
||||||
|
"lint-staged": "^15.5.2",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,jsx,ts,tsx,json,css,md}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8212
pnpm-lock.yaml
generated
Normal file
@@ -1,5 +1,5 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: ["@tailwindcss/postcss"],
|
plugins: ['@tailwindcss/postcss'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- Migration pour ajouter ownerId aux tags
|
||||||
|
-- Les tags existants seront assignés au premier utilisateur
|
||||||
|
-- Cette version préserve les relations TaskTag existantes
|
||||||
|
|
||||||
|
-- Étape 1: Ajouter la colonne ownerId temporairement nullable
|
||||||
|
ALTER TABLE "tags" ADD COLUMN "ownerId" TEXT;
|
||||||
|
|
||||||
|
-- Étape 2: Assigner tous les tags existants au premier utilisateur
|
||||||
|
UPDATE "tags"
|
||||||
|
SET "ownerId" = (
|
||||||
|
SELECT "id" FROM "users"
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE "ownerId" IS NULL;
|
||||||
|
|
||||||
|
-- Étape 3: Sauvegarder les relations TaskTag existantes avec les noms des tags
|
||||||
|
CREATE TEMPORARY TABLE "temp_task_tag_names" AS
|
||||||
|
SELECT tt."taskId", t."name" as "tagName"
|
||||||
|
FROM "task_tags" tt
|
||||||
|
JOIN "tags" t ON tt."tagId" = t."id";
|
||||||
|
|
||||||
|
-- Étape 4: Supprimer les anciennes relations TaskTag
|
||||||
|
DELETE FROM "task_tags";
|
||||||
|
|
||||||
|
-- Étape 5: Créer la nouvelle table avec ownerId non-nullable
|
||||||
|
CREATE TABLE "new_tags" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#6b7280',
|
||||||
|
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "new_tags_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Étape 6: Copier les données des tags
|
||||||
|
INSERT INTO "new_tags" ("id", "name", "color", "isPinned", "ownerId")
|
||||||
|
SELECT "id", "name", "color", "isPinned", "ownerId" FROM "tags";
|
||||||
|
|
||||||
|
-- Étape 7: Supprimer l'ancienne table
|
||||||
|
DROP TABLE "tags";
|
||||||
|
|
||||||
|
-- Étape 8: Renommer la nouvelle table
|
||||||
|
ALTER TABLE "new_tags" RENAME TO "tags";
|
||||||
|
|
||||||
|
-- Étape 9: Créer l'index unique pour (name, ownerId)
|
||||||
|
CREATE UNIQUE INDEX "tags_name_ownerId_key" ON "tags"("name", "ownerId");
|
||||||
|
|
||||||
|
-- Étape 10: Restaurer les relations TaskTag en utilisant les noms des tags
|
||||||
|
INSERT INTO "task_tags" ("taskId", "tagId")
|
||||||
|
SELECT tt."taskId", t."id" as "tagId"
|
||||||
|
FROM "temp_task_tag_names" tt
|
||||||
|
JOIN "tags" t ON tt."tagName" = t."name"
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM "tasks" WHERE "tasks"."id" = tt."taskId"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Étape 11: Nettoyer la table temporaire
|
||||||
|
DROP TABLE "temp_task_tag_names";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Migration pour ajouter userId aux UserPreferences
|
||||||
|
-- et migrer les données existantes vers le premier utilisateur
|
||||||
|
|
||||||
|
-- 1. Ajouter la colonne userId (nullable temporairement)
|
||||||
|
ALTER TABLE "user_preferences" ADD COLUMN "userId" TEXT;
|
||||||
|
|
||||||
|
-- 2. Créer un index unique sur userId
|
||||||
|
CREATE UNIQUE INDEX "user_preferences_userId_key" ON "user_preferences"("userId");
|
||||||
|
|
||||||
|
-- 3. Migrer les données existantes vers le premier utilisateur
|
||||||
|
-- (on suppose qu'il y a au moins un utilisateur dans la table users)
|
||||||
|
UPDATE "user_preferences"
|
||||||
|
SET "userId" = (SELECT id FROM "users" LIMIT 1)
|
||||||
|
WHERE "userId" IS NULL;
|
||||||
|
|
||||||
|
-- 4. Rendre la colonne userId non-nullable
|
||||||
|
-- Note: SQLite ne supporte pas ALTER COLUMN, donc on doit recréer la table
|
||||||
|
-- Mais comme on a déjà des données, on va juste s'assurer que toutes les entrées ont un userId
|
||||||
|
-- En production, on devrait faire une migration plus complexe
|
||||||
|
|
||||||
|
-- 5. Ajouter la contrainte de clé étrangère
|
||||||
|
-- SQLite ne supporte pas les contraintes de clé étrangère dans ALTER TABLE
|
||||||
|
-- La contrainte sera gérée par Prisma au niveau applicatif
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "taskId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Note" ADD CONSTRAINT "Note_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Add ownerId column to tasks table
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "ownerId" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Get the first user ID to assign all existing tasks
|
||||||
|
-- We'll use a subquery to get the first user's ID
|
||||||
|
UPDATE "tasks"
|
||||||
|
SET "ownerId" = (
|
||||||
|
SELECT "id" FROM "users"
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE "ownerId" = '';
|
||||||
|
|
||||||
|
-- Now make ownerId NOT NULL without default
|
||||||
|
-- First, we need to recreate the table since SQLite doesn't support ALTER COLUMN
|
||||||
|
CREATE TABLE "tasks_new" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'todo',
|
||||||
|
"priority" TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"sourceId" TEXT,
|
||||||
|
"dueDate" DATETIME,
|
||||||
|
"completedAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"jiraProject" TEXT,
|
||||||
|
"jiraKey" TEXT,
|
||||||
|
"assignee" TEXT,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"jiraType" TEXT,
|
||||||
|
"tfsProject" TEXT,
|
||||||
|
"tfsPullRequestId" INTEGER,
|
||||||
|
"tfsRepository" TEXT,
|
||||||
|
"tfsSourceBranch" TEXT,
|
||||||
|
"tfsTargetBranch" TEXT,
|
||||||
|
"primaryTagId" TEXT,
|
||||||
|
CONSTRAINT "tasks_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "tasks_primaryTagId_fkey" FOREIGN KEY ("primaryTagId") REFERENCES "tags" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Copy data from old table to new table
|
||||||
|
INSERT INTO "tasks_new" SELECT * FROM "tasks";
|
||||||
|
|
||||||
|
-- Drop old table
|
||||||
|
DROP TABLE "tasks";
|
||||||
|
|
||||||
|
-- Rename new table
|
||||||
|
ALTER TABLE "tasks_new" RENAME TO "tasks";
|
||||||
|
|
||||||
|
-- Recreate indexes
|
||||||
|
CREATE UNIQUE INDEX "tasks_source_sourceId_key" ON "tasks"("source", "sourceId");
|
||||||
|
CREATE INDEX "tasks_ownerId_idx" ON "tasks"("ownerId");
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Add ownerId column to tasks table if it doesn't exist
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "ownerId" TEXT;
|
||||||
|
|
||||||
|
-- Create a temporary user if no users exist
|
||||||
|
INSERT OR IGNORE INTO "users" ("id", "email", "name", "password", "createdAt", "updatedAt")
|
||||||
|
VALUES ('temp-user', 'temp@example.com', 'Temporary User', '$2b$10$temp', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
-- Assign all existing tasks to the first user (or temp user if none exist)
|
||||||
|
UPDATE "tasks"
|
||||||
|
SET "ownerId" = (
|
||||||
|
SELECT "id" FROM "users"
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE "ownerId" IS NULL;
|
||||||
|
|
||||||
|
-- Now make ownerId NOT NULL by recreating the table
|
||||||
|
CREATE TABLE "tasks_new" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'todo',
|
||||||
|
"priority" TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"sourceId" TEXT,
|
||||||
|
"dueDate" DATETIME,
|
||||||
|
"completedAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"jiraProject" TEXT,
|
||||||
|
"jiraKey" TEXT,
|
||||||
|
"assignee" TEXT,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"jiraType" TEXT,
|
||||||
|
"tfsProject" TEXT,
|
||||||
|
"tfsPullRequestId" INTEGER,
|
||||||
|
"tfsRepository" TEXT,
|
||||||
|
"tfsSourceBranch" TEXT,
|
||||||
|
"tfsTargetBranch" TEXT,
|
||||||
|
"primaryTagId" TEXT,
|
||||||
|
CONSTRAINT "tasks_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "tasks_primaryTagId_fkey" FOREIGN KEY ("primaryTagId") REFERENCES "tags" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Copy data from old table to new table
|
||||||
|
INSERT INTO "tasks_new" SELECT * FROM "tasks";
|
||||||
|
|
||||||
|
-- Drop old table
|
||||||
|
DROP TABLE "tasks";
|
||||||
|
|
||||||
|
-- Rename new table
|
||||||
|
ALTER TABLE "tasks_new" RENAME TO "tasks";
|
||||||
|
|
||||||
|
-- Recreate indexes
|
||||||
|
CREATE UNIQUE INDEX "tasks_source_sourceId_key" ON "tasks"("source", "sourceId");
|
||||||
|
CREATE INDEX "tasks_ownerId_idx" ON "tasks"("ownerId");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration pour ajouter userId aux DailyCheckbox
|
||||||
|
-- et associer les entrées existantes au premier utilisateur
|
||||||
|
|
||||||
|
-- 1. Ajouter la colonne userId (nullable temporairement)
|
||||||
|
ALTER TABLE "daily_checkboxes" ADD COLUMN "userId" TEXT;
|
||||||
|
|
||||||
|
-- 2. Migrer les données existantes vers le premier utilisateur
|
||||||
|
-- (on suppose qu'il y a au moins un utilisateur dans la table users)
|
||||||
|
UPDATE "daily_checkboxes"
|
||||||
|
SET "userId" = (SELECT id FROM "users" LIMIT 1)
|
||||||
|
WHERE "userId" IS NULL;
|
||||||
|
|
||||||
|
-- 3. Créer un index sur userId pour les performances
|
||||||
|
CREATE INDEX "daily_checkboxes_userId_idx" ON "daily_checkboxes"("userId");
|
||||||
|
|
||||||
|
-- Note: La contrainte de clé étrangère sera gérée par Prisma
|
||||||
|
-- SQLite ne supporte pas les contraintes de clé étrangère dans ALTER TABLE
|
||||||
@@ -7,6 +7,28 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
avatar String? // URL de l'avatar
|
||||||
|
role String @default("user") // user, admin, etc.
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
password String // Hashé avec bcrypt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
preferences UserPreferences?
|
||||||
|
notes Note[]
|
||||||
|
dailyCheckboxes DailyCheckbox[]
|
||||||
|
tasks Task[] @relation("TaskOwner")
|
||||||
|
tags Tag[] @relation("TagOwner")
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
@@ -21,15 +43,20 @@ model Task {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
jiraProject String?
|
jiraProject String?
|
||||||
jiraKey String?
|
jiraKey String?
|
||||||
assignee String?
|
assignee String? // Legacy field - keep for Jira/TFS compatibility
|
||||||
|
ownerId String // Required - chaque tâche appartient à un user
|
||||||
|
owner User @relation("TaskOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
jiraType String?
|
jiraType String?
|
||||||
tfsProject String?
|
tfsProject String?
|
||||||
tfsPullRequestId Int?
|
tfsPullRequestId Int?
|
||||||
tfsRepository String?
|
tfsRepository String?
|
||||||
tfsSourceBranch String?
|
tfsSourceBranch String?
|
||||||
tfsTargetBranch String?
|
tfsTargetBranch String?
|
||||||
|
primaryTagId String?
|
||||||
|
primaryTag Tag? @relation("PrimaryTag", fields: [primaryTagId], references: [id])
|
||||||
dailyCheckboxes DailyCheckbox[]
|
dailyCheckboxes DailyCheckbox[]
|
||||||
taskTags TaskTag[]
|
taskTags TaskTag[]
|
||||||
|
notes Note[] // Notes associées à cette tâche
|
||||||
|
|
||||||
@@unique([source, sourceId])
|
@@unique([source, sourceId])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
@@ -37,11 +64,16 @@ model Task {
|
|||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String
|
||||||
color String @default("#6b7280")
|
color String @default("#6b7280")
|
||||||
isPinned Boolean @default(false)
|
isPinned Boolean @default(false)
|
||||||
|
ownerId String // Chaque tag appartient à un utilisateur
|
||||||
|
owner User @relation("TagOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
taskTags TaskTag[]
|
taskTags TaskTag[]
|
||||||
|
primaryTasks Task[] @relation("PrimaryTag")
|
||||||
|
noteTags NoteTag[]
|
||||||
|
|
||||||
|
@@unique([name, ownerId]) // Un nom de tag unique par utilisateur
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,16 +106,20 @@ model DailyCheckbox {
|
|||||||
type String @default("task")
|
type String @default("task")
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
taskId String?
|
taskId String?
|
||||||
|
userId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
task Task? @relation(fields: [taskId], references: [id])
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([date])
|
@@index([date])
|
||||||
|
@@index([userId])
|
||||||
@@map("daily_checkboxes")
|
@@map("daily_checkboxes")
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
kanbanFilters Json?
|
kanbanFilters Json?
|
||||||
viewPreferences Json?
|
viewPreferences Json?
|
||||||
columnVisibility Json?
|
columnVisibility Json?
|
||||||
@@ -95,6 +131,30 @@ model UserPreferences {
|
|||||||
tfsSyncInterval String @default("daily")
|
tfsSyncInterval String @default("daily")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("user_preferences")
|
@@map("user_preferences")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String // Markdown content
|
||||||
|
userId String
|
||||||
|
taskId String? // Tâche associée à la note
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
|
noteTags NoteTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model NoteTag {
|
||||||
|
noteId String
|
||||||
|
tagId String
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([noteId, tagId])
|
||||||
|
@@map("note_tags")
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 707 B |
BIN
public/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/icons/iconTC.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
public/icons/iconTC2.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/icons/iconTC3S.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/icons/iconTC4S.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/icons/iconTCAlpha.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/icons/logoTC5.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/icons/logoTC6.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/icons/logoTC7.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/icons/logoTC8.png
Normal file
|
After Width: | Height: | Size: 841 KiB |
185
scripts/auto-version.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
patch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(version: string): Version {
|
||||||
|
const [major, minor, patch] = version.split('.').map(Number);
|
||||||
|
return { major, minor, patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVersion(v: Version): string {
|
||||||
|
return `${v.major}.${v.minor}.${v.patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastVersionTag(): string | null {
|
||||||
|
try {
|
||||||
|
const tag = execSync('git describe --tags --abbrev=0', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim();
|
||||||
|
return tag;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommitsSinceTag(tag: string | null): string[] {
|
||||||
|
try {
|
||||||
|
const range = tag ? `${tag}..HEAD` : 'HEAD';
|
||||||
|
const commits = execSync(`git log ${range} --pretty=format:"%s"`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean);
|
||||||
|
return commits;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineVersionBump(commits: string[]): 'major' | 'minor' | 'patch' {
|
||||||
|
let hasBreaking = false;
|
||||||
|
let hasFeature = false;
|
||||||
|
let hasPatch = false;
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
const lowerCommit = commit.toLowerCase();
|
||||||
|
|
||||||
|
// Breaking changes (major bump)
|
||||||
|
if (
|
||||||
|
lowerCommit.includes('breaking change') ||
|
||||||
|
lowerCommit.includes('breaking:') ||
|
||||||
|
lowerCommit.match(/^[a-z]+!:/) || // feat!:, refactor!:, etc.
|
||||||
|
lowerCommit.includes('!')
|
||||||
|
) {
|
||||||
|
hasBreaking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features (minor bump)
|
||||||
|
if (lowerCommit.startsWith('feat:')) {
|
||||||
|
hasFeature = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch bumps: fixes, performance improvements, security fixes, refactorings
|
||||||
|
if (
|
||||||
|
lowerCommit.startsWith('fix:') ||
|
||||||
|
lowerCommit.startsWith('perf:') ||
|
||||||
|
lowerCommit.startsWith('security:') ||
|
||||||
|
lowerCommit.startsWith('patch:') ||
|
||||||
|
lowerCommit.startsWith('refactor:')
|
||||||
|
) {
|
||||||
|
hasPatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBreaking) return 'major';
|
||||||
|
if (hasFeature) return 'minor';
|
||||||
|
if (hasPatch) return 'patch';
|
||||||
|
|
||||||
|
// Par défaut, patch si on a des commits mais aucun type spécifique
|
||||||
|
return commits.length > 0 ? 'patch' : 'patch';
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementVersion(
|
||||||
|
current: Version,
|
||||||
|
type: 'major' | 'minor' | 'patch'
|
||||||
|
): Version {
|
||||||
|
switch (type) {
|
||||||
|
case 'major':
|
||||||
|
return { major: current.major + 1, minor: 0, patch: 0 };
|
||||||
|
case 'minor':
|
||||||
|
return { major: current.major, minor: current.minor + 1, patch: 0 };
|
||||||
|
case 'patch':
|
||||||
|
return {
|
||||||
|
major: current.major,
|
||||||
|
minor: current.minor,
|
||||||
|
patch: current.patch + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const silent = process.argv.includes('--silent');
|
||||||
|
const hookMode = process.argv.includes('--hook');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packagePath = join(process.cwd(), 'package.json');
|
||||||
|
const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
||||||
|
const currentVersion = parseVersion(packageJson.version);
|
||||||
|
|
||||||
|
const lastTag = getLastVersionTag();
|
||||||
|
const commits = getCommitsSinceTag(lastTag);
|
||||||
|
|
||||||
|
if (commits.length === 0) {
|
||||||
|
if (!silent) {
|
||||||
|
console.log('✅ Aucun nouveau commit depuis la dernière version');
|
||||||
|
console.log(`Version actuelle: ${packageJson.version}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bumpType = determineVersionBump(commits);
|
||||||
|
const newVersion = incrementVersion(currentVersion, bumpType);
|
||||||
|
const newVersionString = formatVersion(newVersion);
|
||||||
|
|
||||||
|
// Si la version n'a pas changé, ne rien faire
|
||||||
|
if (newVersionString === packageJson.version) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
console.log(`📊 Analyse des commits depuis ${lastTag || 'le début'}:`);
|
||||||
|
console.log(` - ${commits.length} commit(s) trouvé(s)`);
|
||||||
|
console.log(` - Type de bump détecté: ${bumpType}`);
|
||||||
|
console.log(` - Version actuelle: ${packageJson.version}`);
|
||||||
|
console.log(` - Nouvelle version: ${newVersionString}`);
|
||||||
|
|
||||||
|
// Afficher les commits pertinents
|
||||||
|
console.log('\n📝 Commits analysés:');
|
||||||
|
commits.slice(0, 10).forEach((commit) => {
|
||||||
|
console.log(` - ${commit}`);
|
||||||
|
});
|
||||||
|
if (commits.length > 10) {
|
||||||
|
console.log(` ... et ${commits.length - 10} autre(s) commit(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour package.json
|
||||||
|
packageJson.version = newVersionString;
|
||||||
|
writeFileSync(packagePath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
console.log(`\n✅ Version mise à jour dans package.json`);
|
||||||
|
console.log(
|
||||||
|
`\n💡 Prochaines étapes:` +
|
||||||
|
`\n 1. git add package.json` +
|
||||||
|
`\n 2. git commit -m "chore: bump version to ${newVersionString}"` +
|
||||||
|
`\n 3. git tag v${newVersionString}`
|
||||||
|
);
|
||||||
|
} else if (hookMode) {
|
||||||
|
// En mode hook, ajouter package.json au staging
|
||||||
|
try {
|
||||||
|
execSync('git add package.json', { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
// Ignorer les erreurs en mode hook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
console.error('❌ Erreur lors de la mise à jour de version:', error);
|
||||||
|
}
|
||||||
|
if (!hookMode) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -4,8 +4,11 @@
|
|||||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { backupService, BackupConfig } from '../src/services/backup';
|
import {
|
||||||
import { backupScheduler } from '../src/services/backup-scheduler';
|
backupService,
|
||||||
|
BackupConfig,
|
||||||
|
} from '../src/services/data-management/backup';
|
||||||
|
import { backupScheduler } from '../src/services/data-management/backup-scheduler';
|
||||||
import { formatDateForDisplay } from '../src/lib/date-utils';
|
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
@@ -70,7 +73,10 @@ OPTIONS:
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async confirmAction(message: string, force?: boolean): Promise<boolean> {
|
private async confirmAction(
|
||||||
|
message: string,
|
||||||
|
force?: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
if (force) return true;
|
if (force) return true;
|
||||||
|
|
||||||
// Simulation d'une confirmation (en CLI réel, utiliser readline)
|
// Simulation d'une confirmation (en CLI réel, utiliser readline)
|
||||||
@@ -170,12 +176,16 @@ OPTIONS:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async createBackup(force: boolean = false): Promise<void> {
|
private async createBackup(force: boolean = false): Promise<void> {
|
||||||
console.log('🔄 Création d\'une sauvegarde...');
|
console.log("🔄 Création d'une sauvegarde...");
|
||||||
const result = await backupService.createBackup('manual', force);
|
const result = await backupService.createBackup('manual', force);
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde');
|
console.log(
|
||||||
console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout');
|
'⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
' 💡 Utilisez --force pour créer une sauvegarde malgré tout'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +210,17 @@ OPTIONS:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${'Nom'.padEnd(40)} ${'Taille'.padEnd(10)} ${'Type'.padEnd(12)} ${'Date'}`);
|
console.log(
|
||||||
|
`${'Nom'.padEnd(40)} ${'Taille'.padEnd(10)} ${'Type'.padEnd(12)} ${'Date'}`
|
||||||
|
);
|
||||||
console.log('─'.repeat(80));
|
console.log('─'.repeat(80));
|
||||||
|
|
||||||
for (const backup of backups) {
|
for (const backup of backups) {
|
||||||
const name = backup.filename.padEnd(40);
|
const name = backup.filename.padEnd(40);
|
||||||
const size = this.formatFileSize(backup.size).padEnd(10);
|
const size = this.formatFileSize(backup.size).padEnd(10);
|
||||||
const type = (backup.type === 'manual' ? 'Manuelle' : 'Automatique').padEnd(12);
|
const type = (
|
||||||
|
backup.type === 'manual' ? 'Manuelle' : 'Automatique'
|
||||||
|
).padEnd(12);
|
||||||
const date = this.formatDate(backup.createdAt);
|
const date = this.formatDate(backup.createdAt);
|
||||||
|
|
||||||
console.log(`${name} ${size} ${type} ${date}`);
|
console.log(`${name} ${size} ${type} ${date}`);
|
||||||
@@ -230,7 +244,10 @@ OPTIONS:
|
|||||||
console.log(`✅ Sauvegarde supprimée: ${filename}`);
|
console.log(`✅ Sauvegarde supprimée: ${filename}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async restoreBackup(filename: string, force?: boolean): Promise<void> {
|
private async restoreBackup(
|
||||||
|
filename: string,
|
||||||
|
force?: boolean
|
||||||
|
): Promise<void> {
|
||||||
const confirmed = await this.confirmAction(
|
const confirmed = await this.confirmAction(
|
||||||
`Restaurer la base de données depuis "${filename}" ? ATTENTION: Cela remplacera toutes les données actuelles !`,
|
`Restaurer la base de données depuis "${filename}" ? ATTENTION: Cela remplacera toutes les données actuelles !`,
|
||||||
force
|
force
|
||||||
@@ -247,24 +264,32 @@ OPTIONS:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async verifyDatabase(): Promise<void> {
|
private async verifyDatabase(): Promise<void> {
|
||||||
console.log('🔍 Vérification de l\'intégrité de la base...');
|
console.log("🔍 Vérification de l'intégrité de la base...");
|
||||||
await backupService.verifyDatabaseHealth();
|
await backupService.verifyDatabaseHealth();
|
||||||
console.log('✅ Base de données vérifiée avec succès');
|
console.log('✅ Base de données vérifiée avec succès');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showConfig(): Promise<void> {
|
private async showConfig(): Promise<void> {
|
||||||
const config = backupService.getConfig();
|
const config = await backupService.getConfig();
|
||||||
const status = backupScheduler.getStatus();
|
const status = backupScheduler.getStatus();
|
||||||
|
|
||||||
console.log('⚙️ Configuration des sauvegardes:\n');
|
console.log('⚙️ Configuration des sauvegardes:\n');
|
||||||
console.log(` Activé: ${config.enabled ? '✅ Oui' : '❌ Non'}`);
|
console.log(
|
||||||
|
` Activé: ${config.enabled ? '✅ Oui' : '❌ Non'}`
|
||||||
|
);
|
||||||
console.log(` Fréquence: ${config.interval}`);
|
console.log(` Fréquence: ${config.interval}`);
|
||||||
console.log(` Max sauvegardes: ${config.maxBackups}`);
|
console.log(` Max sauvegardes: ${config.maxBackups}`);
|
||||||
console.log(` Compression: ${config.compression ? '✅ Oui' : '❌ Non'}`);
|
console.log(
|
||||||
|
` Compression: ${config.compression ? '✅ Oui' : '❌ Non'}`
|
||||||
|
);
|
||||||
console.log(` Chemin: ${config.backupPath}`);
|
console.log(` Chemin: ${config.backupPath}`);
|
||||||
console.log(`\n📊 Statut du planificateur:`);
|
console.log(`\n📊 Statut du planificateur:`);
|
||||||
console.log(` En cours: ${status.isRunning ? '✅ Oui' : '❌ Non'}`);
|
console.log(
|
||||||
console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`);
|
` En cours: ${status.isRunning ? '✅ Oui' : '❌ Non'}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setConfig(configString: string): Promise<void> {
|
private async setConfig(configString: string): Promise<void> {
|
||||||
@@ -283,7 +308,9 @@ OPTIONS:
|
|||||||
break;
|
break;
|
||||||
case 'interval':
|
case 'interval':
|
||||||
if (!['hourly', 'daily', 'weekly'].includes(value)) {
|
if (!['hourly', 'daily', 'weekly'].includes(value)) {
|
||||||
console.error('❌ Interval invalide. Utilisez: hourly, daily, ou weekly');
|
console.error(
|
||||||
|
'❌ Interval invalide. Utilisez: hourly, daily, ou weekly'
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
newConfig.interval = value as BackupConfig['interval'];
|
newConfig.interval = value as BackupConfig['interval'];
|
||||||
@@ -328,10 +355,16 @@ OPTIONS:
|
|||||||
const status = backupScheduler.getStatus();
|
const status = backupScheduler.getStatus();
|
||||||
|
|
||||||
console.log('📊 Statut du planificateur:\n');
|
console.log('📊 Statut du planificateur:\n');
|
||||||
console.log(` État: ${status.isRunning ? '✅ Actif' : '❌ Arrêté'}`);
|
console.log(
|
||||||
console.log(` Activé: ${status.isEnabled ? '✅ Oui' : '❌ Non'}`);
|
` État: ${status.isRunning ? '✅ Actif' : '❌ Arrêté'}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` Activé: ${status.isEnabled ? '✅ Oui' : '❌ Non'}`
|
||||||
|
);
|
||||||
console.log(` Fréquence: ${status.interval}`);
|
console.log(` Fréquence: ${status.interval}`);
|
||||||
console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`);
|
console.log(
|
||||||
|
` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`
|
||||||
|
);
|
||||||
console.log(` Max sauvegardes: ${status.maxBackups}`);
|
console.log(` Max sauvegardes: ${status.maxBackups}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
scripts/cache-monitor.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script de monitoring du cache Jira Analytics
|
||||||
|
* Usage: npm run cache:monitor
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { jiraAnalyticsCache } from '../src/services/integrations/jira/analytics-cache';
|
||||||
|
import * as readline from 'readline';
|
||||||
|
|
||||||
|
function displayCacheStats() {
|
||||||
|
console.log('\n📊 === STATISTIQUES DU CACHE JIRA ANALYTICS ===');
|
||||||
|
|
||||||
|
const stats = jiraAnalyticsCache.getStats();
|
||||||
|
|
||||||
|
console.log(`\n📈 Total des entrées: ${stats.totalEntries}`);
|
||||||
|
|
||||||
|
if (stats.projects.length === 0) {
|
||||||
|
console.log('📭 Aucune donnée en cache');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📋 Projets en cache:');
|
||||||
|
stats.projects.forEach((project) => {
|
||||||
|
const status = project.isExpired ? '❌ EXPIRÉ' : '✅ VALIDE';
|
||||||
|
console.log(` • ${project.projectKey}:`);
|
||||||
|
console.log(` - Âge: ${project.age}`);
|
||||||
|
console.log(` - TTL: ${project.ttl}`);
|
||||||
|
console.log(` - Expire dans: ${project.expiresIn}`);
|
||||||
|
console.log(` - Taille: ${Math.round(project.size / 1024)}KB`);
|
||||||
|
console.log(` - Statut: ${status}`);
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayCacheActions() {
|
||||||
|
console.log('\n🔧 === ACTIONS DISPONIBLES ===');
|
||||||
|
console.log('1. Afficher les statistiques');
|
||||||
|
console.log('2. Forcer le nettoyage');
|
||||||
|
console.log('3. Invalider tout le cache');
|
||||||
|
console.log('4. Surveiller en temps réel (Ctrl+C pour arrêter)');
|
||||||
|
console.log('5. Quitter');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function monitorRealtime() {
|
||||||
|
console.log('\n👀 Surveillance en temps réel (Ctrl+C pour arrêter)...');
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
console.clear();
|
||||||
|
displayCacheStats();
|
||||||
|
console.log('\n⏰ Mise à jour toutes les 5 secondes...');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Gérer l'arrêt propre
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
console.log('\n\n👋 Surveillance arrêtée');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🚀 Cache Monitor Jira Analytics');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'stats':
|
||||||
|
displayCacheStats();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cleanup':
|
||||||
|
console.log('\n🧹 Nettoyage forcé du cache...');
|
||||||
|
const cleaned = jiraAnalyticsCache.forceCleanup();
|
||||||
|
console.log(`✅ ${cleaned} entrées supprimées`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clear':
|
||||||
|
console.log('\n🗑️ Invalidation de tout le cache...');
|
||||||
|
jiraAnalyticsCache.invalidateAll();
|
||||||
|
console.log('✅ Cache vidé');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'monitor':
|
||||||
|
await monitorRealtime();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
displayCacheStats();
|
||||||
|
displayCacheActions();
|
||||||
|
|
||||||
|
// Interface interactive simple
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const askAction = () => {
|
||||||
|
rl.question(
|
||||||
|
'\nChoisissez une action (1-5): ',
|
||||||
|
async (answer: string) => {
|
||||||
|
switch (answer.trim()) {
|
||||||
|
case '1':
|
||||||
|
displayCacheStats();
|
||||||
|
askAction();
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
const cleaned = jiraAnalyticsCache.forceCleanup();
|
||||||
|
console.log(`✅ ${cleaned} entrées supprimées`);
|
||||||
|
askAction();
|
||||||
|
break;
|
||||||
|
case '3':
|
||||||
|
jiraAnalyticsCache.invalidateAll();
|
||||||
|
console.log('✅ Cache vidé');
|
||||||
|
askAction();
|
||||||
|
break;
|
||||||
|
case '4':
|
||||||
|
rl.close();
|
||||||
|
await monitorRealtime();
|
||||||
|
break;
|
||||||
|
case '5':
|
||||||
|
console.log('👋 Au revoir !');
|
||||||
|
rl.close();
|
||||||
|
process.exit(0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('❌ Action invalide');
|
||||||
|
askAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
askAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécution du script
|
||||||
|
main().catch(console.error);
|
||||||
132
scripts/generate-icons-from-jpg.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Script pour générer les icônes PNG et ICO à partir de iconTC4S.png
|
||||||
|
* Préserve la transparence du PNG source
|
||||||
|
*
|
||||||
|
* Usage: pnpm tsx scripts/generate-icons-from-jpg.ts
|
||||||
|
*
|
||||||
|
* Prérequis: npm install -D sharp
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const sizes = [16, 32, 180, 192, 512];
|
||||||
|
const publicDir = join(process.cwd(), 'public');
|
||||||
|
const sourceImage = join(process.cwd(), 'public', 'icons', 'iconTC4S.png');
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
// Vérifier si sharp est disponible
|
||||||
|
let sharp: any;
|
||||||
|
try {
|
||||||
|
sharp = require('sharp');
|
||||||
|
} catch (e) {
|
||||||
|
console.log("⚠️ sharp n'est pas installé. Installation...");
|
||||||
|
console.log(' Exécutez: pnpm add -D sharp');
|
||||||
|
console.log(' Puis relancez ce script.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(sourceImage)) {
|
||||||
|
console.error(`❌ ${sourceImage} introuvable`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'🎨 Génération des icônes à partir de iconTC4S.png (avec transparence)...\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtenir les métadonnées de l'image pour détecter la couleur dominante du fond
|
||||||
|
const metadata = await sharp(sourceImage).metadata();
|
||||||
|
console.log(`📐 Dimensions source: ${metadata.width}x${metadata.height}\n`);
|
||||||
|
|
||||||
|
// Générer les différentes tailles avec cover pour remplir sans bordures
|
||||||
|
// La transparence est préservée automatiquement avec PNG
|
||||||
|
for (const size of sizes) {
|
||||||
|
try {
|
||||||
|
const outputPath = join(publicDir, `icon-${size}x${size}.png`);
|
||||||
|
await sharp(sourceImage)
|
||||||
|
.resize(size, size, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center',
|
||||||
|
})
|
||||||
|
.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
adaptiveFiltering: true,
|
||||||
|
palette: false, // Préserve la transparence et les couleurs
|
||||||
|
})
|
||||||
|
.toFile(outputPath);
|
||||||
|
console.log(`✅ Généré: icon-${size}x${size}.png`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`❌ Erreur lors de la génération de icon-${size}x${size}.png:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer apple-touch-icon
|
||||||
|
try {
|
||||||
|
const outputPath = join(publicDir, 'apple-touch-icon.png');
|
||||||
|
await sharp(sourceImage)
|
||||||
|
.resize(180, 180, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center',
|
||||||
|
})
|
||||||
|
.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
adaptiveFiltering: true,
|
||||||
|
palette: false,
|
||||||
|
})
|
||||||
|
.toFile(outputPath);
|
||||||
|
console.log(`✅ Généré: apple-touch-icon.png`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`❌ Erreur lors de la génération de apple-touch-icon.png:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer favicon.ico (32x32)
|
||||||
|
// Note: ICO peut supporter la transparence, mais on génère un PNG pour compatibilité
|
||||||
|
try {
|
||||||
|
const faviconPath = join(publicDir, 'favicon.ico');
|
||||||
|
await sharp(sourceImage)
|
||||||
|
.resize(32, 32, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center',
|
||||||
|
})
|
||||||
|
.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
adaptiveFiltering: true,
|
||||||
|
palette: false,
|
||||||
|
})
|
||||||
|
.toFile(faviconPath);
|
||||||
|
console.log(`✅ Généré: favicon.ico (32x32 avec transparence)`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Erreur lors de la génération de favicon.ico:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer icon.png (192x192 pour PWA)
|
||||||
|
try {
|
||||||
|
const iconPath = join(publicDir, 'icon.png');
|
||||||
|
await sharp(sourceImage)
|
||||||
|
.resize(192, 192, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center',
|
||||||
|
})
|
||||||
|
.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
adaptiveFiltering: true,
|
||||||
|
palette: false,
|
||||||
|
})
|
||||||
|
.toFile(iconPath);
|
||||||
|
console.log(`✅ Généré: icon.png (192x192)`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Erreur lors de la génération de icon.png:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✨ Génération terminée!');
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIcons().catch(console.error);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma } from '../src/services/database';
|
import { prisma } from '../src/services/core/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script pour reset la base de données et supprimer les anciennes données
|
* Script pour reset la base de données et supprimer les anciennes données
|
||||||
@@ -10,8 +10,12 @@ async function resetDatabase() {
|
|||||||
try {
|
try {
|
||||||
// Compter les tâches avant suppression
|
// Compter les tâches avant suppression
|
||||||
const beforeCount = await prisma.task.count();
|
const beforeCount = await prisma.task.count();
|
||||||
const manualCount = await prisma.task.count({ where: { source: 'manual' } });
|
const manualCount = await prisma.task.count({
|
||||||
const remindersCount = await prisma.task.count({ where: { source: 'reminders' } });
|
where: { source: 'manual' },
|
||||||
|
});
|
||||||
|
const remindersCount = await prisma.task.count({
|
||||||
|
where: { source: 'reminders' },
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`📊 État actuel:`);
|
console.log(`📊 État actuel:`);
|
||||||
console.log(` Total: ${beforeCount} tâches`);
|
console.log(` Total: ${beforeCount} tâches`);
|
||||||
@@ -22,8 +26,8 @@ async function resetDatabase() {
|
|||||||
// Supprimer toutes les tâches de synchronisation
|
// Supprimer toutes les tâches de synchronisation
|
||||||
const deletedTasks = await prisma.task.deleteMany({
|
const deletedTasks = await prisma.task.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
source: 'reminders'
|
source: 'reminders',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ Supprimé ${deletedTasks.count} tâches de synchronisation`);
|
console.log(`✅ Supprimé ${deletedTasks.count} tâches de synchronisation`);
|
||||||
@@ -51,30 +55,32 @@ async function resetDatabase() {
|
|||||||
include: {
|
include: {
|
||||||
taskTags: {
|
taskTags: {
|
||||||
include: {
|
include: {
|
||||||
tag: true
|
tag: true,
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' }
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
remainingTasks.forEach((task, index) => {
|
remainingTasks.forEach((task, index) => {
|
||||||
const statusEmoji = {
|
const statusEmoji =
|
||||||
'todo': '⏳',
|
{
|
||||||
'in_progress': '🔄',
|
todo: '⏳',
|
||||||
'done': '✅',
|
in_progress: '🔄',
|
||||||
'cancelled': '❌'
|
done: '✅',
|
||||||
|
cancelled: '❌',
|
||||||
}[task.status] || '❓';
|
}[task.status] || '❓';
|
||||||
|
|
||||||
// Utiliser les relations TaskTag
|
// Utiliser les relations TaskTag
|
||||||
const tags = task.taskTags ? task.taskTags.map(tt => tt.tag.name) : [];
|
const tags = task.taskTags
|
||||||
|
? task.taskTags.map((tt) => tt.tag.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
||||||
|
|
||||||
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
|
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors du reset:', error);
|
console.error('❌ Erreur lors du reset:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -83,11 +89,13 @@ async function resetDatabase() {
|
|||||||
|
|
||||||
// Exécuter le script
|
// Exécuter le script
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
resetDatabase().then(() => {
|
resetDatabase()
|
||||||
|
.then(() => {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('✨ Reset terminé avec succès !');
|
console.log('✨ Reset terminé avec succès !');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}).catch((error) => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
console.error('💥 Erreur fatale:', error);
|
console.error('💥 Erreur fatale:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { tasksService } from '../src/services/tasks';
|
import { tasksService } from '../src/services/task-management/tasks';
|
||||||
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||||
|
import { prisma } from '../src/services/core/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script pour ajouter des données de test avec tags et variété
|
* Script pour ajouter des données de test avec tags et variété
|
||||||
@@ -8,22 +9,46 @@ async function seedTestData() {
|
|||||||
console.log('🌱 Ajout de données de test...');
|
console.log('🌱 Ajout de données de test...');
|
||||||
console.log('================================');
|
console.log('================================');
|
||||||
|
|
||||||
|
// Récupérer le premier user ou créer un user temporaire
|
||||||
|
let userId: string;
|
||||||
|
const firstUser = await prisma.user.findFirst({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstUser) {
|
||||||
|
userId = firstUser.id;
|
||||||
|
console.log(`👤 Utilisation du user existant: ${firstUser.email}`);
|
||||||
|
} else {
|
||||||
|
// Créer un user temporaire pour les tests
|
||||||
|
const tempUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
password: '$2b$10$temp', // Mot de passe temporaire
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = tempUser.id;
|
||||||
|
console.log(`👤 User temporaire créé: ${tempUser.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
const testTasks = [
|
const testTasks = [
|
||||||
{
|
{
|
||||||
title: '🎨 Design System Implementation',
|
title: '🎨 Design System Implementation',
|
||||||
description: 'Create and implement a comprehensive design system with reusable components',
|
description:
|
||||||
|
'Create and implement a comprehensive design system with reusable components',
|
||||||
status: 'in_progress' as TaskStatus,
|
status: 'in_progress' as TaskStatus,
|
||||||
priority: 'high' as TaskPriority,
|
priority: 'high' as TaskPriority,
|
||||||
tags: ['design', 'ui', 'frontend'],
|
tags: ['design', 'ui', 'frontend'],
|
||||||
dueDate: new Date('2025-12-31')
|
dueDate: new Date('2025-12-31'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '🔧 API Performance Optimization',
|
title: '🔧 API Performance Optimization',
|
||||||
description: 'Optimize API endpoints response time and implement pagination',
|
description:
|
||||||
|
'Optimize API endpoints response time and implement pagination',
|
||||||
status: 'todo' as TaskStatus,
|
status: 'todo' as TaskStatus,
|
||||||
priority: 'medium' as TaskPriority,
|
priority: 'medium' as TaskPriority,
|
||||||
tags: ['backend', 'performance', 'api'],
|
tags: ['backend', 'performance', 'api'],
|
||||||
dueDate: new Date('2025-12-15')
|
dueDate: new Date('2025-12-15'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '✅ Test Coverage Improvement',
|
title: '✅ Test Coverage Improvement',
|
||||||
@@ -31,7 +56,7 @@ async function seedTestData() {
|
|||||||
status: 'todo' as TaskStatus,
|
status: 'todo' as TaskStatus,
|
||||||
priority: 'medium' as TaskPriority,
|
priority: 'medium' as TaskPriority,
|
||||||
tags: ['testing', 'quality'],
|
tags: ['testing', 'quality'],
|
||||||
dueDate: new Date('2025-12-20')
|
dueDate: new Date('2025-12-20'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '📱 Mobile Responsive Design',
|
title: '📱 Mobile Responsive Design',
|
||||||
@@ -39,7 +64,7 @@ async function seedTestData() {
|
|||||||
status: 'todo' as TaskStatus,
|
status: 'todo' as TaskStatus,
|
||||||
priority: 'high' as TaskPriority,
|
priority: 'high' as TaskPriority,
|
||||||
tags: ['frontend', 'mobile', 'ui'],
|
tags: ['frontend', 'mobile', 'ui'],
|
||||||
dueDate: new Date('2025-12-10')
|
dueDate: new Date('2025-12-10'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '🔒 Security Audit',
|
title: '🔒 Security Audit',
|
||||||
@@ -47,8 +72,8 @@ async function seedTestData() {
|
|||||||
status: 'backlog' as TaskStatus,
|
status: 'backlog' as TaskStatus,
|
||||||
priority: 'urgent' as TaskPriority,
|
priority: 'urgent' as TaskPriority,
|
||||||
tags: ['security', 'audit'],
|
tags: ['security', 'audit'],
|
||||||
dueDate: new Date('2026-01-15')
|
dueDate: new Date('2026-01-15'),
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let createdCount = 0;
|
let createdCount = 0;
|
||||||
@@ -56,35 +81,43 @@ async function seedTestData() {
|
|||||||
|
|
||||||
for (const taskData of testTasks) {
|
for (const taskData of testTasks) {
|
||||||
try {
|
try {
|
||||||
const task = await tasksService.createTask(taskData);
|
const task = await tasksService.createTask({
|
||||||
|
...taskData,
|
||||||
|
ownerId: userId, // Ajouter l'ownerId
|
||||||
|
});
|
||||||
|
|
||||||
const statusEmoji = {
|
const statusEmoji = {
|
||||||
'backlog': '📋',
|
backlog: '📋',
|
||||||
'todo': '⏳',
|
todo: '⏳',
|
||||||
'in_progress': '🔄',
|
in_progress: '🔄',
|
||||||
'freeze': '🧊',
|
freeze: '🧊',
|
||||||
'done': '✅',
|
done: '✅',
|
||||||
'cancelled': '❌',
|
cancelled: '❌',
|
||||||
'archived': '📦'
|
archived: '📦',
|
||||||
}[task.status];
|
}[task.status];
|
||||||
|
|
||||||
const priorityEmoji = {
|
const priorityEmoji = {
|
||||||
'low': '🔵',
|
low: '🔵',
|
||||||
'medium': '🟡',
|
medium: '🟡',
|
||||||
'high': '🔴',
|
high: '🔴',
|
||||||
'urgent': '🚨'
|
urgent: '🚨',
|
||||||
}[task.priority];
|
}[task.priority];
|
||||||
|
|
||||||
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
|
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
|
||||||
console.log(` Tags: ${task.tags?.join(', ') || 'aucun'}`);
|
console.log(` Tags: ${task.tags?.join(', ') || 'aucun'}`);
|
||||||
if (task.dueDate) {
|
if (task.dueDate) {
|
||||||
console.log(` Échéance: ${task.dueDate.toLocaleDateString('fr-FR')}`);
|
console.log(
|
||||||
|
` Échéance: ${task.dueDate.toLocaleDateString('fr-FR')}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
createdCount++;
|
createdCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(` ❌ Erreur pour "${taskData.title}":`, error instanceof Error ? error.message : error);
|
console.error(
|
||||||
|
` ❌ Erreur pour "${taskData.title}":`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +127,7 @@ async function seedTestData() {
|
|||||||
console.log(` ❌ Erreurs: ${errorCount}`);
|
console.log(` ❌ Erreurs: ${errorCount}`);
|
||||||
|
|
||||||
// Afficher les stats finales
|
// Afficher les stats finales
|
||||||
const stats = await tasksService.getTaskStats();
|
const stats = await tasksService.getTaskStats(userId);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('📈 Statistiques finales:');
|
console.log('📈 Statistiques finales:');
|
||||||
console.log(` Total: ${stats.total} tâches`);
|
console.log(` Total: ${stats.total} tâches`);
|
||||||
@@ -107,11 +140,13 @@ async function seedTestData() {
|
|||||||
|
|
||||||
// Exécuter le script
|
// Exécuter le script
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
seedTestData().then(() => {
|
seedTestData()
|
||||||
|
.then(() => {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('✨ Données de test ajoutées avec succès !');
|
console.log('✨ Données de test ajoutées avec succès !');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}).catch((error) => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
console.error('💥 Erreur fatale:', error);
|
console.error('💥 Erreur fatale:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { tagsService } from '../src/services/tags';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { tagsService } from '../src/services/task-management/tags';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function seedTags() {
|
async function seedTags() {
|
||||||
console.log('🏷️ Création des tags de test...');
|
console.log('🌱 Début du seeding des tags...');
|
||||||
|
|
||||||
|
// Récupérer le premier utilisateur pour assigner les tags
|
||||||
|
const firstUser = await prisma.user.findFirst({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!firstUser) {
|
||||||
|
console.log("❌ Aucun utilisateur trouvé. Créez d'abord un utilisateur.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`👤 Assignation des tags à: ${firstUser.email}`);
|
||||||
|
|
||||||
const testTags = [
|
const testTags = [
|
||||||
{ name: 'frontend', color: '#3B82F6' },
|
{ name: 'frontend', color: '#3B82F6' },
|
||||||
@@ -19,9 +34,15 @@ async function seedTags() {
|
|||||||
|
|
||||||
for (const tagData of testTags) {
|
for (const tagData of testTags) {
|
||||||
try {
|
try {
|
||||||
const existing = await tagsService.getTagByName(tagData.name);
|
const existing = await tagsService.getTagByName(
|
||||||
|
tagData.name,
|
||||||
|
firstUser.id
|
||||||
|
);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const tag = await tagsService.createTag(tagData);
|
const tag = await tagsService.createTag({
|
||||||
|
...tagData,
|
||||||
|
userId: firstUser.id,
|
||||||
|
});
|
||||||
console.log(`✅ Tag créé: ${tag.name} (${tag.color})`);
|
console.log(`✅ Tag créé: ${tag.name} (${tag.color})`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`⚠️ Tag existe déjà: ${tagData.name}`);
|
console.log(`⚠️ Tag existe déjà: ${tagData.name}`);
|
||||||
|
|||||||
93
scripts/test-jira-fields.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script pour identifier les champs personnalisés disponibles dans Jira
|
||||||
|
* Usage: npm run test:jira-fields
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JiraService } from '../src/services/integrations/jira/jira';
|
||||||
|
import { userPreferencesService } from '../src/services/core/user-preferences';
|
||||||
|
|
||||||
|
async function testJiraFields() {
|
||||||
|
console.log('🔍 Identification des champs personnalisés Jira\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
||||||
|
const userId = process.argv[2] || 'default';
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(userId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!jiraConfig.enabled ||
|
||||||
|
!jiraConfig.baseUrl ||
|
||||||
|
!jiraConfig.email ||
|
||||||
|
!jiraConfig.apiToken
|
||||||
|
) {
|
||||||
|
console.log('❌ Configuration Jira manquante');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jiraConfig.projectKey) {
|
||||||
|
console.log('❌ Aucun projet configuré');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Analyse du projet: ${jiraConfig.projectKey}`);
|
||||||
|
|
||||||
|
// Créer le service Jira
|
||||||
|
const jiraService = new JiraService(jiraConfig);
|
||||||
|
|
||||||
|
// Récupérer un seul ticket pour analyser tous ses champs
|
||||||
|
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||||
|
const issues = await jiraService.searchIssues(jql);
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
console.log('❌ Aucun ticket trouvé');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstIssue = issues[0];
|
||||||
|
console.log(`\n📄 Analyse du ticket: ${firstIssue.key}`);
|
||||||
|
console.log(`Titre: ${firstIssue.summary}`);
|
||||||
|
console.log(`Type: ${firstIssue.issuetype.name}`);
|
||||||
|
|
||||||
|
// Afficher les story points actuels
|
||||||
|
console.log(
|
||||||
|
`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n💡 Pour identifier le bon champ story points:');
|
||||||
|
console.log('1. Connectez-vous à votre instance Jira');
|
||||||
|
console.log('2. Allez dans Administration > Projets > [Votre projet]');
|
||||||
|
console.log('3. Regardez dans "Champs" ou "Story Points"');
|
||||||
|
console.log(
|
||||||
|
'4. Notez le nom du champ personnalisé (ex: customfield_10003)'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n🔧 Champs couramment utilisés pour les story points:');
|
||||||
|
console.log('• customfield_10002 (par défaut)');
|
||||||
|
console.log('• customfield_10003');
|
||||||
|
console.log('• customfield_10004');
|
||||||
|
console.log('• customfield_10005');
|
||||||
|
console.log('• customfield_10006');
|
||||||
|
console.log('• customfield_10007');
|
||||||
|
console.log('• customfield_10008');
|
||||||
|
console.log('• customfield_10009');
|
||||||
|
console.log('• customfield_10010');
|
||||||
|
|
||||||
|
console.log('\n📝 Alternative: Utiliser les estimations par type');
|
||||||
|
console.log('Le système utilise déjà des estimations intelligentes:');
|
||||||
|
console.log('• Epic: 13 points');
|
||||||
|
console.log('• Story: 5 points');
|
||||||
|
console.log('• Task: 3 points');
|
||||||
|
console.log('• Bug: 2 points');
|
||||||
|
console.log('• Subtask: 1 point');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors du test:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécution du script
|
||||||
|
testJiraFields().catch(console.error);
|
||||||
62
scripts/test-runner.js
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script pour gérer PostCSS pendant les tests
|
||||||
|
* Renomme temporairement postcss.config.mjs pour éviter les erreurs Vitest
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const postcssConfigPath = path.join(process.cwd(), 'postcss.config.mjs');
|
||||||
|
const postcssConfigBackupPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'postcss.config.mjs.testbak'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fonction pour restaurer le fichier
|
||||||
|
function restorePostCSS() {
|
||||||
|
if (fs.existsSync(postcssConfigBackupPath)) {
|
||||||
|
fs.renameSync(postcssConfigBackupPath, postcssConfigPath);
|
||||||
|
console.log('✓ PostCSS config restauré');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renommer le fichier PostCSS avant les tests
|
||||||
|
if (fs.existsSync(postcssConfigPath)) {
|
||||||
|
fs.renameSync(postcssConfigPath, postcssConfigBackupPath);
|
||||||
|
console.log('✓ PostCSS config temporairement désactivé pour les tests');
|
||||||
|
|
||||||
|
// Lancer Vitest avec reporter verbose pour plus de détails
|
||||||
|
const vitest = spawn('pnpm', ['vitest', '--run', '--reporter=verbose'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restaurer le fichier après que Vitest ait terminé
|
||||||
|
vitest.on('close', (code) => {
|
||||||
|
restorePostCSS();
|
||||||
|
process.exit(code || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gérer les signaux d'interruption
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
vitest.kill('SIGINT');
|
||||||
|
restorePostCSS();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
vitest.kill('SIGTERM');
|
||||||
|
restorePostCSS();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Si le fichier n'existe pas, lancer Vitest directement
|
||||||
|
const vitest = spawn('pnpm', ['vitest', '--run'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
vitest.on('close', (code) => {
|
||||||
|
process.exit(code || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
130
scripts/test-story-points.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script de test pour vérifier la récupération des story points Jira
|
||||||
|
* Usage: npm run test:story-points
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JiraService } from '../src/services/integrations/jira/jira';
|
||||||
|
import { userPreferencesService } from '../src/services/core/user-preferences';
|
||||||
|
|
||||||
|
async function testStoryPoints() {
|
||||||
|
console.log('🧪 Test de récupération des story points Jira\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
||||||
|
const userId = process.argv[2] || 'default';
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(userId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!jiraConfig.enabled ||
|
||||||
|
!jiraConfig.baseUrl ||
|
||||||
|
!jiraConfig.email ||
|
||||||
|
!jiraConfig.apiToken
|
||||||
|
) {
|
||||||
|
console.log('❌ Configuration Jira manquante');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jiraConfig.projectKey) {
|
||||||
|
console.log('❌ Aucun projet configuré');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Test sur le projet: ${jiraConfig.projectKey}`);
|
||||||
|
|
||||||
|
// Créer le service Jira
|
||||||
|
const jiraService = new JiraService(jiraConfig);
|
||||||
|
|
||||||
|
// Récupérer quelques tickets pour tester
|
||||||
|
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||||
|
const issues = await jiraService.searchIssues(jql);
|
||||||
|
|
||||||
|
console.log(`\n📊 Analyse de ${issues.length} tickets:\n`);
|
||||||
|
|
||||||
|
let totalStoryPoints = 0;
|
||||||
|
let ticketsWithStoryPoints = 0;
|
||||||
|
let ticketsWithoutStoryPoints = 0;
|
||||||
|
|
||||||
|
const storyPointsDistribution: Record<number, number> = {};
|
||||||
|
const typeDistribution: Record<
|
||||||
|
string,
|
||||||
|
{ count: number; totalPoints: number }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
issues.slice(0, 20).forEach((issue, index) => {
|
||||||
|
const storyPoints = issue.storyPoints || 0;
|
||||||
|
const issueType = issue.issuetype.name;
|
||||||
|
|
||||||
|
console.log(`${index + 1}. ${issue.key} (${issueType})`);
|
||||||
|
console.log(` Titre: ${issue.summary.substring(0, 50)}...`);
|
||||||
|
console.log(
|
||||||
|
` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`
|
||||||
|
);
|
||||||
|
console.log(` Statut: ${issue.status.name}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (storyPoints > 0) {
|
||||||
|
ticketsWithStoryPoints++;
|
||||||
|
totalStoryPoints += storyPoints;
|
||||||
|
storyPointsDistribution[storyPoints] =
|
||||||
|
(storyPointsDistribution[storyPoints] || 0) + 1;
|
||||||
|
} else {
|
||||||
|
ticketsWithoutStoryPoints++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribution par type
|
||||||
|
if (!typeDistribution[issueType]) {
|
||||||
|
typeDistribution[issueType] = { count: 0, totalPoints: 0 };
|
||||||
|
}
|
||||||
|
typeDistribution[issueType].count++;
|
||||||
|
typeDistribution[issueType].totalPoints += storyPoints;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📈 === RÉSUMÉ ===\n');
|
||||||
|
console.log(`Total tickets analysés: ${issues.length}`);
|
||||||
|
console.log(`Tickets avec story points: ${ticketsWithStoryPoints}`);
|
||||||
|
console.log(`Tickets sans story points: ${ticketsWithoutStoryPoints}`);
|
||||||
|
console.log(`Total story points: ${totalStoryPoints}`);
|
||||||
|
console.log(
|
||||||
|
`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n📊 Distribution des story points:');
|
||||||
|
Object.entries(storyPointsDistribution)
|
||||||
|
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||||
|
.forEach(([points, count]) => {
|
||||||
|
console.log(` ${points} points: ${count} tickets`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n🏷️ Distribution par type:');
|
||||||
|
Object.entries(typeDistribution)
|
||||||
|
.sort(([, a], [, b]) => b.count - a.count)
|
||||||
|
.forEach(([type, stats]) => {
|
||||||
|
const avgPoints =
|
||||||
|
stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0';
|
||||||
|
console.log(
|
||||||
|
` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ticketsWithoutStoryPoints > 0) {
|
||||||
|
console.log('\n⚠️ Recommandations:');
|
||||||
|
console.log(
|
||||||
|
'• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira'
|
||||||
|
);
|
||||||
|
console.log('• Le champ par défaut est "customfield_10002"');
|
||||||
|
console.log(
|
||||||
|
'• Si votre projet utilise un autre champ, modifiez le code dans jira.ts'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'• En attendant, le système utilise des estimations basées sur le type de ticket'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors du test:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécution du script
|
||||||
|
testStoryPoints().catch(console.error);
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics';
|
|
||||||
|
|
||||||
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data?: ProductivityMetrics;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const metrics = await AnalyticsService.getProductivityMetrics(timeRange);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: metrics
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la récupération des métriques:', error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
64
src/actions/backup.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { backupService } from '@/services/data-management/backup';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function createBackupAction(force: boolean = false) {
|
||||||
|
try {
|
||||||
|
const result = await backupService.createBackup('manual', force);
|
||||||
|
|
||||||
|
// Invalider le cache de la page pour forcer le rechargement des données SSR
|
||||||
|
revalidatePath('/settings/backup');
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
skipped: true,
|
||||||
|
message:
|
||||||
|
'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: `Sauvegarde créée : ${result.filename}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create backup:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la création de la sauvegarde',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyDatabaseAction() {
|
||||||
|
try {
|
||||||
|
await backupService.verifyDatabaseHealth();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Intégrité vérifiée',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database verification failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Vérification échouée',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshBackupStatsAction() {
|
||||||
|
try {
|
||||||
|
// Cette action sert juste à revalider le cache
|
||||||
|
revalidatePath('/settings/backup');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh backup stats:', error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
import {
|
||||||
|
UpdateDailyCheckboxData,
|
||||||
|
DailyCheckbox,
|
||||||
|
CreateDailyCheckboxData,
|
||||||
|
} from '@/lib/types';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
import {
|
||||||
|
getToday,
|
||||||
|
getPreviousWorkday,
|
||||||
|
parseDate,
|
||||||
|
normalizeDate,
|
||||||
|
} from '@/lib/date-utils';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle l'état d'une checkbox
|
* Toggle l'état d'une checkbox
|
||||||
@@ -14,28 +25,13 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// Nous devons d'abord récupérer la checkbox pour connaître son état actuel
|
const session = await getServerSession(authOptions);
|
||||||
// En absence de getCheckboxById, nous allons essayer de la trouver via une vue daily
|
if (!session?.user?.id) {
|
||||||
// Pour l'instant, nous allons simplement toggle via updateCheckbox
|
return { success: false, error: 'Non authentifié' };
|
||||||
// (le front-end gère déjà l'état optimiste)
|
|
||||||
|
|
||||||
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
|
|
||||||
const today = getToday();
|
|
||||||
const dailyView = await dailyService.getDailyView(today);
|
|
||||||
|
|
||||||
let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
|
||||||
if (!checkbox) {
|
|
||||||
checkbox = dailyView.yesterday.find(cb => cb.id === checkboxId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkbox) {
|
// Toggle direct côté service par ID (indépendant de la date)
|
||||||
return { success: false, error: 'Checkbox non trouvée' };
|
const updatedCheckbox = await dailyService.toggleCheckbox(checkboxId);
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle l'état
|
|
||||||
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, {
|
|
||||||
isChecked: !checkbox.isChecked
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
return { success: true, data: updatedCheckbox };
|
return { success: true, data: updatedCheckbox };
|
||||||
@@ -43,26 +39,38 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
|||||||
console.error('Erreur toggleCheckbox:', error);
|
console.error('Erreur toggleCheckbox:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ajoute une checkbox pour aujourd'hui
|
* Ajoute une checkbox pour aujourd'hui
|
||||||
*/
|
*/
|
||||||
export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting', taskId?: string): Promise<{
|
export async function addTodayCheckbox(
|
||||||
|
content: string,
|
||||||
|
type?: 'task' | 'meeting',
|
||||||
|
taskId?: string,
|
||||||
|
date?: Date
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: DailyCheckbox;
|
data?: DailyCheckbox;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDate = normalizeDate(date || getToday());
|
||||||
|
|
||||||
const newCheckbox = await dailyService.addCheckbox({
|
const newCheckbox = await dailyService.addCheckbox({
|
||||||
date: getToday(),
|
date: targetDate,
|
||||||
|
userId: session.user.id,
|
||||||
text: content,
|
text: content,
|
||||||
type: type || 'task',
|
type: type || 'task',
|
||||||
taskId
|
taskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
@@ -71,7 +79,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
|||||||
console.error('Erreur addTodayCheckbox:', error);
|
console.error('Erreur addTodayCheckbox:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,19 +87,31 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
|||||||
/**
|
/**
|
||||||
* Ajoute une checkbox pour hier
|
* Ajoute une checkbox pour hier
|
||||||
*/
|
*/
|
||||||
export async function addYesterdayCheckbox(content: string, type?: 'task' | 'meeting', taskId?: string): Promise<{
|
export async function addYesterdayCheckbox(
|
||||||
|
content: string,
|
||||||
|
type?: 'task' | 'meeting',
|
||||||
|
taskId?: string,
|
||||||
|
baseDate?: Date
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: DailyCheckbox;
|
data?: DailyCheckbox;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const yesterday = getPreviousWorkday(getToday());
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = normalizeDate(baseDate || getToday());
|
||||||
|
const yesterday = getPreviousWorkday(base);
|
||||||
|
|
||||||
const newCheckbox = await dailyService.addCheckbox({
|
const newCheckbox = await dailyService.addCheckbox({
|
||||||
date: yesterday,
|
date: yesterday,
|
||||||
|
userId: session.user.id,
|
||||||
text: content,
|
text: content,
|
||||||
type: type || 'task',
|
type: type || 'task',
|
||||||
taskId
|
taskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
@@ -100,16 +120,18 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
|||||||
console.error('Erreur addYesterdayCheckbox:', error);
|
console.error('Erreur addYesterdayCheckbox:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Met à jour une checkbox complète
|
* Met à jour une checkbox complète
|
||||||
*/
|
*/
|
||||||
export async function updateCheckbox(checkboxId: string, data: UpdateDailyCheckboxData): Promise<{
|
export async function updateCheckbox(
|
||||||
|
checkboxId: string,
|
||||||
|
data: UpdateDailyCheckboxData
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: DailyCheckbox;
|
data?: DailyCheckbox;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -123,7 +145,7 @@ export async function updateCheckbox(checkboxId: string, data: UpdateDailyCheckb
|
|||||||
console.error('Erreur updateCheckbox:', error);
|
console.error('Erreur updateCheckbox:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +166,7 @@ export async function deleteCheckbox(checkboxId: string): Promise<{
|
|||||||
console.error('Erreur deleteCheckbox:', error);
|
console.error('Erreur deleteCheckbox:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,20 +174,30 @@ export async function deleteCheckbox(checkboxId: string): Promise<{
|
|||||||
/**
|
/**
|
||||||
* Ajoute un todo lié à une tâche
|
* Ajoute un todo lié à une tâche
|
||||||
*/
|
*/
|
||||||
export async function addTodoToTask(taskId: string, text: string, date?: Date): Promise<{
|
export async function addTodoToTask(
|
||||||
|
taskId: string,
|
||||||
|
text: string,
|
||||||
|
date?: Date
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: DailyCheckbox;
|
data?: DailyCheckbox;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
const targetDate = normalizeDate(date || getToday());
|
const targetDate = normalizeDate(date || getToday());
|
||||||
|
|
||||||
const checkboxData: CreateDailyCheckboxData = {
|
const checkboxData: CreateDailyCheckboxData = {
|
||||||
date: targetDate,
|
date: targetDate,
|
||||||
|
userId: session.user.id,
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
type: 'task',
|
type: 'task',
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
isChecked: false
|
isChecked: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkbox = await dailyService.addCheckbox(checkboxData);
|
const checkbox = await dailyService.addCheckbox(checkboxData);
|
||||||
@@ -177,7 +209,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
|||||||
console.error('Erreur addTodoToTask:', error);
|
console.error('Erreur addTodoToTask:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +217,10 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
|||||||
/**
|
/**
|
||||||
* Réorganise les checkboxes d'une date
|
* Réorganise les checkboxes d'une date
|
||||||
*/
|
*/
|
||||||
export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]): Promise<{
|
export async function reorderCheckboxes(
|
||||||
|
dailyId: string,
|
||||||
|
checkboxIds: string[]
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
@@ -201,7 +236,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
|||||||
console.error('Erreur reorderCheckboxes:', error);
|
console.error('Erreur reorderCheckboxes:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,7 +258,7 @@ export async function moveCheckboxToToday(checkboxId: string): Promise<{
|
|||||||
console.error('Erreur moveCheckboxToToday:', error);
|
console.error('Erreur moveCheckboxToToday:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService } from '@/services/jira-analytics';
|
import { JiraAnalyticsService } from '@/services/integrations/jira/analytics';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraAnalytics } from '@/lib/types';
|
import { JiraAnalytics } from '@/lib/types';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export type JiraAnalyticsResult = {
|
export type JiraAnalyticsResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -13,22 +15,38 @@ export type JiraAnalyticsResult = {
|
|||||||
/**
|
/**
|
||||||
* Server Action pour récupérer les analytics Jira du projet configuré
|
* Server Action pour récupérer les analytics Jira du projet configuré
|
||||||
*/
|
*/
|
||||||
export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyticsResult> {
|
export async function getJiraAnalytics(
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<JiraAnalyticsResult> {
|
||||||
try {
|
try {
|
||||||
// Récupérer la config Jira depuis la base de données
|
const session = await getServerSession(authOptions);
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
// Récupérer la config Jira depuis la base de données
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!jiraConfig.enabled ||
|
||||||
|
!jiraConfig.baseUrl ||
|
||||||
|
!jiraConfig.email ||
|
||||||
|
!jiraConfig.apiToken
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.'
|
error:
|
||||||
|
'Configuration Jira manquante. Configurez Jira dans les paramètres.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!jiraConfig.projectKey) {
|
if (!jiraConfig.projectKey) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Aucun projet configuré pour les analytics. Configurez un projet dans les paramètres Jira.'
|
error:
|
||||||
|
'Aucun projet configuré pour les analytics. Configurez un projet dans les paramètres Jira.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +56,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
|||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
projectKey: jiraConfig.projectKey
|
projectKey: jiraConfig.projectKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Récupérer les analytics (avec cache ou actualisation forcée)
|
// Récupérer les analytics (avec cache ou actualisation forcée)
|
||||||
@@ -46,15 +64,17 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: analytics
|
data: analytics,
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors du calcul des analytics Jira:', error);
|
console.error('❌ Erreur lors du calcul des analytics Jira:', error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur lors du calcul des analytics'
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors du calcul des analytics',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
import {
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
jiraAnomalyDetection,
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
JiraAnomaly,
|
||||||
|
AnomalyDetectionConfig,
|
||||||
|
} from '@/services/integrations/jira/anomaly-detection';
|
||||||
|
import {
|
||||||
|
JiraAnalyticsService,
|
||||||
|
JiraAnalyticsConfig,
|
||||||
|
} from '@/services/integrations/jira/analytics';
|
||||||
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export interface AnomalyDetectionResult {
|
export interface AnomalyDetectionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -13,15 +22,29 @@ export interface AnomalyDetectionResult {
|
|||||||
/**
|
/**
|
||||||
* Détecte les anomalies dans les métriques Jira actuelles
|
* Détecte les anomalies dans les métriques Jira actuelles
|
||||||
*/
|
*/
|
||||||
export async function detectJiraAnomalies(forceRefresh = false): Promise<AnomalyDetectionResult> {
|
export async function detectJiraAnomalies(
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<AnomalyDetectionResult> {
|
||||||
try {
|
try {
|
||||||
// Récupérer la config Jira
|
const session = await getServerSession(authOptions);
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
// Récupérer la config Jira
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!jiraConfig?.baseUrl ||
|
||||||
|
!jiraConfig?.email ||
|
||||||
|
!jiraConfig?.apiToken ||
|
||||||
|
!jiraConfig?.projectKey
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Configuration Jira incomplète'
|
error: 'Configuration Jira incomplète',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +53,9 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
|
|||||||
return { success: false, error: 'Configuration Jira incomplète' };
|
return { success: false, error: 'Configuration Jira incomplète' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
const analyticsService = new JiraAnalyticsService(
|
||||||
|
jiraConfig as JiraAnalyticsConfig
|
||||||
|
);
|
||||||
const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
|
const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
|
||||||
|
|
||||||
// Détecter les anomalies
|
// Détecter les anomalies
|
||||||
@@ -38,13 +63,13 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: anomalies
|
data: anomalies,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la détection d\'anomalies:', error);
|
console.error("❌ Erreur lors de la détection d'anomalies:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,19 +77,21 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
|
|||||||
/**
|
/**
|
||||||
* Met à jour la configuration de détection d'anomalies
|
* Met à jour la configuration de détection d'anomalies
|
||||||
*/
|
*/
|
||||||
export async function updateAnomalyDetectionConfig(config: Partial<AnomalyDetectionConfig>) {
|
export async function updateAnomalyDetectionConfig(
|
||||||
|
config: Partial<AnomalyDetectionConfig>
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
jiraAnomalyDetection.updateConfig(config);
|
jiraAnomalyDetection.updateConfig(config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: jiraAnomalyDetection.getConfig()
|
data: jiraAnomalyDetection.getConfig(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la mise à jour de la config:', error);
|
console.error('❌ Erreur lors de la mise à jour de la config:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,13 +103,13 @@ export async function getAnomalyDetectionConfig() {
|
|||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: jiraAnomalyDetection.getConfig()
|
data: jiraAnomalyDetection.getConfig(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la récupération de la config:', error);
|
console.error('❌ Erreur lors de la récupération de la config:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export interface JiraAnalytics {
|
|||||||
/**
|
/**
|
||||||
* Server Action pour exporter les analytics Jira au format CSV ou JSON
|
* Server Action pour exporter les analytics Jira au format CSV ou JSON
|
||||||
*/
|
*/
|
||||||
export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise<ExportResult> {
|
export async function exportJiraAnalytics(
|
||||||
|
format: ExportFormat = 'csv'
|
||||||
|
): Promise<ExportResult> {
|
||||||
try {
|
try {
|
||||||
// Récupérer les analytics (force refresh pour avoir les données les plus récentes)
|
// Récupérer les analytics (force refresh pour avoir les données les plus récentes)
|
||||||
const analyticsResult = await getJiraAnalytics(true);
|
const analyticsResult = await getJiraAnalytics(true);
|
||||||
@@ -99,7 +101,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
|||||||
if (!analyticsResult.success || !analyticsResult.data) {
|
if (!analyticsResult.success || !analyticsResult.data) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: analyticsResult.error || 'Impossible de récupérer les analytics'
|
error: analyticsResult.error || 'Impossible de récupérer les analytics',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: JSON.stringify(analytics, null, 2),
|
data: JSON.stringify(analytics, null, 2),
|
||||||
filename: `jira-analytics-${projectKey}-${timestamp}.json`
|
filename: `jira-analytics-${projectKey}-${timestamp}.json`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,15 +123,14 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: csvData,
|
data: csvData,
|
||||||
filename: `jira-analytics-${projectKey}-${timestamp}.csv`
|
filename: `jira-analytics-${projectKey}-${timestamp}.csv`,
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de l\'export des analytics:', error);
|
console.error("❌ Erreur lors de l'export des analytics:", error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,103 +144,126 @@ function generateCSV(analytics: JiraAnalytics): string {
|
|||||||
// Header du rapport
|
// Header du rapport
|
||||||
lines.push('# Rapport Analytics Jira');
|
lines.push('# Rapport Analytics Jira');
|
||||||
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
||||||
lines.push(`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`);
|
lines.push(
|
||||||
|
`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`
|
||||||
|
);
|
||||||
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Section 1: Métriques d'équipe
|
// Section 1: Métriques d'équipe
|
||||||
lines.push('## Répartition de l\'équipe');
|
lines.push("## Répartition de l'équipe");
|
||||||
lines.push('Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage');
|
lines.push(
|
||||||
analytics.teamMetrics.issuesDistribution.forEach((assignee: AssigneeMetrics) => {
|
'Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage'
|
||||||
lines.push([
|
);
|
||||||
|
analytics.teamMetrics.issuesDistribution.forEach(
|
||||||
|
(assignee: AssigneeMetrics) => {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
escapeCsv(assignee.assignee),
|
escapeCsv(assignee.assignee),
|
||||||
escapeCsv(assignee.displayName),
|
escapeCsv(assignee.displayName),
|
||||||
assignee.totalIssues,
|
assignee.totalIssues,
|
||||||
assignee.completedIssues,
|
assignee.completedIssues,
|
||||||
assignee.inProgressIssues,
|
assignee.inProgressIssues,
|
||||||
assignee.percentage.toFixed(1) + '%'
|
assignee.percentage.toFixed(1) + '%',
|
||||||
].join(','));
|
].join(',')
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Section 2: Historique des sprints
|
// Section 2: Historique des sprints
|
||||||
lines.push('## Historique des sprints');
|
lines.push('## Historique des sprints');
|
||||||
lines.push('Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion');
|
lines.push(
|
||||||
|
'Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion'
|
||||||
|
);
|
||||||
analytics.velocityMetrics.sprintHistory.forEach((sprint: SprintHistory) => {
|
analytics.velocityMetrics.sprintHistory.forEach((sprint: SprintHistory) => {
|
||||||
lines.push([
|
lines.push(
|
||||||
|
[
|
||||||
escapeCsv(sprint.sprintName),
|
escapeCsv(sprint.sprintName),
|
||||||
sprint.startDate.slice(0, 10),
|
sprint.startDate.slice(0, 10),
|
||||||
sprint.endDate.slice(0, 10),
|
sprint.endDate.slice(0, 10),
|
||||||
sprint.plannedPoints,
|
sprint.plannedPoints,
|
||||||
sprint.completedPoints,
|
sprint.completedPoints,
|
||||||
sprint.completionRate + '%'
|
sprint.completionRate + '%',
|
||||||
].join(','));
|
].join(',')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Section 3: Cycle time par type
|
// Section 3: Cycle time par type
|
||||||
lines.push('## Cycle Time par type de ticket');
|
lines.push('## Cycle Time par type de ticket');
|
||||||
lines.push('Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons');
|
lines.push(
|
||||||
analytics.cycleTimeMetrics.cycleTimeByType.forEach((type: CycleTimeByType) => {
|
'Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons'
|
||||||
lines.push([
|
);
|
||||||
|
analytics.cycleTimeMetrics.cycleTimeByType.forEach(
|
||||||
|
(type: CycleTimeByType) => {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
escapeCsv(type.issueType),
|
escapeCsv(type.issueType),
|
||||||
type.averageDays,
|
type.averageDays,
|
||||||
type.medianDays,
|
type.medianDays,
|
||||||
type.samples
|
type.samples,
|
||||||
].join(','));
|
].join(',')
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Section 4: Work in Progress
|
// Section 4: Work in Progress
|
||||||
lines.push('## Work in Progress par statut');
|
lines.push('## Work in Progress par statut');
|
||||||
lines.push('Statut,Nombre,Pourcentage');
|
lines.push('Statut,Nombre,Pourcentage');
|
||||||
analytics.workInProgress.byStatus.forEach((status: WorkInProgressStatus) => {
|
analytics.workInProgress.byStatus.forEach((status: WorkInProgressStatus) => {
|
||||||
lines.push([
|
lines.push(
|
||||||
escapeCsv(status.status),
|
[escapeCsv(status.status), status.count, status.percentage + '%'].join(
|
||||||
status.count,
|
','
|
||||||
status.percentage + '%'
|
)
|
||||||
].join(','));
|
);
|
||||||
});
|
});
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Section 5: Charge de travail par assignee
|
// Section 5: Charge de travail par assignee
|
||||||
lines.push('## Charge de travail par assignee');
|
lines.push('## Charge de travail par assignee');
|
||||||
lines.push('Assignee,Nom,À Faire,En Cours,En Revue,Total Actif');
|
lines.push('Assignee,Nom,À Faire,En Cours,En Revue,Total Actif');
|
||||||
analytics.workInProgress.byAssignee.forEach((assignee: WorkInProgressAssignee) => {
|
analytics.workInProgress.byAssignee.forEach(
|
||||||
lines.push([
|
(assignee: WorkInProgressAssignee) => {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
escapeCsv(assignee.assignee),
|
escapeCsv(assignee.assignee),
|
||||||
escapeCsv(assignee.displayName),
|
escapeCsv(assignee.displayName),
|
||||||
assignee.todoCount,
|
assignee.todoCount,
|
||||||
assignee.inProgressCount,
|
assignee.inProgressCount,
|
||||||
assignee.reviewCount,
|
assignee.reviewCount,
|
||||||
assignee.totalActive
|
assignee.totalActive,
|
||||||
].join(','));
|
].join(',')
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Section 6: Métriques résumé
|
// Section 6: Métriques résumé
|
||||||
lines.push('## Métriques de résumé');
|
lines.push('## Métriques de résumé');
|
||||||
lines.push('Métrique,Valeur');
|
lines.push('Métrique,Valeur');
|
||||||
lines.push([
|
lines.push(
|
||||||
'Total membres équipe',
|
['Total membres équipe', analytics.teamMetrics.totalAssignees].join(',')
|
||||||
analytics.teamMetrics.totalAssignees
|
);
|
||||||
].join(','));
|
lines.push(
|
||||||
lines.push([
|
['Membres actifs', analytics.teamMetrics.activeAssignees].join(',')
|
||||||
'Membres actifs',
|
);
|
||||||
analytics.teamMetrics.activeAssignees
|
lines.push(
|
||||||
].join(','));
|
[
|
||||||
lines.push([
|
|
||||||
'Points complétés sprint actuel',
|
'Points complétés sprint actuel',
|
||||||
analytics.velocityMetrics.currentSprintPoints
|
analytics.velocityMetrics.currentSprintPoints,
|
||||||
].join(','));
|
].join(',')
|
||||||
lines.push([
|
);
|
||||||
'Vélocité moyenne',
|
lines.push(
|
||||||
analytics.velocityMetrics.averageVelocity
|
['Vélocité moyenne', analytics.velocityMetrics.averageVelocity].join(',')
|
||||||
].join(','));
|
);
|
||||||
lines.push([
|
lines.push(
|
||||||
|
[
|
||||||
'Cycle time moyen (jours)',
|
'Cycle time moyen (jours)',
|
||||||
analytics.cycleTimeMetrics.averageCycleTime
|
analytics.cycleTimeMetrics.averageCycleTime,
|
||||||
].join(','));
|
].join(',')
|
||||||
|
);
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import {
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
JiraAnalyticsService,
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
JiraAnalyticsConfig,
|
||||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
} from '@/services/integrations/jira/analytics';
|
||||||
|
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||||
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import {
|
||||||
|
AvailableFilters,
|
||||||
|
JiraAnalyticsFilters,
|
||||||
|
JiraAnalytics,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export interface FiltersResult {
|
export interface FiltersResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -22,13 +31,25 @@ export interface FilteredAnalyticsResult {
|
|||||||
*/
|
*/
|
||||||
export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
||||||
try {
|
try {
|
||||||
// Récupérer la config Jira
|
const session = await getServerSession(authOptions);
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
// Récupérer la config Jira
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!jiraConfig?.baseUrl ||
|
||||||
|
!jiraConfig?.email ||
|
||||||
|
!jiraConfig?.apiToken ||
|
||||||
|
!jiraConfig?.projectKey
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Configuration Jira incomplète'
|
error: 'Configuration Jira incomplète',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,23 +58,26 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
|||||||
return { success: false, error: 'Configuration Jira incomplète' };
|
return { success: false, error: 'Configuration Jira incomplète' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
const analyticsService = new JiraAnalyticsService(
|
||||||
|
jiraConfig as JiraAnalyticsConfig
|
||||||
|
);
|
||||||
|
|
||||||
// Récupérer la liste des issues pour extraire les filtres
|
// Récupérer la liste des issues pour extraire les filtres
|
||||||
const allIssues = await analyticsService.getAllProjectIssues();
|
const allIssues = await analyticsService.getAllProjectIssues();
|
||||||
|
|
||||||
// Extraire les filtres disponibles
|
// Extraire les filtres disponibles
|
||||||
const availableFilters = JiraAdvancedFiltersService.extractAvailableFilters(allIssues);
|
const availableFilters =
|
||||||
|
JiraAdvancedFiltersService.extractAvailableFilters(allIssues);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: availableFilters
|
data: availableFilters,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la récupération des filtres:', error);
|
console.error('❌ Erreur lors de la récupération des filtres:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,15 +85,29 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
|||||||
/**
|
/**
|
||||||
* Applique des filtres aux analytics et retourne les données filtrées
|
* Applique des filtres aux analytics et retourne les données filtrées
|
||||||
*/
|
*/
|
||||||
export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFilters>): Promise<FilteredAnalyticsResult> {
|
export async function getFilteredJiraAnalytics(
|
||||||
|
filters: Partial<JiraAnalyticsFilters>
|
||||||
|
): Promise<FilteredAnalyticsResult> {
|
||||||
try {
|
try {
|
||||||
// Récupérer la config Jira
|
const session = await getServerSession(authOptions);
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
// Récupérer la config Jira
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!jiraConfig?.baseUrl ||
|
||||||
|
!jiraConfig?.email ||
|
||||||
|
!jiraConfig?.apiToken ||
|
||||||
|
!jiraConfig?.projectKey
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Configuration Jira incomplète'
|
error: 'Configuration Jira incomplète',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,14 +116,16 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
|
|||||||
return { success: false, error: 'Configuration Jira incomplète' };
|
return { success: false, error: 'Configuration Jira incomplète' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
const analyticsService = new JiraAnalyticsService(
|
||||||
|
jiraConfig as JiraAnalyticsConfig
|
||||||
|
);
|
||||||
const originalAnalytics = await analyticsService.getProjectAnalytics();
|
const originalAnalytics = await analyticsService.getProjectAnalytics();
|
||||||
|
|
||||||
// Si aucun filtre actif, retourner les données originales
|
// Si aucun filtre actif, retourner les données originales
|
||||||
if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) {
|
if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: originalAnalytics
|
data: originalAnalytics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +133,8 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
|
|||||||
const allIssues = await analyticsService.getAllProjectIssues();
|
const allIssues = await analyticsService.getAllProjectIssues();
|
||||||
|
|
||||||
// Appliquer les filtres
|
// Appliquer les filtres
|
||||||
const filteredAnalytics = JiraAdvancedFiltersService.applyFiltersToAnalytics(
|
const filteredAnalytics =
|
||||||
|
JiraAdvancedFiltersService.applyFiltersToAnalytics(
|
||||||
originalAnalytics,
|
originalAnalytics,
|
||||||
filters,
|
filters,
|
||||||
allIssues
|
allIssues
|
||||||
@@ -101,13 +142,13 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: filteredAnalytics
|
data: filteredAnalytics,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors du filtrage des analytics:', error);
|
console.error('❌ Erreur lors du filtrage des analytics:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import {
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
JiraAnalyticsService,
|
||||||
|
JiraAnalyticsConfig,
|
||||||
|
} from '@/services/integrations/jira/analytics';
|
||||||
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
import {
|
||||||
|
JiraTask,
|
||||||
|
AssigneeDistribution,
|
||||||
|
StatusDistribution,
|
||||||
|
SprintVelocity,
|
||||||
|
} from '@/lib/types';
|
||||||
import { parseDate } from '@/lib/date-utils';
|
import { parseDate } from '@/lib/date-utils';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export interface SprintDetailsResult {
|
export interface SprintDetailsResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -15,15 +25,29 @@ export interface SprintDetailsResult {
|
|||||||
/**
|
/**
|
||||||
* Récupère les détails d'un sprint spécifique
|
* Récupère les détails d'un sprint spécifique
|
||||||
*/
|
*/
|
||||||
export async function getSprintDetails(sprintName: string): Promise<SprintDetailsResult> {
|
export async function getSprintDetails(
|
||||||
|
sprintName: string
|
||||||
|
): Promise<SprintDetailsResult> {
|
||||||
try {
|
try {
|
||||||
// Récupérer la config Jira
|
const session = await getServerSession(authOptions);
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
// Récupérer la config Jira
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!jiraConfig?.baseUrl ||
|
||||||
|
!jiraConfig?.email ||
|
||||||
|
!jiraConfig?.apiToken ||
|
||||||
|
!jiraConfig?.projectKey
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Configuration Jira incomplète'
|
error: 'Configuration Jira incomplète',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,14 +56,18 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
|||||||
return { success: false, error: 'Configuration Jira incomplète' };
|
return { success: false, error: 'Configuration Jira incomplète' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
const analyticsService = new JiraAnalyticsService(
|
||||||
|
jiraConfig as JiraAnalyticsConfig
|
||||||
|
);
|
||||||
const analytics = await analyticsService.getProjectAnalytics();
|
const analytics = await analyticsService.getProjectAnalytics();
|
||||||
|
|
||||||
const sprint = analytics.velocityMetrics.sprintHistory.find(s => s.sprintName === sprintName);
|
const sprint = analytics.velocityMetrics.sprintHistory.find(
|
||||||
|
(s) => s.sprintName === sprintName
|
||||||
|
);
|
||||||
if (!sprint) {
|
if (!sprint) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Sprint "${sprintName}" introuvable`
|
error: `Sprint "${sprintName}" introuvable`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +80,7 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
|||||||
const sprintStart = parseDate(sprint.startDate);
|
const sprintStart = parseDate(sprint.startDate);
|
||||||
const sprintEnd = parseDate(sprint.endDate);
|
const sprintEnd = parseDate(sprint.endDate);
|
||||||
|
|
||||||
const sprintIssues = allIssues.filter(issue => {
|
const sprintIssues = allIssues.filter((issue) => {
|
||||||
const issueDate = parseDate(issue.created);
|
const issueDate = parseDate(issue.created);
|
||||||
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
||||||
});
|
});
|
||||||
@@ -71,18 +99,21 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
|||||||
issues: sprintIssues,
|
issues: sprintIssues,
|
||||||
assigneeDistribution,
|
assigneeDistribution,
|
||||||
statusDistribution,
|
statusDistribution,
|
||||||
metrics: sprintMetrics
|
metrics: sprintMetrics,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: sprintDetails
|
data: sprintDetails,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la récupération des détails du sprint:', error);
|
console.error(
|
||||||
|
'❌ Erreur lors de la récupération des détails du sprint:',
|
||||||
|
error
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,25 +123,29 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
|||||||
*/
|
*/
|
||||||
function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
||||||
const totalIssues = issues.length;
|
const totalIssues = issues.length;
|
||||||
const completedIssues = issues.filter(issue =>
|
const completedIssues = issues.filter(
|
||||||
|
(issue) =>
|
||||||
issue.status.category === 'Done' ||
|
issue.status.category === 'Done' ||
|
||||||
issue.status.name.toLowerCase().includes('done') ||
|
issue.status.name.toLowerCase().includes('done') ||
|
||||||
issue.status.name.toLowerCase().includes('closed')
|
issue.status.name.toLowerCase().includes('closed')
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const inProgressIssues = issues.filter(issue =>
|
const inProgressIssues = issues.filter(
|
||||||
|
(issue) =>
|
||||||
issue.status.category === 'In Progress' ||
|
issue.status.category === 'In Progress' ||
|
||||||
issue.status.name.toLowerCase().includes('progress') ||
|
issue.status.name.toLowerCase().includes('progress') ||
|
||||||
issue.status.name.toLowerCase().includes('review')
|
issue.status.name.toLowerCase().includes('review')
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const blockedIssues = issues.filter(issue =>
|
const blockedIssues = issues.filter(
|
||||||
|
(issue) =>
|
||||||
issue.status.name.toLowerCase().includes('blocked') ||
|
issue.status.name.toLowerCase().includes('blocked') ||
|
||||||
issue.status.name.toLowerCase().includes('waiting')
|
issue.status.name.toLowerCase().includes('waiting')
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Calcul du cycle time moyen pour ce sprint
|
// Calcul du cycle time moyen pour ce sprint
|
||||||
const completedIssuesWithDates = issues.filter(issue =>
|
const completedIssuesWithDates = issues.filter(
|
||||||
|
(issue) =>
|
||||||
issue.status.category === 'Done' && issue.created && issue.updated
|
issue.status.category === 'Done' && issue.created && issue.updated
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,7 +154,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
|||||||
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
||||||
const created = parseDate(issue.created);
|
const created = parseDate(issue.created);
|
||||||
const updated = parseDate(issue.updated);
|
const updated = parseDate(issue.updated);
|
||||||
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
const cycleTime =
|
||||||
|
(updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
||||||
return total + cycleTime;
|
return total + cycleTime;
|
||||||
}, 0);
|
}, 0);
|
||||||
averageCycleTime = totalCycleTime / completedIssuesWithDates.length;
|
averageCycleTime = totalCycleTime / completedIssuesWithDates.length;
|
||||||
@@ -139,19 +175,28 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
|||||||
inProgressIssues,
|
inProgressIssues,
|
||||||
blockedIssues,
|
blockedIssues,
|
||||||
averageCycleTime,
|
averageCycleTime,
|
||||||
velocityTrend
|
velocityTrend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule la distribution par assigné pour le sprint
|
* Calcule la distribution par assigné pour le sprint
|
||||||
*/
|
*/
|
||||||
function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution[] {
|
function calculateAssigneeDistribution(
|
||||||
const assigneeMap = new Map<string, { total: number; completed: number; inProgress: number }>();
|
issues: JiraTask[]
|
||||||
|
): AssigneeDistribution[] {
|
||||||
|
const assigneeMap = new Map<
|
||||||
|
string,
|
||||||
|
{ total: number; completed: number; inProgress: number }
|
||||||
|
>();
|
||||||
|
|
||||||
issues.forEach(issue => {
|
issues.forEach((issue) => {
|
||||||
const assigneeName = issue.assignee?.displayName || 'Non assigné';
|
const assigneeName = issue.assignee?.displayName || 'Non assigné';
|
||||||
const current = assigneeMap.get(assigneeName) || { total: 0, completed: 0, inProgress: 0 };
|
const current = assigneeMap.get(assigneeName) || {
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
inProgress: 0,
|
||||||
|
};
|
||||||
|
|
||||||
current.total++;
|
current.total++;
|
||||||
|
|
||||||
@@ -164,15 +209,17 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
|||||||
assigneeMap.set(assigneeName, current);
|
assigneeMap.set(assigneeName, current);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(assigneeMap.entries()).map(([displayName, stats]) => ({
|
return Array.from(assigneeMap.entries())
|
||||||
|
.map(([displayName, stats]) => ({
|
||||||
assignee: displayName === 'Non assigné' ? '' : displayName,
|
assignee: displayName === 'Non assigné' ? '' : displayName,
|
||||||
displayName,
|
displayName,
|
||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||||
count: stats.total // Ajout pour compatibilité
|
count: stats.total, // Ajout pour compatibilité
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
}))
|
||||||
|
.sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,13 +228,18 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
|||||||
function calculateStatusDistribution(issues: JiraTask[]): StatusDistribution[] {
|
function calculateStatusDistribution(issues: JiraTask[]): StatusDistribution[] {
|
||||||
const statusMap = new Map<string, number>();
|
const statusMap = new Map<string, number>();
|
||||||
|
|
||||||
issues.forEach(issue => {
|
issues.forEach((issue) => {
|
||||||
statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1);
|
statusMap.set(
|
||||||
|
issue.status.name,
|
||||||
|
(statusMap.get(issue.status.name) || 0) + 1
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(statusMap.entries()).map(([status, count]) => ({
|
return Array.from(statusMap.entries())
|
||||||
|
.map(([status, count]) => ({
|
||||||
status,
|
status,
|
||||||
count,
|
count,
|
||||||
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0
|
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0,
|
||||||
})).sort((a, b) => b.count - a.count);
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
import {
|
||||||
|
MetricsService,
|
||||||
|
WeeklyMetricsOverview,
|
||||||
|
VelocityTrend,
|
||||||
|
} from '@/services/analytics/metrics';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère les métriques hebdomadaires pour une date donnée
|
* Récupère les métriques hebdomadaires pour une date donnée
|
||||||
@@ -12,18 +18,33 @@ export async function getWeeklyMetrics(date?: Date): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
|
// Récupérer l'utilisateur connecté
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Utilisateur non authentifié',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const targetDate = date || getToday();
|
const targetDate = date || getToday();
|
||||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
const metrics = await MetricsService.getWeeklyMetrics(
|
||||||
|
session.user.id,
|
||||||
|
targetDate
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: metrics
|
data: metrics,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching weekly metrics:', error);
|
console.error('Error fetching weekly metrics:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics'
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to fetch weekly metrics',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,25 +58,39 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
if (weeksBack < 1 || weeksBack > 12) {
|
// Récupérer l'utilisateur connecté
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid weeksBack parameter (must be 1-12)'
|
error: 'Utilisateur non authentifié',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
if (weeksBack < 1 || weeksBack > 12) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid weeksBack parameter (must be 1-12)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const trends = await MetricsService.getVelocityTrends(
|
||||||
|
session.user.id,
|
||||||
|
weeksBack
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: trends
|
data: trends,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching velocity trends:', error);
|
console.error('Error fetching velocity trends:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch velocity trends'
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to fetch velocity trends',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,72 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
import {
|
||||||
|
KanbanFilters,
|
||||||
|
ViewPreferences,
|
||||||
|
ColumnVisibility,
|
||||||
|
TaskStatus,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { Theme } from '@/lib/ui-config';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Met à jour les préférences de vue
|
* Met à jour les préférences de vue
|
||||||
*/
|
*/
|
||||||
export async function updateViewPreferences(updates: Partial<ViewPreferences>): Promise<{
|
export async function updateViewPreferences(
|
||||||
|
updates: Partial<ViewPreferences>
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
await userPreferencesService.updateViewPreferences(updates);
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await userPreferencesService.updateViewPreferences(
|
||||||
|
session.user.id,
|
||||||
|
updates
|
||||||
|
);
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur updateViewPreferences:', error);
|
console.error('Erreur updateViewPreferences:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour l'image de fond
|
||||||
|
*/
|
||||||
|
export async function setBackgroundImage(
|
||||||
|
backgroundImage: string | undefined
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||||
|
backgroundImage,
|
||||||
|
});
|
||||||
|
revalidatePath('/');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur setBackgroundImage:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,19 +74,26 @@ export async function updateViewPreferences(updates: Partial<ViewPreferences>):
|
|||||||
/**
|
/**
|
||||||
* Met à jour les filtres Kanban
|
* Met à jour les filtres Kanban
|
||||||
*/
|
*/
|
||||||
export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Promise<{
|
export async function updateKanbanFilters(
|
||||||
|
updates: Partial<KanbanFilters>
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
await userPreferencesService.updateKanbanFilters(updates);
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await userPreferencesService.updateKanbanFilters(session.user.id, updates);
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur updateKanbanFilters:', error);
|
console.error('Erreur updateKanbanFilters:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,25 +101,37 @@ export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Prom
|
|||||||
/**
|
/**
|
||||||
* Met à jour la visibilité des colonnes
|
* Met à jour la visibilité des colonnes
|
||||||
*/
|
*/
|
||||||
export async function updateColumnVisibility(updates: Partial<ColumnVisibility>): Promise<{
|
export async function updateColumnVisibility(
|
||||||
|
updates: Partial<ColumnVisibility>
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = await userPreferencesService.getAllPreferences(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
const newColumnVisibility: ColumnVisibility = {
|
const newColumnVisibility: ColumnVisibility = {
|
||||||
...preferences.columnVisibility,
|
...preferences.columnVisibility,
|
||||||
...updates
|
...updates,
|
||||||
};
|
};
|
||||||
|
|
||||||
await userPreferencesService.saveColumnVisibility(newColumnVisibility);
|
await userPreferencesService.saveColumnVisibility(
|
||||||
|
session.user.id,
|
||||||
|
newColumnVisibility
|
||||||
|
);
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur updateColumnVisibility:', error);
|
console.error('Erreur updateColumnVisibility:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,17 +144,26 @@ export async function toggleObjectivesVisibility(): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = await userPreferencesService.getAllPreferences(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
const showObjectives = !preferences.viewPreferences.showObjectives;
|
const showObjectives = !preferences.viewPreferences.showObjectives;
|
||||||
|
|
||||||
await userPreferencesService.updateViewPreferences({ showObjectives });
|
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||||
|
showObjectives,
|
||||||
|
});
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur toggleObjectivesVisibility:', error);
|
console.error('Erreur toggleObjectivesVisibility:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,37 +176,53 @@ export async function toggleObjectivesCollapse(): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = await userPreferencesService.getAllPreferences(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
const collapseObjectives = !preferences.viewPreferences.collapseObjectives;
|
const collapseObjectives = !preferences.viewPreferences.collapseObjectives;
|
||||||
|
|
||||||
await userPreferencesService.updateViewPreferences({ collapseObjectives });
|
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||||
|
collapseObjectives,
|
||||||
|
});
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur toggleObjectivesCollapse:', error);
|
console.error('Erreur toggleObjectivesCollapse:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change le thème (light/dark)
|
* Change le thème (light/dark/dracula/monokai/nord)
|
||||||
*/
|
*/
|
||||||
export async function setTheme(theme: 'light' | 'dark'): Promise<{
|
export async function setTheme(theme: Theme): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
await userPreferencesService.updateViewPreferences({ theme });
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||||
|
theme,
|
||||||
|
});
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur setTheme:', error);
|
console.error('Erreur setTheme:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,17 +235,27 @@ export async function toggleTheme(): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const session = await getServerSession(authOptions);
|
||||||
const newTheme = preferences.viewPreferences.theme === 'dark' ? 'light' : 'dark';
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
await userPreferencesService.updateViewPreferences({ theme: newTheme });
|
const preferences = await userPreferencesService.getAllPreferences(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
const newTheme =
|
||||||
|
preferences.viewPreferences.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
|
||||||
|
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||||
|
theme: newTheme,
|
||||||
|
});
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur toggleTheme:', error);
|
console.error('Erreur toggleTheme:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,20 +268,35 @@ export async function toggleFontSize(): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const session = await getServerSession(authOptions);
|
||||||
const fontSizes: ('small' | 'medium' | 'large')[] = ['small', 'medium', 'large'];
|
if (!session?.user?.id) {
|
||||||
const currentIndex = fontSizes.indexOf(preferences.viewPreferences.fontSize);
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = await userPreferencesService.getAllPreferences(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
const fontSizes: ('small' | 'medium' | 'large')[] = [
|
||||||
|
'small',
|
||||||
|
'medium',
|
||||||
|
'large',
|
||||||
|
];
|
||||||
|
const currentIndex = fontSizes.indexOf(
|
||||||
|
preferences.viewPreferences.fontSize
|
||||||
|
);
|
||||||
const nextIndex = (currentIndex + 1) % fontSizes.length;
|
const nextIndex = (currentIndex + 1) % fontSizes.length;
|
||||||
const newFontSize = fontSizes[nextIndex];
|
const newFontSize = fontSizes[nextIndex];
|
||||||
|
|
||||||
await userPreferencesService.updateViewPreferences({ fontSize: newFontSize });
|
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||||
|
fontSize: newFontSize,
|
||||||
|
});
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur toggleFontSize:', error);
|
console.error('Erreur toggleFontSize:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +309,14 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = await userPreferencesService.getAllPreferences(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
const hiddenStatuses = new Set(preferences.columnVisibility.hiddenStatuses);
|
const hiddenStatuses = new Set(preferences.columnVisibility.hiddenStatuses);
|
||||||
|
|
||||||
if (hiddenStatuses.has(status)) {
|
if (hiddenStatuses.has(status)) {
|
||||||
@@ -202,8 +325,8 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
|
|||||||
hiddenStatuses.add(status);
|
hiddenStatuses.add(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
await userPreferencesService.saveColumnVisibility({
|
await userPreferencesService.saveColumnVisibility(session.user.id, {
|
||||||
hiddenStatuses: Array.from(hiddenStatuses)
|
hiddenStatuses: Array.from(hiddenStatuses),
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
@@ -212,7 +335,7 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
|
|||||||
console.error('Erreur toggleColumnVisibility:', error);
|
console.error('Erreur toggleColumnVisibility:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
176
src/actions/profile.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { usersService } from '@/services/users';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
|
|
||||||
|
export async function updateProfile(formData: {
|
||||||
|
name?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
useGravatar?: boolean;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (formData.firstName && formData.firstName.length > 50) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Le prénom ne peut pas dépasser 50 caractères',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.lastName && formData.lastName.length > 50) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Le nom ne peut pas dépasser 50 caractères',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.name && formData.name.length > 100) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Le nom d'affichage ne peut pas dépasser 100 caractères",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.avatar && formData.avatar.length > 500) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "L'URL de l'avatar ne peut pas dépasser 500 caractères",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déterminer l'URL de l'avatar
|
||||||
|
let finalAvatarUrl: string | null = null;
|
||||||
|
|
||||||
|
if (formData.useGravatar) {
|
||||||
|
// Utiliser Gravatar si demandé
|
||||||
|
finalAvatarUrl = getGravatarUrl(session.user.email || '', { size: 200 });
|
||||||
|
} else if (formData.avatar) {
|
||||||
|
// Utiliser l'URL custom si fournie
|
||||||
|
finalAvatarUrl = formData.avatar;
|
||||||
|
} else {
|
||||||
|
// Garder l'avatar actuel ou null
|
||||||
|
const currentUser = await usersService.getUserById(session.user.id);
|
||||||
|
finalAvatarUrl = currentUser?.avatar || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour l'utilisateur
|
||||||
|
const updatedUser = await usersService.updateUser(session.user.id, {
|
||||||
|
name: formData.name || null,
|
||||||
|
firstName: formData.firstName || null,
|
||||||
|
lastName: formData.lastName || null,
|
||||||
|
avatar: finalAvatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revalider la page de profil
|
||||||
|
revalidatePath('/profile');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: updatedUser.id,
|
||||||
|
email: updatedUser.email,
|
||||||
|
name: updatedUser.name,
|
||||||
|
firstName: updatedUser.firstName,
|
||||||
|
lastName: updatedUser.lastName,
|
||||||
|
avatar: updatedUser.avatar,
|
||||||
|
role: updatedUser.role,
|
||||||
|
createdAt: updatedUser.createdAt.toISOString(),
|
||||||
|
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Profile update error:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la mise à jour du profil' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await usersService.getUserById(session.user.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Utilisateur non trouvé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
role: user.role,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
lastLoginAt: user.lastLoginAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Profile get error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Erreur lors de la récupération du profil',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyGravatar() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.user?.email) {
|
||||||
|
return { success: false, error: 'Email requis pour Gravatar' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer l'URL Gravatar
|
||||||
|
const gravatarUrl = getGravatarUrl(session.user.email, { size: 200 });
|
||||||
|
|
||||||
|
// Mettre à jour l'utilisateur
|
||||||
|
const updatedUser = await usersService.updateUser(session.user.id, {
|
||||||
|
avatar: gravatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revalider la page de profil
|
||||||
|
revalidatePath('/profile');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: updatedUser.id,
|
||||||
|
email: updatedUser.email,
|
||||||
|
name: updatedUser.name,
|
||||||
|
firstName: updatedUser.firstName,
|
||||||
|
lastName: updatedUser.lastName,
|
||||||
|
avatar: updatedUser.avatar,
|
||||||
|
role: updatedUser.role,
|
||||||
|
createdAt: updatedUser.createdAt.toISOString(),
|
||||||
|
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gravatar update error:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la mise à jour Gravatar' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { SystemInfoService } from '@/services/system-info';
|
import { SystemInfoService } from '@/services/core/system-info';
|
||||||
|
|
||||||
export async function getSystemInfo() {
|
export async function getSystemInfo() {
|
||||||
try {
|
try {
|
||||||
@@ -10,7 +10,8 @@ export async function getSystemInfo() {
|
|||||||
console.error('Error getting system info:', error);
|
console.error('Error getting system info:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to get system info'
|
error:
|
||||||
|
error instanceof Error ? error.message : 'Failed to get system info',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export type ActionResult<T = void> = {
|
export type ActionResult<T = void> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -18,7 +20,16 @@ export async function createTag(
|
|||||||
color: string
|
color: string
|
||||||
): Promise<ActionResult<Tag>> {
|
): Promise<ActionResult<Tag>> {
|
||||||
try {
|
try {
|
||||||
const tag = await tagsService.createTag({ name, color });
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'User not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = await tagsService.createTag({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
// Revalider les pages qui utilisent les tags
|
// Revalider les pages qui utilisent les tags
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
@@ -30,7 +41,7 @@ export async function createTag(
|
|||||||
console.error('Error creating tag:', error);
|
console.error('Error creating tag:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to create tag'
|
error: error instanceof Error ? error.message : 'Failed to create tag',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +54,12 @@ export async function updateTag(
|
|||||||
data: { name?: string; color?: string; isPinned?: boolean }
|
data: { name?: string; color?: string; isPinned?: boolean }
|
||||||
): Promise<ActionResult<Tag>> {
|
): Promise<ActionResult<Tag>> {
|
||||||
try {
|
try {
|
||||||
const tag = await tagsService.updateTag(tagId, data);
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'User not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = await tagsService.updateTag(tagId, session.user.id, data);
|
||||||
|
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
return { success: false, error: 'Tag non trouvé' };
|
return { success: false, error: 'Tag non trouvé' };
|
||||||
@@ -59,7 +75,7 @@ export async function updateTag(
|
|||||||
console.error('Error updating tag:', error);
|
console.error('Error updating tag:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to update tag'
|
error: error instanceof Error ? error.message : 'Failed to update tag',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +85,12 @@ export async function updateTag(
|
|||||||
*/
|
*/
|
||||||
export async function deleteTag(tagId: string): Promise<ActionResult> {
|
export async function deleteTag(tagId: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await tagsService.deleteTag(tagId);
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'User not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tagsService.deleteTag(tagId, session.user.id);
|
||||||
|
|
||||||
// Revalider les pages qui utilisent les tags
|
// Revalider les pages qui utilisent les tags
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
@@ -81,8 +102,7 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
|
|||||||
console.error('Error deleting tag:', error);
|
console.error('Error deleting tag:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete tag'
|
error: error instanceof Error ? error.message : 'Failed to delete tag',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use server'
|
'use server';
|
||||||
|
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
import { TaskStatus, TaskPriority } from '@/lib/types';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export type ActionResult<T = unknown> = {
|
export type ActionResult<T = unknown> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -10,6 +12,30 @@ export type ActionResult<T = unknown> = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper pour vérifier l'authentification
|
||||||
|
*/
|
||||||
|
async function getAuthenticatedUser() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw new Error('Non authentifié');
|
||||||
|
}
|
||||||
|
return session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper pour vérifier qu'une tâche appartient au user connecté
|
||||||
|
*/
|
||||||
|
async function verifyTaskOwnership(taskId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUser();
|
||||||
|
const tasks = await tasksService.getTasks(userId);
|
||||||
|
return tasks.some((t) => t.id === taskId);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server Action pour mettre à jour le statut d'une tâche
|
* Server Action pour mettre à jour le statut d'une tâche
|
||||||
*/
|
*/
|
||||||
@@ -18,18 +44,30 @@ export async function updateTaskStatus(
|
|||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const task = await tasksService.updateTask(taskId, { status });
|
// Vérifier l'authentification et récupérer l'ID du user
|
||||||
|
const userId = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
// Vérifier que la tâche appartient au user connecté
|
||||||
|
const isOwner = await verifyTaskOwnership(taskId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask = await tasksService.updateTask(userId, taskId, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
|
||||||
return { success: true, data: task };
|
return { success: true, data: updatedTask };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating task status:', error);
|
console.error('Error updating task status:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to update task status'
|
error:
|
||||||
|
error instanceof Error ? error.message : 'Failed to update task status',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +84,18 @@ export async function updateTaskTitle(
|
|||||||
return { success: false, error: 'Title cannot be empty' };
|
return { success: false, error: 'Title cannot be empty' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = await tasksService.updateTask(taskId, { title: title.trim() });
|
// Vérifier l'authentification et récupérer l'ID du user
|
||||||
|
const userId = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
// Vérifier que la tâche appartient au user connecté
|
||||||
|
const isOwner = await verifyTaskOwnership(taskId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await tasksService.updateTask(userId, taskId, {
|
||||||
|
title: title.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
@@ -57,7 +106,8 @@ export async function updateTaskTitle(
|
|||||||
console.error('Error updating task title:', error);
|
console.error('Error updating task title:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to update task title'
|
error:
|
||||||
|
error instanceof Error ? error.message : 'Failed to update task title',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +117,16 @@ export async function updateTaskTitle(
|
|||||||
*/
|
*/
|
||||||
export async function deleteTask(taskId: string): Promise<ActionResult> {
|
export async function deleteTask(taskId: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await tasksService.deleteTask(taskId);
|
// Vérifier l'authentification et récupérer l'ID du user
|
||||||
|
const userId = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
// Vérifier que la tâche appartient au user connecté
|
||||||
|
const isOwner = await verifyTaskOwnership(taskId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tasksService.deleteTask(userId, taskId);
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
@@ -78,7 +137,7 @@ export async function deleteTask(taskId: string): Promise<ActionResult> {
|
|||||||
console.error('Error deleting task:', error);
|
console.error('Error deleting task:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete task'
|
error: error instanceof Error ? error.message : 'Failed to delete task',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,9 +152,19 @@ export async function updateTask(data: {
|
|||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
primaryTagId?: string;
|
||||||
dueDate?: Date;
|
dueDate?: Date;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
|
// Vérifier l'authentification et récupérer l'ID du user
|
||||||
|
const userId = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
// Vérifier que la tâche appartient au user connecté
|
||||||
|
const isOwner = await verifyTaskOwnership(data.taskId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||||
|
}
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (data.title !== undefined) {
|
if (data.title !== undefined) {
|
||||||
@@ -105,13 +174,16 @@ export async function updateTask(data: {
|
|||||||
updateData.title = data.title.trim();
|
updateData.title = data.title.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.description !== undefined) updateData.description = data.description.trim();
|
if (data.description !== undefined)
|
||||||
|
updateData.description = data.description.trim();
|
||||||
if (data.status !== undefined) updateData.status = data.status;
|
if (data.status !== undefined) updateData.status = data.status;
|
||||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||||
if (data.tags !== undefined) updateData.tags = data.tags;
|
if (data.tags !== undefined) updateData.tags = data.tags;
|
||||||
|
if (data.primaryTagId !== undefined)
|
||||||
|
updateData.primaryTagId = data.primaryTagId;
|
||||||
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
|
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
|
||||||
|
|
||||||
const task = await tasksService.updateTask(data.taskId, updateData);
|
const task = await tasksService.updateTask(userId, data.taskId, updateData);
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
@@ -122,7 +194,7 @@ export async function updateTask(data: {
|
|||||||
console.error('Error updating task:', error);
|
console.error('Error updating task:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to update task'
|
error: error instanceof Error ? error.message : 'Failed to update task',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,18 +208,24 @@ export async function createTask(data: {
|
|||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
primaryTagId?: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
if (!data.title.trim()) {
|
if (!data.title.trim()) {
|
||||||
return { success: false, error: 'Title is required' };
|
return { success: false, error: 'Title is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifier l'authentification et récupérer l'ID du user
|
||||||
|
const userId = await getAuthenticatedUser();
|
||||||
|
|
||||||
const task = await tasksService.createTask({
|
const task = await tasksService.createTask({
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
description: data.description?.trim() || '',
|
description: data.description?.trim() || '',
|
||||||
status: data.status || 'todo',
|
status: data.status || 'todo',
|
||||||
priority: data.priority || 'medium',
|
priority: data.priority || 'medium',
|
||||||
tags: data.tags || []
|
tags: data.tags || [],
|
||||||
|
primaryTagId: data.primaryTagId,
|
||||||
|
ownerId: userId, // Assigner la tâche au user connecté
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
@@ -159,7 +237,7 @@ export async function createTask(data: {
|
|||||||
console.error('Error creating task:', error);
|
console.error('Error creating task:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to create task'
|
error: error instanceof Error ? error.message : 'Failed to create task',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { tfsService, TfsConfig } from '@/services/tfs';
|
import { tfsService, TfsConfig } from '@/services/integrations/tfs/tfs';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sauvegarde la configuration TFS
|
* Sauvegarde la configuration TFS
|
||||||
*/
|
*/
|
||||||
export async function saveTfsConfig(config: TfsConfig) {
|
export async function saveTfsConfig(config: TfsConfig) {
|
||||||
try {
|
try {
|
||||||
await userPreferencesService.saveTfsConfig(config);
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await userPreferencesService.saveTfsConfig(session.user.id, config);
|
||||||
|
|
||||||
// Réinitialiser le service pour prendre en compte la nouvelle config
|
// Réinitialiser le service pour prendre en compte la nouvelle config
|
||||||
tfsService.reset();
|
tfsService.reset();
|
||||||
@@ -34,7 +41,12 @@ export async function saveTfsConfig(config: TfsConfig) {
|
|||||||
*/
|
*/
|
||||||
export async function getTfsConfig() {
|
export async function getTfsConfig() {
|
||||||
try {
|
try {
|
||||||
const config = await userPreferencesService.getTfsConfig();
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await userPreferencesService.getTfsConfig(session.user.id);
|
||||||
return { success: true, data: config };
|
return { success: true, data: config };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur récupération config TFS:', error);
|
console.error('Erreur récupération config TFS:', error);
|
||||||
@@ -64,7 +76,13 @@ export async function saveTfsSchedulerConfig(
|
|||||||
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
|
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
await userPreferencesService.saveTfsSchedulerConfig(
|
await userPreferencesService.saveTfsSchedulerConfig(
|
||||||
|
session.user.id,
|
||||||
tfsAutoSync,
|
tfsAutoSync,
|
||||||
tfsSyncInterval
|
tfsSyncInterval
|
||||||
);
|
);
|
||||||
@@ -90,8 +108,17 @@ export async function saveTfsSchedulerConfig(
|
|||||||
*/
|
*/
|
||||||
export async function syncTfsPullRequests() {
|
export async function syncTfsPullRequests() {
|
||||||
try {
|
try {
|
||||||
|
// Récupérer l'utilisateur connecté
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Utilisateur non authentifié',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Lancer la synchronisation via le service singleton
|
// Lancer la synchronisation via le service singleton
|
||||||
const result = await tfsService.syncTasks();
|
const result = await tfsService.syncTasks(session.user.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
|
|||||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
58
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { usersService } from '@/services/users';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, name, firstName, lastName, password } = await request.json();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email et mot de passe requis' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'email existe déjà
|
||||||
|
const emailExists = await usersService.emailExists(email);
|
||||||
|
if (emailExists) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Un compte avec cet email existe déjà' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer l'utilisateur
|
||||||
|
const user = await usersService.createUser({
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Compte créé avec succès',
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erreur lors de la création du compte' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -7,16 +7,15 @@ interface RouteParams {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
request: NextRequest,
|
|
||||||
{ params }: RouteParams
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const { filename } = await params;
|
const { filename } = await params;
|
||||||
|
|
||||||
// Vérification de sécurité - s'assurer que c'est bien un fichier de backup
|
// Vérification de sécurité - s'assurer que c'est bien un fichier de backup
|
||||||
if (!filename.startsWith('towercontrol_') ||
|
if (
|
||||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
!filename.startsWith('towercontrol_') ||
|
||||||
|
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))
|
||||||
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Invalid backup filename' },
|
{ success: false, error: 'Invalid backup filename' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -27,24 +26,22 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Backup ${filename} deleted successfully`
|
message: `Backup ${filename} deleted successfully`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting backup:', error);
|
console.error('Error deleting backup:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete backup'
|
error:
|
||||||
|
error instanceof Error ? error.message : 'Failed to delete backup',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
request: NextRequest,
|
|
||||||
{ params }: RouteParams
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const { filename } = await params;
|
const { filename } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -52,8 +49,10 @@ export async function POST(
|
|||||||
|
|
||||||
if (action === 'restore') {
|
if (action === 'restore') {
|
||||||
// Vérification de sécurité
|
// Vérification de sécurité
|
||||||
if (!filename.startsWith('towercontrol_') ||
|
if (
|
||||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
!filename.startsWith('towercontrol_') ||
|
||||||
|
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))
|
||||||
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Invalid backup filename' },
|
{ success: false, error: 'Invalid backup filename' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -63,7 +62,10 @@ export async function POST(
|
|||||||
// Protection environnement de production
|
// Protection environnement de production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Restore not allowed in production via API' },
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Restore not allowed in production via API',
|
||||||
|
},
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,7 +74,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Database restored from ${filename}`
|
message: `Database restored from ${filename}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +87,9 @@ export async function POST(
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Operation failed'
|
error: error instanceof Error ? error.message : 'Operation failed',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
import { backupScheduler } from '@/services/backup-scheduler';
|
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -13,14 +13,24 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: { logs }
|
data: { logs },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'stats') {
|
||||||
|
const days = parseInt(searchParams.get('days') || '30');
|
||||||
|
const stats = await backupService.getBackupStats(days);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 API GET /api/backups called');
|
console.log('🔄 API GET /api/backups called');
|
||||||
|
|
||||||
// Test de la configuration d'abord
|
// Test de la configuration d'abord
|
||||||
const config = backupService.getConfig();
|
const config = await backupService.getConfig();
|
||||||
console.log('✅ Config loaded:', config);
|
console.log('✅ Config loaded:', config);
|
||||||
|
|
||||||
// Test du scheduler
|
// Test du scheduler
|
||||||
@@ -37,20 +47,24 @@ export async function GET(request: NextRequest) {
|
|||||||
backups,
|
backups,
|
||||||
scheduler: schedulerStatus,
|
scheduler: schedulerStatus,
|
||||||
config,
|
config,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('✅ API response ready');
|
console.log('✅ API response ready');
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error fetching backups:', error);
|
console.error('❌ Error fetching backups:', error);
|
||||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown');
|
console.error(
|
||||||
|
'Error stack:',
|
||||||
|
error instanceof Error ? error.stack : 'Unknown'
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch backups',
|
error:
|
||||||
details: error instanceof Error ? error.stack : undefined
|
error instanceof Error ? error.message : 'Failed to fetch backups',
|
||||||
|
details: error instanceof Error ? error.stack : undefined,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
@@ -71,7 +85,8 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
skipped: true,
|
skipped: true,
|
||||||
message: 'No changes detected since last backup. Use force=true to create anyway.'
|
message:
|
||||||
|
'No changes detected since last backup. Use force=true to create anyway.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,19 +96,22 @@ export async function POST(request: NextRequest) {
|
|||||||
await backupService.verifyDatabaseHealth();
|
await backupService.verifyDatabaseHealth();
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Database health check passed'
|
message: 'Database health check passed',
|
||||||
});
|
});
|
||||||
|
|
||||||
case 'config':
|
case 'config':
|
||||||
await backupService.updateConfig(params.config);
|
await backupService.updateConfig(params.config);
|
||||||
// Redémarrer le scheduler si la config a changé
|
// Redémarrer le scheduler si la config a changé
|
||||||
if (params.config.enabled !== undefined || params.config.interval !== undefined) {
|
if (
|
||||||
|
params.config.enabled !== undefined ||
|
||||||
|
params.config.interval !== undefined
|
||||||
|
) {
|
||||||
backupScheduler.restart();
|
backupScheduler.restart();
|
||||||
}
|
}
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Configuration updated',
|
message: 'Configuration updated',
|
||||||
data: backupService.getConfig()
|
data: await backupService.getConfig(),
|
||||||
});
|
});
|
||||||
|
|
||||||
case 'scheduler':
|
case 'scheduler':
|
||||||
@@ -104,7 +122,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: backupScheduler.getStatus()
|
data: backupScheduler.getStatus(),
|
||||||
});
|
});
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -118,7 +136,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API route pour récupérer toutes les dates avec des dailies
|
* API route pour récupérer toutes les dates avec des dailies
|
||||||
@@ -7,9 +9,13 @@ import { dailyService } from '@/services/daily';
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const dates = await dailyService.getDailyDates();
|
const session = await getServerSession(authOptions);
|
||||||
return NextResponse.json({ dates });
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = await dailyService.getDailyDates(session.user.id);
|
||||||
|
return NextResponse.json({ dates });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la récupération des dates:', error);
|
console.error('Erreur lors de la récupération des dates:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
42
src/app/api/daily/deadline-tasks/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { parseDate, isValidAPIDate } from '@/lib/date-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route pour récupérer les tâches avec deadline pour une date donnée
|
||||||
|
* GET /api/daily/deadline-tasks?date=YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const dateStr = searchParams.get('date');
|
||||||
|
|
||||||
|
if (!dateStr || !isValidAPIDate(dateStr)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Date invalide. Format attendu: YYYY-MM-DD' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = parseDate(dateStr);
|
||||||
|
const tasks = await dailyService.getTasksByDeadlineDate(
|
||||||
|
session.user.id,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ tasks });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des tâches:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erreur interne du serveur' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/api/daily/deadlines/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route pour récupérer toutes les dates de fin des tâches avec leurs noms
|
||||||
|
* GET /api/daily/deadlines
|
||||||
|
* Retourne un objet { dates: Record<string, string[]> } où chaque clé est une date (YYYY-MM-DD)
|
||||||
|
* et la valeur est un tableau de noms de tâches
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadlineDates = await dailyService.getTaskDeadlineDates(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
return NextResponse.json({ dates: deadlineDates });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des dates de fin:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erreur interne du serveur' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,34 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
import { DailyCheckboxType } from '@/lib/types';
|
import { DailyCheckboxType } from '@/lib/types';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined;
|
const maxDays = searchParams.get('maxDays')
|
||||||
|
? parseInt(searchParams.get('maxDays')!)
|
||||||
|
: undefined;
|
||||||
const excludeToday = searchParams.get('excludeToday') === 'true';
|
const excludeToday = searchParams.get('excludeToday') === 'true';
|
||||||
const type = searchParams.get('type') as DailyCheckboxType | undefined;
|
const type = searchParams.get('type') as DailyCheckboxType | undefined;
|
||||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
|
const limit = searchParams.get('limit')
|
||||||
|
? parseInt(searchParams.get('limit')!)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
|
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
|
||||||
maxDays,
|
maxDays,
|
||||||
excludeToday,
|
excludeToday,
|
||||||
type,
|
type,
|
||||||
limit
|
limit,
|
||||||
|
userId: session.user.id, // Filtrer par user connecté
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(pendingCheckboxes);
|
return NextResponse.json(pendingCheckboxes);
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
import {
|
||||||
|
getToday,
|
||||||
|
parseDate,
|
||||||
|
isValidAPIDate,
|
||||||
|
createDateFromParts,
|
||||||
|
} from '@/lib/date-utils';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const action = searchParams.get('action');
|
const action = searchParams.get('action');
|
||||||
@@ -15,7 +27,10 @@ export async function GET(request: Request) {
|
|||||||
if (action === 'history') {
|
if (action === 'history') {
|
||||||
// Récupérer l'historique
|
// Récupérer l'historique
|
||||||
const limit = parseInt(searchParams.get('limit') || '30');
|
const limit = parseInt(searchParams.get('limit') || '30');
|
||||||
const history = await dailyService.getCheckboxHistory(limit);
|
const history = await dailyService.getCheckboxHistory(
|
||||||
|
session.user.id,
|
||||||
|
limit
|
||||||
|
);
|
||||||
return NextResponse.json(history);
|
return NextResponse.json(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +40,10 @@ export async function GET(request: Request) {
|
|||||||
const limit = parseInt(searchParams.get('limit') || '20');
|
const limit = parseInt(searchParams.get('limit') || '20');
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return NextResponse.json({ error: 'Query parameter required' }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: 'Query parameter required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkboxes = await dailyService.searchCheckboxes(query, limit);
|
const checkboxes = await dailyService.searchCheckboxes(query, limit);
|
||||||
@@ -47,9 +65,11 @@ export async function GET(request: Request) {
|
|||||||
targetDate = getToday();
|
targetDate = getToday();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dailyView = await dailyService.getDailyView(targetDate);
|
const dailyView = await dailyService.getDailyView(
|
||||||
|
targetDate,
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
return NextResponse.json(dailyView);
|
return NextResponse.json(dailyView);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la récupération du daily:', error);
|
console.error('Erreur lors de la récupération du daily:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -64,6 +84,10 @@ export async function GET(request: Request) {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Validation des données
|
// Validation des données
|
||||||
@@ -93,17 +117,17 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const checkbox = await dailyService.addCheckbox({
|
const checkbox = await dailyService.addCheckbox({
|
||||||
date,
|
date,
|
||||||
|
userId: session.user.id,
|
||||||
text: body.text,
|
text: body.text,
|
||||||
type: body.type,
|
type: body.type,
|
||||||
taskId: body.taskId,
|
taskId: body.taskId,
|
||||||
order: body.order,
|
order: body.order,
|
||||||
isChecked: body.isChecked
|
isChecked: body.isChecked,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(checkbox, { status: 201 });
|
return NextResponse.json(checkbox, { status: 201 });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de l\'ajout de la checkbox:', error);
|
console.error("Erreur lors de l'ajout de la checkbox:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Erreur interne du serveur' },
|
{ error: 'Erreur interne du serveur' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/services/database';
|
import { prisma } from '@/services/core/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route GET /api/jira/logs
|
* Route GET /api/jira/logs
|
||||||
@@ -12,25 +12,24 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const logs = await prisma.syncLog.findMany({
|
const logs = await prisma.syncLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
source: 'jira'
|
source: 'jira',
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc'
|
createdAt: 'desc',
|
||||||
},
|
},
|
||||||
take: limit
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: logs
|
data: logs,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur récupération logs Jira:', error);
|
console.error('❌ Erreur récupération logs Jira:', error);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Erreur lors de la récupération des logs',
|
error: 'Erreur lors de la récupération des logs',
|
||||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createJiraService, JiraService } from '@/services/jira';
|
import {
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
createJiraService,
|
||||||
import { jiraScheduler } from '@/services/jira-scheduler';
|
JiraService,
|
||||||
|
} from '@/services/integrations/jira/jira';
|
||||||
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { jiraScheduler } from '@/services/integrations/jira/scheduler';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route POST /api/jira/sync
|
* Route POST /api/jira/sync
|
||||||
@@ -10,6 +15,14 @@ import { jiraScheduler } from '@/services/jira-scheduler';
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier s'il y a des actions spécifiques (scheduler)
|
// Vérifier s'il y a des actions spécifiques (scheduler)
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const { action, ...params } = body;
|
const { action, ...params } = body;
|
||||||
@@ -19,26 +32,27 @@ export async function POST(request: Request) {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case 'scheduler':
|
case 'scheduler':
|
||||||
if (params.enabled) {
|
if (params.enabled) {
|
||||||
await jiraScheduler.start();
|
await jiraScheduler.start(session.user.id);
|
||||||
} else {
|
} else {
|
||||||
jiraScheduler.stop();
|
jiraScheduler.stop();
|
||||||
}
|
}
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: await jiraScheduler.getStatus()
|
data: await jiraScheduler.getStatus(session.user.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
case 'config':
|
case 'config':
|
||||||
await userPreferencesService.saveJiraSchedulerConfig(
|
await userPreferencesService.saveJiraSchedulerConfig(
|
||||||
|
session.user.id,
|
||||||
params.jiraAutoSync,
|
params.jiraAutoSync,
|
||||||
params.jiraSyncInterval
|
params.jiraSyncInterval
|
||||||
);
|
);
|
||||||
// Redémarrer le scheduler si la config a changé
|
// Redémarrer le scheduler si la config a changé
|
||||||
await jiraScheduler.restart();
|
await jiraScheduler.restart(session.user.id);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Configuration scheduler mise à jour',
|
message: 'Configuration scheduler mise à jour',
|
||||||
data: await jiraScheduler.getStatus()
|
data: await jiraScheduler.getStatus(session.user.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -50,11 +64,18 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Synchronisation normale (manuelle)
|
// Synchronisation normale (manuelle)
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
let jiraService: JiraService | null = null;
|
let jiraService: JiraService | null = null;
|
||||||
|
|
||||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
if (
|
||||||
|
jiraConfig.enabled &&
|
||||||
|
jiraConfig.baseUrl &&
|
||||||
|
jiraConfig.email &&
|
||||||
|
jiraConfig.apiToken
|
||||||
|
) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
enabled: jiraConfig.enabled,
|
enabled: jiraConfig.enabled,
|
||||||
@@ -62,7 +83,7 @@ export async function POST(request: Request) {
|
|||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
projectKey: jiraConfig.projectKey,
|
projectKey: jiraConfig.projectKey,
|
||||||
ignoredProjects: jiraConfig.ignoredProjects || []
|
ignoredProjects: jiraConfig.ignoredProjects || [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback sur les variables d'environnement
|
// Fallback sur les variables d'environnement
|
||||||
@@ -71,7 +92,10 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (!jiraService) {
|
if (!jiraService) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Configuration Jira manquante. Configurez Jira dans les paramètres ou vérifiez les variables d\'environnement.' },
|
{
|
||||||
|
error:
|
||||||
|
"Configuration Jira manquante. Configurez Jira dans les paramètres ou vérifiez les variables d'environnement.",
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,36 +106,62 @@ export async function POST(request: Request) {
|
|||||||
const connectionOk = await jiraService.testConnection();
|
const connectionOk = await jiraService.testConnection();
|
||||||
if (!connectionOk) {
|
if (!connectionOk) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Impossible de se connecter à Jira. Vérifiez la configuration.' },
|
{
|
||||||
|
error:
|
||||||
|
'Impossible de se connecter à Jira. Vérifiez la configuration.',
|
||||||
|
},
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effectuer la synchronisation
|
// Effectuer la synchronisation
|
||||||
const result = await jiraService.syncTasks();
|
const syncResult = await jiraService.syncTasks(session.user.id);
|
||||||
|
|
||||||
if (result.success) {
|
// Convertir SyncResult en JiraSyncResult pour le client
|
||||||
|
// Utiliser les actions Jira originales si disponibles pour préserver les détails (changes, etc.)
|
||||||
|
const actions =
|
||||||
|
syncResult.jiraActions ||
|
||||||
|
syncResult.actions.map((action) => ({
|
||||||
|
type: action.type as 'created' | 'updated' | 'skipped' | 'deleted',
|
||||||
|
taskKey: action.itemId.toString(),
|
||||||
|
taskTitle: action.title,
|
||||||
|
reason: action.message,
|
||||||
|
changes: action.message ? [action.message] : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jiraSyncResult = {
|
||||||
|
success: syncResult.success,
|
||||||
|
tasksFound: syncResult.totalItems,
|
||||||
|
tasksCreated: syncResult.stats.created,
|
||||||
|
tasksUpdated: syncResult.stats.updated,
|
||||||
|
tasksSkipped: syncResult.stats.skipped,
|
||||||
|
tasksDeleted: syncResult.stats.deleted,
|
||||||
|
errors: syncResult.errors,
|
||||||
|
unknownStatuses: syncResult.unknownStatuses || [], // Nouveaux statuts inconnus
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (syncResult.success) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Synchronisation Jira terminée avec succès',
|
message: 'Synchronisation Jira terminée avec succès',
|
||||||
data: result
|
data: jiraSyncResult,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Synchronisation Jira terminée avec des erreurs',
|
error: 'Synchronisation Jira terminée avec des erreurs',
|
||||||
data: result
|
data: jiraSyncResult,
|
||||||
},
|
},
|
||||||
{ status: 207 } // Multi-Status
|
{ status: 207 } // Multi-Status
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur API sync Jira:', error);
|
console.error('❌ Erreur API sync Jira:', error);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Erreur interne lors de la synchronisation',
|
error: 'Erreur interne lors de la synchronisation',
|
||||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
@@ -124,12 +174,27 @@ export async function POST(request: Request) {
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Essayer d'abord la config depuis la base de données
|
// Essayer d'abord la config depuis la base de données
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
let jiraService: JiraService | null = null;
|
let jiraService: JiraService | null = null;
|
||||||
|
|
||||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
if (
|
||||||
|
jiraConfig.enabled &&
|
||||||
|
jiraConfig.baseUrl &&
|
||||||
|
jiraConfig.email &&
|
||||||
|
jiraConfig.apiToken
|
||||||
|
) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
enabled: jiraConfig.enabled,
|
enabled: jiraConfig.enabled,
|
||||||
@@ -137,7 +202,7 @@ export async function GET() {
|
|||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
projectKey: jiraConfig.projectKey,
|
projectKey: jiraConfig.projectKey,
|
||||||
ignoredProjects: jiraConfig.ignoredProjects || []
|
ignoredProjects: jiraConfig.ignoredProjects || [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback sur les variables d'environnement
|
// Fallback sur les variables d'environnement
|
||||||
@@ -145,12 +210,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!jiraService) {
|
if (!jiraService) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({
|
||||||
{
|
|
||||||
connected: false,
|
connected: false,
|
||||||
message: 'Configuration Jira manquante'
|
message: 'Configuration Jira manquante',
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = await jiraService.testConnection();
|
const connected = await jiraService.testConnection();
|
||||||
@@ -158,33 +221,36 @@ export async function GET() {
|
|||||||
// Si connexion OK et qu'un projet est configuré, tester aussi le projet
|
// Si connexion OK et qu'un projet est configuré, tester aussi le projet
|
||||||
let projectValidation = null;
|
let projectValidation = null;
|
||||||
if (connected && jiraConfig.projectKey) {
|
if (connected && jiraConfig.projectKey) {
|
||||||
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
projectValidation = await jiraService.validateProject(
|
||||||
|
jiraConfig.projectKey
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer aussi le statut du scheduler
|
// Récupérer aussi le statut du scheduler avec l'utilisateur connecté
|
||||||
const schedulerStatus = await jiraScheduler.getStatus();
|
const schedulerStatus = await jiraScheduler.getStatus(session.user.id);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
connected,
|
connected,
|
||||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
message: connected
|
||||||
project: projectValidation ? {
|
? 'Connexion Jira OK'
|
||||||
|
: 'Impossible de se connecter à Jira',
|
||||||
|
project: projectValidation
|
||||||
|
? {
|
||||||
key: jiraConfig.projectKey,
|
key: jiraConfig.projectKey,
|
||||||
exists: projectValidation.exists,
|
exists: projectValidation.exists,
|
||||||
name: projectValidation.name,
|
name: projectValidation.name,
|
||||||
error: projectValidation.error
|
error: projectValidation.error,
|
||||||
} : null,
|
}
|
||||||
scheduler: schedulerStatus
|
: null,
|
||||||
|
scheduler: schedulerStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur test connexion Jira:', error);
|
console.error('❌ Erreur test connexion Jira:', error);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json({
|
||||||
{
|
|
||||||
connected: false,
|
connected: false,
|
||||||
message: 'Erreur lors du test de connexion',
|
message: 'Erreur lors du test de connexion',
|
||||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createJiraService } from '@/services/jira';
|
import { createJiraService } from '@/services/integrations/jira/jira';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/jira/validate-project
|
* POST /api/jira/validate-project
|
||||||
@@ -8,6 +10,11 @@ import { userPreferencesService } from '@/services/user-preferences';
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { projectKey } = body;
|
const { projectKey } = body;
|
||||||
|
|
||||||
@@ -19,11 +26,21 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la config Jira depuis la base de données
|
// Récupérer la config Jira depuis la base de données
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
if (
|
||||||
|
!jiraConfig.enabled ||
|
||||||
|
!jiraConfig.baseUrl ||
|
||||||
|
!jiraConfig.email ||
|
||||||
|
!jiraConfig.apiToken
|
||||||
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.' },
|
{
|
||||||
|
error:
|
||||||
|
'Configuration Jira manquante. Configurez Jira dans les paramètres.',
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,37 +49,44 @@ export async function POST(request: NextRequest) {
|
|||||||
const jiraService = createJiraService();
|
const jiraService = createJiraService();
|
||||||
if (!jiraService) {
|
if (!jiraService) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Impossible de créer le service Jira. Vérifiez la configuration.' },
|
{
|
||||||
|
error:
|
||||||
|
'Impossible de créer le service Jira. Vérifiez la configuration.',
|
||||||
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valider le projet
|
// Valider le projet
|
||||||
const validation = await jiraService.validateProject(projectKey.trim().toUpperCase());
|
const validation = await jiraService.validateProject(
|
||||||
|
projectKey.trim().toUpperCase()
|
||||||
|
);
|
||||||
|
|
||||||
if (validation.exists) {
|
if (validation.exists) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
exists: true,
|
exists: true,
|
||||||
projectName: validation.name,
|
projectName: validation.name,
|
||||||
message: `Projet "${projectKey}" trouvé : ${validation.name}`
|
message: `Projet "${projectKey}" trouvé : ${validation.name}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
success: false,
|
success: false,
|
||||||
exists: false,
|
exists: false,
|
||||||
error: validation.error,
|
error: validation.error,
|
||||||
message: validation.error || `Projet "${projectKey}" introuvable`
|
message: validation.error || `Projet "${projectKey}" introuvable`,
|
||||||
}, { status: 404 });
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la validation du projet Jira:', error);
|
console.error('Erreur lors de la validation du projet Jira:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Erreur lors de la validation du projet',
|
error: 'Erreur lors de la validation du projet',
|
||||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
116
src/app/api/notes/[id]/route.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { notesService } from '@/services/notes';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route pour récupérer une note spécifique
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const note = await notesService.getNoteById(
|
||||||
|
resolvedParams.id,
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ note });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching note:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch note' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route pour mettre à jour une note
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, content, taskId, tags } = body;
|
||||||
|
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const note = await notesService.updateNote(
|
||||||
|
resolvedParams.id,
|
||||||
|
session.user.id,
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
taskId,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ note });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating note:', error);
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === 'Note not found or access denied'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update note' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route pour supprimer une note
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedParams = await params;
|
||||||
|
await notesService.deleteNote(resolvedParams.id, session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting note:', error);
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === 'Note not found or access denied'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete note' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/api/notes/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { notesService } from '@/services/notes';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route pour récupérer toutes les notes de l'utilisateur connecté
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
|
||||||
|
let notes;
|
||||||
|
if (search) {
|
||||||
|
notes = await notesService.searchNotes(session.user.id, search);
|
||||||
|
} else {
|
||||||
|
notes = await notesService.getNotes(session.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ notes });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notes:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch notes' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route pour créer une nouvelle note
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, content, taskId, tags } = body;
|
||||||
|
|
||||||
|
if (!title || !content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Title and content are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await notesService.createNote({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
userId: session.user.id,
|
||||||
|
taskId,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ note }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating note:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create note' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tags/[id] - Récupère un tag par son ID
|
* GET /api/tags/[id] - Récupère un tag par son ID
|
||||||
@@ -9,27 +11,29 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const tag = await tagsService.getTagById(id);
|
const tag = await tagsService.getTagById(id, session.user.id);
|
||||||
|
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Tag non trouvé' }, { status: 404 });
|
||||||
{ error: 'Tag non trouvé' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: tag,
|
data: tag,
|
||||||
message: 'Tag récupéré avec succès'
|
message: 'Tag récupéré avec succès',
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la récupération du tag:', error);
|
console.error('Erreur lors de la récupération du tag:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Erreur lors de la récupération du tag',
|
error: 'Erreur lors de la récupération du tag',
|
||||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tags - Récupère tous les tags ou recherche par query
|
* GET /api/tags - Récupère tous les tags ou recherche par query
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const query = searchParams.get('q');
|
const query = searchParams.get('q');
|
||||||
const popular = searchParams.get('popular');
|
const popular = searchParams.get('popular');
|
||||||
@@ -14,27 +22,26 @@ export async function GET(request: NextRequest) {
|
|||||||
let tags;
|
let tags;
|
||||||
|
|
||||||
if (popular === 'true') {
|
if (popular === 'true') {
|
||||||
// Récupérer les tags les plus utilisés
|
// Récupérer les tags les plus utilisés pour cet utilisateur
|
||||||
tags = await tagsService.getPopularTags(limit);
|
tags = await tagsService.getPopularTags(session.user.id, limit);
|
||||||
} else if (query) {
|
} else if (query) {
|
||||||
// Recherche par nom (pour autocomplete)
|
// Recherche par nom (pour autocomplete) pour cet utilisateur
|
||||||
tags = await tagsService.searchTags(query, limit);
|
tags = await tagsService.searchTags(query, session.user.id, limit);
|
||||||
} else {
|
} else {
|
||||||
// Récupérer tous les tags
|
// Récupérer tous les tags de cet utilisateur
|
||||||
tags = await tagsService.getTags();
|
tags = await tagsService.getTags(session.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: tags,
|
data: tags,
|
||||||
message: 'Tags récupérés avec succès'
|
message: 'Tags récupérés avec succès',
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la récupération des tags:', error);
|
console.error('Erreur lors de la récupération des tags:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Erreur lors de la récupération des tags',
|
error: 'Erreur lors de la récupération des tags',
|
||||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -15,7 +17,16 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkboxes = await tasksService.getTaskRelatedCheckboxes(id);
|
// Vérifier l'authentification
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxes = await tasksService.getTaskRelatedCheckboxes(
|
||||||
|
session.user.id,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ data: checkboxes });
|
return NextResponse.json({ data: checkboxes });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { TaskStatus } from '@/lib/types';
|
import { TaskStatus } from '@/lib/types';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API route pour récupérer les tâches avec filtres optionnels
|
* API route pour récupérer les tâches avec filtres optionnels
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
// Extraire les paramètres de filtre
|
// Extraire les paramètres de filtre
|
||||||
@@ -16,6 +27,7 @@ export async function GET(request: Request) {
|
|||||||
search?: string;
|
search?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
ownerId?: string; // Filtre par propriétaire
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const status = searchParams.get('status');
|
const status = searchParams.get('status');
|
||||||
@@ -44,24 +56,26 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer les tâches
|
// Récupérer les tâches
|
||||||
const tasks = await tasksService.getTasks(filters);
|
const tasks = await tasksService.getTasks(session.user.id, filters);
|
||||||
const stats = await tasksService.getTaskStats();
|
const stats = await tasksService.getTaskStats(session.user.id);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: tasks,
|
data: tasks,
|
||||||
stats,
|
stats,
|
||||||
filters: filters,
|
filters: filters,
|
||||||
count: tasks.length
|
count: tasks.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la récupération des tâches:', error);
|
console.error('❌ Erreur lors de la récupération des tâches:', error);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
}, { status: 500 });
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { tfsService } from '@/services/tfs';
|
import { tfsService } from '@/services/integrations/tfs/tfs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supprime toutes les tâches TFS de la base de données locale
|
* Supprime toutes les tâches TFS de la base de données locale
|
||||||
@@ -14,27 +14,33 @@ export async function DELETE() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: result.deletedCount > 0
|
message:
|
||||||
|
result.deletedCount > 0
|
||||||
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
|
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
|
||||||
: 'Aucune tâche TFS trouvée à supprimer',
|
: 'Aucune tâche TFS trouvée à supprimer',
|
||||||
data: {
|
data: {
|
||||||
deletedCount: result.deletedCount
|
deletedCount: result.deletedCount,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Erreur lors de la suppression',
|
error: result.error || 'Erreur lors de la suppression',
|
||||||
}, { status: 500 });
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
|
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Erreur lors de la suppression des tâches TFS',
|
error: 'Erreur lors de la suppression des tâches TFS',
|
||||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
}, { status: 500 });
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
111
src/app/api/tfs/scheduler-config/route.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { tfsScheduler } from '@/services/integrations/tfs/scheduler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tfs/scheduler-config
|
||||||
|
* Récupère la configuration du scheduler TFS
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedulerConfig = await userPreferencesService.getTfsSchedulerConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: schedulerConfig,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur récupération config scheduler TFS:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la récupération',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tfs/scheduler-config
|
||||||
|
* Sauvegarde la configuration du scheduler TFS
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { tfsAutoSync, tfsSyncInterval } = body;
|
||||||
|
|
||||||
|
if (typeof tfsAutoSync !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'tfsAutoSync doit être un booléen',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['hourly', 'daily', 'weekly'].includes(tfsSyncInterval)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'tfsSyncInterval doit être hourly, daily ou weekly',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await userPreferencesService.saveTfsSchedulerConfig(
|
||||||
|
session.user.id,
|
||||||
|
tfsAutoSync,
|
||||||
|
tfsSyncInterval
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redémarrer le scheduler avec la nouvelle configuration
|
||||||
|
await tfsScheduler.restart(session.user.id);
|
||||||
|
|
||||||
|
// Récupérer le statut mis à jour
|
||||||
|
const status = await tfsScheduler.getStatus(session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Configuration scheduler TFS mise à jour',
|
||||||
|
data: status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur sauvegarde config scheduler TFS:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la sauvegarde',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/tfs/scheduler-status/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { tfsScheduler } from '@/services/integrations/tfs/scheduler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tfs/scheduler-status
|
||||||
|
* Récupère le statut du scheduler TFS
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await tfsScheduler.getStatus(session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur récupération statut scheduler TFS:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la récupération',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { tfsService } from '@/services/tfs';
|
import { tfsService } from '@/services/integrations/tfs/tfs';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route POST /api/tfs/sync
|
* Route POST /api/tfs/sync
|
||||||
@@ -8,10 +10,18 @@ import { tfsService } from '@/services/tfs';
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export async function POST(_request: Request) {
|
export async function POST(_request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🔄 Début de la synchronisation TFS manuelle...');
|
console.log('🔄 Début de la synchronisation TFS manuelle...');
|
||||||
|
|
||||||
// Effectuer la synchronisation via le service singleton
|
// Effectuer la synchronisation via le service singleton avec l'utilisateur connecté
|
||||||
const result = await tfsService.syncTasks();
|
const result = await tfsService.syncTasks(session.user.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -46,8 +56,16 @@ export async function POST(_request: Request) {
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// Tester la connexion via le service singleton
|
const session = await getServerSession(authOptions);
|
||||||
const isConnected = await tfsService.testConnection();
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester la connexion via le service singleton avec l'utilisateur connecté
|
||||||
|
const isConnected = await tfsService.testConnection(session.user.id);
|
||||||
|
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -76,4 +94,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { tfsService } from '@/services/tfs';
|
import { tfsService } from '@/services/integrations/tfs/tfs';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route GET /api/tfs/test
|
* Route GET /api/tfs/test
|
||||||
@@ -7,10 +9,18 @@ import { tfsService } from '@/services/tfs';
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🔄 Test de connexion TFS...');
|
console.log('🔄 Test de connexion TFS...');
|
||||||
|
|
||||||
// Valider la configuration via le service singleton
|
// Valider la configuration via le service singleton avec l'utilisateur connecté
|
||||||
const configValidation = await tfsService.validateConfig();
|
const configValidation = await tfsService.validateConfig(session.user.id);
|
||||||
if (!configValidation.valid) {
|
if (!configValidation.valid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -22,8 +32,8 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tester la connexion
|
// Tester la connexion avec l'utilisateur connecté
|
||||||
const isConnected = await tfsService.testConnection();
|
const isConnected = await tfsService.testConnection(session.user.id);
|
||||||
|
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
// Test approfondi : récupérer des métadonnées
|
// Test approfondi : récupérer des métadonnées
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraConfig } from '@/lib/types';
|
import { JiraConfig } from '@/lib/types';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/user-preferences/jira-config
|
* GET /api/user-preferences/jira-config
|
||||||
@@ -8,7 +10,14 @@ import { JiraConfig } from '@/lib/types';
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
return NextResponse.json({ jiraConfig });
|
return NextResponse.json({ jiraConfig });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la récupération de la config Jira:', error);
|
console.error('Erreur lors de la récupération de la config Jira:', error);
|
||||||
@@ -25,6 +34,11 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { baseUrl, email, apiToken, projectKey, ignoredProjects } = body;
|
const { baseUrl, email, apiToken, projectKey, ignoredProjects } = body;
|
||||||
|
|
||||||
@@ -62,19 +76,21 @@ export async function PUT(request: NextRequest) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
projectKey: projectKey ? projectKey.trim().toUpperCase() : undefined,
|
projectKey: projectKey ? projectKey.trim().toUpperCase() : undefined,
|
||||||
ignoredProjects: Array.isArray(ignoredProjects)
|
ignoredProjects: Array.isArray(ignoredProjects)
|
||||||
? ignoredProjects.map((p: string) => p.trim().toUpperCase()).filter((p: string) => p.length > 0)
|
? ignoredProjects
|
||||||
: []
|
.map((p: string) => p.trim().toUpperCase())
|
||||||
|
.filter((p: string) => p.length > 0)
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await userPreferencesService.saveJiraConfig(jiraConfig);
|
await userPreferencesService.saveJiraConfig(session.user.id, jiraConfig);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Configuration Jira sauvegardée avec succès',
|
message: 'Configuration Jira sauvegardée avec succès',
|
||||||
jiraConfig: {
|
jiraConfig: {
|
||||||
...jiraConfig,
|
...jiraConfig,
|
||||||
apiToken: '••••••••' // Masquer le token dans la réponse
|
apiToken: '••••••••', // Masquer le token dans la réponse
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la sauvegarde de la config Jira:', error);
|
console.error('Erreur lors de la sauvegarde de la config Jira:', error);
|
||||||
@@ -91,19 +107,24 @@ export async function PUT(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const defaultConfig: JiraConfig = {
|
const defaultConfig: JiraConfig = {
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
email: '',
|
email: '',
|
||||||
apiToken: '',
|
apiToken: '',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
ignoredProjects: []
|
ignoredProjects: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await userPreferencesService.saveJiraConfig(defaultConfig);
|
await userPreferencesService.saveJiraConfig(session.user.id, defaultConfig);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Configuration Jira réinitialisée avec succès'
|
message: 'Configuration Jira réinitialisée avec succès',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la suppression de la config Jira:', error);
|
console.error('Erreur lors de la suppression de la config Jira:', error);
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = await userPreferencesService.getAllPreferences(
|
||||||
|
session.user.id
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: preferences
|
data: preferences,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la récupération des préférences:', error);
|
console.error('Erreur lors de la récupération des préférences:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Erreur lors de la récupération des préférences'
|
error: 'Erreur lors de la récupération des préférences',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
@@ -29,20 +41,31 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Non authentifié' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const preferences = await request.json();
|
const preferences = await request.json();
|
||||||
|
|
||||||
await userPreferencesService.saveAllPreferences(preferences);
|
await userPreferencesService.saveAllPreferences(
|
||||||
|
session.user.id,
|
||||||
|
preferences
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Préférences sauvegardées avec succès'
|
message: 'Préférences sauvegardées avec succès',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la sauvegarde des préférences:', error);
|
console.error('Erreur lors de la sauvegarde des préférences:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Erreur lors de la sauvegarde des préférences'
|
error: 'Erreur lors de la sauvegarde des préférences',
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,27 +3,46 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDaily } from '@/hooks/useDaily';
|
import { useDaily } from '@/hooks/useDaily';
|
||||||
import { DailyView, DailyCheckboxType } from '@/lib/types';
|
import { DailyView, DailyCheckboxType, DailyCheckbox, Task } from '@/lib/types';
|
||||||
|
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
import { TaskCard } from '@/components/ui/TaskCard';
|
||||||
|
import { useTags } from '@/hooks/useTags';
|
||||||
|
import { Calendar } from '@/components/ui/Calendar';
|
||||||
|
import { AlertBanner, AlertItem } from '@/components/ui/AlertBanner';
|
||||||
import { DailySection } from '@/components/daily/DailySection';
|
import { DailySection } from '@/components/daily/DailySection';
|
||||||
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||||
import { dailyClient } from '@/clients/daily-client';
|
import { dailyClient } from '@/clients/daily-client';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
import {
|
||||||
|
getPreviousWorkday,
|
||||||
|
formatDateLong,
|
||||||
|
isToday,
|
||||||
|
generateDateTitle,
|
||||||
|
formatDateForAPI,
|
||||||
|
} from '@/lib/date-utils';
|
||||||
|
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
|
||||||
|
import { Emoji } from '@/components/ui/Emoji';
|
||||||
|
|
||||||
interface DailyPageClientProps {
|
interface DailyPageClientProps {
|
||||||
initialDailyView?: DailyView;
|
initialDailyView?: DailyView;
|
||||||
initialDailyDates?: string[];
|
initialDailyDates?: string[];
|
||||||
|
initialDeadlineDates?: Record<string, string[]>; // Date -> Array de noms de tâches
|
||||||
initialDate?: Date;
|
initialDate?: Date;
|
||||||
|
initialDeadlineMetrics?: DeadlineMetrics | null;
|
||||||
|
initialPendingTasks?: DailyCheckbox[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DailyPageClient({
|
export function DailyPageClient({
|
||||||
initialDailyView,
|
initialDailyView,
|
||||||
initialDailyDates = [],
|
initialDailyDates = [],
|
||||||
initialDate
|
initialDeadlineDates = {},
|
||||||
|
initialDate,
|
||||||
|
initialDeadlineMetrics,
|
||||||
|
initialPendingTasks = [],
|
||||||
}: DailyPageClientProps = {}) {
|
}: DailyPageClientProps = {}) {
|
||||||
|
const { tags: availableTags } = useTags();
|
||||||
const {
|
const {
|
||||||
dailyView,
|
dailyView,
|
||||||
loading,
|
loading,
|
||||||
@@ -43,11 +62,15 @@ export function DailyPageClient({
|
|||||||
goToNextDay,
|
goToNextDay,
|
||||||
goToToday,
|
goToToday,
|
||||||
setDate,
|
setDate,
|
||||||
refreshDailySilent
|
refreshDailySilent,
|
||||||
} = useDaily(initialDate, initialDailyView);
|
} = useDaily(initialDate, initialDailyView);
|
||||||
|
|
||||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [deadlineDates, setDeadlineDates] =
|
||||||
|
useState<Record<string, string[]>>(initialDeadlineDates);
|
||||||
|
const [deadlineTasks, setDeadlineTasks] = useState<Task[]>([]);
|
||||||
|
const [loadingDeadlineTasks, setLoadingDeadlineTasks] = useState(false);
|
||||||
|
const [pendingRefreshTrigger, setPendingRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
// Fonction pour rafraîchir la liste des dates avec des dailies
|
// Fonction pour rafraîchir la liste des dates avec des dailies
|
||||||
const refreshDailyDates = async () => {
|
const refreshDailyDates = async () => {
|
||||||
@@ -62,42 +85,84 @@ export function DailyPageClient({
|
|||||||
// Charger les dates avec des dailies pour le calendrier (seulement si pas de données SSR)
|
// Charger les dates avec des dailies pour le calendrier (seulement si pas de données SSR)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialDailyDates.length === 0) {
|
if (initialDailyDates.length === 0) {
|
||||||
import('@/clients/daily-client').then(({ dailyClient }) => {
|
import('@/clients/daily-client')
|
||||||
|
.then(({ dailyClient }) => {
|
||||||
return dailyClient.getDailyDates();
|
return dailyClient.getDailyDates();
|
||||||
}).then(setDailyDates).catch(console.error);
|
})
|
||||||
|
.then(setDailyDates)
|
||||||
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}, [initialDailyDates.length]);
|
}, [initialDailyDates.length]);
|
||||||
|
|
||||||
const handleAddTodayCheckbox = async (text: string, type: DailyCheckboxType) => {
|
// Charger les dates de fin pour le calendrier (seulement si pas de données SSR)
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(initialDeadlineDates).length === 0) {
|
||||||
|
import('@/clients/daily-client')
|
||||||
|
.then(({ dailyClient }) => {
|
||||||
|
return dailyClient.getDeadlineDates();
|
||||||
|
})
|
||||||
|
.then(setDeadlineDates)
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}, [initialDeadlineDates]);
|
||||||
|
|
||||||
|
const handleAddTodayCheckbox = async (
|
||||||
|
text: string,
|
||||||
|
type: DailyCheckboxType
|
||||||
|
) => {
|
||||||
await addTodayCheckbox(text, type);
|
await addTodayCheckbox(text, type);
|
||||||
// Recharger aussi les dates pour le calendrier
|
// Recharger aussi les dates pour le calendrier
|
||||||
await refreshDailyDates();
|
await refreshDailyDates();
|
||||||
|
setPendingRefreshTrigger((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddYesterdayCheckbox = async (text: string, type: DailyCheckboxType) => {
|
const handleAddYesterdayCheckbox = async (
|
||||||
|
text: string,
|
||||||
|
type: DailyCheckboxType
|
||||||
|
) => {
|
||||||
await addYesterdayCheckbox(text, type);
|
await addYesterdayCheckbox(text, type);
|
||||||
// Recharger aussi les dates pour le calendrier
|
// Recharger aussi les dates pour le calendrier
|
||||||
await refreshDailyDates();
|
await refreshDailyDates();
|
||||||
|
setPendingRefreshTrigger((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Raccourcis clavier globaux pour la page Daily
|
||||||
|
useGlobalKeyboardShortcuts({
|
||||||
|
onNavigatePrevious: goToPreviousDay,
|
||||||
|
onNavigateNext: goToNextDay,
|
||||||
|
onGoToToday: goToToday,
|
||||||
|
});
|
||||||
|
|
||||||
const handleToggleCheckbox = async (checkboxId: string) => {
|
const handleToggleCheckbox = async (checkboxId: string) => {
|
||||||
await toggleCheckbox(checkboxId);
|
await toggleCheckbox(checkboxId);
|
||||||
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
setPendingRefreshTrigger((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCheckbox = async (checkboxId: string) => {
|
const handleDeleteCheckbox = async (checkboxId: string) => {
|
||||||
await deleteCheckbox(checkboxId);
|
await deleteCheckbox(checkboxId);
|
||||||
// Refresh dates après suppression pour mettre à jour le calendrier
|
// Refresh dates après suppression pour mettre à jour le calendrier
|
||||||
await refreshDailyDates();
|
await refreshDailyDates();
|
||||||
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
setPendingRefreshTrigger((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
const handleUpdateCheckbox = async (
|
||||||
|
checkboxId: string,
|
||||||
|
text: string,
|
||||||
|
type: DailyCheckboxType,
|
||||||
|
taskId?: string,
|
||||||
|
date?: Date
|
||||||
|
) => {
|
||||||
await updateCheckbox(checkboxId, {
|
await updateCheckbox(checkboxId, {
|
||||||
text,
|
text,
|
||||||
type,
|
type,
|
||||||
taskId // Permet la liaison tâche pour tous les types
|
taskId, // Permet la liaison tâche pour tous les types
|
||||||
|
date, // Permet la modification de la date/heure
|
||||||
});
|
});
|
||||||
|
// Refresh dates après modification pour mettre à jour le calendrier si la date a changé
|
||||||
|
if (date) {
|
||||||
|
await refreshDailyDates();
|
||||||
|
}
|
||||||
|
setPendingRefreshTrigger((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReorderCheckboxes = async (date: Date, checkboxIds: string[]) => {
|
const handleReorderCheckboxes = async (date: Date, checkboxIds: string[]) => {
|
||||||
@@ -120,20 +185,98 @@ export function DailyPageClient({
|
|||||||
return formatDateLong(currentDate);
|
return formatDateLong(currentDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Charger les tâches complètes pour la date sélectionnée
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDeadlineTasks = async () => {
|
||||||
|
const dateKey = formatDateForAPI(currentDate);
|
||||||
|
if (deadlineDates[dateKey] && deadlineDates[dateKey].length > 0) {
|
||||||
|
setLoadingDeadlineTasks(true);
|
||||||
|
try {
|
||||||
|
const tasks = await dailyClient.getDeadlineTasksForDate(currentDate);
|
||||||
|
setDeadlineTasks(tasks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des tâches:', error);
|
||||||
|
setDeadlineTasks([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingDeadlineTasks(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDeadlineTasks([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDeadlineTasks();
|
||||||
|
}, [currentDate, deadlineDates]);
|
||||||
|
|
||||||
const isTodayDate = () => {
|
const isTodayDate = () => {
|
||||||
return isToday(currentDate);
|
return isToday(currentDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTodayTitle = () => {
|
const getTodayTitle = () => {
|
||||||
return generateDateTitle(currentDate, '🎯');
|
const { emoji, text } = generateDateTitle(currentDate, '🎯');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Emoji emoji={emoji} /> {text}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getYesterdayTitle = () => {
|
const getYesterdayTitle = () => {
|
||||||
const yesterdayDate = getYesterdayDate();
|
const yesterdayDate = getYesterdayDate();
|
||||||
if (isYesterday(yesterdayDate)) {
|
const { emoji, text } = generateDateTitle(yesterdayDate, '📋');
|
||||||
return "📋 Hier";
|
return (
|
||||||
|
<>
|
||||||
|
<Emoji emoji={emoji} /> {text}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convertir les métriques de deadline en AlertItem
|
||||||
|
const convertDeadlineMetricsToAlertItems = (
|
||||||
|
metrics: DeadlineMetrics | null
|
||||||
|
): AlertItem[] => {
|
||||||
|
if (!metrics) return [];
|
||||||
|
|
||||||
|
const urgentTasks = [
|
||||||
|
...metrics.overdue,
|
||||||
|
...metrics.critical,
|
||||||
|
...metrics.warning,
|
||||||
|
].sort((a, b) => {
|
||||||
|
const urgencyOrder: Record<string, number> = {
|
||||||
|
overdue: 0,
|
||||||
|
critical: 1,
|
||||||
|
warning: 2,
|
||||||
|
};
|
||||||
|
if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) {
|
||||||
|
return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
|
||||||
}
|
}
|
||||||
return `📋 ${formatDateShort(yesterdayDate)}`;
|
return a.daysRemaining - b.daysRemaining;
|
||||||
|
});
|
||||||
|
|
||||||
|
return urgentTasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
icon:
|
||||||
|
task.urgencyLevel === 'overdue'
|
||||||
|
? '🔴'
|
||||||
|
: task.urgencyLevel === 'critical'
|
||||||
|
? '🟠'
|
||||||
|
: '🟡',
|
||||||
|
urgency: task.urgencyLevel as 'low' | 'medium' | 'high' | 'critical',
|
||||||
|
source: task.source,
|
||||||
|
metadata:
|
||||||
|
task.urgencyLevel === 'overdue'
|
||||||
|
? task.daysRemaining === -1
|
||||||
|
? 'En retard de 1 jour'
|
||||||
|
: `En retard de ${Math.abs(task.daysRemaining)} jours`
|
||||||
|
: task.urgencyLevel === 'critical'
|
||||||
|
? task.daysRemaining === 0
|
||||||
|
? "Échéance aujourd'hui"
|
||||||
|
: task.daysRemaining === 1
|
||||||
|
? 'Échéance demain'
|
||||||
|
: `Dans ${task.daysRemaining} jours`
|
||||||
|
: `Dans ${task.daysRemaining} jours`,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -211,8 +354,24 @@ export function DailyPageClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rappel des échéances urgentes - Desktop uniquement */}
|
||||||
|
<div className="hidden sm:block container mx-auto px-4 pt-4 pb-2">
|
||||||
|
<AlertBanner
|
||||||
|
title="Rappel - Tâches urgentes"
|
||||||
|
items={convertDeadlineMetricsToAlertItems(
|
||||||
|
initialDeadlineMetrics || null
|
||||||
|
)}
|
||||||
|
icon="⚠️"
|
||||||
|
variant="warning"
|
||||||
|
onItemClick={(item) => {
|
||||||
|
// Rediriger vers la page Kanban avec la tâche sélectionnée
|
||||||
|
window.location.href = `/kanban?taskId=${item.id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-6 sm:py-4">
|
||||||
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
||||||
<div className="block sm:hidden">
|
<div className="block sm:hidden">
|
||||||
{dailyView && (
|
{dailyView && (
|
||||||
@@ -233,11 +392,59 @@ export function DailyPageClient({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Calendrier en bas sur mobile */}
|
{/* Calendrier en bas sur mobile */}
|
||||||
<DailyCalendar
|
<Calendar
|
||||||
currentDate={currentDate}
|
currentDate={currentDate}
|
||||||
onDateSelect={handleDateSelect}
|
onDateSelect={handleDateSelect}
|
||||||
dailyDates={dailyDates}
|
markedDates={dailyDates}
|
||||||
|
deadlineDates={deadlineDates}
|
||||||
|
showTodayButton={true}
|
||||||
|
showLegend={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Section des tâches avec deadline pour la date sélectionnée - Mobile */}
|
||||||
|
{deadlineTasks.length > 0 && (
|
||||||
|
<Card variant="glass">
|
||||||
|
<CardHeader padding="sm" separator={false}>
|
||||||
|
<CardTitle size="sm" className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[var(--destructive)]"></div>
|
||||||
|
Tâches à terminer
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent padding="sm">
|
||||||
|
{loadingDeadlineTasks ? (
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)] text-center py-4">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deadlineTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
variant="compact"
|
||||||
|
title={task.title}
|
||||||
|
description={task.description}
|
||||||
|
tags={task.tags}
|
||||||
|
primaryTagId={task.primaryTagId}
|
||||||
|
priority={task.priority}
|
||||||
|
status={task.status}
|
||||||
|
dueDate={task.dueDate}
|
||||||
|
source={task.source}
|
||||||
|
jiraKey={task.jiraKey}
|
||||||
|
jiraProject={task.jiraProject}
|
||||||
|
jiraType={task.jiraType}
|
||||||
|
todosCount={task.todosCount}
|
||||||
|
availableTags={availableTags}
|
||||||
|
fontSize="small"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `/kanban?taskId=${task.id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -246,12 +453,60 @@ export function DailyPageClient({
|
|||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
{/* Calendrier - Desktop */}
|
{/* Calendrier - Desktop */}
|
||||||
<div className="xl:col-span-1">
|
<div className="xl:col-span-1 space-y-6">
|
||||||
<DailyCalendar
|
<Calendar
|
||||||
currentDate={currentDate}
|
currentDate={currentDate}
|
||||||
onDateSelect={handleDateSelect}
|
onDateSelect={handleDateSelect}
|
||||||
dailyDates={dailyDates}
|
markedDates={dailyDates}
|
||||||
|
deadlineDates={deadlineDates}
|
||||||
|
showTodayButton={true}
|
||||||
|
showLegend={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Section des tâches avec deadline pour la date sélectionnée */}
|
||||||
|
{deadlineTasks.length > 0 && (
|
||||||
|
<Card variant="glass">
|
||||||
|
<CardHeader padding="sm" separator={false}>
|
||||||
|
<CardTitle size="sm" className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[var(--destructive)]"></div>
|
||||||
|
Tâches à terminer
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent padding="sm">
|
||||||
|
{loadingDeadlineTasks ? (
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)] text-center py-4">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deadlineTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
variant="compact"
|
||||||
|
title={task.title}
|
||||||
|
description={task.description}
|
||||||
|
tags={task.tags}
|
||||||
|
primaryTagId={task.primaryTagId}
|
||||||
|
priority={task.priority}
|
||||||
|
status={task.status}
|
||||||
|
dueDate={task.dueDate}
|
||||||
|
source={task.source}
|
||||||
|
jiraKey={task.jiraKey}
|
||||||
|
jiraProject={task.jiraProject}
|
||||||
|
jiraType={task.jiraType}
|
||||||
|
todosCount={task.todosCount}
|
||||||
|
availableTags={availableTags}
|
||||||
|
fontSize="small"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `/kanban?taskId=${task.id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sections daily - Desktop */}
|
{/* Sections daily - Desktop */}
|
||||||
@@ -296,7 +551,8 @@ export function DailyPageClient({
|
|||||||
onToggleCheckbox={handleToggleCheckbox}
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
onDeleteCheckbox={handleDeleteCheckbox}
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
onRefreshDaily={refreshDailySilent}
|
onRefreshDaily={refreshDailySilent}
|
||||||
refreshTrigger={refreshTrigger}
|
refreshTrigger={pendingRefreshTrigger}
|
||||||
|
initialPendingTasks={initialPendingTasks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Footer avec stats - dans le flux normal */}
|
{/* Footer avec stats - dans le flux normal */}
|
||||||
@@ -305,9 +561,20 @@ export function DailyPageClient({
|
|||||||
<div className="text-center text-sm text-[var(--muted-foreground)] font-mono">
|
<div className="text-center text-sm text-[var(--muted-foreground)] font-mono">
|
||||||
Daily pour {formatCurrentDate()}
|
Daily pour {formatCurrentDate()}
|
||||||
{' • '}
|
{' • '}
|
||||||
{dailyView.yesterday.length + dailyView.today.length} tâche{dailyView.yesterday.length + dailyView.today.length > 1 ? 's' : ''} au total
|
{dailyView.yesterday.length + dailyView.today.length} tâche
|
||||||
|
{dailyView.yesterday.length + dailyView.today.length > 1
|
||||||
|
? 's'
|
||||||
|
: ''}{' '}
|
||||||
|
au total
|
||||||
{' • '}
|
{' • '}
|
||||||
{dailyView.yesterday.filter(cb => cb.isChecked).length + dailyView.today.filter(cb => cb.isChecked).length} complétée{(dailyView.yesterday.filter(cb => cb.isChecked).length + dailyView.today.filter(cb => cb.isChecked).length) > 1 ? 's' : ''}
|
{dailyView.yesterday.filter((cb) => cb.isChecked).length +
|
||||||
|
dailyView.today.filter((cb) => cb.isChecked).length}{' '}
|
||||||
|
complétée
|
||||||
|
{dailyView.yesterday.filter((cb) => cb.isChecked).length +
|
||||||
|
dailyView.today.filter((cb) => cb.isChecked).length >
|
||||||
|
1
|
||||||
|
? 's'
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||