Compare commits
270 Commits
backup_bef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
040efcdf29 | ||
|
|
b969ab2bd8 | ||
|
|
c437baa8e6 | ||
|
|
69f8dcb7bf | ||
|
|
7c777a87fb | ||
|
|
1eef15f502 | ||
|
|
1ea76fed64 | ||
|
|
75d31e86ac | ||
|
|
31d01c2926 | ||
|
|
2354a353d1 | ||
|
|
38ccaf8785 | ||
|
|
13070790cc | ||
|
|
6c4c6992a9 | ||
|
|
7ce8057c6b | ||
|
|
5415247f47 | ||
|
|
f57ea205c7 | ||
|
|
4fc41a5b2c | ||
|
|
4c0f227e27 | ||
|
|
ddba4eca37 | ||
|
|
411bac8162 | ||
|
|
4496cd97f9 | ||
|
|
5c9b2b9d8f | ||
|
|
a1d631037e | ||
|
|
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 | ||
|
|
ee64fe2ff3 | ||
|
|
e36291a552 | ||
|
|
723a44df32 | ||
|
|
472135a97f | ||
|
|
b5d53ef0f1 | ||
|
|
f9d0641d77 | ||
|
|
361fc0eaac | ||
|
|
2194744eef | ||
|
|
8be5cb6f70 | ||
|
|
3cfed60f43 | ||
|
|
0a03e40469 | ||
|
|
c650c67627 | ||
|
|
4ba6ba2c0b | ||
|
|
c3c1d24fa2 | ||
|
|
557cdebc13 | ||
|
|
799a21df5c | ||
|
|
a0e2a78372 | ||
|
|
4152b0bdfc | ||
|
|
9dc1fafa76 | ||
|
|
d7140507e5 | ||
|
|
43998425e6 | ||
|
|
618e774a30 | ||
|
|
c5bfcc50f8 | ||
|
|
6e2b0abc8d | ||
|
|
9da824993d | ||
|
|
e88b1aad32 | ||
|
|
3c20df95d9 | ||
|
|
da0565472d | ||
|
|
9a33d1ee48 | ||
|
|
ee442de773 | ||
|
|
329018161c | ||
|
|
dfa8d34855 | ||
|
|
339661aa13 | ||
|
|
9d0b6da3a0 | ||
|
|
888e81d15d | ||
|
|
c4d8bacd97 | ||
|
|
d6722e90d1 | ||
|
|
f16ae2e017 | ||
|
|
fded7d0078 | ||
|
|
f9c0035c82 | ||
|
|
dfeac94993 |
@@ -9,7 +9,7 @@ description: Enforce business logic separation between frontend and backend
|
|||||||
|
|
||||||
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
|
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
|
||||||
|
|
||||||
## ✅ ALLOWED in Frontend ([components/](mdc:components/), [hooks/](mdc:hooks/), [clients/](mdc:clients/))
|
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
- UI rendering and presentation logic
|
- UI rendering and presentation logic
|
||||||
@@ -73,7 +73,7 @@ const calculateTeamVelocity = (sprints) => {
|
|||||||
// This belongs in services/team-analytics.ts
|
// This belongs in services/team-analytics.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## ✅ REQUIRED in Backend ([services/](mdc:services/), [app/api/](mdc:app/api/))
|
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
|
||||||
|
|
||||||
### Services Layer
|
### Services Layer
|
||||||
- All business rules and domain logic
|
- All business rules and domain logic
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
globs: components/**/*.tsx
|
globs: src/components/**/*.tsx
|
||||||
---
|
---
|
||||||
|
|
||||||
# Components Rules
|
# Components Rules
|
||||||
|
|
||||||
1. UI components MUST be in components/ui/
|
1. UI components MUST be in src/components/ui/
|
||||||
2. Feature components MUST be in their feature folder
|
2. Feature components MUST be in their feature folder
|
||||||
3. Components MUST use clients for data fetching
|
3. Components MUST use clients for data fetching
|
||||||
4. Components MUST be properly typed
|
4. Components MUST be properly typed
|
||||||
|
|||||||
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.**
|
||||||
@@ -5,26 +5,26 @@ alwaysApply: true
|
|||||||
# Project Structure Rules
|
# Project Structure Rules
|
||||||
|
|
||||||
1. Backend:
|
1. Backend:
|
||||||
- [services/](mdc:services/) - ALL database access
|
- [src/services/](mdc:src/services/) - ALL database access
|
||||||
- [app/api/](mdc:app/api/) - API routes using services
|
- [src/app/api/](mdc:src/app/api/) - API routes using services
|
||||||
|
|
||||||
2. Frontend:
|
2. Frontend:
|
||||||
- [clients/](mdc:clients/) - HTTP clients
|
- [src/clients/](mdc:src/clients/) - HTTP clients
|
||||||
- [components/](mdc:components/) - React components (organized by domain)
|
- [src/components/](mdc:src/components/) - React components (organized by domain)
|
||||||
- [hooks/](mdc:hooks/) - React hooks
|
- [src/hooks/](mdc:src/hooks/) - React hooks
|
||||||
|
|
||||||
3. Shared:
|
3. Shared:
|
||||||
- [lib/](mdc:lib/) - Types and utilities
|
- [src/lib/](mdc:src/lib/) - Types and utilities
|
||||||
- [scripts/](mdc:scripts/) - Utility scripts
|
- [scripts/](mdc:scripts/) - Utility scripts
|
||||||
|
|
||||||
Key Files:
|
Key Files:
|
||||||
|
|
||||||
- [services/database.ts](mdc:services/database.ts) - Database pool
|
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
|
||||||
- [clients/base/http-client.ts](mdc:clients/base/http-client.ts) - Base HTTP client
|
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
|
||||||
- [lib/types.ts](mdc:lib/types.ts) - Shared types
|
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
|
||||||
|
|
||||||
❌ FORBIDDEN:
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
- Database access outside services/
|
- Database access outside src/services/
|
||||||
- HTTP calls outside clients/
|
- HTTP calls outside src/clients/
|
||||||
- Business logic in components/
|
- Business logic in src/components/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
globs: services/*.ts
|
globs: src/services/*.ts
|
||||||
---
|
---
|
||||||
|
|
||||||
# Services Rules
|
# Services Rules
|
||||||
@@ -7,7 +7,7 @@ globs: services/*.ts
|
|||||||
1. Services MUST contain ALL PostgreSQL queries
|
1. Services MUST contain ALL PostgreSQL queries
|
||||||
2. Services are the ONLY layer allowed to communicate with the database
|
2. Services are the ONLY layer allowed to communicate with the database
|
||||||
3. Each service MUST:
|
3. Each service MUST:
|
||||||
- Use the pool from [services/database.ts](mdc:services/database.ts)
|
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
|
||||||
- Implement proper transaction management
|
- Implement proper transaction management
|
||||||
- Handle errors and logging
|
- Handle errors and logging
|
||||||
- Validate data before insertion
|
- Validate data before insertion
|
||||||
@@ -37,6 +37,6 @@ export class MyService {
|
|||||||
|
|
||||||
❌ FORBIDDEN:
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
- Direct database queries outside services
|
- Direct database queries outside src/services
|
||||||
- Raw SQL in API routes
|
- Raw SQL in API routes
|
||||||
- Database logic in components
|
- Database logic in components
|
||||||
|
|||||||
10
.gitignore
vendored
@@ -41,5 +41,13 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
/prisma/dev.db
|
||||||
|
|
||||||
backups/
|
/data/*.db
|
||||||
|
/data/backups/*
|
||||||
|
/data/uploads/notes/*
|
||||||
|
!/data/uploads/notes/.gitkeep
|
||||||
|
|
||||||
|
# Uploaded images (legacy - now using data/uploads)
|
||||||
|
/public/uploads/notes/*
|
||||||
|
!/public/uploads/notes/.gitkeep
|
||||||
|
|||||||
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.
|
||||||
117
BACKUP.md
@@ -40,16 +40,29 @@ 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via variable d'environnement permanente (.env)
|
||||||
|
BACKUP_STORAGE_PATH="./custom-backups"
|
||||||
|
|
||||||
|
# Via variable temporaire (une seule fois)
|
||||||
|
BACKUP_STORAGE_PATH="./my-backups" pnpm run backup:create
|
||||||
|
|
||||||
|
# Exemple avec un chemin absolu
|
||||||
|
BACKUP_STORAGE_PATH="/var/backups/towercontrol" pnpm run backup:create
|
||||||
```
|
```
|
||||||
|
|
||||||
## Utilisation
|
## Utilisation
|
||||||
@@ -57,12 +70,14 @@ tsx scripts/backup-manager.ts config-set compression=true
|
|||||||
### 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)
|
||||||
@@ -72,29 +87,29 @@ tsx scripts/backup-manager.ts config-set compression=true
|
|||||||
|
|
||||||
```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
|
||||||
@@ -115,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
|
||||||
@@ -140,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)
|
||||||
@@ -159,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é
|
||||||
@@ -184,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
|
||||||
@@ -208,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
|
||||||
@@ -223,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
|
||||||
@@ -232,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/
|
||||||
@@ -241,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
|
||||||
@@ -255,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 =
|
||||||
log: ['query', 'info', 'warn', 'error'], // Debug activé
|
globalThis.__prisma ||
|
||||||
});
|
new PrismaClient({
|
||||||
|
log: ['query', 'info', 'warn', 'error'], // Debug activé
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
@@ -272,8 +295,35 @@ export const prisma = globalThis.__prisma || new PrismaClient({
|
|||||||
### Variables d'environnement
|
### Variables d'environnement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Optionnel : personnaliser le chemin de la base
|
# Configuration des chemins de base de données
|
||||||
DATABASE_URL="file:./custom/path/dev.db"
|
DATABASE_URL="file:./prisma/dev.db" # Pour Prisma
|
||||||
|
BACKUP_DATABASE_PATH="./prisma/dev.db" # Base à sauvegarder (optionnel)
|
||||||
|
BACKUP_STORAGE_PATH="./backups" # Dossier des sauvegardes (optionnel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
En environnement Docker, tout est centralisé dans le dossier `data/` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: 'file:./data/prod.db' # Base de données Prisma
|
||||||
|
BACKUP_DATABASE_PATH: './data/prod.db' # Base à sauvegarder
|
||||||
|
BACKUP_STORAGE_PATH: './data/backups' # Dossier des sauvegardes
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data # Bind mount vers dossier local
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure des dossiers :**
|
||||||
|
|
||||||
|
```
|
||||||
|
./data/ # Dossier local mappé
|
||||||
|
├── prod.db # Base de données production
|
||||||
|
├── dev.db # Base de données développement
|
||||||
|
└── backups/ # Sauvegardes (créé automatiquement)
|
||||||
|
├── towercontrol_*.db.gz
|
||||||
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -294,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
|
||||||
@@ -327,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
|
||||||
|
|
||||||
|
|||||||
217
DOCKER.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# 🐳 Docker - TowerControl
|
||||||
|
|
||||||
|
Guide d'utilisation de TowerControl avec Docker.
|
||||||
|
|
||||||
|
## 🚀 Démarrage rapide
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrer le service de production
|
||||||
|
docker-compose up -d towercontrol
|
||||||
|
|
||||||
|
# Accéder à l'application
|
||||||
|
open http://localhost:3006
|
||||||
|
```
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrer le service de développement avec live reload
|
||||||
|
docker-compose --profile dev up towercontrol-dev
|
||||||
|
|
||||||
|
# Accéder à l'application
|
||||||
|
open http://localhost:3005
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Services disponibles
|
||||||
|
|
||||||
|
### 🚀 `towercontrol` (Production)
|
||||||
|
|
||||||
|
- **Port** : 3006
|
||||||
|
- **Base de données** : `./data/prod.db`
|
||||||
|
- **Sauvegardes** : `./data/backups/`
|
||||||
|
- **Mode** : Optimisé, standalone
|
||||||
|
- **Restart** : Automatique
|
||||||
|
|
||||||
|
### 🛠️ `towercontrol-dev` (Développement)
|
||||||
|
|
||||||
|
- **Port** : 3005
|
||||||
|
- **Base de données** : `./data/dev.db`
|
||||||
|
- **Sauvegardes** : `./data/backups/` (partagées)
|
||||||
|
- **Mode** : Live reload, debug
|
||||||
|
- **Profile** : `dev`
|
||||||
|
|
||||||
|
## 📁 Structure des données
|
||||||
|
|
||||||
|
```
|
||||||
|
./data/ # Mappé vers /app/data dans les conteneurs
|
||||||
|
├── README.md # Documentation du dossier data
|
||||||
|
├── prod.db # Base SQLite production
|
||||||
|
├── dev.db # Base SQLite développement
|
||||||
|
└── backups/ # Sauvegardes automatiques
|
||||||
|
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Variables d'environnement
|
||||||
|
|
||||||
|
| Variable | Production | Développement | Description |
|
||||||
|
| ---------------------- | --------------------- | -------------------- | ---------------- |
|
||||||
|
| `NODE_ENV` | `production` | `development` | Mode d'exécution |
|
||||||
|
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
|
||||||
|
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
|
||||||
|
| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup |
|
||||||
|
| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire |
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
- **Production** : `3006:3000`
|
||||||
|
- **Développement** : `3005:3000`
|
||||||
|
|
||||||
|
## 📚 Commandes utiles
|
||||||
|
|
||||||
|
### Gestion des conteneurs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Voir les logs
|
||||||
|
docker-compose logs -f towercontrol
|
||||||
|
docker-compose logs -f towercontrol-dev
|
||||||
|
|
||||||
|
# Arrêter les services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Reconstruire les images
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Nettoyer tout
|
||||||
|
docker-compose down -v --rmi all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestion des données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sauvegarder les données
|
||||||
|
docker-compose exec towercontrol pnpm run backup:create
|
||||||
|
|
||||||
|
# Lister les sauvegardes
|
||||||
|
docker-compose exec towercontrol pnpm run backup:list
|
||||||
|
|
||||||
|
# Accéder au shell du conteneur
|
||||||
|
docker-compose exec towercontrol sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Base de données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Migrations Prisma
|
||||||
|
docker-compose exec towercontrol pnpm prisma migrate deploy
|
||||||
|
|
||||||
|
# Reset de la base (dev uniquement)
|
||||||
|
docker-compose exec towercontrol-dev pnpm prisma migrate reset
|
||||||
|
|
||||||
|
# Studio Prisma (dev)
|
||||||
|
docker-compose exec towercontrol-dev pnpm prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Debugging
|
||||||
|
|
||||||
|
### Vérifier la santé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3006/api/health
|
||||||
|
curl http://localhost:3005/api/health
|
||||||
|
|
||||||
|
# Vérifier les variables d'env
|
||||||
|
docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs détaillés
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs avec timestamps
|
||||||
|
docker-compose logs -f -t towercontrol
|
||||||
|
|
||||||
|
# Logs des 100 dernières lignes
|
||||||
|
docker-compose logs --tail=100 towercontrol
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Dépannage
|
||||||
|
|
||||||
|
### Problèmes courants
|
||||||
|
|
||||||
|
**Port déjà utilisé**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trouver le processus qui utilise le port
|
||||||
|
lsof -i :3006
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Base de données corrompue**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restaurer depuis une sauvegarde
|
||||||
|
docker-compose exec towercontrol pnpm run backup:restore filename.db.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permissions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Corriger les permissions du dossier data
|
||||||
|
sudo chown -R $USER:$USER ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Espace disque
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Taille du dossier data
|
||||||
|
du -sh ./data
|
||||||
|
|
||||||
|
# Espace libre
|
||||||
|
df -h .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stats des conteneurs
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Utilisation mémoire
|
||||||
|
docker-compose exec towercontrol free -h
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Production
|
||||||
|
|
||||||
|
### Recommandations
|
||||||
|
|
||||||
|
- Utiliser un reverse proxy (nginx, traefik)
|
||||||
|
- Configurer HTTPS
|
||||||
|
- Sauvegarder régulièrement `./data/`
|
||||||
|
- Monitorer l'espace disque
|
||||||
|
- Logs centralisés
|
||||||
|
|
||||||
|
### Exemple nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name towercontrol.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3006;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
📚 **Voir aussi** : [data/README.md](./data/README.md)
|
||||||
46
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
|
# Set timezone to Europe/Paris and install sqlite3 for backups
|
||||||
RUN apk add --no-cache tzdata
|
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
|
# Copy pnpm node_modules (includes .pnpm store with Prisma client)
|
||||||
RUN mkdir -p /app/data && chown 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
|
||||||
270
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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -289,10 +473,10 @@ npm run seed # Ajouter des données de test
|
|||||||
```typescript
|
```typescript
|
||||||
// lib/config.ts
|
// lib/config.ts
|
||||||
export const UI_CONFIG = {
|
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
|
||||||
|
|||||||
116
TFS_UPGRADE_SUMMARY.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
|
||||||
|
|
||||||
|
## 🎯 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.
|
||||||
|
|
||||||
|
## ⚡ Changements apportés
|
||||||
|
|
||||||
|
### 1. Service TFS (`src/services/tfs.ts`)
|
||||||
|
|
||||||
|
#### Nouvelles méthodes ajoutées :
|
||||||
|
|
||||||
|
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
|
||||||
|
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
|
||||||
|
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
|
||||||
|
- **`filterPullRequests()`** : Applique les filtres de configuration
|
||||||
|
|
||||||
|
#### Méthode syncTasks refactorisée :
|
||||||
|
|
||||||
|
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
|
||||||
|
- Plus efficace et centrée sur l'utilisateur
|
||||||
|
- Récupération directe via l'API Azure DevOps avec critères `@me`
|
||||||
|
|
||||||
|
#### Configuration mise à jour :
|
||||||
|
|
||||||
|
- **`projectName`** devient **optionnel**
|
||||||
|
- Validation assouplie dans les factories
|
||||||
|
- Comportement adaptatif : projet spécifique OU toute l'organisation
|
||||||
|
|
||||||
|
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
|
||||||
|
|
||||||
|
#### Modifications du formulaire :
|
||||||
|
|
||||||
|
- Champ "Nom du projet" marqué comme **optionnel**
|
||||||
|
- Validation `required` supprimée
|
||||||
|
- Placeholder mis à jour : _"laisser vide pour toute l'organisation"_
|
||||||
|
- Affichage du statut : _"Toute l'organisation"_ si pas de projet
|
||||||
|
|
||||||
|
#### Instructions mises à jour :
|
||||||
|
|
||||||
|
- Explique le nouveau comportement **synchronisation intelligente**
|
||||||
|
- Précise que les PRs sont récupérées automatiquement selon l'assignation
|
||||||
|
- Note sur la portée projet vs organisation
|
||||||
|
|
||||||
|
### 3. Endpoints API
|
||||||
|
|
||||||
|
#### `/api/tfs/test/route.ts`
|
||||||
|
|
||||||
|
- Validation mise à jour (projectName optionnel)
|
||||||
|
- Message de réponse enrichi avec portée (projet/organisation)
|
||||||
|
- Retour détaillé du scope de synchronisation
|
||||||
|
|
||||||
|
#### `/api/tfs/sync/route.ts`
|
||||||
|
|
||||||
|
- Validation assouplie pour les deux méthodes GET/POST
|
||||||
|
- Configuration adaptative selon la présence du projectName
|
||||||
|
|
||||||
|
## 🔧 API Azure DevOps utilisées
|
||||||
|
|
||||||
|
### Nouvelles requêtes :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PRs créées par l'utilisateur
|
||||||
|
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
|
||||||
|
|
||||||
|
// PRs où je suis reviewer
|
||||||
|
/_apis/git/pullrequests?searchCriteria.reviewerId=@me&searchCriteria.status=active
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comportement intelligent :
|
||||||
|
|
||||||
|
- **Fusion automatique** des deux types de PRs
|
||||||
|
- **Déduplication** basée sur `pullRequestId`
|
||||||
|
- **Filtrage** selon la configuration (repositories, branches, projet)
|
||||||
|
|
||||||
|
## 📊 Avantages
|
||||||
|
|
||||||
|
1. **Centré utilisateur** : Récupère seulement les PRs pertinentes
|
||||||
|
2. **Performance améliorée** : Une seule requête API au lieu de parcourir tous les repos
|
||||||
|
3. **Flexibilité** : Projet spécifique OU toute l'organisation
|
||||||
|
4. **Scalabilité** : Fonctionne avec des organisations de grande taille
|
||||||
|
5. **Simplicité** : Configuration minimale requise
|
||||||
|
|
||||||
|
## 🎨 Interface utilisateur
|
||||||
|
|
||||||
|
### Avant :
|
||||||
|
|
||||||
|
- Champ projet **obligatoire**
|
||||||
|
- Synchronisation limitée à UN projet
|
||||||
|
- Configuration rigide
|
||||||
|
|
||||||
|
### Après :
|
||||||
|
|
||||||
|
- Champ projet **optionnel**
|
||||||
|
- Synchronisation intelligente de TOUTES les PRs assignées
|
||||||
|
- Configuration flexible et adaptative
|
||||||
|
- Instructions claires sur le comportement
|
||||||
|
|
||||||
|
## ✅ Tests recommandés
|
||||||
|
|
||||||
|
1. **Configuration avec projet spécifique** : Vérifier le filtrage par projet
|
||||||
|
2. **Configuration sans projet** : Vérifier la récupération organisation complète
|
||||||
|
3. **Test de connexion** : Valider le nouveau comportement API
|
||||||
|
4. **Synchronisation** : Contrôler que seules les PRs assignées sont récupérées
|
||||||
|
|
||||||
|
## 🚀 Déploiement
|
||||||
|
|
||||||
|
La migration est **transparente** :
|
||||||
|
|
||||||
|
- Les configurations existantes continuent à fonctionner
|
||||||
|
- Possibilité de supprimer le `projectName` pour étendre la portée
|
||||||
|
- 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._ 🎯
|
||||||
789
TODO.md
@@ -1,522 +1,285 @@
|
|||||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||||
|
|
||||||
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
## Fix
|
||||||
|
|
||||||
### 1.1 Configuration projet Next.js
|
- [ ] Calendrier n'a plus le bouton calendrier d'ouverture du calendrier visuel dans les inputs datetime
|
||||||
- [x] Initialiser Next.js avec TypeScript
|
- [ ] Un raccourci pour chercher dans la page de Kanban
|
||||||
- [x] Configurer ESLint, Prettier
|
- [ ] Bouton cloner une tache dans la modale d'edition
|
||||||
- [x] Setup structure de dossiers selon les règles du workspace
|
|
||||||
- [x] Configurer base de données (SQLite local)
|
|
||||||
- [x] Setup Prisma ORM
|
|
||||||
|
|
||||||
### 1.2 Architecture backend standalone
|
## Idées à developper
|
||||||
- [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 `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
|
||||||
- [x] Nettoyer l'ancien code de synchronisation
|
|
||||||
|
|
||||||
### 1.3 API moderne et propre
|
- [ ] Optimisations Perf : requetes DB
|
||||||
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
|
||||||
- [x] Supprimer les routes de synchronisation obsolètes
|
|
||||||
- [x] Configuration moderne dans `lib/config.ts`
|
|
||||||
|
|
||||||
**Architecture finale** : App standalone avec backend propre et API REST moderne
|
|
||||||
|
|
||||||
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
|
||||||
|
|
||||||
### 2.1 Système de design et composants UI
|
|
||||||
- [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] Setup Tailwind CSS avec classes utilitaires personnalisées
|
|
||||||
- [x] Créer une palette de couleurs tech/cyberpunk
|
|
||||||
|
|
||||||
### 2.2 Composants Kanban existants (à améliorer)
|
|
||||||
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
|
||||||
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
|
||||||
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
|
||||||
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
|
||||||
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
|
||||||
|
|
||||||
### 2.3 Gestion des tâches (CRUD)
|
|
||||||
- [x] Formulaire de création de tâche (Modal + Form)
|
|
||||||
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
|
||||||
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
|
||||||
- [x] Édition inline du titre des tâches (clic sur titre → input)
|
|
||||||
- [x] Suppression de tâche (icône discrète + API call)
|
|
||||||
- [x] Changement de statut par drag & drop (@dnd-kit)
|
|
||||||
- [x] Validation des formulaires et gestion d'erreurs
|
|
||||||
|
|
||||||
### 2.4 Gestion des tags
|
|
||||||
- [x] Créer/éditer des tags avec sélecteur de couleur
|
|
||||||
- [x] Autocomplete pour les tags existants
|
|
||||||
- [x] Suppression de tags (avec vérification des dépendances)
|
|
||||||
- [x] Affichage des tags avec couleurs personnalisées
|
|
||||||
- [x] Service tags avec CRUD complet (Prisma)
|
|
||||||
- [x] API routes /api/tags avec validation
|
|
||||||
- [x] Client HTTP et hook useTags
|
|
||||||
- [x] Composants UI (TagInput, TagDisplay, TagForm)
|
|
||||||
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
|
|
||||||
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
|
|
||||||
- [x] Contexte global pour partager les tags
|
|
||||||
- [x] Page de gestion des tags (/tags) avec interface complète
|
|
||||||
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
|
||||||
- [x] Filtrage par tags (intégration dans Kanban)
|
|
||||||
- [x] Interface de filtrage complète (recherche, priorités, tags)
|
|
||||||
- [x] Logique de filtrage temps réel dans le contexte
|
|
||||||
- [x] Intégration des filtres dans KanbanBoard
|
|
||||||
|
|
||||||
### 2.5 Clients HTTP et hooks
|
|
||||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
|
||||||
- [x] `clients/tags-client.ts` - Client pour les tags
|
|
||||||
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
|
||||||
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
|
||||||
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
|
||||||
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
|
|
||||||
- [x] Gestion des erreurs et loading states
|
|
||||||
- [x] Architecture SSR + hydratation client optimisée
|
|
||||||
|
|
||||||
### 2.6 Fonctionnalités Kanban avancées
|
|
||||||
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
|
||||||
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
|
||||||
- [x] Filtrage par statut/priorité/assigné
|
|
||||||
- [x] Recherche en temps réel dans les tâches
|
|
||||||
- [x] Interface de filtrage complète (KanbanFilters.tsx)
|
|
||||||
- [x] Logique de filtrage dans TasksContext
|
|
||||||
- [x] Tri des tâches (date, priorité, alphabétique)
|
|
||||||
|
|
||||||
### 2.7 Système de thèmes (clair/sombre)
|
|
||||||
- [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] Définir les variables CSS pour le thème clair
|
|
||||||
- [x] Adapter tous les composants UI pour supporter les deux thèmes
|
|
||||||
- [x] Modifier la palette de couleurs pour le mode clair
|
|
||||||
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
|
|
||||||
- [x] Adapter les formulaires et modales
|
|
||||||
- [x] Adapter la page de gestion des tags
|
|
||||||
- [x] Sauvegarder la préférence de thème (localStorage)
|
|
||||||
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
|
|
||||||
|
|
||||||
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
|
||||||
|
|
||||||
### 3.1 Gestion du Daily
|
|
||||||
- [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] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
|
||||||
- [x] Checkboxes interactives avec état coché/non-coché
|
|
||||||
- [x] Liaison optionnelle checkbox ↔ tâche existante
|
|
||||||
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
|
|
||||||
- [x] Navigation par date (daily précédent/suivant)
|
|
||||||
- [x] Auto-création du daily du jour si inexistant
|
|
||||||
- [x] UX améliorée : édition au clic, focus persistant, input large
|
|
||||||
- [x] Vue calendar/historique des dailies
|
|
||||||
|
|
||||||
### 3.2 Intégration 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] Authentification Basic Auth (email + API token)
|
|
||||||
- [x] Récupération des tickets assignés à l'utilisateur
|
|
||||||
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
|
||||||
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
|
|
||||||
- [x] Gestion des diffs - ne pas écraser les modifications locales
|
|
||||||
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
|
||||||
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
|
|
||||||
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
|
|
||||||
- [x] Interface de configuration dans les paramètres
|
|
||||||
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
|
|
||||||
- [x] Logs de synchronisation pour debug
|
|
||||||
- [x] Gestion des erreurs et timeouts API
|
|
||||||
|
|
||||||
### 3.3 Page d'accueil/dashboard
|
|
||||||
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
|
||||||
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
|
||||||
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
|
||||||
- [x] Actions rapides vers les différentes sections
|
|
||||||
- [x] Affichage des tâches récentes
|
|
||||||
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
|
|
||||||
- [x] Indicateurs de performance personnels
|
|
||||||
- [x] Intégration des analytics dans le dashboard
|
|
||||||
|
|
||||||
### 3.4 Analytics et métriques
|
|
||||||
- [x] `services/analytics.ts` - Calculs statistiques
|
|
||||||
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
|
||||||
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
|
||||||
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
|
|
||||||
- [x] Insights automatiques et métriques visuelles
|
|
||||||
|
|
||||||
## Autre Todo
|
|
||||||
- [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] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
|
||||||
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
|
|
||||||
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
|
|
||||||
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
|
|
||||||
- [x] Système de sauvegarde automatique base de données
|
|
||||||
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
|
|
||||||
- [x] Configuration complète dans les paramètres avec interface dédiée
|
|
||||||
- [x] Rotation automatique des sauvegardes (configurable)
|
|
||||||
- [x] Format de sauvegarde avec timestamp + compression optionnelle
|
|
||||||
- [x] Interface complète pour visualiser et gérer les sauvegardes
|
|
||||||
- [x] CLI d'administration pour les opérations avancées
|
|
||||||
- [x] API REST complète pour la gestion programmatique
|
|
||||||
- [x] Vérification d'intégrité et restauration sécurisée
|
|
||||||
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
|
||||||
|
|
||||||
|
|
||||||
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
|
||||||
|
|
||||||
### 4.1 Migration vers Server Actions - Actions rapides
|
|
||||||
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
|
||||||
|
|
||||||
#### Actions TaskCard (Priorité 1)
|
|
||||||
- [x] Créer `actions/tasks.ts` avec server actions de base
|
|
||||||
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
|
||||||
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
|
||||||
- [x] `deleteTask(taskId)` - Suppression de tâche
|
|
||||||
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
|
||||||
- [x] Remplacer les props callbacks par calls directs aux actions
|
|
||||||
- [x] Intégrer `useTransition` pour les loading states natifs
|
|
||||||
- [x] Tester la revalidation automatique du cache
|
|
||||||
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
|
|
||||||
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
|
||||||
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
|
||||||
|
|
||||||
#### Actions Daily (Priorité 2)
|
|
||||||
- [x] Créer `actions/daily.ts` pour les checkboxes
|
|
||||||
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
|
||||||
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
|
||||||
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
|
|
||||||
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
|
|
||||||
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
|
|
||||||
- [x] Modifier les composants Daily pour utiliser server actions
|
|
||||||
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
|
|
||||||
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
|
|
||||||
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
|
||||||
|
|
||||||
#### Actions User Preferences (Priorité 3)
|
|
||||||
- [x] Créer `actions/preferences.ts` pour les toggles
|
|
||||||
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
|
||||||
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
|
||||||
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
|
||||||
- [x] `updateTheme(theme)` - Changement de thème
|
|
||||||
- [x] Remplacer les hooks par server actions directes
|
|
||||||
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
|
|
||||||
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
|
|
||||||
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
|
||||||
|
|
||||||
#### Actions Tags (Priorité 4)
|
|
||||||
- [x] Créer `actions/tags.ts` pour la gestion tags
|
|
||||||
- [x] `createTag(name, color)` - Création tag
|
|
||||||
- [x] `updateTag(tagId, data)` - Modification tag
|
|
||||||
- [x] `deleteTag(tagId)` - Suppression tag
|
|
||||||
- [x] Modifier les formulaires tags pour server actions
|
|
||||||
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
|
|
||||||
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
|
|
||||||
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
|
||||||
|
|
||||||
#### Migration progressive avec nettoyage immédiat
|
|
||||||
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
|
||||||
|
|
||||||
### 4.2 Conservation API Routes - Endpoints complexes
|
|
||||||
**À GARDER en API routes** (pas de migration)
|
|
||||||
|
|
||||||
#### Endpoints de fetching initial
|
|
||||||
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
|
||||||
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
|
||||||
- ✅ `GET /api/tags` - Liste tags avec recherche
|
|
||||||
- ✅ `GET /api/user-preferences` - Préférences initiales
|
|
||||||
|
|
||||||
#### Endpoints d'intégration externe
|
|
||||||
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
|
||||||
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
|
||||||
- ✅ Configuration Jira (formulaires complexes)
|
|
||||||
|
|
||||||
#### Raisons de conservation
|
|
||||||
- **API publique** : Réutilisable depuis mobile/externe
|
|
||||||
- **Logique complexe** : Synchronisation, analytics, rapports
|
|
||||||
- **Monitoring** : Besoin de logs HTTP séparés
|
|
||||||
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
|
||||||
|
|
||||||
### 4.3 Architecture hybride cible
|
|
||||||
```
|
|
||||||
Actions rapides → Server Actions directes
|
|
||||||
├── TaskCard actions (status, title, delete)
|
|
||||||
├── Daily checkboxes (toggle, add, edit)
|
|
||||||
├── Preferences toggles (theme, filters)
|
|
||||||
└── Tags CRUD (create, update, delete)
|
|
||||||
|
|
||||||
Endpoints complexes → API Routes conservées
|
|
||||||
├── Fetching initial avec filtres
|
|
||||||
├── Intégrations externes (Jira, webhooks)
|
|
||||||
├── Analytics et rapports
|
|
||||||
└── Future real-time features
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Avantages attendus
|
|
||||||
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
|
||||||
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
|
||||||
- **📦 Bundle reduction** : Moins de code client HTTP
|
|
||||||
- **⚡ UX** : `useTransition` loading states natifs
|
|
||||||
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
|
|
||||||
|
|
||||||
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
|
||||||
|
|
||||||
### 5.1 Configuration projet Jira
|
|
||||||
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
|
||||||
- [x] Interface pour sélectionner le projet à surveiller
|
|
||||||
- [x] Validation de l'existence du projet via API Jira
|
|
||||||
- [x] Sauvegarde de la configuration projet dans les préférences
|
|
||||||
- [x] Test de connexion spécifique au projet configuré
|
|
||||||
|
|
||||||
### 5.2 Service d'analytics Jira
|
|
||||||
- [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] Calculs de vélocité d'équipe (story points par sprint)
|
|
||||||
- [x] Métriques de cycle time (temps entre statuts)
|
|
||||||
- [x] Analyse de la répartition des tâches par assignee
|
|
||||||
- [x] Détection des goulots d'étranglement (tickets bloqués)
|
|
||||||
- [x] Historique des sprints et burndown charts
|
|
||||||
- [x] Cache intelligent des métriques (éviter API rate limits)
|
|
||||||
|
|
||||||
### 5.3 Page de surveillance `/jira-dashboard`
|
|
||||||
- [x] Créer page dédiée avec navigation depuis settings Jira
|
|
||||||
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
|
||||||
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
|
||||||
- [x] Graphiques de vélocité avec Recharts
|
|
||||||
- [x] Heatmap d'activité de l'équipe
|
|
||||||
- [x] Timeline des releases et milestones
|
|
||||||
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
|
||||||
|
|
||||||
### 5.4 Métriques et graphiques avancés
|
|
||||||
- [x] **Vélocité** : Story points complétés par sprint
|
|
||||||
- [x] **Burndown chart** : Progression vs planifié
|
|
||||||
- [x] **Cycle time** : Temps moyen par type de ticket
|
|
||||||
- [x] **Throughput** : Nombre de tickets complétés par période
|
|
||||||
- [x] **Work in Progress** : Répartition par statut et assignee
|
|
||||||
- [x] **Quality metrics** : Ratio bugs/features, retours clients
|
|
||||||
- [x] **Predictability** : Variance entre estimé et réel
|
|
||||||
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
|
||||||
|
|
||||||
### 5.5 Fonctionnalités de surveillance
|
|
||||||
- [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] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
|
||||||
- [x] Détection automatique d'anomalies (alertes)
|
|
||||||
- [x] Filtrage par composant, version, type de ticket
|
|
||||||
- [x] Vue détaillée par sprint avec drill-down
|
|
||||||
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
|
||||||
|
|
||||||
## 📊 Phase 5.6: Résumé hebdomadaire pour Individual Review (EN COURS)
|
|
||||||
|
|
||||||
### 5.6.1 Fonctionnalités de base (TERMINÉ)
|
|
||||||
- [x] Vue résumé des 7 derniers jours (daily items + tâches)
|
|
||||||
- [x] Statistiques globales (completion rates, jour le plus productif)
|
|
||||||
- [x] Timeline chronologique des activités
|
|
||||||
- [x] Filtrage par jour de la semaine
|
|
||||||
- [x] Architecture SSR pour performance optimale
|
|
||||||
|
|
||||||
### 5.6.2 Améliorations pour l'Individual Review Manager 🎯
|
|
||||||
- [ ] **Métriques de performance personnelles**
|
|
||||||
- [ ] Vélocité hebdomadaire (tasks completed/week)
|
|
||||||
- [ ] Temps moyen de completion des tâches
|
|
||||||
- [ ] Répartition par priorité (high/medium/low tasks)
|
|
||||||
- [ ] Taux de respect des deadlines
|
|
||||||
- [ ] Evolution des performances sur 4 semaines (tendance)
|
|
||||||
|
|
||||||
- [ ] **Catégorisation des activités professionnelles**
|
|
||||||
- [ ] Auto-tagging par type : "Development", "Meetings", "Documentation", "Code Review"
|
|
||||||
- [ ] Répartition temps par catégorie (% dev vs meetings vs admin)
|
|
||||||
- [ ] Identification des "deep work" sessions vs interruptions
|
|
||||||
- [ ] Tracking des objectifs OKRs/KPIs assignés
|
|
||||||
|
|
||||||
- [ ] **Visualisations pour manager**
|
|
||||||
- [ ] Graphique en aires : progression hebdomadaire
|
|
||||||
- [ ] Heatmap de productivité : heures/jours les plus productifs
|
|
||||||
- [ ] Radar chart : compétences/domaines travaillés
|
|
||||||
- [ ] Burndown chart personnel : objectifs vs réalisé
|
|
||||||
|
|
||||||
- [ ] **Rapport automatique formaté**
|
|
||||||
- [ ] Export PDF professionnel avec métriques
|
|
||||||
- [ ] Template "Weekly Accomplishments" pré-rempli
|
|
||||||
- [ ] Bullet points des principales réalisations
|
|
||||||
- [ ] Section "Challenges & Blockers" automatique
|
|
||||||
- [ ] Recommandations d'amélioration basées sur les patterns
|
|
||||||
|
|
||||||
- [ ] **Contexte business et impact**
|
|
||||||
- [ ] Liaison tâches → tickets Jira → business value
|
|
||||||
- [ ] Calcul d'impact estimé (story points, business priority)
|
|
||||||
- [ ] Suivi des initiatives stratégiques
|
|
||||||
- [ ] Corrélation avec les métriques d'équipe
|
|
||||||
|
|
||||||
- [ ] **Intelligence et insights**
|
|
||||||
- [ ] Détection patterns de productivité personnels
|
|
||||||
- [ ] Suggestions d'optimisation du planning
|
|
||||||
- [ ] Alertes sur la charge de travail excessive
|
|
||||||
- [ ] Comparaison avec moyennes d'équipe (anonyme)
|
|
||||||
- [ ] Prédiction de capacity pour la semaine suivante
|
|
||||||
|
|
||||||
- [ ] **Fonctionnalités avancées pour 1-on-1**
|
|
||||||
- [ ] Mode "Manager View" : vue consolidée pour discussions
|
|
||||||
- [ ] Annotations et notes privées sur les réalisations
|
|
||||||
- [ ] Objectifs SMART tracking avec progress bars
|
|
||||||
- [ ] Archivage des reviews précédentes pour suivi long terme
|
|
||||||
- [ ] Templates de questions pour auto-reflection
|
|
||||||
|
|
||||||
### 5.6.3 Intégrations externes pour contexte pro
|
|
||||||
- [ ] **Import calendrier** : Meetings duration & frequency
|
|
||||||
- [ ] **GitHub/GitLab integration** : Commits, PRs, code reviews
|
|
||||||
- [ ] **Slack integration** : Messages envoyés, réactions, temps de réponse
|
|
||||||
- [ ] **Confluence/Notion** : Documents créés/édités
|
|
||||||
- [ ] **Time tracking tools** : Import depuis Toggl, Clockify, etc.
|
|
||||||
|
|
||||||
### 5.6.4 Machine Learning & Predictions
|
|
||||||
- [ ] **Modèle de productivité personnelle**
|
|
||||||
- [ ] Prédiction des jours de forte/faible productivité
|
|
||||||
- [ ] Recommandations de planning optimal
|
|
||||||
- [ ] Détection automatique de burnout patterns
|
|
||||||
- [ ] Suggestions de breaks et équilibre work-life
|
|
||||||
|
|
||||||
- [ ] **Insights business automatiques**
|
|
||||||
- [ ] "Cette semaine, tu as contribué à 3 initiatives stratégiques"
|
|
||||||
- [ ] "Ton focus sur la qualité (code reviews) est 20% au-dessus de la moyenne"
|
|
||||||
- [ ] "Suggestion: bloquer 2h demain pour deep work sur Project X"
|
|
||||||
|
|
||||||
### 🚀 Quick Wins pour démarrer (Priorité 1)
|
|
||||||
- [ ] **Métriques de vélocité personnelle** (1-2h)
|
|
||||||
- [ ] Calcul tâches complétées par jour/semaine
|
|
||||||
- [ ] Graphique simple ligne de tendance sur 4 semaines
|
|
||||||
- [ ] Comparaison semaine actuelle vs semaine précédente
|
|
||||||
|
|
||||||
- [ ] **Export PDF basique** (2-3h)
|
|
||||||
- [ ] Génération PDF simple avec statistiques actuelles
|
|
||||||
- [ ] Template "Weekly Summary" avec logo/header pro
|
|
||||||
- [ ] Liste des principales réalisations de la semaine
|
|
||||||
|
|
||||||
- [ ] **Catégorisation simple par tags** (1h)
|
|
||||||
- [ ] Tags prédéfinis : "Dev", "Meeting", "Admin", "Learning"
|
|
||||||
- [ ] Auto-suggestion basée sur mots-clés dans les titres
|
|
||||||
- [ ] Répartition en camembert par catégorie
|
|
||||||
|
|
||||||
- [ ] **Connexion Jira pour contexte business** (3-4h)
|
|
||||||
- [ ] Affichage des story points complétés
|
|
||||||
- [ ] Lien vers les tickets Jira depuis les tâches
|
|
||||||
- [ ] Récap des sprints/epics contributés
|
|
||||||
|
|
||||||
- [ ] **Période flexible** (1h)
|
|
||||||
- [ ] Sélecteur de période : dernière semaine, 2 semaines, mois
|
|
||||||
- [ ] Comparaison période courante vs période précédente
|
|
||||||
- [ ] Sauvegarde de la période préférée
|
|
||||||
|
|
||||||
### 💡 Idées spécifiques pour Individual Review
|
|
||||||
|
|
||||||
#### **Sections du rapport idéal :**
|
|
||||||
1. **Executive Summary** (3-4 bullet points impact business)
|
|
||||||
2. **Quantified Achievements** (metrics, numbers, scope)
|
|
||||||
3. **Technical Contributions** (code, architecture, tools)
|
|
||||||
4. **Collaboration Impact** (reviews, mentoring, knowledge sharing)
|
|
||||||
5. **Process Improvements** (efficiency gains, automation)
|
|
||||||
6. **Learning & Growth** (new skills, certifications, initiatives)
|
|
||||||
7. **Challenges & Solutions** (blockers overcome, lessons learned)
|
|
||||||
8. **Next Period Goals** (SMART objectives, capacity planning)
|
|
||||||
|
|
||||||
#### **Métriques qui impressionnent un manager :**
|
|
||||||
- **Velocity & Consistency** : "Completed 23 tasks with 94% on-time delivery"
|
|
||||||
- **Quality Focus** : "15 code reviews provided, 0 production bugs"
|
|
||||||
- **Initiative** : "Automated deployment reducing release time by 30%"
|
|
||||||
- **Business Impact** : "Features delivered serve 10K+ users daily"
|
|
||||||
- **Collaboration** : "Mentored 2 junior devs, led 3 technical sessions"
|
|
||||||
- **Efficiency** : "Process optimization saved team 5h/week"
|
|
||||||
|
|
||||||
#### **Questions auto-reflection intégrées :**
|
|
||||||
- "What was your biggest technical achievement this week?"
|
|
||||||
- "Which tasks had the highest business impact?"
|
|
||||||
- "What blockers did you encounter and how did you solve them?"
|
|
||||||
- "What did you learn that you can share with the team?"
|
|
||||||
- "What would you do differently next week?"
|
|
||||||
|
|
||||||
## Autre Todos #2
|
|
||||||
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
|
||||||
- [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
|
||||||
|
|
||||||
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
|
||||||
|
|
||||||
### 6.1 Gestion avancée des tâches
|
|
||||||
- [ ] 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
|
|
||||||
- [ ] 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
|
||||||
|
|
||||||
## 🛠️ Configuration technique
|
|
||||||
|
|
||||||
### Stack moderne
|
|
||||||
- **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
|
|
||||||
- **Backend**: Next.js API Routes, Prisma ORM
|
|
||||||
- **Database**: SQLite (local) → PostgreSQL (production future)
|
|
||||||
- **UI**: Composants custom + Shadcn/ui, React Beautiful DnD
|
|
||||||
- **Charts**: Recharts ou Chart.js pour les analytics
|
|
||||||
|
|
||||||
### Architecture respectée
|
|
||||||
```
|
|
||||||
src/app/
|
|
||||||
├── api/tasks/ # API CRUD complète
|
|
||||||
├── page.tsx # Page principale
|
|
||||||
└── layout.tsx
|
|
||||||
|
|
||||||
services/
|
|
||||||
├── database.ts # Pool Prisma
|
|
||||||
└── tasks.ts # Service tâches standalone
|
|
||||||
|
|
||||||
components/
|
|
||||||
├── kanban/ # Board Kanban
|
|
||||||
├── ui/ # Composants UI de base
|
|
||||||
└── dashboard/ # Widgets dashboard (futur)
|
|
||||||
|
|
||||||
clients/ # Clients HTTP (à créer)
|
|
||||||
hooks/ # Hooks React (à créer)
|
|
||||||
lib/
|
|
||||||
├── types.ts # Types TypeScript
|
|
||||||
└── config.ts # Config app moderne
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Prochaines étapes immédiates
|
|
||||||
|
|
||||||
1. **Drag & drop entre colonnes** - react-beautiful-dnd pour changer les statuts
|
|
||||||
2. **Gestion avancée des tags** - Couleurs, autocomplete, filtrage
|
|
||||||
3. **Recherche et filtres** - Filtrage temps réel par titre, tags, statut
|
|
||||||
4. **Dashboard et analytics** - Graphiques de productivité
|
|
||||||
|
|
||||||
## ✅ **Fonctionnalités terminées (Phase 2.1-2.3)**
|
|
||||||
|
|
||||||
- ✅ Système de design tech dark complet
|
|
||||||
- ✅ Composants UI de base (Button, Input, Card, Modal, Badge)
|
|
||||||
- ✅ Architecture SSR + hydratation client
|
|
||||||
- ✅ CRUD tâches complet (création, édition, suppression)
|
|
||||||
- ✅ Création rapide inline (QuickAddTask)
|
|
||||||
- ✅ Édition inline du titre (clic sur titre → input éditable)
|
|
||||||
- ✅ Drag & drop entre colonnes (@dnd-kit) + optimiste
|
|
||||||
- ✅ Client HTTP et hooks React
|
|
||||||
- ✅ Refactoring Kanban avec nouveaux composants
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer.*
|
## 🐛 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
|
||||||
|
|
||||||
|
### 🎯 Jira - Suivi des demandes en attente
|
||||||
|
|
||||||
|
- [ ] **Page "Jiras en attente"**
|
||||||
|
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
|
||||||
|
- [ ] Suivi des demandes formulées à d'autres équipes
|
||||||
|
- [ ] Filtrage par projet, équipe cible, ancienneté
|
||||||
|
- [ ] **Nouveau modèle de données**
|
||||||
|
- [ ] Table séparée pour les "demandes en attente" (différent des tâches Kanban)
|
||||||
|
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
|
||||||
|
- [ ] Notifications quand une demande change de statut
|
||||||
|
|
||||||
|
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||||
|
|
||||||
|
#### **Architecture actuelle → Multi-tenant**
|
||||||
|
|
||||||
|
- **Problème** : App mono-utilisateur avec données globales
|
||||||
|
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données + système de rôles
|
||||||
|
|
||||||
|
#### **Plan de migration**
|
||||||
|
|
||||||
|
- [ ] **Phase 1: Authentification**
|
||||||
|
- [ ] Système de login/mot de passe (NextAuth.js)
|
||||||
|
- [ ] Gestion des sessions sécurisées
|
||||||
|
- [ ] Pages de connexion/inscription/mot de passe oublié
|
||||||
|
- [ ] Middleware de protection des routes
|
||||||
|
|
||||||
|
- [ ] **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.)
|
||||||
|
- [ ] Contraintes de base de données pour l'isolation
|
||||||
|
- [ ] Index sur `userId` pour les performances
|
||||||
|
|
||||||
|
- [ ] **Phase 3: Système de rôles et permissions**
|
||||||
|
- [ ] **Rôle ADMIN**
|
||||||
|
- [ ] Gestion complète des utilisateurs (CRUD)
|
||||||
|
- [ ] Assignation/modification des rôles
|
||||||
|
- [ ] 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: Services et API avec rôles**
|
||||||
|
- [ ] **Services utilisateurs**
|
||||||
|
- [ ] `user-management.ts` : CRUD utilisateurs (admin only)
|
||||||
|
- [ ] `team-management.ts` : Gestion des équipes (admin/manager)
|
||||||
|
- [ ] `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**
|
||||||
|
|
||||||
|
- **Base de données** : Ajouter `userId` partout + contraintes
|
||||||
|
- **Sécurité** : Validation côté serveur de l'isolation des données
|
||||||
|
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||||
|
- **Migration** : Script de migration des données existantes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 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._
|
||||||
|
|||||||
595
TODO_ARCHIVE.md
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||||
|
|
||||||
|
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
||||||
|
|
||||||
|
### 1.1 Configuration projet Next.js
|
||||||
|
|
||||||
|
- [x] Initialiser Next.js avec TypeScript
|
||||||
|
- [x] Configurer ESLint, Prettier
|
||||||
|
- [x] Setup structure de dossiers selon les règles du workspace
|
||||||
|
- [x] Configurer base de données (SQLite local)
|
||||||
|
- [x] Setup Prisma ORM
|
||||||
|
|
||||||
|
### 1.2 Architecture backend standalone
|
||||||
|
|
||||||
|
- [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 `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
||||||
|
- [x] Nettoyer l'ancien code de synchronisation
|
||||||
|
|
||||||
|
### 1.3 API moderne et propre
|
||||||
|
|
||||||
|
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
||||||
|
- [x] Supprimer les routes de synchronisation obsolètes
|
||||||
|
- [x] Configuration moderne dans `lib/config.ts`
|
||||||
|
|
||||||
|
**Architecture finale** : App standalone avec backend propre et API REST moderne
|
||||||
|
|
||||||
|
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
||||||
|
|
||||||
|
### 2.1 Système de design et composants UI
|
||||||
|
|
||||||
|
- [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] Setup Tailwind CSS avec classes utilitaires personnalisées
|
||||||
|
- [x] Créer une palette de couleurs tech/cyberpunk
|
||||||
|
|
||||||
|
### 2.2 Composants Kanban existants (à améliorer)
|
||||||
|
|
||||||
|
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
||||||
|
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
||||||
|
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
||||||
|
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
||||||
|
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
||||||
|
|
||||||
|
### 2.3 Gestion des tâches (CRUD)
|
||||||
|
|
||||||
|
- [x] Formulaire de création de tâche (Modal + Form)
|
||||||
|
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
||||||
|
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
||||||
|
- [x] Édition inline du titre des tâches (clic sur titre → input)
|
||||||
|
- [x] Suppression de tâche (icône discrète + API call)
|
||||||
|
- [x] Changement de statut par drag & drop (@dnd-kit)
|
||||||
|
- [x] Validation des formulaires et gestion d'erreurs
|
||||||
|
|
||||||
|
### 2.4 Gestion des tags
|
||||||
|
|
||||||
|
- [x] Créer/éditer des tags avec sélecteur de couleur
|
||||||
|
- [x] Autocomplete pour les tags existants
|
||||||
|
- [x] Suppression de tags (avec vérification des dépendances)
|
||||||
|
- [x] Affichage des tags avec couleurs personnalisées
|
||||||
|
- [x] Service tags avec CRUD complet (Prisma)
|
||||||
|
- [x] API routes /api/tags avec validation
|
||||||
|
- [x] Client HTTP et hook useTags
|
||||||
|
- [x] Composants UI (TagInput, TagDisplay, TagForm)
|
||||||
|
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
|
||||||
|
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
|
||||||
|
- [x] Contexte global pour partager les tags
|
||||||
|
- [x] Page de gestion des tags (/tags) avec interface complète
|
||||||
|
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
||||||
|
- [x] Filtrage par tags (intégration dans Kanban)
|
||||||
|
- [x] Interface de filtrage complète (recherche, priorités, tags)
|
||||||
|
- [x] Logique de filtrage temps réel dans le contexte
|
||||||
|
- [x] Intégration des filtres dans KanbanBoard
|
||||||
|
|
||||||
|
### 2.5 Clients HTTP et hooks
|
||||||
|
|
||||||
|
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
||||||
|
- [x] `clients/tags-client.ts` - Client pour les tags
|
||||||
|
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
||||||
|
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
||||||
|
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
||||||
|
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
|
||||||
|
- [x] Gestion des erreurs et loading states
|
||||||
|
- [x] Architecture SSR + hydratation client optimisée
|
||||||
|
|
||||||
|
### 2.6 Fonctionnalités Kanban avancées
|
||||||
|
|
||||||
|
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
||||||
|
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
||||||
|
- [x] Filtrage par statut/priorité/assigné
|
||||||
|
- [x] Recherche en temps réel dans les tâches
|
||||||
|
- [x] Interface de filtrage complète (KanbanFilters.tsx)
|
||||||
|
- [x] Logique de filtrage dans TasksContext
|
||||||
|
- [x] Tri des tâches (date, priorité, alphabétique)
|
||||||
|
|
||||||
|
### 2.7 Système de thèmes (clair/sombre)
|
||||||
|
|
||||||
|
- [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] Définir les variables CSS pour le thème clair
|
||||||
|
- [x] Adapter tous les composants UI pour supporter les deux thèmes
|
||||||
|
- [x] Modifier la palette de couleurs pour le mode clair
|
||||||
|
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
|
||||||
|
- [x] Adapter les formulaires et modales
|
||||||
|
- [x] Adapter la page de gestion des tags
|
||||||
|
- [x] Sauvegarder la préférence de thème (localStorage)
|
||||||
|
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
|
||||||
|
|
||||||
|
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
||||||
|
|
||||||
|
### 3.1 Gestion du Daily
|
||||||
|
|
||||||
|
- [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] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
||||||
|
- [x] Checkboxes interactives avec état coché/non-coché
|
||||||
|
- [x] Liaison optionnelle checkbox ↔ tâche existante
|
||||||
|
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
|
||||||
|
- [x] Navigation par date (daily précédent/suivant)
|
||||||
|
- [x] Auto-création du daily du jour si inexistant
|
||||||
|
- [x] UX améliorée : édition au clic, focus persistant, input large
|
||||||
|
- [x] Vue calendar/historique des dailies
|
||||||
|
|
||||||
|
### 3.2 Intégration 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] Authentification Basic Auth (email + API token)
|
||||||
|
- [x] Récupération des tickets assignés à l'utilisateur
|
||||||
|
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
||||||
|
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
|
||||||
|
- [x] Gestion des diffs - ne pas écraser les modifications locales
|
||||||
|
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
||||||
|
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
|
||||||
|
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
|
||||||
|
- [x] Interface de configuration dans les paramètres
|
||||||
|
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
|
||||||
|
- [x] Logs de synchronisation pour debug
|
||||||
|
- [x] Gestion des erreurs et timeouts API
|
||||||
|
|
||||||
|
### 3.3 Page d'accueil/dashboard
|
||||||
|
|
||||||
|
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
||||||
|
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
||||||
|
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
||||||
|
- [x] Actions rapides vers les différentes sections
|
||||||
|
- [x] Affichage des tâches récentes
|
||||||
|
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
|
||||||
|
- [x] Indicateurs de performance personnels
|
||||||
|
- [x] Intégration des analytics dans le dashboard
|
||||||
|
|
||||||
|
### 3.4 Analytics et métriques
|
||||||
|
|
||||||
|
- [x] `services/analytics.ts` - Calculs statistiques
|
||||||
|
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||||
|
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
||||||
|
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
|
||||||
|
- [x] Insights automatiques et métriques visuelles
|
||||||
|
|
||||||
|
## Autre Todo
|
||||||
|
|
||||||
|
- [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] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
||||||
|
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
|
||||||
|
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
|
||||||
|
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
|
||||||
|
- [x] Système de sauvegarde automatique base de données
|
||||||
|
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
|
||||||
|
- [x] Configuration complète dans les paramètres avec interface dédiée
|
||||||
|
- [x] Rotation automatique des sauvegardes (configurable)
|
||||||
|
- [x] Format de sauvegarde avec timestamp + compression optionnelle
|
||||||
|
- [x] Interface complète pour visualiser et gérer les sauvegardes
|
||||||
|
- [x] CLI d'administration pour les opérations avancées
|
||||||
|
- [x] API REST complète pour la gestion programmatique
|
||||||
|
- [x] Vérification d'intégrité et restauration sécurisée
|
||||||
|
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
||||||
|
|
||||||
|
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
||||||
|
|
||||||
|
### 4.1 Migration vers Server Actions - Actions rapides
|
||||||
|
|
||||||
|
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
||||||
|
|
||||||
|
#### Actions TaskCard (Priorité 1)
|
||||||
|
|
||||||
|
- [x] Créer `actions/tasks.ts` avec server actions de base
|
||||||
|
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
||||||
|
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
||||||
|
- [x] `deleteTask(taskId)` - Suppression de tâche
|
||||||
|
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
||||||
|
- [x] Remplacer les props callbacks par calls directs aux actions
|
||||||
|
- [x] Intégrer `useTransition` pour les loading states natifs
|
||||||
|
- [x] Tester la revalidation automatique du cache
|
||||||
|
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
|
||||||
|
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
||||||
|
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
||||||
|
|
||||||
|
#### Actions Daily (Priorité 2)
|
||||||
|
|
||||||
|
- [x] Créer `actions/daily.ts` pour les checkboxes
|
||||||
|
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
||||||
|
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
||||||
|
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
|
||||||
|
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
|
||||||
|
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
|
||||||
|
- [x] Modifier les composants Daily pour utiliser server actions
|
||||||
|
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
|
||||||
|
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
|
||||||
|
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
||||||
|
|
||||||
|
#### Actions User Preferences (Priorité 3)
|
||||||
|
|
||||||
|
- [x] Créer `actions/preferences.ts` pour les toggles
|
||||||
|
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
||||||
|
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
||||||
|
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
||||||
|
- [x] `updateTheme(theme)` - Changement de thème
|
||||||
|
- [x] Remplacer les hooks par server actions directes
|
||||||
|
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
|
||||||
|
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
|
||||||
|
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
||||||
|
|
||||||
|
#### Actions Tags (Priorité 4)
|
||||||
|
|
||||||
|
- [x] Créer `actions/tags.ts` pour la gestion tags
|
||||||
|
- [x] `createTag(name, color)` - Création tag
|
||||||
|
- [x] `updateTag(tagId, data)` - Modification tag
|
||||||
|
- [x] `deleteTag(tagId)` - Suppression tag
|
||||||
|
- [x] Modifier les formulaires tags pour server actions
|
||||||
|
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
|
||||||
|
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
|
||||||
|
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
||||||
|
|
||||||
|
#### Migration progressive avec nettoyage immédiat
|
||||||
|
|
||||||
|
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
||||||
|
|
||||||
|
### 4.2 Conservation API Routes - Endpoints complexes
|
||||||
|
|
||||||
|
**À GARDER en API routes** (pas de migration)
|
||||||
|
|
||||||
|
#### Endpoints de fetching initial
|
||||||
|
|
||||||
|
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
||||||
|
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
||||||
|
- ✅ `GET /api/tags` - Liste tags avec recherche
|
||||||
|
- ✅ `GET /api/user-preferences` - Préférences initiales
|
||||||
|
|
||||||
|
#### Endpoints d'intégration externe
|
||||||
|
|
||||||
|
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
||||||
|
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
||||||
|
- ✅ Configuration Jira (formulaires complexes)
|
||||||
|
|
||||||
|
#### Raisons de conservation
|
||||||
|
|
||||||
|
- **API publique** : Réutilisable depuis mobile/externe
|
||||||
|
- **Logique complexe** : Synchronisation, analytics, rapports
|
||||||
|
- **Monitoring** : Besoin de logs HTTP séparés
|
||||||
|
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
||||||
|
|
||||||
|
### 4.3 Architecture hybride cible
|
||||||
|
|
||||||
|
```
|
||||||
|
Actions rapides → Server Actions directes
|
||||||
|
├── TaskCard actions (status, title, delete)
|
||||||
|
├── Daily checkboxes (toggle, add, edit)
|
||||||
|
├── Preferences toggles (theme, filters)
|
||||||
|
└── Tags CRUD (create, update, delete)
|
||||||
|
|
||||||
|
Endpoints complexes → API Routes conservées
|
||||||
|
├── Fetching initial avec filtres
|
||||||
|
├── Intégrations externes (Jira, webhooks)
|
||||||
|
├── Analytics et rapports
|
||||||
|
└── Future real-time features
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Avantages attendus
|
||||||
|
|
||||||
|
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
||||||
|
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
||||||
|
- **📦 Bundle reduction** : Moins de code client HTTP
|
||||||
|
- **⚡ UX** : `useTransition` loading states natifs
|
||||||
|
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
|
||||||
|
|
||||||
|
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
||||||
|
|
||||||
|
### 5.1 Configuration projet Jira
|
||||||
|
|
||||||
|
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
||||||
|
- [x] Interface pour sélectionner le projet à surveiller
|
||||||
|
- [x] Validation de l'existence du projet via API Jira
|
||||||
|
- [x] Sauvegarde de la configuration projet dans les préférences
|
||||||
|
- [x] Test de connexion spécifique au projet configuré
|
||||||
|
|
||||||
|
### 5.2 Service d'analytics Jira
|
||||||
|
|
||||||
|
- [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] Calculs de vélocité d'équipe (story points par sprint)
|
||||||
|
- [x] Métriques de cycle time (temps entre statuts)
|
||||||
|
- [x] Analyse de la répartition des tâches par assignee
|
||||||
|
- [x] Détection des goulots d'étranglement (tickets bloqués)
|
||||||
|
- [x] Historique des sprints et burndown charts
|
||||||
|
- [x] Cache intelligent des métriques (éviter API rate limits)
|
||||||
|
|
||||||
|
### 5.3 Page de surveillance `/jira-dashboard`
|
||||||
|
|
||||||
|
- [x] Créer page dédiée avec navigation depuis settings Jira
|
||||||
|
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
||||||
|
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
||||||
|
- [x] Graphiques de vélocité avec Recharts
|
||||||
|
- [x] Heatmap d'activité de l'équipe
|
||||||
|
- [x] Timeline des releases et milestones
|
||||||
|
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
||||||
|
|
||||||
|
### 5.4 Métriques et graphiques avancés
|
||||||
|
|
||||||
|
- [x] **Vélocité** : Story points complétés par sprint
|
||||||
|
- [x] **Burndown chart** : Progression vs planifié
|
||||||
|
- [x] **Cycle time** : Temps moyen par type de ticket
|
||||||
|
- [x] **Throughput** : Nombre de tickets complétés par période
|
||||||
|
- [x] **Work in Progress** : Répartition par statut et assignee
|
||||||
|
- [x] **Quality metrics** : Ratio bugs/features, retours clients
|
||||||
|
- [x] **Predictability** : Variance entre estimé et réel
|
||||||
|
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
||||||
|
|
||||||
|
### 5.5 Fonctionnalités de surveillance
|
||||||
|
|
||||||
|
- [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] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||||
|
- [x] Détection automatique d'anomalies (alertes)
|
||||||
|
- [x] Filtrage par composant, version, type de ticket
|
||||||
|
- [x] Vue détaillée par sprint avec drill-down
|
||||||
|
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||||
|
|
||||||
|
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
||||||
|
|
||||||
|
#### **Problème actuel**
|
||||||
|
|
||||||
|
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
||||||
|
- Alias TypeScript incohérents dans `tsconfig.json`
|
||||||
|
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
||||||
|
|
||||||
|
#### **Plan de migration**
|
||||||
|
|
||||||
|
- [x] **Phase 1: Migration des dossiers**
|
||||||
|
- [x] `mv components/ src/components/`
|
||||||
|
- [x] `mv lib/ src/lib/`
|
||||||
|
- [x] `mv hooks/ src/hooks/`
|
||||||
|
- [x] `mv clients/ src/clients/`
|
||||||
|
- [x] `mv services/ src/services/`
|
||||||
|
|
||||||
|
- [x] **Phase 2: Mise à jour tsconfig.json**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
// Supprimer les alias spécifiques devenus inutiles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Phase 3: Correction des imports**
|
||||||
|
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
|
||||||
|
- [x] Vérifier les imports relatifs dans les scripts/
|
||||||
|
|
||||||
|
- [x] **Phase 4: Mise à jour des règles Cursor**
|
||||||
|
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
|
||||||
|
- [x] Règle "components" : Mettre à jour avec `src/components/`
|
||||||
|
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
|
||||||
|
- [x] Vérifier tous les liens MDC dans les règles
|
||||||
|
|
||||||
|
- [x] **Phase 5: Tests et validation**
|
||||||
|
- [x] `npm run build` - Vérifier que le build passe
|
||||||
|
- [x] `npm run dev` - Vérifier que le dev fonctionne
|
||||||
|
- [x] `npm run lint` - Vérifier ESLint
|
||||||
|
- [x] `npx tsc --noEmit` - Vérifier TypeScript
|
||||||
|
- [x] Tester les fonctionnalités principales
|
||||||
|
|
||||||
|
#### **Structure finale attendue**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Pages Next.js (déjà OK)
|
||||||
|
├── actions/ # Server Actions (déjà OK)
|
||||||
|
├── contexts/ # React Contexts (déjà OK)
|
||||||
|
├── components/ # Composants React (à déplacer)
|
||||||
|
├── lib/ # Utilitaires et types (à déplacer)
|
||||||
|
├── hooks/ # Hooks React (à déplacer)
|
||||||
|
├── clients/ # Clients HTTP (à déplacer)
|
||||||
|
└── services/ # Services backend (à déplacer)
|
||||||
|
|
||||||
|
## Autre Todos
|
||||||
|
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||||
|
- [x] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||||
|
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||||
|
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
||||||
|
- [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 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
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { httpClient } from './base/http-client';
|
|
||||||
import { DailyCheckbox, DailyView, Task } from '@/lib/types';
|
|
||||||
|
|
||||||
// Types pour les réponses API (avec dates en string)
|
|
||||||
interface ApiCheckbox {
|
|
||||||
id: string;
|
|
||||||
date: string;
|
|
||||||
text: string;
|
|
||||||
isChecked: boolean;
|
|
||||||
type: 'task' | 'meeting';
|
|
||||||
order: number;
|
|
||||||
taskId?: string;
|
|
||||||
task?: Task;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiDailyView {
|
|
||||||
date: string;
|
|
||||||
yesterday: ApiCheckbox[];
|
|
||||||
today: ApiCheckbox[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiHistoryItem {
|
|
||||||
date: string;
|
|
||||||
checkboxes: ApiCheckbox[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyHistoryFilters {
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailySearchFilters {
|
|
||||||
query: string;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types conservés pour la compatibilité des hooks d'historique et de recherche
|
|
||||||
export interface ReorderCheckboxesData {
|
|
||||||
date: Date;
|
|
||||||
checkboxIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client HTTP pour les données Daily (lecture seule)
|
|
||||||
* Les mutations sont gérées par les server actions dans actions/daily.ts
|
|
||||||
*/
|
|
||||||
export class DailyClient {
|
|
||||||
/**
|
|
||||||
* Récupère la vue daily d'aujourd'hui (hier + aujourd'hui)
|
|
||||||
*/
|
|
||||||
async getTodaysDailyView(): Promise<DailyView> {
|
|
||||||
const result = await httpClient.get<ApiDailyView>('/daily');
|
|
||||||
return this.transformDailyViewDates(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère la vue daily pour une date donnée
|
|
||||||
*/
|
|
||||||
async getDailyView(date: Date): Promise<DailyView> {
|
|
||||||
const dateStr = this.formatDateForAPI(date);
|
|
||||||
const result = await httpClient.get<ApiDailyView>(`/daily?date=${dateStr}`);
|
|
||||||
return this.transformDailyViewDates(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère l'historique des checkboxes
|
|
||||||
*/
|
|
||||||
async getCheckboxHistory(filters?: DailyHistoryFilters): Promise<{ date: Date; checkboxes: DailyCheckbox[] }[]> {
|
|
||||||
const params = new URLSearchParams({ action: 'history' });
|
|
||||||
|
|
||||||
if (filters?.limit) params.append('limit', filters.limit.toString());
|
|
||||||
|
|
||||||
const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`);
|
|
||||||
return result.map(item => ({
|
|
||||||
date: new Date(item.date),
|
|
||||||
checkboxes: item.checkboxes.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recherche dans les checkboxes
|
|
||||||
*/
|
|
||||||
async searchCheckboxes(filters: DailySearchFilters): Promise<DailyCheckbox[]> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
action: 'search',
|
|
||||||
q: filters.query
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filters.limit) params.append('limit', filters.limit.toString());
|
|
||||||
|
|
||||||
const result = await httpClient.get<ApiCheckbox[]>(`/daily?${params}`);
|
|
||||||
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formate une date pour l'API (évite les décalages timezone)
|
|
||||||
*/
|
|
||||||
formatDateForAPI(date: Date): string {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`; // YYYY-MM-DD
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforme les dates string d'une checkbox en objets Date
|
|
||||||
*/
|
|
||||||
private transformCheckboxDates(checkbox: ApiCheckbox): DailyCheckbox {
|
|
||||||
return {
|
|
||||||
...checkbox,
|
|
||||||
date: new Date(checkbox.date),
|
|
||||||
createdAt: new Date(checkbox.createdAt),
|
|
||||||
updatedAt: new Date(checkbox.updatedAt)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforme les dates string d'une vue daily en objets Date
|
|
||||||
*/
|
|
||||||
private transformDailyViewDates(view: ApiDailyView): DailyView {
|
|
||||||
return {
|
|
||||||
date: new Date(view.date),
|
|
||||||
yesterday: view.yesterday.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)),
|
|
||||||
today: view.today.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère la vue daily d'une date relative (hier, aujourd'hui, demain)
|
|
||||||
*/
|
|
||||||
async getDailyViewByRelativeDate(relative: 'yesterday' | 'today' | 'tomorrow'): Promise<DailyView> {
|
|
||||||
const date = new Date();
|
|
||||||
|
|
||||||
switch (relative) {
|
|
||||||
case 'yesterday':
|
|
||||||
date.setDate(date.getDate() - 1);
|
|
||||||
break;
|
|
||||||
case 'tomorrow':
|
|
||||||
date.setDate(date.getDate() + 1);
|
|
||||||
break;
|
|
||||||
// 'today' ne change rien
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getDailyView(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère toutes les dates qui ont des dailies
|
|
||||||
*/
|
|
||||||
async getDailyDates(): Promise<string[]> {
|
|
||||||
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
|
||||||
return response.dates;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance singleton du client
|
|
||||||
export const dailyClient = new DailyClient();
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client pour l'API Jira
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { HttpClient } from './base/http-client';
|
|
||||||
import { JiraSyncResult } from '@/services/jira';
|
|
||||||
|
|
||||||
export interface JiraConnectionStatus {
|
|
||||||
connected: boolean;
|
|
||||||
message: string;
|
|
||||||
details?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JiraClient extends HttpClient {
|
|
||||||
constructor() {
|
|
||||||
super('/api/jira');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Teste la connexion à Jira
|
|
||||||
*/
|
|
||||||
async testConnection(): Promise<JiraConnectionStatus> {
|
|
||||||
return this.get<JiraConnectionStatus>('/sync');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lance la synchronisation manuelle des tickets Jira
|
|
||||||
*/
|
|
||||||
async syncTasks(): Promise<JiraSyncResult> {
|
|
||||||
const response = await this.post<{ data: JiraSyncResult }>('/sync');
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance singleton
|
|
||||||
export const jiraClient = new JiraClient();
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { httpClient } from './base/http-client';
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
|
|
||||||
export interface UserPreferencesResponse {
|
|
||||||
success: boolean;
|
|
||||||
data?: UserPreferences;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client HTTP pour les préférences utilisateur (lecture seule)
|
|
||||||
* Les mutations sont gérées par les server actions dans actions/preferences.ts
|
|
||||||
*/
|
|
||||||
export const userPreferencesClient = {
|
|
||||||
/**
|
|
||||||
* Récupère toutes les préférences utilisateur
|
|
||||||
*/
|
|
||||||
async getPreferences(): Promise<UserPreferences> {
|
|
||||||
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
|
|
||||||
|
|
||||||
if (!response.success || !response.data) {
|
|
||||||
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
|
||||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
|
||||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
|
||||||
import { RecentTasks } from '@/components/dashboard/RecentTasks';
|
|
||||||
import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics';
|
|
||||||
|
|
||||||
interface HomePageClientProps {
|
|
||||||
initialTasks: Task[];
|
|
||||||
initialTags: (Tag & { usage: number })[];
|
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialStats: TaskStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function HomePageContent() {
|
|
||||||
const { stats, syncing, createTask, tasks } = useTasksContext();
|
|
||||||
|
|
||||||
// Handler pour la création de tâche
|
|
||||||
const handleCreateTask = async (data: CreateTaskData) => {
|
|
||||||
await createTask(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Dashboard - Vue d'ensemble"
|
|
||||||
syncing={syncing}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="container mx-auto px-6 py-8">
|
|
||||||
{/* Statistiques */}
|
|
||||||
<DashboardStats stats={stats} />
|
|
||||||
|
|
||||||
{/* Actions rapides */}
|
|
||||||
<QuickActions onCreateTask={handleCreateTask} />
|
|
||||||
|
|
||||||
{/* Analytics et métriques */}
|
|
||||||
<ProductivityAnalytics />
|
|
||||||
|
|
||||||
{/* Tâches récentes */}
|
|
||||||
<RecentTasks tasks={tasks} />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
|
||||||
return (
|
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<TasksProvider
|
|
||||||
initialTasks={initialTasks}
|
|
||||||
initialTags={initialTags}
|
|
||||||
initialStats={initialStats}
|
|
||||||
>
|
|
||||||
<HomePageContent />
|
|
||||||
</TasksProvider>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
|
||||||
import { DailyCheckboxType } from '@/lib/types';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
|
|
||||||
interface DailyAddFormProps {
|
|
||||||
onAdd: (text: string, type: DailyCheckboxType) => Promise<void>;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DailyAddForm({ onAdd, disabled = false, placeholder = "Ajouter une tâche..." }: DailyAddFormProps) {
|
|
||||||
const [newCheckboxText, setNewCheckboxText] = useState('');
|
|
||||||
const [selectedType, setSelectedType] = useState<DailyCheckboxType>('meeting');
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleAddCheckbox = () => {
|
|
||||||
if (!newCheckboxText.trim()) return;
|
|
||||||
|
|
||||||
const text = newCheckboxText.trim();
|
|
||||||
|
|
||||||
// Vider et refocus IMMÉDIATEMENT pour l'UX optimiste
|
|
||||||
setNewCheckboxText('');
|
|
||||||
inputRef.current?.focus();
|
|
||||||
|
|
||||||
// Lancer l'ajout en arrière-plan (fire and forget)
|
|
||||||
onAdd(text, selectedType).catch(error => {
|
|
||||||
console.error('Erreur lors de l\'ajout:', error);
|
|
||||||
// En cas d'erreur, on pourrait restaurer le texte
|
|
||||||
// setNewCheckboxText(text);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAddCheckbox();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlaceholder = () => {
|
|
||||||
if (placeholder !== "Ajouter une tâche...") return placeholder;
|
|
||||||
return selectedType === 'meeting' ? 'Ajouter une réunion...' : 'Ajouter une tâche...';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Sélecteur de type */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedType('task')}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 text-xs border-l-4 ${
|
|
||||||
selectedType === 'task'
|
|
||||||
? 'border-l-green-500 bg-green-500/30 text-white font-medium'
|
|
||||||
: 'border-l-green-300 hover:border-l-green-400 opacity-70 hover:opacity-90'
|
|
||||||
}`}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
✅ Tâche
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedType('meeting')}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-1 text-xs border-l-4 ${
|
|
||||||
selectedType === 'meeting'
|
|
||||||
? 'border-l-blue-500 bg-blue-500/30 text-white font-medium'
|
|
||||||
: 'border-l-blue-300 hover:border-l-blue-400 opacity-70 hover:opacity-90'
|
|
||||||
}`}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
🗓️ Réunion
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Champ de saisie et options */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder={getPlaceholder()}
|
|
||||||
value={newCheckboxText}
|
|
||||||
onChange={(e) => setNewCheckboxText(e.target.value)}
|
|
||||||
onKeyDown={handleKeyPress}
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex-1 min-w-[300px]"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleAddCheckbox}
|
|
||||||
disabled={!newCheckboxText.trim() || disabled}
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
className="min-w-[40px]"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { DailyCheckbox, DailyCheckboxType, Task } from '@/lib/types';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { tasksClient } from '@/clients/tasks-client';
|
|
||||||
|
|
||||||
interface EditCheckboxModalProps {
|
|
||||||
checkbox: DailyCheckbox;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>;
|
|
||||||
saving?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditCheckboxModal({
|
|
||||||
checkbox,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
saving = false
|
|
||||||
}: EditCheckboxModalProps) {
|
|
||||||
const [text, setText] = useState(checkbox.text);
|
|
||||||
const [type, setType] = useState<DailyCheckboxType>(checkbox.type);
|
|
||||||
const [taskId, setTaskId] = useState<string | undefined>(checkbox.taskId);
|
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | undefined>(undefined);
|
|
||||||
const [allTasks, setAllTasks] = useState<Task[]>([]);
|
|
||||||
const [tasksLoading, setTasksLoading] = useState(false);
|
|
||||||
const [taskSearch, setTaskSearch] = useState('');
|
|
||||||
|
|
||||||
// Charger toutes les tâches au début
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setTasksLoading(true);
|
|
||||||
tasksClient.getTasks()
|
|
||||||
.then(response => {
|
|
||||||
setAllTasks(response.data);
|
|
||||||
// Trouver la tâche sélectionnée si elle existe
|
|
||||||
if (taskId) {
|
|
||||||
const task = response.data.find((t: Task) => t.id === taskId);
|
|
||||||
setSelectedTask(task);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setTasksLoading(false));
|
|
||||||
}
|
|
||||||
}, [isOpen, taskId]);
|
|
||||||
|
|
||||||
// Mettre à jour la tâche sélectionnée quand taskId change
|
|
||||||
useEffect(() => {
|
|
||||||
if (taskId && allTasks.length > 0) {
|
|
||||||
const task = allTasks.find((t: Task) => t.id === taskId);
|
|
||||||
setSelectedTask(task);
|
|
||||||
} else {
|
|
||||||
setSelectedTask(undefined);
|
|
||||||
}
|
|
||||||
}, [taskId, allTasks]);
|
|
||||||
|
|
||||||
// Filtrer les tâches selon la recherche
|
|
||||||
const filteredTasks = allTasks.filter(task =>
|
|
||||||
task.title.toLowerCase().includes(taskSearch.toLowerCase()) ||
|
|
||||||
(task.description && task.description.toLowerCase().includes(taskSearch.toLowerCase()))
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTaskSelect = (task: Task) => {
|
|
||||||
setTaskId(task.id);
|
|
||||||
setTaskSearch(''); // Fermer la recherche après sélection
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!text.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSave(text.trim(), type, taskId);
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la sauvegarde:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setText(checkbox.text);
|
|
||||||
setType(checkbox.type);
|
|
||||||
setTaskId(checkbox.taskId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Texte */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onKeyDown={handleKeyPress}
|
|
||||||
placeholder="Description de la tâche..."
|
|
||||||
className="w-full"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
|
||||||
Type
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setType('task')}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-2 border-l-4 ${
|
|
||||||
type === 'task'
|
|
||||||
? 'border-l-green-500 bg-green-500/30 text-white font-medium'
|
|
||||||
: 'border-l-green-300 hover:border-l-green-400 opacity-70 hover:opacity-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
✅ Tâche
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setType('meeting')}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`flex items-center gap-2 border-l-4 ${
|
|
||||||
type === 'meeting'
|
|
||||||
? 'border-l-blue-500 bg-blue-500/30 text-white font-medium'
|
|
||||||
: 'border-l-blue-300 hover:border-l-blue-400 opacity-70 hover:opacity-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
🗓️ Réunion
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liaison tâche (pour tous les types) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
|
||||||
Lier à une tâche (optionnel)
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{selectedTask ? (
|
|
||||||
// Tâche déjà sélectionnée
|
|
||||||
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-sm">{selectedTask.title}</div>
|
|
||||||
{selectedTask.description && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
|
||||||
{selectedTask.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className={`inline-block px-1 py-0.5 rounded text-xs mt-1 ${
|
|
||||||
selectedTask.status === 'todo' ? 'bg-blue-100 text-blue-800' :
|
|
||||||
selectedTask.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{selectedTask.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTaskId(undefined)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-[var(--destructive)] hover:bg-[var(--destructive)]/10"
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Interface de sélection simplifiée
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Rechercher une tâche..."
|
|
||||||
value={taskSearch}
|
|
||||||
onChange={(e) => setTaskSearch(e.target.value)}
|
|
||||||
disabled={saving || tasksLoading}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{taskSearch.trim() && (
|
|
||||||
<div className="border border-[var(--border)] rounded-lg max-h-40 overflow-y-auto">
|
|
||||||
{tasksLoading ? (
|
|
||||||
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
) : filteredTasks.length === 0 ? (
|
|
||||||
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
|
||||||
Aucune tâche trouvée
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTasks.slice(0, 5).map((task) => (
|
|
||||||
<button
|
|
||||||
key={task.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTaskSelect(task)}
|
|
||||||
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-sm">{task.title}</div>
|
|
||||||
{task.description && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1">
|
|
||||||
{task.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className={`inline-block px-1 py-0.5 rounded text-xs mt-1 ${
|
|
||||||
task.status === 'todo' ? 'bg-blue-100 text-blue-800' :
|
|
||||||
task.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{task.status}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 justify-end pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
variant="ghost"
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
variant="primary"
|
|
||||||
disabled={!text.trim() || saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { TaskStats } from '@/lib/types';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { getDashboardStatColors } from '@/lib/status-config';
|
|
||||||
|
|
||||||
interface DashboardStatsProps {
|
|
||||||
stats: TaskStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardStats({ stats }: DashboardStatsProps) {
|
|
||||||
const totalTasks = stats.total;
|
|
||||||
const completionRate = totalTasks > 0 ? Math.round((stats.completed / totalTasks) * 100) : 0;
|
|
||||||
const inProgressRate = totalTasks > 0 ? Math.round((stats.inProgress / totalTasks) * 100) : 0;
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{
|
|
||||||
title: 'Total Tâches',
|
|
||||||
value: stats.total,
|
|
||||||
icon: '📋',
|
|
||||||
type: 'total' as const,
|
|
||||||
...getDashboardStatColors('total')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'À Faire',
|
|
||||||
value: stats.todo,
|
|
||||||
icon: '⏳',
|
|
||||||
type: 'todo' as const,
|
|
||||||
...getDashboardStatColors('todo')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'En Cours',
|
|
||||||
value: stats.inProgress,
|
|
||||||
icon: '🔄',
|
|
||||||
type: 'inProgress' as const,
|
|
||||||
...getDashboardStatColors('inProgress')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Terminées',
|
|
||||||
value: stats.completed,
|
|
||||||
icon: '✅',
|
|
||||||
type: 'completed' as const,
|
|
||||||
...getDashboardStatColors('completed')
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
{statCards.map((stat, index) => (
|
|
||||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--muted-foreground)] mb-1">
|
|
||||||
{stat.title}
|
|
||||||
</p>
|
|
||||||
<p className={`text-3xl font-bold ${stat.textColor}`}>
|
|
||||||
{stat.value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-3xl">
|
|
||||||
{stat.icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Cartes de pourcentage */}
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-2 lg:col-span-2">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Taux de Completion</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium">Terminées</span>
|
|
||||||
<span className={`font-bold ${getDashboardStatColors('completed').textColor}`}>{completionRate}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${getDashboardStatColors('completed').progressColor}`}
|
|
||||||
style={{ width: `${completionRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium">En Cours</span>
|
|
||||||
<span className={`font-bold ${getDashboardStatColors('inProgress').textColor}`}>{inProgressRate}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${getDashboardStatColors('inProgress').progressColor}`}
|
|
||||||
style={{ width: `${inProgressRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Insights rapides */}
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-2 lg:col-span-2">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Aperçu Rapide</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('completed').dotColor}`}></span>
|
|
||||||
<span className="text-sm">
|
|
||||||
{stats.completed} tâches terminées sur {totalTasks}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('inProgress').dotColor}`}></span>
|
|
||||||
<span className="text-sm">
|
|
||||||
{stats.inProgress} tâches en cours de traitement
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('todo').dotColor}`}></span>
|
|
||||||
<span className="text-sm">
|
|
||||||
{stats.todo} tâches en attente
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{totalTasks > 0 && (
|
|
||||||
<div className="pt-2 border-t border-[var(--border)]">
|
|
||||||
<span className="text-sm font-medium text-[var(--muted-foreground)]">
|
|
||||||
Productivité: {completionRate}% de completion
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useTransition } from 'react';
|
|
||||||
import { ProductivityMetrics } from '@/services/analytics';
|
|
||||||
import { getProductivityMetrics } from '@/actions/analytics';
|
|
||||||
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
|
||||||
import { VelocityChart } from '@/components/charts/VelocityChart';
|
|
||||||
import { PriorityDistributionChart } from '@/components/charts/PriorityDistributionChart';
|
|
||||||
import { WeeklyStatsCard } from '@/components/charts/WeeklyStatsCard';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
|
|
||||||
export function ProductivityAnalytics() {
|
|
||||||
const [metrics, setMetrics] = useState<ProductivityMetrics | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadMetrics = () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
const response = await getProductivityMetrics();
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setMetrics(response.data);
|
|
||||||
} else {
|
|
||||||
setError(response.error || 'Erreur lors du chargement des métriques');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement des métriques');
|
|
||||||
console.error('Erreur analytics:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
loadMetrics();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Card key={i} className="p-6 animate-pulse">
|
|
||||||
<div className="h-4 bg-[var(--border)] rounded mb-4 w-1/3"></div>
|
|
||||||
<div className="h-64 bg-[var(--border)] rounded"></div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6 mb-8 mt-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-red-500 text-4xl mb-2">⚠️</div>
|
|
||||||
<h3 className="text-lg font-semibold mb-2">Erreur de chargement</h3>
|
|
||||||
<p className="text-[var(--muted-foreground)] text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metrics) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Titre de section */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold">📊 Analytics & Métriques</h2>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Derniers 30 jours
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Performance hebdomadaire */}
|
|
||||||
<WeeklyStatsCard stats={metrics.weeklyStats} />
|
|
||||||
|
|
||||||
{/* Graphiques principaux */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<CompletionTrendChart data={metrics.completionTrend} />
|
|
||||||
<VelocityChart data={metrics.velocityData} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Distributions */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<PriorityDistributionChart data={metrics.priorityDistribution} />
|
|
||||||
|
|
||||||
{/* Status Flow - Graphique simple en barres horizontales */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Répartition par Statut</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{metrics.statusFlow.map((item, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-3">
|
|
||||||
<div className="w-20 text-sm text-[var(--muted-foreground)] text-right">
|
|
||||||
{item.status}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-[var(--border)] rounded-full h-2 relative">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-blue-500 to-cyan-500 h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${item.percentage}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 text-sm font-medium text-right">
|
|
||||||
{item.count}
|
|
||||||
</div>
|
|
||||||
<div className="w-10 text-xs text-[var(--muted-foreground)] text-right">
|
|
||||||
{item.percentage}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Insights automatiques */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">💡 Insights</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors">
|
|
||||||
<div className="text-[var(--primary)] font-medium text-sm mb-1">
|
|
||||||
Vélocité Moyenne
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-[var(--foreground)]">
|
|
||||||
{metrics.velocityData.length > 0
|
|
||||||
? Math.round(metrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / metrics.velocityData.length)
|
|
||||||
: 0
|
|
||||||
} <span className="text-sm font-normal text-[var(--muted-foreground)]">tâches/sem</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors">
|
|
||||||
<div className="text-[var(--success)] font-medium text-sm mb-1">
|
|
||||||
Priorité Principale
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-bold text-[var(--foreground)]">
|
|
||||||
{metrics.priorityDistribution.reduce((max, item) =>
|
|
||||||
item.count > max.count ? item : max,
|
|
||||||
metrics.priorityDistribution[0]
|
|
||||||
)?.priority || 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors">
|
|
||||||
<div className="text-[var(--accent)] font-medium text-sm mb-1">
|
|
||||||
Taux de Completion
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-[var(--foreground)]">
|
|
||||||
{(() => {
|
|
||||||
const completed = metrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0;
|
|
||||||
const total = metrics.statusFlow.reduce((acc, s) => acc + s.count, 0);
|
|
||||||
return total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
||||||
})()}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface QuickActionsProps {
|
|
||||||
onCreateTask: (data: CreateTaskData) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuickActions({ onCreateTask }: QuickActionsProps) {
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleCreateTask = async (data: CreateTaskData) => {
|
|
||||||
await onCreateTask(data);
|
|
||||||
setIsCreateModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
className="flex items-center gap-2 p-6 h-auto"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-semibold">Nouvelle Tâche</div>
|
|
||||||
<div className="text-sm opacity-80">Créer une nouvelle tâche</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Link href="/kanban">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 0V5a2 2 0 012-2h2a2 2 0 002-2" />
|
|
||||||
</svg>
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-semibold">Kanban Board</div>
|
|
||||||
<div className="text-sm opacity-80">Gérer les tâches</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/daily">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-semibold">Daily</div>
|
|
||||||
<div className="text-sm opacity-80">Checkboxes quotidiennes</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/settings">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-semibold">Paramètres</div>
|
|
||||||
<div className="text-sm opacity-80">Configuration</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CreateTaskForm
|
|
||||||
isOpen={isCreateModalOpen}
|
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
|
||||||
onSubmit={handleCreateTask}
|
|
||||||
loading={false}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Task } from '@/lib/types';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
|
||||||
import { getPriorityConfig, getPriorityColorHex, getStatusBadgeClasses, getStatusLabel } from '@/lib/status-config';
|
|
||||||
import { TaskPriority } from '@/lib/types';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface RecentTasksProps {
|
|
||||||
tasks: Task[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentTasks({ tasks }: RecentTasksProps) {
|
|
||||||
const { tags: availableTags } = useTasksContext();
|
|
||||||
|
|
||||||
// Prendre les 5 tâches les plus récentes (créées ou modifiées)
|
|
||||||
const recentTasks = tasks
|
|
||||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
// Fonctions simplifiées utilisant la configuration centralisée
|
|
||||||
|
|
||||||
const getPriorityStyle = (priority: string) => {
|
|
||||||
try {
|
|
||||||
const config = getPriorityConfig(priority as TaskPriority);
|
|
||||||
const hexColor = getPriorityColorHex(config.color);
|
|
||||||
return { color: hexColor };
|
|
||||||
} catch {
|
|
||||||
return { color: '#6b7280' }; // gray-500 par défaut
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-6 mt-8">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">Tâches Récentes</h3>
|
|
||||||
<Link href="/kanban">
|
|
||||||
<button className="text-sm text-[var(--primary)] hover:underline">
|
|
||||||
Voir toutes
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{recentTasks.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
<p>Aucune tâche disponible</p>
|
|
||||||
<p className="text-sm">Créez votre première tâche pour commencer</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{recentTasks.map((task) => (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
className="p-3 border border-[var(--border)] rounded-lg hover:bg-[var(--card)]/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-sm truncate">{task.title}</h4>
|
|
||||||
{task.source === 'jira' && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Jira
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{task.description && (
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-1">
|
|
||||||
{task.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Badge className={`text-xs ${getStatusBadgeClasses(task.status)}`}>
|
|
||||||
{getStatusLabel(task.status)}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{task.priority && (
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium"
|
|
||||||
style={getPriorityStyle(task.priority)}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
try {
|
|
||||||
return getPriorityConfig(task.priority as TaskPriority).label;
|
|
||||||
} catch {
|
|
||||||
return task.priority;
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.tags && task.tags.length > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<TagDisplay
|
|
||||||
tags={task.tags.slice(0, 2)}
|
|
||||||
availableTags={availableTags}
|
|
||||||
size="sm"
|
|
||||||
maxTags={2}
|
|
||||||
showColors={true}
|
|
||||||
/>
|
|
||||||
{task.tags.length > 2 && (
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
+{task.tags.length - 2}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
|
|
||||||
{new Date(task.updatedAt).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { WeeklySummary, WeeklyActivity } from '@/services/weekly-summary';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
|
|
||||||
interface WeeklySummaryClientProps {
|
|
||||||
initialSummary: WeeklySummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WeeklySummaryClient({ initialSummary }: WeeklySummaryClientProps) {
|
|
||||||
const [summary] = useState<WeeklySummary>(initialSummary);
|
|
||||||
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
// Recharger la page pour refaire le fetch côté serveur
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return new Date(date).toLocaleDateString('fr-FR', {
|
|
||||||
weekday: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActivityIcon = (activity: WeeklyActivity) => {
|
|
||||||
if (activity.type === 'checkbox') {
|
|
||||||
return activity.completed ? '✅' : '☐';
|
|
||||||
}
|
|
||||||
return activity.completed ? '🎯' : '📝';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActivityTypeLabel = (type: 'checkbox' | 'task') => {
|
|
||||||
return type === 'checkbox' ? 'Daily' : 'Tâche';
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredActivities = selectedDay
|
|
||||||
? summary.activities.filter(a => a.dayName === selectedDay)
|
|
||||||
: summary.activities;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">📅 Résumé de la semaine</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Du {formatDate(summary.period.start)} au {formatDate(summary.period.end)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
>
|
|
||||||
{isRefreshing ? '🔄' : '🔄'} {isRefreshing ? 'Actualisation...' : 'Actualiser'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Statistiques globales */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{summary.stats.completedCheckboxes}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-blue-600">Daily items</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
sur {summary.stats.totalCheckboxes} ({summary.stats.checkboxCompletionRate.toFixed(0)}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{summary.stats.completedTasks}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-green-600">Tâches</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
sur {summary.stats.totalTasks} ({summary.stats.taskCompletionRate.toFixed(0)}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
|
||||||
{summary.stats.completedCheckboxes + summary.stats.completedTasks}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-purple-600">Total complété</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
sur {summary.stats.totalCheckboxes + summary.stats.totalTasks}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-orange-50 rounded-lg p-4 text-center">
|
|
||||||
<div className="text-lg font-bold text-orange-600">
|
|
||||||
{summary.stats.mostProductiveDay}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-orange-600">Jour le plus productif</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Breakdown par jour */}
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-3">📊 Répartition par jour</h3>
|
|
||||||
<div className="grid grid-cols-7 gap-2 mb-4">
|
|
||||||
{summary.stats.dailyBreakdown.map((day) => (
|
|
||||||
<button
|
|
||||||
key={day.date}
|
|
||||||
onClick={() => setSelectedDay(selectedDay === day.dayName ? null : day.dayName)}
|
|
||||||
className={`p-2 rounded-lg text-center transition-colors ${
|
|
||||||
selectedDay === day.dayName
|
|
||||||
? 'bg-blue-100 border-2 border-blue-300'
|
|
||||||
: 'bg-[var(--muted)] hover:bg-[var(--muted)]/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-medium">
|
|
||||||
{day.dayName.slice(0, 3)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold">
|
|
||||||
{day.completedCheckboxes + day.completedTasks}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
/{day.checkboxes + day.tasks}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{selectedDay && (
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)] mb-4">
|
|
||||||
📍 Filtré sur: <strong>{selectedDay}</strong>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedDay(null)}
|
|
||||||
className="ml-2 text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
(voir tout)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline des activités */}
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-3">
|
|
||||||
🕒 Timeline des activités
|
|
||||||
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
|
||||||
({filteredActivities.length} items)
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{filteredActivities.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
|
||||||
{selectedDay ? 'Aucune activité ce jour-là' : 'Aucune activité cette semaine'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{filteredActivities.map((activity) => (
|
|
||||||
<div
|
|
||||||
key={activity.id}
|
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
|
||||||
activity.completed
|
|
||||||
? 'bg-green-50 border-green-200'
|
|
||||||
: 'bg-[var(--card)] border-[var(--border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg flex-shrink-0">
|
|
||||||
{getActivityIcon(activity)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className={`text-sm ${activity.completed ? 'line-through text-[var(--muted-foreground)]' : ''}`}>
|
|
||||||
{activity.title}
|
|
||||||
</span>
|
|
||||||
<Badge className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)]">
|
|
||||||
{getActivityTypeLabel(activity.type)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{activity.dayName} • {new Date(activity.createdAt).toLocaleDateString('fr-FR')}
|
|
||||||
{activity.completedAt && (
|
|
||||||
<span> • Complété le {new Date(activity.completedAt).toLocaleDateString('fr-FR')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { TagInput } from '@/components/ui/TagInput';
|
|
||||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
|
||||||
// UpdateTaskData removed - using Server Actions directly
|
|
||||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
|
||||||
|
|
||||||
interface EditTaskFormProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
|
|
||||||
task: Task | null;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
|
||||||
const { preferences } = useUserPreferences();
|
|
||||||
const [formData, setFormData] = useState<{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
status: TaskStatus;
|
|
||||||
priority: TaskPriority;
|
|
||||||
tags: string[];
|
|
||||||
dueDate?: Date;
|
|
||||||
}>({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
status: 'todo' as TaskStatus,
|
|
||||||
priority: 'medium' as TaskPriority,
|
|
||||||
tags: [],
|
|
||||||
dueDate: undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// Helper pour construire l'URL Jira
|
|
||||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
|
||||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
|
||||||
if (!baseUrl || !jiraKey) return '';
|
|
||||||
return `${baseUrl}/browse/${jiraKey}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pré-remplir le formulaire quand la tâche change
|
|
||||||
useEffect(() => {
|
|
||||||
if (task) {
|
|
||||||
setFormData({
|
|
||||||
title: task.title,
|
|
||||||
description: task.description || '',
|
|
||||||
status: task.status,
|
|
||||||
priority: task.priority,
|
|
||||||
tags: task.tags || [],
|
|
||||||
dueDate: task.dueDate ? new Date(task.dueDate) : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [task]);
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.title?.trim()) {
|
|
||||||
newErrors.title = 'Le titre est requis';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.title && formData.title.length > 200) {
|
|
||||||
newErrors.title = 'Le titre ne peut pas dépasser 200 caractères';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.description && formData.description.length > 1000) {
|
|
||||||
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!validateForm() || !task) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSubmit({
|
|
||||||
taskId: task.id,
|
|
||||||
...formData
|
|
||||||
});
|
|
||||||
handleClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la mise à jour:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setErrors({});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
if (!task) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
|
||||||
{/* Titre */}
|
|
||||||
<Input
|
|
||||||
label="Titre *"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
||||||
placeholder="Titre de la tâche..."
|
|
||||||
error={errors.title}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
placeholder="Description détaillée..."
|
|
||||||
rows={4}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
|
||||||
/>
|
|
||||||
{errors.description && (
|
|
||||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
|
||||||
<span className="text-red-500">⚠</span>
|
|
||||||
{errors.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priorité et Statut */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Priorité
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.priority}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllPriorities().map(priorityConfig => (
|
|
||||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
|
||||||
{priorityConfig.icon} {priorityConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Statut
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllStatuses().map(statusConfig => (
|
|
||||||
<option key={statusConfig.key} value={statusConfig.key}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date d'échéance */}
|
|
||||||
<Input
|
|
||||||
label="Date d'échéance"
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
|
||||||
onChange={(e) => setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
|
||||||
}))}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Informations Jira */}
|
|
||||||
{task.source === 'jira' && task.jiraKey && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Jira
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{preferences.jiraConfig.baseUrl ? (
|
|
||||||
<a
|
|
||||||
href={getJiraTicketUrl(task.jiraKey)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:scale-105 transition-transform inline-flex"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraProject && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
|
||||||
{task.jiraProject}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraType && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
|
||||||
{task.jiraType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<TagInput
|
|
||||||
tags={formData.tags || []}
|
|
||||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
|
||||||
placeholder="Ajouter des tags..."
|
|
||||||
maxTags={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Todos reliés */}
|
|
||||||
<RelatedTodos taskId={task.id} />
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
|
|
||||||
interface AdvancedFiltersPanelProps {
|
|
||||||
availableFilters: AvailableFilters;
|
|
||||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
|
||||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterSectionProps {
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
options: FilterOption[];
|
|
||||||
selectedValues: string[];
|
|
||||||
onSelectionChange: (values: string[]) => void;
|
|
||||||
maxDisplay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
|
||||||
const hasMore = options.length > maxDisplay;
|
|
||||||
|
|
||||||
const handleToggle = (value: string) => {
|
|
||||||
const newValues = selectedValues.includes(value)
|
|
||||||
? selectedValues.filter(v => v !== value)
|
|
||||||
: [...selectedValues, value];
|
|
||||||
onSelectionChange(newValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
onSelectionChange(options.map(opt => opt.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
onSelectionChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
|
||||||
<span>{icon}</span>
|
|
||||||
{title}
|
|
||||||
{selectedValues.length > 0 && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{selectedValues.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{options.length > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={selectAll}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Tout
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-400">|</span>
|
|
||||||
<button
|
|
||||||
onClick={clearAll}
|
|
||||||
className="text-xs text-gray-600 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
Aucun
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{options.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
|
||||||
{displayOptions.map(option => (
|
|
||||||
<label
|
|
||||||
key={option.value}
|
|
||||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedValues.includes(option.value)}
|
|
||||||
onChange={() => handleToggle(option.value)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate">{option.label}</span>
|
|
||||||
<span className="text-xs text-gray-500">({option.count})</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMore && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAll(!showAll)}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdvancedFiltersPanel({
|
|
||||||
availableFilters,
|
|
||||||
activeFilters,
|
|
||||||
onFiltersChange,
|
|
||||||
className = ''
|
|
||||||
}: AdvancedFiltersPanelProps) {
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTempFilters(activeFilters);
|
|
||||||
}, [activeFilters]);
|
|
||||||
|
|
||||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
|
||||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
|
||||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
|
||||||
|
|
||||||
const applyFilters = () => {
|
|
||||||
onFiltersChange(tempFilters);
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
|
||||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
|
||||||
setTempFilters(emptyFilters);
|
|
||||||
onFiltersChange(emptyFilters);
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
|
|
||||||
key: K,
|
|
||||||
value: JiraAnalyticsFilters[K]
|
|
||||||
) => {
|
|
||||||
setTempFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
🗑️ Effacer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
⚙️ Configurer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
|
||||||
{filtersSummary}
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* Aperçu rapide des filtres actifs */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="p-3 bg-blue-50 rounded-lg">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{activeFilters.components?.map(comp => (
|
|
||||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
|
||||||
📦 {comp}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.fixVersions?.map(version => (
|
|
||||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
|
||||||
🏷️ {version}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.issueTypes?.map(type => (
|
|
||||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
📋 {type}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.statuses?.map(status => (
|
|
||||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
🔄 {status}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.assignees?.map(assignee => (
|
|
||||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
|
||||||
👤 {assignee}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.labels?.map(label => (
|
|
||||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
🏷️ {label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.priorities?.map(priority => (
|
|
||||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
|
||||||
⚡ {priority}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modal de configuration des filtres */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showModal}
|
|
||||||
onClose={() => setShowModal(false)}
|
|
||||||
title="Configuration des filtres avancés"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
|
||||||
<FilterSection
|
|
||||||
title="Composants"
|
|
||||||
icon="📦"
|
|
||||||
options={availableFilters.components}
|
|
||||||
selectedValues={tempFilters.components || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Versions"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.fixVersions}
|
|
||||||
selectedValues={tempFilters.fixVersions || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Types de tickets"
|
|
||||||
icon="📋"
|
|
||||||
options={availableFilters.issueTypes}
|
|
||||||
selectedValues={tempFilters.issueTypes || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Statuts"
|
|
||||||
icon="🔄"
|
|
||||||
options={availableFilters.statuses}
|
|
||||||
selectedValues={tempFilters.statuses || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Assignés"
|
|
||||||
icon="👤"
|
|
||||||
options={availableFilters.assignees}
|
|
||||||
selectedValues={tempFilters.assignees || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Labels"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.labels}
|
|
||||||
selectedValues={tempFilters.labels || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Priorités"
|
|
||||||
icon="⚡"
|
|
||||||
options={availableFilters.priorities}
|
|
||||||
selectedValues={tempFilters.priorities || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-6 border-t">
|
|
||||||
<Button
|
|
||||||
onClick={applyFilters}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
✅ Appliquer les filtres
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
🗑️ Effacer tout
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
|
||||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
|
|
||||||
interface AnomalyDetectionPanelProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetectionPanelProps) {
|
|
||||||
const [anomalies, setAnomalies] = useState<JiraAnomaly[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
|
||||||
const [config, setConfig] = useState<AnomalyDetectionConfig | null>(null);
|
|
||||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
// Charger la config au montage, les anomalies seulement si expanded
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Charger les anomalies quand on ouvre le panneau
|
|
||||||
useEffect(() => {
|
|
||||||
if (isExpanded && anomalies.length === 0) {
|
|
||||||
loadAnomalies();
|
|
||||||
}
|
|
||||||
}, [isExpanded, anomalies.length]);
|
|
||||||
|
|
||||||
const loadAnomalies = async (forceRefresh = false) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await detectJiraAnomalies(forceRefresh);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setAnomalies(result.data);
|
|
||||||
setLastUpdate(new Date().toLocaleString('fr-FR'));
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors de la détection');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Erreur de connexion');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
const result = await getAnomalyDetectionConfig();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setConfig(result.data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erreur lors du chargement de la config:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfigUpdate = async (newConfig: AnomalyDetectionConfig) => {
|
|
||||||
try {
|
|
||||||
const result = await updateAnomalyDetectionConfig(newConfig);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setConfig(result.data);
|
|
||||||
setShowConfig(false);
|
|
||||||
// Recharger les anomalies avec la nouvelle config
|
|
||||||
loadAnomalies(true);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erreur lors de la mise à jour de la config:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSeverityColor = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
|
||||||
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
||||||
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
||||||
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
||||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSeverityIcon = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return '🚨';
|
|
||||||
case 'high': return '⚠️';
|
|
||||||
case 'medium': return '⚡';
|
|
||||||
case 'low': return 'ℹ️';
|
|
||||||
default: return '📊';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
|
||||||
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
|
||||||
const totalCount = anomalies.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader
|
|
||||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
|
||||||
{totalCount > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{criticalCount > 0 && (
|
|
||||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
|
||||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{highCount > 0 && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowConfig(true)}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
⚙️ Config
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => loadAnomalies(true)}
|
|
||||||
disabled={loading}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && lastUpdate && (
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
Dernière analyse: {lastUpdate}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<CardContent>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
|
||||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
|
||||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && anomalies.length === 0 && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-4xl mb-2">✅</div>
|
|
||||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && anomalies.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{anomalies.map((anomaly) => (
|
|
||||||
<div
|
|
||||||
key={anomaly.id}
|
|
||||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
|
||||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
|
||||||
{anomaly.severity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
|
||||||
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
|
||||||
{anomaly.threshold > 0 && (
|
|
||||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{anomaly.affectedItems.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
|
||||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
|
||||||
{item}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{anomaly.affectedItems.length > 2 && (
|
|
||||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modal de configuration */}
|
|
||||||
{showConfig && config && (
|
|
||||||
<Modal
|
|
||||||
isOpen={showConfig}
|
|
||||||
onClose={() => setShowConfig(false)}
|
|
||||||
title="Configuration de la détection d'anomalies"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Seuil de variance de vélocité (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.velocityVarianceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage de variance acceptable dans la vélocité
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Multiplicateur de cycle time
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.cycleTimeThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Ratio de déséquilibre de charge
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.workloadImbalanceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Ratio maximum acceptable entre les charges de travail
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Taux de completion minimum (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.completionRateThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage minimum de completion des sprints
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => handleConfigUpdate(config)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
💾 Sauvegarder
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowConfig(false)}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { JiraAnalytics } from '@/lib/types';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
|
|
||||||
interface CollaborationMatrixProps {
|
|
||||||
analytics: JiraAnalytics;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollaborationData {
|
|
||||||
assignee: string;
|
|
||||||
displayName: string;
|
|
||||||
collaborationScore: number;
|
|
||||||
dependencies: Array<{
|
|
||||||
partner: string;
|
|
||||||
partnerDisplayName: string;
|
|
||||||
sharedTickets: number;
|
|
||||||
intensity: 'low' | 'medium' | 'high';
|
|
||||||
}>;
|
|
||||||
isolation: number; // Score d'isolation (0-100, plus c'est élevé plus isolé)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CollaborationMatrix({ analytics, className }: CollaborationMatrixProps) {
|
|
||||||
// Analyser les patterns de collaboration basés sur les données existantes
|
|
||||||
const collaborationData: CollaborationData[] = analytics.teamMetrics.issuesDistribution.map(assignee => {
|
|
||||||
// Simuler des collaborations basées sur les données réelles
|
|
||||||
const totalTickets = assignee.totalIssues;
|
|
||||||
|
|
||||||
// Générer des partenaires de collaboration réalistes
|
|
||||||
const otherAssignees = analytics.teamMetrics.issuesDistribution.filter(a => a.assignee !== assignee.assignee);
|
|
||||||
const dependencies = otherAssignees
|
|
||||||
.slice(0, Math.min(3, otherAssignees.length)) // Maximum 3 collaborations principales
|
|
||||||
.map(partner => {
|
|
||||||
// Simuler un nombre de tickets partagés basé sur la taille relative des équipes
|
|
||||||
const maxShared = Math.min(totalTickets, partner.totalIssues);
|
|
||||||
const sharedTickets = Math.floor(Math.random() * Math.max(1, maxShared * 0.3));
|
|
||||||
|
|
||||||
const intensity: 'low' | 'medium' | 'high' =
|
|
||||||
sharedTickets > maxShared * 0.2 ? 'high' :
|
|
||||||
sharedTickets > maxShared * 0.1 ? 'medium' : 'low';
|
|
||||||
|
|
||||||
return {
|
|
||||||
partner: partner.assignee,
|
|
||||||
partnerDisplayName: partner.displayName,
|
|
||||||
sharedTickets,
|
|
||||||
intensity
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(dep => dep.sharedTickets > 0)
|
|
||||||
.sort((a, b) => b.sharedTickets - a.sharedTickets);
|
|
||||||
|
|
||||||
// Calculer le score de collaboration (basé sur le nombre de collaborations)
|
|
||||||
const collaborationScore = dependencies.reduce((score, dep) => score + dep.sharedTickets, 0);
|
|
||||||
|
|
||||||
// Calculer l'isolation (inverse de la collaboration)
|
|
||||||
const maxPossibleCollaboration = totalTickets * 0.5; // 50% max de collaboration
|
|
||||||
const isolation = Math.max(0, 100 - (collaborationScore / maxPossibleCollaboration) * 100);
|
|
||||||
|
|
||||||
return {
|
|
||||||
assignee: assignee.assignee,
|
|
||||||
displayName: assignee.displayName,
|
|
||||||
collaborationScore,
|
|
||||||
dependencies,
|
|
||||||
isolation: Math.round(isolation)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Statistiques globales
|
|
||||||
const avgCollaboration = collaborationData.reduce((sum, d) => sum + d.collaborationScore, 0) / collaborationData.length;
|
|
||||||
const avgIsolation = collaborationData.reduce((sum, d) => sum + d.isolation, 0) / collaborationData.length;
|
|
||||||
const mostCollaborative = collaborationData.reduce((max, current) =>
|
|
||||||
current.collaborationScore > max.collaborationScore ? current : max, collaborationData[0]);
|
|
||||||
const mostIsolated = collaborationData.reduce((max, current) =>
|
|
||||||
current.isolation > max.isolation ? current : max, collaborationData[0]);
|
|
||||||
|
|
||||||
// Couleur d'intensité
|
|
||||||
const getIntensityColor = (intensity: 'low' | 'medium' | 'high') => {
|
|
||||||
switch (intensity) {
|
|
||||||
case 'high': return 'bg-green-600 dark:bg-green-500';
|
|
||||||
case 'medium': return 'bg-yellow-600 dark:bg-yellow-500';
|
|
||||||
case 'low': return 'bg-gray-500 dark:bg-gray-400';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Matrice de collaboration */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<h4 className="text-sm font-medium mb-3">Réseau de collaboration</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto">
|
|
||||||
{collaborationData.map(person => (
|
|
||||||
<Card key={person.assignee} className="p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="font-medium text-sm">{person.displayName}</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Score: {person.collaborationScore}
|
|
||||||
</span>
|
|
||||||
<div className={`w-3 h-3 rounded-full ${
|
|
||||||
person.isolation < 30 ? 'bg-green-600 dark:bg-green-500' :
|
|
||||||
person.isolation < 60 ? 'bg-yellow-600 dark:bg-yellow-500' : 'bg-red-600 dark:bg-red-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{person.dependencies.length > 0 ? (
|
|
||||||
person.dependencies.map(dep => (
|
|
||||||
<div key={dep.partner} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-[var(--muted-foreground)] truncate">
|
|
||||||
→ {dep.partnerDisplayName}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<span>{dep.sharedTickets} tickets</span>
|
|
||||||
<div className={`w-2 h-2 rounded-full ${getIntensityColor(dep.intensity)}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] italic">
|
|
||||||
Aucune collaboration détectée
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Métriques de collaboration */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-3">Analyse d'équipe</h4>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Graphique de répartition */}
|
|
||||||
<Card className="p-3">
|
|
||||||
<h5 className="text-xs font-medium mb-2">Répartition par niveau</h5>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{['Très collaboratif', 'Collaboratif', 'Isolé', 'Très isolé'].map((level, index) => {
|
|
||||||
const ranges = [[0, 30], [30, 50], [50, 70], [70, 100]];
|
|
||||||
const [min, max] = ranges[index];
|
|
||||||
const count = collaborationData.filter(d => d.isolation >= min && d.isolation < max).length;
|
|
||||||
const colors = ['bg-green-600 dark:bg-green-500', 'bg-blue-600 dark:bg-blue-500', 'bg-yellow-600 dark:bg-yellow-500', 'bg-red-600 dark:bg-red-500'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={level} className="flex items-center gap-2 text-xs">
|
|
||||||
<div className={`w-3 h-3 rounded-sm ${colors[index]}`} />
|
|
||||||
<span className="flex-1 truncate">{level}</span>
|
|
||||||
<span className="font-mono text-xs">{count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Insights */}
|
|
||||||
<Card className="p-3">
|
|
||||||
<h5 className="text-xs font-medium mb-2">🏆 Plus collaboratif</h5>
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium truncate">{mostCollaborative?.displayName}</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{mostCollaborative?.collaborationScore} interactions
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-3">
|
|
||||||
<h5 className="text-xs font-medium mb-2">⚠️ Plus isolé</h5>
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium truncate">{mostIsolated?.displayName}</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{mostIsolated?.isolation}% d'isolation
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Légende des intensités */}
|
|
||||||
<Card className="p-3">
|
|
||||||
<h5 className="text-xs font-medium mb-2">Légende</h5>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{[
|
|
||||||
{ intensity: 'high' as const, label: 'Forte' },
|
|
||||||
{ intensity: 'medium' as const, label: 'Modérée' },
|
|
||||||
{ intensity: 'low' as const, label: 'Faible' }
|
|
||||||
].map(item => (
|
|
||||||
<div key={item.intensity} className="flex items-center gap-2 text-xs">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${getIntensityColor(item.intensity)}`} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Métriques globales */}
|
|
||||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className="text-lg font-bold text-blue-500">
|
|
||||||
{Math.round(avgCollaboration)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Collaboration moyenne
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className={`text-lg font-bold ${avgIsolation < 40 ? 'text-green-500' : avgIsolation < 60 ? 'text-orange-500' : 'text-red-500'}`}>
|
|
||||||
{Math.round(avgIsolation)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Isolation moyenne
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className="text-lg font-bold text-purple-500">
|
|
||||||
{collaborationData.filter(d => d.dependencies.length > 0).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Membres connectés
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className="text-lg font-bold text-indigo-500">
|
|
||||||
{collaborationData.reduce((sum, d) => sum + d.dependencies.length, 0)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Connexions totales
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recommandations */}
|
|
||||||
<div className="mt-4 p-4 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<h4 className="text-sm font-medium mb-2">Recommandations d'équipe</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{avgIsolation > 60 && (
|
|
||||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
|
||||||
<span>⚠️</span>
|
|
||||||
<span>Isolation élevée - Encourager le pair programming et les reviews croisées</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{avgIsolation < 30 && (
|
|
||||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
|
||||||
<span>✅</span>
|
|
||||||
<span>Excellente collaboration - L'équipe travaille bien ensemble</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mostIsolated && mostIsolated.isolation > 80 && (
|
|
||||||
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
|
||||||
<span>👥</span>
|
|
||||||
<span>Attention à {mostIsolated.displayName} - Considérer du mentoring ou du binômage</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{collaborationData.filter(d => d.dependencies.length === 0).length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
|
||||||
<span>🔗</span>
|
|
||||||
<span>Quelques membres travaillent en silo - Organiser des sessions de partage</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { jiraClient } from '@/clients/jira-client';
|
|
||||||
import { JiraSyncResult, JiraSyncAction } from '@/services/jira';
|
|
||||||
|
|
||||||
interface JiraSyncProps {
|
|
||||||
onSyncComplete?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const [lastSyncResult, setLastSyncResult] = useState<JiraSyncResult | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
|
||||||
|
|
||||||
const testConnection = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await jiraClient.testConnection();
|
|
||||||
setIsConnected(status.connected);
|
|
||||||
if (!status.connected) {
|
|
||||||
setError(status.message);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setIsConnected(false);
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur de connexion');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startSync = async () => {
|
|
||||||
setIsSyncing(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await jiraClient.syncTasks();
|
|
||||||
setLastSyncResult(result);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
onSyncComplete?.();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur de synchronisation');
|
|
||||||
} finally {
|
|
||||||
setIsSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConnectionStatus = () => {
|
|
||||||
if (isConnected === null) return null;
|
|
||||||
return isConnected ? (
|
|
||||||
<Badge variant="success" size="sm">✓ Connecté</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="danger" size="sm">✗ Déconnecté</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSyncStatus = () => {
|
|
||||||
if (!lastSyncResult) return null;
|
|
||||||
|
|
||||||
const { success, tasksFound, tasksCreated, tasksUpdated, tasksSkipped, tasksDeleted = 0, errors, actions = [] } = lastSyncResult;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={success ? "success" : "danger"} size="sm">
|
|
||||||
{success ? "✓ Succès" : "⚠ Erreurs"}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-[var(--muted-foreground)] text-xs">
|
|
||||||
{new Date().toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{tasksFound} trouvé{tasksFound > 1 ? 's' : ''} dans Jira
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
|
||||||
<div className="font-mono font-bold text-emerald-400">{tasksCreated}</div>
|
|
||||||
<div className="text-[var(--muted-foreground)]">Créées</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
|
||||||
<div className="font-mono font-bold text-blue-400">{tasksUpdated}</div>
|
|
||||||
<div className="text-[var(--muted-foreground)]">Mises à jour</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
|
||||||
<div className="font-mono font-bold text-orange-400">{tasksSkipped}</div>
|
|
||||||
<div className="text-[var(--muted-foreground)]">Ignorées</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
|
||||||
<div className="font-mono font-bold text-red-400">{tasksDeleted}</div>
|
|
||||||
<div className="text-[var(--muted-foreground)]">Supprimées</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Résumé textuel avec bouton détails */}
|
|
||||||
<div className="p-2 bg-[var(--muted)]/5 rounded text-xs">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<div className="font-medium text-[var(--muted-foreground)]">Résumé:</div>
|
|
||||||
{actions.length > 0 && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowDetails(true)}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs px-2 py-1 h-auto"
|
|
||||||
>
|
|
||||||
Voir détails ({actions.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
{tasksCreated > 0 && `${tasksCreated} nouvelle${tasksCreated > 1 ? 's' : ''} • `}
|
|
||||||
{tasksUpdated > 0 && `${tasksUpdated} mise${tasksUpdated > 1 ? 's' : ''} à jour • `}
|
|
||||||
{tasksDeleted > 0 && `${tasksDeleted} supprimée${tasksDeleted > 1 ? 's' : ''} (réassignées) • `}
|
|
||||||
{tasksSkipped > 0 && `${tasksSkipped} ignorée${tasksSkipped > 1 ? 's' : ''} • `}
|
|
||||||
{(tasksCreated + tasksUpdated + tasksDeleted + tasksSkipped) === 0 && 'Aucune modification'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errors.length > 0 && (
|
|
||||||
<div className="p-2 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-xs">
|
|
||||||
<div className="font-semibold text-[var(--destructive)] mb-1">Erreurs ({errors.length}):</div>
|
|
||||||
<div className="space-y-1 max-h-20 overflow-y-auto">
|
|
||||||
{errors.map((err, i) => (
|
|
||||||
<div key={i} className="text-[var(--destructive)] font-mono text-xs">{err}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={`${className}`}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 animate-pulse"></div>
|
|
||||||
<h3 className="font-mono text-sm font-bold text-blue-400 uppercase tracking-wider">
|
|
||||||
JIRA SYNC
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{getConnectionStatus()}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Test de connexion */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={testConnection}
|
|
||||||
disabled={isLoading}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 border border-[var(--muted-foreground)] border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
Test...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'Tester connexion'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={startSync}
|
|
||||||
disabled={isSyncing || isConnected === false}
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{isSyncing ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
Sync...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'🔄 Synchroniser'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages d'erreur */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-sm text-[var(--destructive)]">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Résultats de sync */}
|
|
||||||
{getSyncStatus()}
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] space-y-1">
|
|
||||||
<div>• Synchronisation unidirectionnelle (Jira → TowerControl)</div>
|
|
||||||
<div>• Les modifications locales sont préservées</div>
|
|
||||||
<div>• Seuls les tickets assignés sont synchronisés</div>
|
|
||||||
<div>• Les tickets réassignés sont automatiquement supprimés</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
{/* Modal détails de synchronisation */}
|
|
||||||
{lastSyncResult && (
|
|
||||||
<Modal
|
|
||||||
isOpen={showDetails}
|
|
||||||
onClose={() => setShowDetails(false)}
|
|
||||||
title="📋 DÉTAILS DE SYNCHRONISATION"
|
|
||||||
size="xl"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
{(lastSyncResult.actions || []).length} action{(lastSyncResult.actions || []).length > 1 ? 's' : ''} effectuée{(lastSyncResult.actions || []).length > 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto">
|
|
||||||
{(lastSyncResult.actions || []).length > 0 ? (
|
|
||||||
<SyncActionsList actions={lastSyncResult.actions || []} />
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
|
||||||
<div className="text-2xl mb-2">📝</div>
|
|
||||||
<div>Aucun détail disponible pour cette synchronisation</div>
|
|
||||||
<div className="text-sm mt-1">Les détails sont disponibles pour les nouvelles synchronisations</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composant pour afficher la liste des actions
|
|
||||||
function SyncActionsList({ actions }: { actions: JiraSyncAction[] }) {
|
|
||||||
const getActionIcon = (type: JiraSyncAction['type']) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'created': return '➕';
|
|
||||||
case 'updated': return '🔄';
|
|
||||||
case 'skipped': return '⏭️';
|
|
||||||
case 'deleted': return '🗑️';
|
|
||||||
default: return '❓';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActionColor = (type: JiraSyncAction['type']) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'created': return 'text-emerald-400';
|
|
||||||
case 'updated': return 'text-blue-400';
|
|
||||||
case 'skipped': return 'text-orange-400';
|
|
||||||
case 'deleted': return 'text-red-400';
|
|
||||||
default: return 'text-gray-400';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActionLabel = (type: JiraSyncAction['type']) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'created': return 'Créée';
|
|
||||||
case 'updated': return 'Mise à jour';
|
|
||||||
case 'skipped': return 'Ignorée';
|
|
||||||
case 'deleted': return 'Supprimée';
|
|
||||||
default: return 'Inconnue';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Grouper les actions par type
|
|
||||||
const groupedActions = actions.reduce((acc, action) => {
|
|
||||||
if (!acc[action.type]) acc[action.type] = [];
|
|
||||||
acc[action.type].push(action);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, JiraSyncAction[]>);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.entries(groupedActions).map(([type, typeActions]) => (
|
|
||||||
<div key={type} className="space-y-3">
|
|
||||||
<h4 className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as JiraSyncAction['type'])}`}>
|
|
||||||
{getActionIcon(type as JiraSyncAction['type'])}
|
|
||||||
{getActionLabel(type as JiraSyncAction['type'])} ({typeActions.length})
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{typeActions.map((action, index) => (
|
|
||||||
<div key={index} className="p-2 bg-[var(--muted)]/10 rounded border border-[var(--border)]">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="font-mono text-sm font-bold text-[var(--foreground)] shrink-0">
|
|
||||||
{action.taskKey}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-[var(--muted-foreground)] truncate">
|
|
||||||
{action.taskTitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" size="sm" className="shrink-0">
|
|
||||||
{getActionLabel(action.type)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{action.reason && (
|
|
||||||
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
|
|
||||||
💡 {action.reason}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{action.changes && action.changes.length > 0 && (
|
|
||||||
<div className="mt-1 space-y-0.5">
|
|
||||||
<div className="text-xs font-medium text-[var(--muted-foreground)]">
|
|
||||||
Modifications:
|
|
||||||
</div>
|
|
||||||
{action.changes.map((change, changeIndex) => (
|
|
||||||
<div key={changeIndex} className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-blue-400/30">
|
|
||||||
{change}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from 'recharts';
|
|
||||||
import { SprintVelocity } from '@/lib/types';
|
|
||||||
|
|
||||||
interface PredictabilityMetricsProps {
|
|
||||||
sprintHistory: SprintVelocity[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PredictabilityDataPoint {
|
|
||||||
sprint: string;
|
|
||||||
planned: number;
|
|
||||||
actual: number;
|
|
||||||
variance: number; // Pourcentage de variance (positif = dépassement, négatif = sous-performance)
|
|
||||||
accuracy: number; // Pourcentage d'exactitude (100% = parfait)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PredictabilityMetrics({ sprintHistory, className }: PredictabilityMetricsProps) {
|
|
||||||
// Calculer les métriques de predictabilité
|
|
||||||
const predictabilityData: PredictabilityDataPoint[] = sprintHistory.map(sprint => {
|
|
||||||
const variance = sprint.plannedPoints > 0
|
|
||||||
? ((sprint.completedPoints - sprint.plannedPoints) / sprint.plannedPoints) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const accuracy = sprint.plannedPoints > 0
|
|
||||||
? Math.max(0, 100 - Math.abs(variance))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
sprint: sprint.sprintName.replace('Sprint ', ''),
|
|
||||||
planned: sprint.plannedPoints,
|
|
||||||
actual: sprint.completedPoints,
|
|
||||||
variance: Math.round(variance * 10) / 10,
|
|
||||||
accuracy: Math.round(accuracy * 10) / 10
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculer les statistiques globales
|
|
||||||
const averageVariance = predictabilityData.length > 0
|
|
||||||
? predictabilityData.reduce((sum, d) => sum + Math.abs(d.variance), 0) / predictabilityData.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const averageAccuracy = predictabilityData.length > 0
|
|
||||||
? predictabilityData.reduce((sum, d) => sum + d.accuracy, 0) / predictabilityData.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const consistencyScore = averageVariance < 10 ? 'Excellent' :
|
|
||||||
averageVariance < 20 ? 'Bon' :
|
|
||||||
averageVariance < 30 ? 'Moyen' : 'À améliorer';
|
|
||||||
|
|
||||||
// Tendance de l'exactitude (en amélioration ou dégradation)
|
|
||||||
const recentAccuracy = predictabilityData.slice(-2);
|
|
||||||
const trend = recentAccuracy.length >= 2
|
|
||||||
? recentAccuracy[1].accuracy - recentAccuracy[0].accuracy
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ payload: PredictabilityDataPoint; value: number; name: string; color: string }>;
|
|
||||||
label?: string
|
|
||||||
}) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
|
||||||
<p className="font-medium text-sm mb-2">Sprint {label}</p>
|
|
||||||
<div className="space-y-1 text-xs">
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Planifié:</span>
|
|
||||||
<span className="font-mono text-gray-500">{data.planned} pts</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Réalisé:</span>
|
|
||||||
<span className="font-mono text-blue-500">{data.actual} pts</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Variance:</span>
|
|
||||||
<span className={`font-mono ${data.variance > 0 ? 'text-green-500' : data.variance < 0 ? 'text-red-500' : 'text-gray-500'}`}>
|
|
||||||
{data.variance > 0 ? '+' : ''}{data.variance}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Exactitude:</span>
|
|
||||||
<span className="font-mono text-orange-500">{data.accuracy}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Graphique de variance */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-3">Variance planifié vs réalisé</h4>
|
|
||||||
<div style={{ width: '100%', height: '200px' }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={predictabilityData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="sprint"
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={10}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={10}
|
|
||||||
label={{ value: '%', angle: 0, position: 'insideLeft' }}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Bar
|
|
||||||
dataKey="variance"
|
|
||||||
radius={[2, 2, 2, 2]}
|
|
||||||
>
|
|
||||||
{predictabilityData.map((entry, index) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={entry.variance > 0 ? 'hsl(142, 76%, 36%)' : entry.variance < 0 ? 'hsl(0, 84%, 60%)' : 'hsl(240, 5%, 64%)'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Graphique d'exactitude */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-3">Évolution de l'exactitude</h4>
|
|
||||||
<div style={{ width: '100%', height: '200px' }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={predictabilityData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="sprint"
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={10}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={10}
|
|
||||||
domain={[0, 100]}
|
|
||||||
label={{ value: '%', angle: 0, position: 'insideLeft' }}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="accuracy"
|
|
||||||
stroke="hsl(45, 93%, 47%)"
|
|
||||||
strokeWidth={3}
|
|
||||||
dot={{ fill: 'hsl(45, 93%, 47%)', strokeWidth: 2, r: 4 }}
|
|
||||||
name="Exactitude"
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Métriques de predictabilité */}
|
|
||||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className={`text-lg font-bold ${averageAccuracy > 80 ? 'text-green-500' : averageAccuracy > 60 ? 'text-orange-500' : 'text-red-500'}`}>
|
|
||||||
{Math.round(averageAccuracy)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Exactitude moyenne
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className={`text-lg font-bold ${averageVariance < 10 ? 'text-green-500' : averageVariance < 20 ? 'text-orange-500' : 'text-red-500'}`}>
|
|
||||||
{Math.round(averageVariance * 10) / 10}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Variance moyenne
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className={`text-lg font-bold ${consistencyScore === 'Excellent' ? 'text-green-500' : consistencyScore === 'Bon' ? 'text-blue-500' : consistencyScore === 'Moyen' ? 'text-orange-500' : 'text-red-500'}`}>
|
|
||||||
{consistencyScore}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Consistance
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<div className={`text-lg font-bold ${trend > 5 ? 'text-green-500' : trend < -5 ? 'text-red-500' : 'text-blue-500'}`}>
|
|
||||||
{trend > 0 ? '↗️' : trend < 0 ? '↘️' : '→'} {Math.abs(Math.round(trend))}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Tendance récente
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Analyse et recommandations */}
|
|
||||||
<div className="mt-4 p-4 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
|
||||||
<h4 className="text-sm font-medium mb-2">Analyse de predictabilité</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{averageAccuracy > 80 && (
|
|
||||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
|
||||||
<span>✅</span>
|
|
||||||
<span>Excellente predictabilité - L'équipe estime bien sa capacité</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{averageAccuracy < 60 && (
|
|
||||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
|
||||||
<span>⚠️</span>
|
|
||||||
<span>Predictabilité faible - Revoir les méthodes d'estimation</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{averageVariance > 25 && (
|
|
||||||
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
|
||||||
<span>📊</span>
|
|
||||||
<span>Variance élevée - Considérer des sprints plus courts ou un meilleur découpage</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{trend > 10 && (
|
|
||||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
|
||||||
<span>📈</span>
|
|
||||||
<span>Tendance positive - L'équipe s'améliore dans ses estimations</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{trend < -10 && (
|
|
||||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
|
||||||
<span>📉</span>
|
|
||||||
<span>Tendance négative - Attention aux changements récents (équipe, processus)</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
|
|
||||||
interface SprintDetailModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
sprint: SprintVelocity | null;
|
|
||||||
onLoadSprintDetails: (sprintName: string) => Promise<SprintDetails>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SprintDetails {
|
|
||||||
sprint: SprintVelocity;
|
|
||||||
issues: JiraTask[];
|
|
||||||
assigneeDistribution: AssigneeDistribution[];
|
|
||||||
statusDistribution: StatusDistribution[];
|
|
||||||
metrics: {
|
|
||||||
totalIssues: number;
|
|
||||||
completedIssues: number;
|
|
||||||
inProgressIssues: number;
|
|
||||||
blockedIssues: number;
|
|
||||||
averageCycleTime: number;
|
|
||||||
velocityTrend: 'up' | 'down' | 'stable';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SprintDetailModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
sprint,
|
|
||||||
onLoadSprintDetails
|
|
||||||
}: SprintDetailModalProps) {
|
|
||||||
const [sprintDetails, setSprintDetails] = useState<SprintDetails | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
|
||||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSprintDetails = useCallback(async () => {
|
|
||||||
if (!sprint) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const details = await onLoadSprintDetails(sprint.sprintName);
|
|
||||||
setSprintDetails(details);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [sprint, onLoadSprintDetails]);
|
|
||||||
|
|
||||||
// Charger les détails du sprint quand le modal s'ouvre
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && sprint && !sprintDetails) {
|
|
||||||
loadSprintDetails();
|
|
||||||
}
|
|
||||||
}, [isOpen, sprint, sprintDetails, loadSprintDetails]);
|
|
||||||
|
|
||||||
// Reset quand on change de sprint
|
|
||||||
useEffect(() => {
|
|
||||||
if (sprint) {
|
|
||||||
setSprintDetails(null);
|
|
||||||
setSelectedAssignee(null);
|
|
||||||
setSelectedStatus(null);
|
|
||||||
setSelectedTab('overview');
|
|
||||||
}
|
|
||||||
}, [sprint]);
|
|
||||||
|
|
||||||
// Filtrer les issues selon les sélections
|
|
||||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
|
||||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
|
||||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
|
||||||
return 'bg-green-100 text-green-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
|
||||||
return 'bg-red-100 text-red-800';
|
|
||||||
}
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority?: string): string => {
|
|
||||||
switch (priority?.toLowerCase()) {
|
|
||||||
case 'highest': return 'bg-red-500 text-white';
|
|
||||||
case 'high': return 'bg-orange-500 text-white';
|
|
||||||
case 'medium': return 'bg-yellow-500 text-white';
|
|
||||||
case 'low': return 'bg-green-500 text-white';
|
|
||||||
case 'lowest': return 'bg-gray-500 text-white';
|
|
||||||
default: return 'bg-gray-300 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!sprint) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={`Sprint: ${sprint.sprintName}`}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* En-tête du sprint */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{sprint.completedPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Points complétés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-gray-800">
|
|
||||||
{sprint.plannedPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
|
||||||
{sprint.completionRate.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-600">Période</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{new Date(sprint.startDate).toLocaleDateString('fr-FR')} - {new Date(sprint.endDate).toLocaleDateString('fr-FR')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Onglets */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8">
|
|
||||||
{[
|
|
||||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
|
||||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
|
||||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
selectedTab === tab.id
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu selon l'onglet */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<p className="text-red-700">❌ {error}</p>
|
|
||||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
|
||||||
Réessayer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && sprintDetails && (
|
|
||||||
<>
|
|
||||||
{/* Vue d'ensemble */}
|
|
||||||
{selectedTab === 'overview' && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
|
||||||
<div
|
|
||||||
key={assignee.assignee}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedAssignee === assignee.displayName
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedAssignee(
|
|
||||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{assignee.displayName}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
|
||||||
✅ {assignee.completedIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
🔄 {assignee.inProgressIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
📋 {assignee.totalIssues}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.statusDistribution.map(status => (
|
|
||||||
<div
|
|
||||||
key={status.status}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedStatus === status.status
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedStatus(
|
|
||||||
selectedStatus === status.status ? null : status.status
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{status.status}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
|
||||||
{status.count} ({status.percentage.toFixed(1)}%)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Liste des tickets */}
|
|
||||||
{selectedTab === 'issues' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-lg">
|
|
||||||
📋 Tickets du sprint ({filteredIssues.length})
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{selectedAssignee && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800">
|
|
||||||
👤 {selectedAssignee}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedAssignee(null)}
|
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{selectedStatus && (
|
|
||||||
<Badge className="bg-purple-100 text-purple-800">
|
|
||||||
🔄 {selectedStatus}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedStatus(null)}
|
|
||||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{filteredIssues.map(issue => (
|
|
||||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
|
||||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
|
||||||
{issue.status.name}
|
|
||||||
</Badge>
|
|
||||||
{issue.priority && (
|
|
||||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
|
||||||
{issue.priority.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
||||||
<span>📋 {issue.issuetype.name}</span>
|
|
||||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
|
||||||
<span>📅 {new Date(issue.created).toLocaleDateString('fr-FR')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Métriques détaillées */}
|
|
||||||
{selectedTab === 'metrics' && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📊 Métriques générales</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Total tickets:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Tickets complétés:</span>
|
|
||||||
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>En cours:</span>
|
|
||||||
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Cycle time moyen:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`text-4xl mb-2 ${
|
|
||||||
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
|
||||||
'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{sprint.completionRate < 70 && (
|
|
||||||
<div className="text-red-600">
|
|
||||||
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.blockedIssues > 0 && (
|
|
||||||
<div className="text-orange-600">
|
|
||||||
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.averageCycleTime > 14 && (
|
|
||||||
<div className="text-yellow-600">
|
|
||||||
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
|
||||||
<div className="text-green-600">
|
|
||||||
• Sprint réussi sans blockers majeurs
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={onClose} variant="secondary">
|
|
||||||
Fermer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { KanbanBoard } from './Board';
|
|
||||||
import { SwimlanesBoard } from './SwimlanesBoard';
|
|
||||||
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
|
||||||
import { ObjectivesBoard } from './ObjectivesBoard';
|
|
||||||
import { KanbanFilters } from './KanbanFilters';
|
|
||||||
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
|
||||||
import { Task, TaskStatus, TaskPriority } from '@/lib/types';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
|
||||||
import { updateTask, createTask } from '@/actions/tasks';
|
|
||||||
import { getAllStatuses } from '@/lib/status-config';
|
|
||||||
|
|
||||||
interface KanbanBoardContainerProps {
|
|
||||||
showFilters?: boolean;
|
|
||||||
showObjectives?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KanbanBoardContainer({
|
|
||||||
showFilters = true,
|
|
||||||
showObjectives = true
|
|
||||||
}: KanbanBoardContainerProps = {}) {
|
|
||||||
const {
|
|
||||||
filteredTasks,
|
|
||||||
pinnedTasks,
|
|
||||||
loading,
|
|
||||||
updateTaskOptimistic,
|
|
||||||
kanbanFilters,
|
|
||||||
setKanbanFilters,
|
|
||||||
tags,
|
|
||||||
refreshTasks
|
|
||||||
} = useTasksContext();
|
|
||||||
|
|
||||||
const { preferences, toggleColumnVisibility, isColumnVisible } = useUserPreferences();
|
|
||||||
|
|
||||||
const allStatuses = getAllStatuses();
|
|
||||||
const visibleStatuses = allStatuses.filter(status => isColumnVisible(status.key)).map(s => s.key);
|
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
|
||||||
|
|
||||||
const handleEditTask = (task: Task) => {
|
|
||||||
setEditingTask(task);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateTask = async (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => {
|
|
||||||
const result = await updateTask(data);
|
|
||||||
if (result.success) {
|
|
||||||
await refreshTasks(); // Rafraîchir les données
|
|
||||||
setEditingTask(null);
|
|
||||||
} else {
|
|
||||||
console.error('Error updating task:', result.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleUpdateStatus = async (taskId: string, newStatus: TaskStatus) => {
|
|
||||||
// Utiliser la mise à jour optimiste pour le drag & drop
|
|
||||||
await updateTaskOptimistic(taskId, newStatus);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Obtenir le nom du tag épinglé pour l'affichage
|
|
||||||
const pinnedTagName = tags.find(tag => tag.isPinned)?.name;
|
|
||||||
|
|
||||||
// Wrapper pour adapter le type de createTask
|
|
||||||
const handleCreateTask = async (data: CreateTaskData): Promise<void> => {
|
|
||||||
const result = await createTask(data);
|
|
||||||
if (result.success) {
|
|
||||||
await refreshTasks(); // Rafraîchir les données
|
|
||||||
} else {
|
|
||||||
console.error('Error creating task:', result.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Barre de filtres - conditionnelle */}
|
|
||||||
{showFilters && (
|
|
||||||
<KanbanFilters
|
|
||||||
filters={kanbanFilters}
|
|
||||||
onFiltersChange={setKanbanFilters}
|
|
||||||
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
|
||||||
onToggleStatusVisibility={toggleColumnVisibility}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Section Objectifs Principaux - conditionnelle */}
|
|
||||||
{showObjectives && pinnedTasks.length > 0 && (
|
|
||||||
<ObjectivesBoard
|
|
||||||
tasks={pinnedTasks}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
pinnedTagName={pinnedTagName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{kanbanFilters.swimlanesByTags ? (
|
|
||||||
kanbanFilters.swimlanesMode === 'priority' ? (
|
|
||||||
<PrioritySwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<KanbanBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EditTaskForm
|
|
||||||
isOpen={!!editingTask}
|
|
||||||
onClose={() => setEditingTask(null)}
|
|
||||||
onSubmit={handleUpdateTask}
|
|
||||||
task={editingTask}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { Task, TaskStatus } from '@/lib/types';
|
|
||||||
import { TaskCard } from './TaskCard';
|
|
||||||
import { QuickAddTask } from './QuickAddTask';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
|
||||||
import { getStatusConfig, getTechStyle, getBadgeVariant } from '@/lib/status-config';
|
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
|
||||||
id: TaskStatus;
|
|
||||||
tasks: Task[];
|
|
||||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
|
||||||
onEditTask?: (task: Task) => void;
|
|
||||||
compactView?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView = false }: KanbanColumnProps) {
|
|
||||||
const [showQuickAdd, setShowQuickAdd] = useState(false);
|
|
||||||
|
|
||||||
// Configuration de la zone droppable
|
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
|
||||||
id: id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Récupération de la config du statut
|
|
||||||
const statusConfig = getStatusConfig(id);
|
|
||||||
const style = getTechStyle(statusConfig.color);
|
|
||||||
const badgeVariant = getBadgeVariant(statusConfig.color);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-shrink-0 w-80 md:w-1/4 md:flex-1 h-full">
|
|
||||||
<Card
|
|
||||||
ref={setNodeRef}
|
|
||||||
variant="column"
|
|
||||||
className={`h-full flex flex-col transition-all duration-200 ${
|
|
||||||
isOver ? 'ring-2 ring-[var(--primary)]/50 bg-[var(--card-hover)]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${style.accent.replace('text-', 'bg-')} animate-pulse`}></div>
|
|
||||||
<h3 className={`font-mono text-sm font-bold ${style.accent} uppercase tracking-wider`}>
|
|
||||||
{statusConfig.label} {statusConfig.icon}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={badgeVariant} size="sm">
|
|
||||||
{String(tasks.length).padStart(2, '0')}
|
|
||||||
</Badge>
|
|
||||||
{onCreateTask && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowQuickAdd(true)}
|
|
||||||
className={`w-5 h-5 rounded-full border border-dashed ${style.border} ${style.accent} hover:bg-[var(--card-hover)] transition-colors flex items-center justify-center text-xs font-mono`}
|
|
||||||
title="Ajouter une tâche rapide"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex-1 p-4 h-[calc(100vh-220px)] overflow-y-auto">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Quick Add Task */}
|
|
||||||
{showQuickAdd && onCreateTask && (
|
|
||||||
<QuickAddTask
|
|
||||||
status={id}
|
|
||||||
onSubmit={async (data) => {
|
|
||||||
await onCreateTask(data);
|
|
||||||
// Ne pas fermer automatiquement pour permettre la création en série
|
|
||||||
}}
|
|
||||||
onCancel={() => setShowQuickAdd(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tasks.length === 0 && !showQuickAdd ? (
|
|
||||||
<div className="text-center py-20">
|
|
||||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--card)] border-2 border-dashed ${style.border} flex items-center justify-center`}>
|
|
||||||
<span className={`text-2xl ${style.accent} opacity-50`}>{statusConfig.icon}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide">NO DATA</p>
|
|
||||||
<div className="mt-2 flex justify-center">
|
|
||||||
<div className={`w-8 h-0.5 ${style.accent.replace('text-', 'bg-')} opacity-30`}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
tasks.map((task) => (
|
|
||||||
<TaskCard key={task.id} task={task} onEdit={onEditTask} compactView={compactView} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
|
||||||
import { KanbanFilters } from './KanbanFilters';
|
|
||||||
|
|
||||||
interface JiraQuickFilterProps {
|
|
||||||
filters: KanbanFilters;
|
|
||||||
onFiltersChange: (filters: KanbanFilters) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JiraQuickFilter({ filters, onFiltersChange }: JiraQuickFilterProps) {
|
|
||||||
const { regularTasks } = useTasksContext();
|
|
||||||
|
|
||||||
// Vérifier s'il y a des tâches Jira dans le système
|
|
||||||
const hasJiraTasks = useMemo(() => {
|
|
||||||
return regularTasks.some(task => task.source === 'jira');
|
|
||||||
}, [regularTasks]);
|
|
||||||
|
|
||||||
// Si pas de tâches Jira, on n'affiche rien
|
|
||||||
if (!hasJiraTasks) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Déterminer l'état actuel
|
|
||||||
const currentMode = filters.showJiraOnly ? 'show' : filters.hideJiraTasks ? 'hide' : 'all';
|
|
||||||
|
|
||||||
const handleJiraCycle = () => {
|
|
||||||
const updates: Partial<KanbanFilters> = {};
|
|
||||||
|
|
||||||
// Cycle : All -> Jira only -> No Jira -> All
|
|
||||||
switch (currentMode) {
|
|
||||||
case 'all':
|
|
||||||
// All -> Jira only
|
|
||||||
updates.showJiraOnly = true;
|
|
||||||
updates.hideJiraTasks = false;
|
|
||||||
break;
|
|
||||||
case 'show':
|
|
||||||
// Jira only -> No Jira
|
|
||||||
updates.showJiraOnly = false;
|
|
||||||
updates.hideJiraTasks = true;
|
|
||||||
break;
|
|
||||||
case 'hide':
|
|
||||||
// No Jira -> All
|
|
||||||
updates.showJiraOnly = false;
|
|
||||||
updates.hideJiraTasks = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFiltersChange({ ...filters, ...updates });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Définir l'apparence selon l'état
|
|
||||||
const getButtonStyle = () => {
|
|
||||||
switch (currentMode) {
|
|
||||||
case 'show':
|
|
||||||
return 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30';
|
|
||||||
case 'hide':
|
|
||||||
return 'bg-red-500/20 text-red-400 border border-red-400/30';
|
|
||||||
default:
|
|
||||||
return 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonContent = () => {
|
|
||||||
switch (currentMode) {
|
|
||||||
case 'show':
|
|
||||||
return { icon: '🔹', text: 'Jira only' };
|
|
||||||
case 'hide':
|
|
||||||
return { icon: '🚫', text: 'No Jira' };
|
|
||||||
default:
|
|
||||||
return { icon: '🔌', text: 'All tasks' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTooltip = () => {
|
|
||||||
switch (currentMode) {
|
|
||||||
case 'all':
|
|
||||||
return 'Cliquer pour afficher seulement Jira';
|
|
||||||
case 'show':
|
|
||||||
return 'Cliquer pour masquer Jira';
|
|
||||||
case 'hide':
|
|
||||||
return 'Cliquer pour afficher tout';
|
|
||||||
default:
|
|
||||||
return 'Filtrer les tâches Jira';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = getButtonContent();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleJiraCycle}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${getButtonStyle()}`}
|
|
||||||
title={getTooltip()}
|
|
||||||
>
|
|
||||||
<span>{content.icon}</span>
|
|
||||||
{content.text}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { TaskPriority, TaskStatus } from '@/lib/types';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
|
||||||
import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
|
|
||||||
import { SORT_OPTIONS } from '@/lib/sort-config';
|
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
|
||||||
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
|
||||||
|
|
||||||
export interface KanbanFilters {
|
|
||||||
search?: string;
|
|
||||||
tags?: string[];
|
|
||||||
priorities?: TaskPriority[];
|
|
||||||
showCompleted?: boolean;
|
|
||||||
compactView?: boolean;
|
|
||||||
swimlanesByTags?: boolean;
|
|
||||||
swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes
|
|
||||||
pinnedTag?: string; // Tag pour les objectifs principaux
|
|
||||||
sortBy?: string; // Clé de l'option de tri sélectionnée
|
|
||||||
// Filtres spécifiques Jira
|
|
||||||
showJiraOnly?: boolean; // Afficher seulement les tâches Jira
|
|
||||||
hideJiraTasks?: boolean; // Masquer toutes les tâches Jira
|
|
||||||
jiraProjects?: string[]; // Filtrer par projet Jira
|
|
||||||
jiraTypes?: string[]; // Filtrer par type Jira (Story, Task, Bug, etc.)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KanbanFiltersProps {
|
|
||||||
filters: KanbanFilters;
|
|
||||||
onFiltersChange: (filters: KanbanFilters) => void;
|
|
||||||
hiddenStatuses?: Set<TaskStatus>;
|
|
||||||
onToggleStatusVisibility?: (status: TaskStatus) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsHiddenStatuses, onToggleStatusVisibility }: KanbanFiltersProps) {
|
|
||||||
const { tags: availableTags, regularTasks, activeFiltersCount } = useTasksContext();
|
|
||||||
const { preferences, toggleColumnVisibility } = useUserPreferences();
|
|
||||||
|
|
||||||
// Utiliser les props si disponibles, sinon utiliser le context
|
|
||||||
const hiddenStatuses = propsHiddenStatuses || new Set(preferences.columnVisibility.hiddenStatuses);
|
|
||||||
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
|
||||||
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
|
||||||
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
|
||||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
|
||||||
|
|
||||||
// Fermer les dropdowns en cliquant à l'extérieur
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsSortExpanded(false);
|
|
||||||
}
|
|
||||||
if (swimlaneModeDropdownRef.current && !swimlaneModeDropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsSwimlaneModeExpanded(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSortExpanded || isSwimlaneModeExpanded) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [isSortExpanded, isSwimlaneModeExpanded]);
|
|
||||||
|
|
||||||
const handleSearchChange = (search: string) => {
|
|
||||||
onFiltersChange({ ...filters, search: search || undefined });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagToggle = (tagName: string) => {
|
|
||||||
const currentTags = filters.tags || [];
|
|
||||||
const newTags = currentTags.includes(tagName)
|
|
||||||
? currentTags.filter(t => t !== tagName)
|
|
||||||
: [...currentTags, tagName];
|
|
||||||
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
tags: newTags
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePriorityToggle = (priority: TaskPriority) => {
|
|
||||||
const currentPriorities = filters.priorities || [];
|
|
||||||
const newPriorities = currentPriorities.includes(priority)
|
|
||||||
? currentPriorities.filter(p => p !== priority)
|
|
||||||
: [...currentPriorities, priority];
|
|
||||||
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
priorities: newPriorities
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleSwimlanesToggle = () => {
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
swimlanesByTags: !filters.swimlanesByTags
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwimlaneModeChange = (mode: 'tags' | 'priority') => {
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
swimlanesByTags: true,
|
|
||||||
swimlanesMode: mode
|
|
||||||
});
|
|
||||||
setIsSwimlaneModeExpanded(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwimlaneModeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
const button = event.currentTarget;
|
|
||||||
const rect = button.getBoundingClientRect();
|
|
||||||
setDropdownPosition({
|
|
||||||
top: rect.bottom + window.scrollY + 4,
|
|
||||||
left: rect.left + window.scrollX
|
|
||||||
});
|
|
||||||
setIsSwimlaneModeExpanded(!isSwimlaneModeExpanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (sortKey: string) => {
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
sortBy: sortKey
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortToggle = () => {
|
|
||||||
if (!isSortExpanded && sortButtonRef.current) {
|
|
||||||
const rect = sortButtonRef.current.getBoundingClientRect();
|
|
||||||
setDropdownPosition({
|
|
||||||
top: rect.bottom + window.scrollY + 4,
|
|
||||||
left: rect.left + window.scrollX
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setIsSortExpanded(!isSortExpanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJiraToggle = (mode: 'show' | 'hide' | 'all') => {
|
|
||||||
const updates: Partial<KanbanFilters> = {};
|
|
||||||
|
|
||||||
switch (mode) {
|
|
||||||
case 'show':
|
|
||||||
updates.showJiraOnly = true;
|
|
||||||
updates.hideJiraTasks = false;
|
|
||||||
break;
|
|
||||||
case 'hide':
|
|
||||||
updates.showJiraOnly = false;
|
|
||||||
updates.hideJiraTasks = true;
|
|
||||||
break;
|
|
||||||
case 'all':
|
|
||||||
updates.showJiraOnly = false;
|
|
||||||
updates.hideJiraTasks = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFiltersChange({ ...filters, ...updates });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJiraProjectToggle = (project: string) => {
|
|
||||||
const currentProjects = filters.jiraProjects || [];
|
|
||||||
const newProjects = currentProjects.includes(project)
|
|
||||||
? currentProjects.filter(p => p !== project)
|
|
||||||
: [...currentProjects, project];
|
|
||||||
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
jiraProjects: newProjects
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJiraTypeToggle = (type: string) => {
|
|
||||||
const currentTypes = filters.jiraTypes || [];
|
|
||||||
const newTypes = currentTypes.includes(type)
|
|
||||||
? currentTypes.filter(t => t !== type)
|
|
||||||
: [...currentTypes, type];
|
|
||||||
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
jiraTypes: newTypes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
|
||||||
onFiltersChange({});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Récupérer les projets et types Jira disponibles dans TOUTES les tâches (pas seulement les filtrées)
|
|
||||||
// regularTasks est déjà disponible depuis la ligne 39
|
|
||||||
const availableJiraProjects = useMemo(() => {
|
|
||||||
const projects = new Set<string>();
|
|
||||||
regularTasks.forEach(task => {
|
|
||||||
if (task.source === 'jira' && task.jiraProject) {
|
|
||||||
projects.add(task.jiraProject);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(projects).sort();
|
|
||||||
}, [regularTasks]);
|
|
||||||
|
|
||||||
const availableJiraTypes = useMemo(() => {
|
|
||||||
const types = new Set<string>();
|
|
||||||
regularTasks.forEach(task => {
|
|
||||||
if (task.source === 'jira' && task.jiraType) {
|
|
||||||
types.add(task.jiraType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(types).sort();
|
|
||||||
}, [regularTasks]);
|
|
||||||
|
|
||||||
// Vérifier s'il y a des tâches Jira dans le système (même masquées)
|
|
||||||
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
|
|
||||||
|
|
||||||
// Calculer les compteurs pour les priorités
|
|
||||||
const priorityCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
getAllPriorities().forEach(priority => {
|
|
||||||
counts[priority.key] = regularTasks.filter(task => task.priority === priority.key).length;
|
|
||||||
});
|
|
||||||
return counts;
|
|
||||||
}, [regularTasks]);
|
|
||||||
|
|
||||||
// Calculer les compteurs pour les tags
|
|
||||||
const tagCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
availableTags.forEach(tag => {
|
|
||||||
counts[tag.name] = regularTasks.filter(task => task.tags?.includes(tag.name)).length;
|
|
||||||
});
|
|
||||||
return counts;
|
|
||||||
}, [regularTasks, availableTags]);
|
|
||||||
|
|
||||||
const priorityOptions = getAllPriorities().map(priorityConfig => ({
|
|
||||||
value: priorityConfig.key,
|
|
||||||
label: priorityConfig.label,
|
|
||||||
color: priorityConfig.color,
|
|
||||||
count: priorityCounts[priorityConfig.key] || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Trier les tags par nombre d'utilisation (décroissant)
|
|
||||||
const sortedTags = useMemo(() => {
|
|
||||||
return [...availableTags].sort((a, b) => {
|
|
||||||
const countA = tagCounts[a.name] || 0;
|
|
||||||
const countB = tagCounts[b.name] || 0;
|
|
||||||
return countB - countA; // Décroissant
|
|
||||||
});
|
|
||||||
}, [availableTags, tagCounts]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/50 backdrop-blur-sm">
|
|
||||||
<div className="container mx-auto px-6 py-4">
|
|
||||||
{/* Header avec recherche et bouton expand */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex-1 max-w-md">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={filters.search || ''}
|
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
|
||||||
placeholder="Rechercher des tâches..."
|
|
||||||
className="bg-[var(--card)] border-[var(--border)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu swimlanes */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
|
||||||
onClick={handleSwimlanesToggle}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
title="Mode d'affichage"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
{filters.swimlanesByTags ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{!filters.swimlanesByTags
|
|
||||||
? 'Normal'
|
|
||||||
: filters.swimlanesMode === 'priority'
|
|
||||||
? 'Par priorité'
|
|
||||||
: 'Par tags'
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Bouton pour changer le mode des swimlanes */}
|
|
||||||
{filters.swimlanesByTags && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSwimlaneModeToggle}
|
|
||||||
className="flex items-center gap-1 px-2"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 transition-transform ${isSwimlaneModeExpanded ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Bouton de tri */}
|
|
||||||
<div className="relative" ref={sortDropdownRef}>
|
|
||||||
<Button
|
|
||||||
ref={sortButtonRef}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSortToggle}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
|
||||||
</svg>
|
|
||||||
Tris
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 transition-transform ${isSortExpanded ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleClearFilters}
|
|
||||||
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
|
|
||||||
>
|
|
||||||
Effacer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtres étendus */}
|
|
||||||
<div className="mt-4 border-t border-[var(--border)]/50 pt-4">
|
|
||||||
{/* Grille responsive pour les filtres principaux */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6 lg:gap-8">
|
|
||||||
{/* Filtres par priorité */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Priorités
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{priorityOptions.filter(priority => priority.count > 0).map((priority) => (
|
|
||||||
<button
|
|
||||||
key={priority.value}
|
|
||||||
onClick={() => handlePriorityToggle(priority.value)}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium whitespace-nowrap ${
|
|
||||||
filters.priorities?.includes(priority.value)
|
|
||||||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
|
||||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: getPriorityColorHex(priority.color) }}
|
|
||||||
/>
|
|
||||||
{priority.label} ({priority.count})
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtres par tags */}
|
|
||||||
{availableTags.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
|
||||||
{sortedTags.filter(tag => (tagCounts[tag.name] || 0) > 0).map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
onClick={() => handleTagToggle(tag.name)}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium ${
|
|
||||||
filters.tags?.includes(tag.name)
|
|
||||||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
|
||||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
{tag.name} ({tagCounts[tag.name]})
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtres Jira - Ligne séparée mais intégrée */}
|
|
||||||
{hasJiraTasks && (
|
|
||||||
<div className="border-t border-[var(--border)]/30 pt-4 mt-4">
|
|
||||||
<div className="flex items-center gap-4 mb-3">
|
|
||||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
🔌 Jira
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Toggle Jira Show/Hide - inline avec le titre */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant={filters.showJiraOnly ? "primary" : "ghost"}
|
|
||||||
onClick={() => handleJiraToggle('show')}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs px-2 py-1 h-auto"
|
|
||||||
>
|
|
||||||
🔹 Seul
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filters.hideJiraTasks ? "danger" : "ghost"}
|
|
||||||
onClick={() => handleJiraToggle('hide')}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs px-2 py-1 h-auto"
|
|
||||||
>
|
|
||||||
🚫 Mask
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={(!filters.showJiraOnly && !filters.hideJiraTasks) ? "primary" : "ghost"}
|
|
||||||
onClick={() => handleJiraToggle('all')}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs px-2 py-1 h-auto"
|
|
||||||
>
|
|
||||||
📋 All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Projets et Types en 2 colonnes */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* Projets Jira */}
|
|
||||||
{availableJiraProjects.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
|
||||||
Projets
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{availableJiraProjects.map((project) => (
|
|
||||||
<button
|
|
||||||
key={project}
|
|
||||||
onClick={() => handleJiraProjectToggle(project)}
|
|
||||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
|
||||||
filters.jiraProjects?.includes(project)
|
|
||||||
? 'border-blue-400 bg-blue-400/10 text-blue-400'
|
|
||||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
📋 {project}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Types Jira */}
|
|
||||||
{availableJiraTypes.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
|
||||||
Types
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{availableJiraTypes.map((type) => (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
onClick={() => handleJiraTypeToggle(type)}
|
|
||||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
|
||||||
filters.jiraTypes?.includes(type)
|
|
||||||
? 'border-purple-400 bg-purple-400/10 text-purple-400'
|
|
||||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{type === 'Feature' && '✨ '}
|
|
||||||
{type === 'Story' && '📖 '}
|
|
||||||
{type === 'Task' && '📝 '}
|
|
||||||
{type === 'Bug' && '🐛 '}
|
|
||||||
{type === 'Support' && '🛠️ '}
|
|
||||||
{type === 'Enabler' && '🔧 '}
|
|
||||||
{type}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Visibilité des colonnes */}
|
|
||||||
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
|
|
||||||
<ColumnVisibilityToggle
|
|
||||||
hiddenStatuses={hiddenStatuses}
|
|
||||||
onToggleStatus={toggleStatusVisibility}
|
|
||||||
tasks={regularTasks}
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Résumé des filtres actifs */}
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<div className="bg-[var(--card)]/30 rounded-lg p-3 border border-[var(--border)]/50 mt-4">
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] font-mono uppercase tracking-wider mb-2">
|
|
||||||
Filtres actifs
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-xs">
|
|
||||||
{filters.search && (
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(filters.priorities?.filter(Boolean).length || 0) > 0 && (
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
Priorités: <span className="text-cyan-400">{filters.priorities?.filter(Boolean).join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(filters.tags?.filter(Boolean).length || 0) > 0 && (
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
Tags: <span className="text-cyan-400">{filters.tags?.filter(Boolean).join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{filters.showJiraOnly && (
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
Affichage: <span className="text-blue-400">Jira seulement</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{filters.hideJiraTasks && (
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
Affichage: <span className="text-red-400">Masquer Jira</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(filters.jiraProjects?.filter(Boolean).length || 0) > 0 && (
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
Projets Jira: <span className="text-blue-400">{filters.jiraProjects?.filter(Boolean).join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(filters.jiraTypes?.filter(Boolean).length || 0) > 0 && (
|
|
||||||
<div className="text-[var(--muted-foreground)]">
|
|
||||||
Types Jira: <span className="text-purple-400">{filters.jiraTypes?.filter(Boolean).join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */}
|
|
||||||
{isSortExpanded && typeof window !== 'undefined' && createPortal(
|
|
||||||
<div
|
|
||||||
ref={sortDropdownRef}
|
|
||||||
className="fixed w-80 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] max-h-64 overflow-y-auto"
|
|
||||||
style={{
|
|
||||||
top: dropdownPosition.top,
|
|
||||||
left: dropdownPosition.left
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{SORT_OPTIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.key}
|
|
||||||
onClick={() => {
|
|
||||||
handleSortChange(option.key);
|
|
||||||
setIsSortExpanded(false);
|
|
||||||
}}
|
|
||||||
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 ${
|
|
||||||
(filters.sortBy || 'priority-desc') === option.key
|
|
||||||
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
|
|
||||||
: 'text-[var(--muted-foreground)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-base">{option.icon}</span>
|
|
||||||
<span className="flex-1">{option.label}</span>
|
|
||||||
{(filters.sortBy || 'priority-desc') === option.key && (
|
|
||||||
<svg className="w-4 h-4 text-cyan-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
|
|
||||||
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
|
||||||
<div
|
|
||||||
ref={swimlaneModeDropdownRef}
|
|
||||||
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
|
||||||
style={{
|
|
||||||
top: dropdownPosition.top,
|
|
||||||
left: dropdownPosition.left,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSwimlaneModeChange('tags')}
|
|
||||||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 first:rounded-t-lg ${
|
|
||||||
(!filters.swimlanesMode || filters.swimlanesMode === 'tags') ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
🏷️ Par tags
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSwimlaneModeChange('priority')}
|
|
||||||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 last:rounded-b-lg ${
|
|
||||||
filters.swimlanesMode === 'priority' ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
🎯 Par priorité
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
|
||||||
import { useDragAndDrop } from '@/hooks/useDragAndDrop';
|
|
||||||
import { Task, TaskStatus } from '@/lib/types';
|
|
||||||
import { TaskCard } from './TaskCard';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragOverlay,
|
|
||||||
DragStartEvent
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
|
||||||
|
|
||||||
interface ObjectivesBoardProps {
|
|
||||||
tasks: Task[];
|
|
||||||
onEditTask?: (task: Task) => void;
|
|
||||||
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
|
||||||
compactView?: boolean;
|
|
||||||
pinnedTagName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composant pour les colonnes droppables
|
|
||||||
function DroppableColumn({
|
|
||||||
status,
|
|
||||||
tasks,
|
|
||||||
title,
|
|
||||||
color,
|
|
||||||
icon,
|
|
||||||
onEditTask,
|
|
||||||
compactView
|
|
||||||
}: {
|
|
||||||
status: TaskStatus;
|
|
||||||
tasks: Task[];
|
|
||||||
title: string;
|
|
||||||
color: string;
|
|
||||||
icon: string;
|
|
||||||
onEditTask?: (task: Task) => void;
|
|
||||||
compactView: boolean;
|
|
||||||
}) {
|
|
||||||
const { setNodeRef } = useDroppable({
|
|
||||||
id: status,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2 pt-2 pb-2 border-b border-[var(--accent)]/20">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${color}`}></div>
|
|
||||||
<h3 className={`text-sm font-mono font-medium uppercase tracking-wider ${color.replace('bg-', 'text-').replace('400', '300')}`}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex-1"></div>
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] px-2 py-1 rounded">
|
|
||||||
{tasks.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tasks.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)] text-sm">
|
|
||||||
<div className="text-2xl mb-2">{icon}</div>
|
|
||||||
Aucun objectif {title.toLowerCase()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{tasks.map(task => (
|
|
||||||
<div key={task.id} className="transform hover:scale-[1.02] transition-transform duration-200">
|
|
||||||
<TaskCard
|
|
||||||
task={task}
|
|
||||||
onEdit={onEditTask}
|
|
||||||
compactView={compactView}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ObjectivesBoard({
|
|
||||||
tasks,
|
|
||||||
onEditTask,
|
|
||||||
onUpdateStatus,
|
|
||||||
compactView = false,
|
|
||||||
pinnedTagName = "Objectifs"
|
|
||||||
}: ObjectivesBoardProps) {
|
|
||||||
const { preferences, toggleObjectivesCollapse } = useUserPreferences();
|
|
||||||
const isCollapsed = preferences.viewPreferences.objectivesCollapsed;
|
|
||||||
const { isMounted, sensors } = useDragAndDrop();
|
|
||||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
|
||||||
|
|
||||||
// Handlers pour le drag & drop
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
|
||||||
const task = tasks.find(t => t.id === event.active.id);
|
|
||||||
setActiveTask(task || null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveTask(null);
|
|
||||||
|
|
||||||
if (!over || !onUpdateStatus) return;
|
|
||||||
|
|
||||||
const taskId = active.id as string;
|
|
||||||
const newStatus = over.id as TaskStatus;
|
|
||||||
|
|
||||||
// Vérifier si le statut a changé
|
|
||||||
const task = tasks.find(t => t.id === taskId);
|
|
||||||
if (task && task.status !== newStatus) {
|
|
||||||
await onUpdateStatus(taskId, newStatus);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
|
||||||
return null; // Ne rien afficher s'il n'y a pas d'objectifs
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<div className="bg-[var(--card)]/30 border-b border-[var(--accent)]/30">
|
|
||||||
<div className="container mx-auto px-6 py-4">
|
|
||||||
<Card variant="column" className="border-[var(--accent)]/30 shadow-[var(--accent)]/10">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={toggleObjectivesCollapse}
|
|
||||||
className="flex items-center gap-3 hover:bg-[var(--accent)]/20 rounded-lg p-2 -m-2 transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="w-3 h-3 bg-[var(--accent)] rounded-full animate-pulse shadow-[var(--accent)]/50 shadow-lg"></div>
|
|
||||||
<h2 className="text-lg font-mono font-bold text-[var(--accent)] uppercase tracking-wider">
|
|
||||||
🎯 Objectifs Principaux
|
|
||||||
</h2>
|
|
||||||
{pinnedTagName && (
|
|
||||||
<Badge variant="outline" className="border-[var(--accent)]/50 text-[var(--accent)]">
|
|
||||||
{pinnedTagName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Flèche de collapse */}
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 text-[var(--accent)] transition-transform duration-200 ${
|
|
||||||
isCollapsed ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="primary" size="sm" className="bg-[var(--accent)]">
|
|
||||||
{String(tasks.length).padStart(2, '0')}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Bouton collapse séparé pour mobile */}
|
|
||||||
<button
|
|
||||||
onClick={toggleObjectivesCollapse}
|
|
||||||
className="lg:hidden p-1 hover:bg-[var(--accent)]/20 rounded transition-colors"
|
|
||||||
aria-label={isCollapsed ? "Développer" : "Réduire"}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 text-[var(--accent)] transition-transform duration-200 ${
|
|
||||||
isCollapsed ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
{(() => {
|
|
||||||
// Séparer les tâches par statut
|
|
||||||
const inProgressTasks = tasks.filter(task => task.status === 'in_progress');
|
|
||||||
const todoTasks = tasks.filter(task => task.status === 'todo' || task.status === 'backlog');
|
|
||||||
const completedTasks = tasks.filter(task => task.status === 'done');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<DroppableColumn
|
|
||||||
status="todo"
|
|
||||||
tasks={todoTasks}
|
|
||||||
title="À faire"
|
|
||||||
color="bg-[var(--primary)]"
|
|
||||||
icon="📋"
|
|
||||||
onEditTask={onEditTask}
|
|
||||||
compactView={compactView}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DroppableColumn
|
|
||||||
status="in_progress"
|
|
||||||
tasks={inProgressTasks}
|
|
||||||
title="En cours"
|
|
||||||
color="bg-yellow-400"
|
|
||||||
icon="🔄"
|
|
||||||
onEditTask={onEditTask}
|
|
||||||
compactView={compactView}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DroppableColumn
|
|
||||||
status="done"
|
|
||||||
tasks={completedTasks}
|
|
||||||
title="Terminé"
|
|
||||||
color="bg-green-400"
|
|
||||||
icon="✅"
|
|
||||||
onEditTask={onEditTask}
|
|
||||||
compactView={compactView}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
id="objectives-board"
|
|
||||||
sensors={sensors}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
|
|
||||||
{/* Overlay pour le drag & drop */}
|
|
||||||
<DragOverlay>
|
|
||||||
{activeTask ? (
|
|
||||||
<div className="rotate-3 opacity-90">
|
|
||||||
<TaskCard
|
|
||||||
task={activeTask}
|
|
||||||
onEdit={undefined}
|
|
||||||
compactView={compactView}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { TagInput } from '@/components/ui/TagInput';
|
|
||||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
|
||||||
import { getAllPriorities } from '@/lib/status-config';
|
|
||||||
|
|
||||||
interface QuickAddTaskProps {
|
|
||||||
status: TaskStatus;
|
|
||||||
onSubmit: (data: CreateTaskData) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
|
||||||
// Contexte pour les swimlanes
|
|
||||||
swimlaneContext?: {
|
|
||||||
type: 'tag' | 'priority';
|
|
||||||
value: string; // nom du tag ou clé de la priorité
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuickAddTask({ status, onSubmit, onCancel, swimlaneContext }: QuickAddTaskProps) {
|
|
||||||
// Fonction pour initialiser les données selon le contexte
|
|
||||||
const getInitialFormData = (): CreateTaskData => {
|
|
||||||
const baseData: CreateTaskData = {
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
status,
|
|
||||||
priority: 'medium' as TaskPriority,
|
|
||||||
tags: [],
|
|
||||||
dueDate: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pré-remplir selon le contexte de swimlane
|
|
||||||
if (swimlaneContext) {
|
|
||||||
if (swimlaneContext.type === 'tag' && swimlaneContext.value !== 'Sans tag') {
|
|
||||||
baseData.tags = [swimlaneContext.value];
|
|
||||||
} else if (swimlaneContext.type === 'priority') {
|
|
||||||
baseData.priority = swimlaneContext.value as TaskPriority;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<CreateTaskData>(getInitialFormData());
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [activeField, setActiveField] = useState<'title' | 'description' | 'tags' | 'date' | null>('title');
|
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Focus automatique sur le titre
|
|
||||||
useEffect(() => {
|
|
||||||
titleRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const trimmedTitle = formData.title.trim();
|
|
||||||
console.log('handleSubmit called:', { trimmedTitle, isSubmitting });
|
|
||||||
if (!trimmedTitle || isSubmitting) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
console.log('Submitting task:', { ...formData, title: trimmedTitle });
|
|
||||||
await onSubmit({
|
|
||||||
...formData,
|
|
||||||
title: trimmedTitle
|
|
||||||
});
|
|
||||||
|
|
||||||
// Réinitialiser pour la prochaine tâche (en gardant le contexte)
|
|
||||||
setFormData(getInitialFormData());
|
|
||||||
setActiveField('title');
|
|
||||||
setIsSubmitting(false);
|
|
||||||
titleRef.current?.focus();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la création:', error);
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
|
||||||
console.log('Key pressed:', e.key, 'field:', field, 'title:', formData.title);
|
|
||||||
|
|
||||||
// Seulement intercepter les touches spécifiques qu'on veut gérer
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (field === 'title' && formData.title.trim()) {
|
|
||||||
console.log('Calling handleSubmit from title');
|
|
||||||
handleSubmit();
|
|
||||||
} else if (field === 'tags') {
|
|
||||||
// TagInput gère ses propres événements Enter
|
|
||||||
} else if (formData.title.trim()) {
|
|
||||||
// Permettre création depuis n'importe quel champ si titre rempli
|
|
||||||
console.log('Calling handleSubmit from other field');
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
onCancel();
|
|
||||||
} else if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey) {
|
|
||||||
// Navigation entre les champs seulement si pas de modificateur
|
|
||||||
e.preventDefault();
|
|
||||||
const fields = ['title', 'description', 'tags', 'date'];
|
|
||||||
const currentIndex = fields.indexOf(activeField || 'title');
|
|
||||||
const nextField = fields[(currentIndex + 1) % fields.length] as typeof activeField;
|
|
||||||
setActiveField(nextField);
|
|
||||||
}
|
|
||||||
// Laisser passer tous les autres événements (y compris les raccourcis système)
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagsChange = (tags: string[]) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
tags
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = (e: React.FocusEvent) => {
|
|
||||||
// Vérifier si le focus reste dans le composant
|
|
||||||
setTimeout(() => {
|
|
||||||
const currentTarget = e.currentTarget;
|
|
||||||
const relatedTarget = e.relatedTarget as Node | null;
|
|
||||||
|
|
||||||
// Si le focus sort complètement du composant ET qu'il n'y a pas de titre
|
|
||||||
if (currentTarget && (!relatedTarget || !currentTarget.contains(relatedTarget)) && !formData.title.trim()) {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div onBlur={handleBlur}>
|
|
||||||
<Card className="p-3 border-dashed border-[var(--primary)]/30 bg-[var(--card)]/50 hover:border-[var(--primary)]/50 transition-all duration-300">
|
|
||||||
{/* Header avec titre et priorité */}
|
|
||||||
<div className="flex items-start gap-2 mb-2 min-w-0">
|
|
||||||
<input
|
|
||||||
ref={titleRef}
|
|
||||||
type="text"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
||||||
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
|
||||||
onFocus={() => setActiveField('title')}
|
|
||||||
placeholder="Titre de la tâche..."
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium placeholder-[var(--muted-foreground)] leading-tight"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Indicateur de priorité */}
|
|
||||||
<select
|
|
||||||
value={formData.priority}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="flex-shrink-0 w-10 bg-transparent border-none outline-none text-lg text-[var(--muted-foreground)] cursor-pointer text-center"
|
|
||||||
title={getAllPriorities().find(p => p.key === formData.priority)?.label}
|
|
||||||
>
|
|
||||||
{getAllPriorities().map(priorityConfig => (
|
|
||||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
|
||||||
{priorityConfig.icon}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
onKeyDown={(e) => handleKeyDown(e, 'description')}
|
|
||||||
onFocus={() => setActiveField('description')}
|
|
||||||
placeholder="Description..."
|
|
||||||
rows={2}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full bg-transparent border-none outline-none text-xs text-[var(--muted-foreground)] font-mono placeholder-[var(--muted-foreground)] resize-none mb-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="mb-2">
|
|
||||||
<TagInput
|
|
||||||
tags={formData.tags || []}
|
|
||||||
onChange={handleTagsChange}
|
|
||||||
placeholder="Tags..."
|
|
||||||
maxTags={5}
|
|
||||||
className="text-xs"
|
|
||||||
compactSuggestions={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer avec date et actions */}
|
|
||||||
<div className="pt-2 border-t border-[var(--border)]/50">
|
|
||||||
<div className="flex items-center justify-between text-xs min-w-0">
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
|
||||||
onChange={(e) => setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
|
||||||
}))}
|
|
||||||
onFocus={() => setActiveField('date')}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="bg-transparent border-none outline-none text-[var(--muted-foreground)] font-mono text-xs flex-shrink min-w-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isSubmitting && (
|
|
||||||
<div className="flex items-center gap-1 text-[var(--primary)] font-mono text-xs flex-shrink-0">
|
|
||||||
<div className="w-3 h-3 border border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
<span>...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useTransition } from 'react';
|
|
||||||
import { Task } from '@/lib/types';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
import { fr } from 'date-fns/locale';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
|
||||||
import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config';
|
|
||||||
import { updateTaskTitle, deleteTask } from '@/actions/tasks';
|
|
||||||
|
|
||||||
interface TaskCardProps {
|
|
||||||
task: Task;
|
|
||||||
onEdit?: (task: Task) => void;
|
|
||||||
compactView?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
|
||||||
const [editTitle, setEditTitle] = useState(task.title);
|
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const { tags: availableTags, refreshTasks } = useTasksContext();
|
|
||||||
const { preferences } = useUserPreferences();
|
|
||||||
|
|
||||||
// Classes CSS pour les différentes tailles de police
|
|
||||||
const getFontSizeClasses = () => {
|
|
||||||
switch (preferences.viewPreferences.fontSize) {
|
|
||||||
case 'small':
|
|
||||||
return {
|
|
||||||
title: 'text-xs',
|
|
||||||
description: 'text-xs',
|
|
||||||
meta: 'text-xs'
|
|
||||||
};
|
|
||||||
case 'large':
|
|
||||||
return {
|
|
||||||
title: 'text-base',
|
|
||||||
description: 'text-sm',
|
|
||||||
meta: 'text-sm'
|
|
||||||
};
|
|
||||||
default: // medium
|
|
||||||
return {
|
|
||||||
title: 'text-sm',
|
|
||||||
description: 'text-xs',
|
|
||||||
meta: 'text-xs'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fontClasses = getFontSizeClasses();
|
|
||||||
|
|
||||||
// Helper pour construire l'URL Jira
|
|
||||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
|
||||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
|
||||||
if (!baseUrl || !jiraKey) return '';
|
|
||||||
return `${baseUrl}/browse/${jiraKey}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configuration du draggable
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
isDragging,
|
|
||||||
} = useDraggable({
|
|
||||||
id: task.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le titre local quand la tâche change
|
|
||||||
useEffect(() => {
|
|
||||||
setEditTitle(task.title);
|
|
||||||
}, [task.title]);
|
|
||||||
|
|
||||||
// Nettoyer le timeout au démontage
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await deleteTask(task.id);
|
|
||||||
if (!result.success) {
|
|
||||||
console.error('Error deleting task:', result.error);
|
|
||||||
// TODO: Afficher une notification d'erreur
|
|
||||||
} else {
|
|
||||||
// Rafraîchir les données après suppression réussie
|
|
||||||
await refreshTasks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onEdit) {
|
|
||||||
onEdit(task);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleClick = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!isDragging && !isPending) {
|
|
||||||
setIsEditingTitle(true);
|
|
||||||
setShowTooltip(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleSave = async () => {
|
|
||||||
const trimmedTitle = editTitle.trim();
|
|
||||||
if (trimmedTitle && trimmedTitle !== task.title) {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await updateTaskTitle(task.id, trimmedTitle);
|
|
||||||
if (!result.success) {
|
|
||||||
console.error('Error updating task title:', result.error);
|
|
||||||
// Remettre l'ancien titre en cas d'erreur
|
|
||||||
setEditTitle(task.title);
|
|
||||||
} else {
|
|
||||||
// Mettre à jour optimistiquement le titre local
|
|
||||||
// La Server Action a déjà mis à jour la DB, on synchronise juste l'affichage
|
|
||||||
task.title = trimmedTitle;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setIsEditingTitle(false);
|
|
||||||
setShowTooltip(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleCancel = () => {
|
|
||||||
setEditTitle(task.title);
|
|
||||||
setIsEditingTitle(false);
|
|
||||||
setShowTooltip(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleTitleSave();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleTitleCancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (!isEditingTitle) {
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
setShowTooltip(true);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setShowTooltip(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Style de transformation pour le drag
|
|
||||||
const style = transform ? {
|
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
// Extraire les emojis du titre pour les afficher comme tags visuels
|
|
||||||
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
|
|
||||||
const titleEmojis = task.title.match(emojiRegex) || [];
|
|
||||||
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
|
|
||||||
|
|
||||||
// Composant titre avec tooltip
|
|
||||||
const TitleWithTooltip = () => (
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<h4
|
|
||||||
className={`font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
|
|
||||||
onClick={handleTitleClick}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
title="Cliquer pour éditer"
|
|
||||||
>
|
|
||||||
{titleWithoutEmojis}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{showTooltip && (
|
|
||||||
<div className="absolute z-50 bottom-full left-0 mb-2 px-2 py-1 bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg max-w-xs whitespace-normal break-words text-xs font-mono text-[var(--foreground)]">
|
|
||||||
{titleWithoutEmojis}
|
|
||||||
<div className="absolute top-full left-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--border)]"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
|
|
||||||
let displayEmojis: string[] = titleEmojis;
|
|
||||||
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
|
|
||||||
const firstTag = availableTags.find(tag => tag.name === task.tags[0]);
|
|
||||||
if (firstTag) {
|
|
||||||
const tagEmojis = firstTag.name.match(emojiRegex);
|
|
||||||
if (tagEmojis && tagEmojis.length > 0) {
|
|
||||||
displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Styles spéciaux pour les tâches Jira
|
|
||||||
const isJiraTask = task.source === 'jira';
|
|
||||||
const jiraStyles = isJiraTask ? {
|
|
||||||
border: '1px solid rgba(0, 130, 201, 0.3)',
|
|
||||||
borderLeft: '3px solid #0082C9',
|
|
||||||
background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)'
|
|
||||||
} : {};
|
|
||||||
|
|
||||||
// Vue compacte : seulement le titre
|
|
||||||
if (compactView) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={{ ...style, ...jiraStyles }}
|
|
||||||
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
|
||||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
|
||||||
} ${
|
|
||||||
task.status === 'done' ? 'opacity-60' : ''
|
|
||||||
} ${
|
|
||||||
isJiraTask ? 'jira-task' : ''
|
|
||||||
} ${
|
|
||||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
|
||||||
}`}
|
|
||||||
{...attributes}
|
|
||||||
{...(isEditingTitle ? {} : listeners)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{displayEmojis.length > 0 && (
|
|
||||||
<div className="flex gap-1 flex-shrink-0">
|
|
||||||
{displayEmojis.slice(0, 1).map((emoji, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className="text-base opacity-90 font-emoji"
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
|
||||||
fontVariantEmoji: 'normal'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditingTitle ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editTitle}
|
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
|
||||||
onKeyDown={handleTitleKeyPress}
|
|
||||||
onBlur={handleTitleSave}
|
|
||||||
autoFocus
|
|
||||||
className={`flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono ${fontClasses.title} font-medium leading-tight`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TitleWithTooltip />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{/* Boutons d'action compacts - masqués en mode édition */}
|
|
||||||
{!isEditingTitle && onEdit && (
|
|
||||||
<button
|
|
||||||
onClick={handleEdit}
|
|
||||||
disabled={isPending}
|
|
||||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
|
|
||||||
title="Modifier la tâche"
|
|
||||||
>
|
|
||||||
✎
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isEditingTitle && (
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isPending}
|
|
||||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
|
|
||||||
title="Supprimer la tâche"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Indicateur de priorité compact */}
|
|
||||||
<div
|
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
|
||||||
style={{ backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vue détaillée : version complète
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={{ ...style, ...jiraStyles }}
|
|
||||||
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
|
||||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
|
||||||
} ${
|
|
||||||
task.status === 'done' ? 'opacity-60' : ''
|
|
||||||
} ${
|
|
||||||
isJiraTask ? 'jira-task' : ''
|
|
||||||
} ${
|
|
||||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
|
||||||
}`}
|
|
||||||
{...attributes}
|
|
||||||
{...(isEditingTitle ? {} : listeners)}
|
|
||||||
>
|
|
||||||
{/* Header tech avec titre et status */}
|
|
||||||
<div className="flex items-start gap-2 mb-2">
|
|
||||||
{displayEmojis.length > 0 && (
|
|
||||||
<div className="flex gap-1 flex-shrink-0">
|
|
||||||
{displayEmojis.slice(0, 2).map((emoji, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className="text-sm opacity-80 font-emoji"
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
|
||||||
fontVariantEmoji: 'normal'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditingTitle ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editTitle}
|
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
|
||||||
onKeyDown={handleTitleKeyPress}
|
|
||||||
onBlur={handleTitleSave}
|
|
||||||
autoFocus
|
|
||||||
className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TitleWithTooltip />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{/* Bouton d'édition discret - masqué en mode édition */}
|
|
||||||
{!isEditingTitle && onEdit && (
|
|
||||||
<button
|
|
||||||
onClick={handleEdit}
|
|
||||||
disabled={isPending}
|
|
||||||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
|
|
||||||
title="Modifier la tâche"
|
|
||||||
>
|
|
||||||
✎
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bouton de suppression discret - masqué en mode édition */}
|
|
||||||
{!isEditingTitle && (
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isPending}
|
|
||||||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
|
|
||||||
title="Supprimer la tâche"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Indicateur de priorité tech */}
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
|
|
||||||
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description tech */}
|
|
||||||
{task.description && (
|
|
||||||
<p className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}>
|
|
||||||
{task.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags avec couleurs */}
|
|
||||||
{task.tags && task.tags.length > 0 && (
|
|
||||||
<div className={
|
|
||||||
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
|
|
||||||
? "mb-3"
|
|
||||||
: "mb-0"
|
|
||||||
}>
|
|
||||||
<TagDisplay
|
|
||||||
tags={task.tags}
|
|
||||||
availableTags={availableTags}
|
|
||||||
size="sm"
|
|
||||||
maxTags={3}
|
|
||||||
showColors={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
|
|
||||||
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
|
|
||||||
<div className="pt-2 border-t border-[var(--border)]/50">
|
|
||||||
<div className={`flex items-center justify-between ${fontClasses.meta}`}>
|
|
||||||
{task.dueDate ? (
|
|
||||||
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
|
|
||||||
<span className="text-[var(--primary)]">⏰</span>
|
|
||||||
{formatDistanceToNow(new Date(task.dueDate), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: fr
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div></div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{task.source !== 'manual' && task.source && (
|
|
||||||
task.source === 'jira' && task.jiraKey ? (
|
|
||||||
preferences.jiraConfig.baseUrl ? (
|
|
||||||
<a
|
|
||||||
href={getJiraTicketUrl(task.jiraKey)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="hover:scale-105 transition-transform"
|
|
||||||
>
|
|
||||||
<Badge variant="outline" size="sm" className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer">
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{task.source}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraProject && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
|
||||||
{task.jiraProject}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraType && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
|
||||||
{task.jiraType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.completedAt && (
|
|
||||||
<span className="text-emerald-400 font-mono font-bold">✓ DONE</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface DatabaseStats {
|
|
||||||
taskCount: number;
|
|
||||||
tagCount: number;
|
|
||||||
completionRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdvancedSettingsPageClientProps {
|
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialDbStats: DatabaseStats;
|
|
||||||
initialBackupData: BackupListResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdvancedSettingsPageClient({
|
|
||||||
initialPreferences,
|
|
||||||
initialDbStats,
|
|
||||||
initialBackupData
|
|
||||||
}: AdvancedSettingsPageClientProps) {
|
|
||||||
const [backupData, setBackupData] = useState<BackupListResponse>(initialBackupData);
|
|
||||||
const [dbStats] = useState<DatabaseStats>(initialDbStats);
|
|
||||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
|
||||||
|
|
||||||
const reloadBackupData = async () => {
|
|
||||||
try {
|
|
||||||
const data = await backupClient.listBackups();
|
|
||||||
setBackupData(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to reload backup data:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
|
||||||
setIsCreatingBackup(true);
|
|
||||||
try {
|
|
||||||
await backupClient.createBackup();
|
|
||||||
await reloadBackupData();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create backup:', error);
|
|
||||||
} finally {
|
|
||||||
setIsCreatingBackup(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerifyDatabase = async () => {
|
|
||||||
setIsVerifying(true);
|
|
||||||
try {
|
|
||||||
await backupClient.verifyDatabase();
|
|
||||||
alert('✅ Base de données vérifiée avec succès');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Database verification failed:', error);
|
|
||||||
alert('❌ Erreur lors de la vérification de la base');
|
|
||||||
} finally {
|
|
||||||
setIsVerifying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let size = bytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimeAgo = (date: Date): string => {
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - new Date(date).getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
|
||||||
|
|
||||||
if (diffMins < 60) {
|
|
||||||
return `il y a ${diffMins}min`;
|
|
||||||
} else if (diffHours < 24) {
|
|
||||||
return `il y a ${diffHours}h ${diffMins % 60}min`;
|
|
||||||
} else {
|
|
||||||
return `il y a ${diffDays}j`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNextBackupTime = (): string => {
|
|
||||||
if (!backupData.scheduler.nextBackup) return 'Non planifiée';
|
|
||||||
|
|
||||||
const nextBackup = new Date(backupData.scheduler.nextBackup);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = nextBackup.getTime() - now.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
|
|
||||||
if (diffMins < 60) {
|
|
||||||
return `dans ${diffMins}min`;
|
|
||||||
} else {
|
|
||||||
return `dans ${diffHours}h ${diffMins % 60}min`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Paramètres avancés"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="mb-4 text-sm">
|
|
||||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
|
||||||
Paramètres
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
|
||||||
<span className="text-[var(--foreground)]">Avancé</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
|
||||||
🛠️ Paramètres avancés
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--muted-foreground)]">
|
|
||||||
Configuration système, sauvegarde et outils de développement
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Sauvegarde et données */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">💾 Sauvegarde et données</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Gestion des sauvegardes automatiques et manuelles
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="font-medium">Sauvegarde automatique</h3>
|
|
||||||
<span className={`h-2 w-2 rounded-full ${
|
|
||||||
backupData.scheduler.isRunning ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
}`}></span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
|
||||||
{backupData.scheduler.isEnabled
|
|
||||||
? `Sauvegarde ${backupData.scheduler.interval === 'hourly' ? 'toutes les heures' :
|
|
||||||
backupData.scheduler.interval === 'daily' ? 'quotidienne' : 'hebdomadaire'}`
|
|
||||||
: 'Sauvegarde automatique désactivée'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{backupData.scheduler.isRunning
|
|
||||||
? `Prochaine sauvegarde: ${getNextBackupTime()}`
|
|
||||||
: 'Planificateur arrêté'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-2">Sauvegardes disponibles</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
|
||||||
{backupData.backups.length} sauvegarde{backupData.backups.length > 1 ? 's' : ''} conservée{backupData.backups.length > 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
{backupData.backups.length > 0 ? (
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Dernière: {formatTimeAgo(backupData.backups[0].createdAt)}
|
|
||||||
({formatFileSize(backupData.backups[0].size)})
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Aucune sauvegarde disponible
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateBackup}
|
|
||||||
disabled={isCreatingBackup}
|
|
||||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm font-medium"
|
|
||||||
>
|
|
||||||
{isCreatingBackup ? 'Création...' : 'Créer une sauvegarde'}
|
|
||||||
</Button>
|
|
||||||
<Link href="/settings/backup">
|
|
||||||
<Button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium">
|
|
||||||
Gérer les sauvegardes
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
onClick={handleVerifyDatabase}
|
|
||||||
disabled={isVerifying}
|
|
||||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium"
|
|
||||||
>
|
|
||||||
{isVerifying ? 'Vérification...' : 'Vérifier DB'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Base de données */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">🗄️ Base de données</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Informations et maintenance de la base de données
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-1">Tâches</h3>
|
|
||||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
|
||||||
{dbStats.taskCount}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-1">Tags</h3>
|
|
||||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
|
||||||
{dbStats.tagCount}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-1">Taux complétion</h3>
|
|
||||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
|
||||||
{dbStats.completionRate}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface GeneralSettingsPageClientProps {
|
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeneralSettingsPageClient({ initialPreferences }: GeneralSettingsPageClientProps) {
|
|
||||||
return (
|
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Paramètres généraux"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="mb-4 text-sm">
|
|
||||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
|
||||||
Paramètres
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
|
||||||
<span className="text-[var(--foreground)]">Général</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
|
||||||
⚙️ Paramètres généraux
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--muted-foreground)]">
|
|
||||||
Configuration des préférences de l'interface et du comportement général
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Note développement futur */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
|
||||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
|
||||||
🚧 Interface de configuration en développement
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Les contrôles interactifs pour modifier ces préférences seront disponibles dans une prochaine version.
|
|
||||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { UserPreferences, JiraConfig } from '@/lib/types';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
|
||||||
import { JiraSync } from '@/components/jira/JiraSync';
|
|
||||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface IntegrationsSettingsPageClientProps {
|
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialJiraConfig: JiraConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IntegrationsSettingsPageClient({
|
|
||||||
initialPreferences,
|
|
||||||
initialJiraConfig
|
|
||||||
}: IntegrationsSettingsPageClientProps) {
|
|
||||||
return (
|
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Intégrations externes"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="mb-4 text-sm">
|
|
||||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
|
||||||
Paramètres
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
|
||||||
<span className="text-[var(--foreground)]">Intégrations</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
|
||||||
🔌 Intégrations externes
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--muted-foreground)]">
|
|
||||||
Configuration des intégrations avec les outils externes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
|
||||||
|
|
||||||
{/* Colonne principale: Configuration Jira */}
|
|
||||||
<div className="xl:col-span-2 space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
|
||||||
<span className="text-blue-600">🏢</span>
|
|
||||||
Jira Cloud
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Synchronisation automatique des tickets Jira vers TowerControl
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<JiraConfigForm />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Futures intégrations */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-xl font-semibold">Autres intégrations</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Intégrations prévues pour les prochaines versions
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">📧</span>
|
|
||||||
<h3 className="font-medium">Slack/Teams</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Notifications et commandes via chat
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">🐙</span>
|
|
||||||
<h3 className="font-medium">GitHub/GitLab</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Synchronisation des issues et PR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">📊</span>
|
|
||||||
<h3 className="font-medium">Calendriers</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Google Calendar, Outlook, etc.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">⏱️</span>
|
|
||||||
<h3 className="font-medium">Time tracking</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Toggl, RescueTime, etc.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Colonne latérale: Actions et Logs Jira */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{initialJiraConfig?.enabled && (
|
|
||||||
<>
|
|
||||||
{/* Dashboard Analytics */}
|
|
||||||
{initialJiraConfig.projectKey && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Surveillance du projet {initialJiraConfig.projectKey}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/jira-dashboard"
|
|
||||||
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
|
||||||
>
|
|
||||||
Voir le Dashboard
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JiraSync />
|
|
||||||
<JiraLogs />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!initialJiraConfig?.enabled && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="text-center py-6">
|
|
||||||
<span className="text-4xl mb-4 block">🔧</span>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Configurez Jira pour accéder aux outils de synchronisation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
|
||||||
import { jiraConfigClient } from '@/clients/jira-config-client';
|
|
||||||
|
|
||||||
export function JiraConfigForm() {
|
|
||||||
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
baseUrl: '',
|
|
||||||
email: '',
|
|
||||||
apiToken: '',
|
|
||||||
projectKey: '',
|
|
||||||
ignoredProjects: [] as string[]
|
|
||||||
});
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
|
||||||
const [validationResult, setValidationResult] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
|
|
||||||
// Charger les données existantes dans le formulaire
|
|
||||||
useEffect(() => {
|
|
||||||
if (config) {
|
|
||||||
setFormData({
|
|
||||||
baseUrl: config.baseUrl || '',
|
|
||||||
email: config.email || '',
|
|
||||||
apiToken: config.apiToken || '',
|
|
||||||
projectKey: config.projectKey || '',
|
|
||||||
ignoredProjects: config.ignoredProjects || []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
// Afficher le formulaire par défaut si Jira n'est pas configuré
|
|
||||||
useEffect(() => {
|
|
||||||
const isConfigured = config?.enabled && (config?.baseUrl || config?.email);
|
|
||||||
if (!configLoading && !isConfigured) {
|
|
||||||
setShowForm(true);
|
|
||||||
}
|
|
||||||
}, [config, configLoading]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await saveConfig(formData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: result.message
|
|
||||||
});
|
|
||||||
// Masquer le formulaire après une sauvegarde réussie
|
|
||||||
setShowForm(false);
|
|
||||||
} else {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: result.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : 'Erreur lors de la sauvegarde de la configuration'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration Jira ?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await deleteConfig();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setFormData({
|
|
||||||
baseUrl: '',
|
|
||||||
email: '',
|
|
||||||
apiToken: '',
|
|
||||||
projectKey: '',
|
|
||||||
ignoredProjects: []
|
|
||||||
});
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: result.message
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: result.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : 'Erreur lors de la suppression de la configuration'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidateProject = async () => {
|
|
||||||
if (!formData.projectKey.trim()) {
|
|
||||||
setValidationResult({
|
|
||||||
type: 'error',
|
|
||||||
text: 'Veuillez saisir une clé de projet'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsValidating(true);
|
|
||||||
setValidationResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await jiraConfigClient.validateProject(formData.projectKey);
|
|
||||||
|
|
||||||
if (result.success && result.exists) {
|
|
||||||
setValidationResult({
|
|
||||||
type: 'success',
|
|
||||||
text: `✓ Projet trouvé : ${result.projectName}`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setValidationResult({
|
|
||||||
type: 'error',
|
|
||||||
text: result.error || result.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setValidationResult({
|
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : 'Erreur lors de la validation'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsValidating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isJiraConfigured = config?.enabled && (config?.baseUrl || config?.email);
|
|
||||||
const isLoading = configLoading || isSubmitting;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Statut actuel */}
|
|
||||||
<div className="flex items-center justify-between p-4 bg-[var(--card)] rounded border">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Statut de l'intégration</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
{isJiraConfigured
|
|
||||||
? 'Jira est configuré et prêt à être utilisé'
|
|
||||||
: 'Jira n\'est pas configuré'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant={isJiraConfigured ? 'success' : 'danger'}>
|
|
||||||
{isJiraConfigured ? '✓ Configuré' : '✗ Non configuré'}
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
>
|
|
||||||
{showForm ? 'Masquer' : (isJiraConfigured ? 'Modifier' : 'Configurer')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isJiraConfigured && (
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border">
|
|
||||||
<h3 className="font-medium mb-2">Configuration actuelle</h3>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--muted-foreground)]">URL de base:</span>{' '}
|
|
||||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
|
||||||
{config?.baseUrl || 'Non définie'}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--muted-foreground)]">Email:</span>{' '}
|
|
||||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
|
||||||
{config?.email || 'Non défini'}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--muted-foreground)]">Token API:</span>{' '}
|
|
||||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
|
||||||
{config?.apiToken ? '••••••••' : 'Non défini'}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--muted-foreground)]">Projet surveillé:</span>{' '}
|
|
||||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
|
||||||
{config?.projectKey || 'Non défini'}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[var(--muted-foreground)]">Projets ignorés:</span>{' '}
|
|
||||||
{config?.ignoredProjects && config.ignoredProjects.length > 0 ? (
|
|
||||||
<div className="mt-1 space-x-1">
|
|
||||||
{config.ignoredProjects.map(project => (
|
|
||||||
<code key={project} className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
|
||||||
{project}
|
|
||||||
</code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs">Aucun</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Formulaire de configuration */}
|
|
||||||
{showForm && (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
URL de base Jira Cloud
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData.baseUrl}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
placeholder="https://votre-domaine.atlassian.net"
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
L'URL de votre instance Jira Cloud (ex: https://monentreprise.atlassian.net)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Email Jira
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
|
||||||
placeholder="votre-email@exemple.com"
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
L'email de votre compte Jira
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Token API Jira
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.apiToken}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, apiToken: e.target.value }))}
|
|
||||||
placeholder="Votre token API Jira"
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
Créez un token API depuis{' '}
|
|
||||||
<a
|
|
||||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[var(--primary)] hover:underline"
|
|
||||||
>
|
|
||||||
votre profil Atlassian
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Projet à surveiller (optionnel)
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.projectKey}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormData(prev => ({ ...prev, projectKey: e.target.value.trim().toUpperCase() }));
|
|
||||||
setValidationResult(null); // Reset validation when input changes
|
|
||||||
}}
|
|
||||||
placeholder="MYTEAM"
|
|
||||||
className="flex-1 px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleValidateProject}
|
|
||||||
disabled={isValidating || !formData.projectKey.trim() || !isJiraConfigured}
|
|
||||||
className="px-4 shrink-0"
|
|
||||||
>
|
|
||||||
{isValidating ? 'Validation...' : 'Valider'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Résultat de la validation */}
|
|
||||||
{validationResult && (
|
|
||||||
<div className={`mt-2 p-2 rounded text-sm ${
|
|
||||||
validationResult.type === 'success'
|
|
||||||
? 'bg-green-50 border border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
|
||||||
: 'bg-red-50 border border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
|
||||||
}`}>
|
|
||||||
{validationResult.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
Clé du projet pour les analytics d'équipe (ex: MYTEAM, DEV, PROD).
|
|
||||||
Laissez vide pour désactiver la surveillance d'équipe.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Projets à ignorer (optionnel)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.ignoredProjects.join(', ')}
|
|
||||||
onChange={(e) => {
|
|
||||||
const projects = e.target.value
|
|
||||||
.split(',')
|
|
||||||
.map(p => p.trim().toUpperCase())
|
|
||||||
.filter(p => p.length > 0);
|
|
||||||
setFormData(prev => ({ ...prev, ignoredProjects: projects }));
|
|
||||||
}}
|
|
||||||
placeholder="DEMO, TEST, SANDBOX"
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
Liste des clés de projets à ignorer lors de la synchronisation, séparées par des virgules (ex: DEMO, TEST, SANDBOX).
|
|
||||||
Ces projets ne seront pas synchronisés vers TowerControl.
|
|
||||||
</p>
|
|
||||||
{formData.ignoredProjects.length > 0 && (
|
|
||||||
<div className="mt-2 space-x-1">
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">Projets qui seront ignorés:</span>
|
|
||||||
{formData.ignoredProjects.map(project => (
|
|
||||||
<code key={project} className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs">
|
|
||||||
{project}
|
|
||||||
</code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Sauvegarde...' : 'Sauvegarder la configuration'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isJiraConfigured && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="px-6"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instructions */}
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-2">💡 Instructions de configuration</h3>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)] space-y-2">
|
|
||||||
<p><strong>1. URL de base:</strong> Votre domaine Jira Cloud (ex: https://monentreprise.atlassian.net)</p>
|
|
||||||
<p><strong>2. Email:</strong> L'email de votre compte Jira/Atlassian</p>
|
|
||||||
<p><strong>3. Token API:</strong> Créez un token depuis votre profil Atlassian :</p>
|
|
||||||
<ul className="ml-4 space-y-1 list-disc">
|
|
||||||
<li>Allez sur <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener noreferrer" className="text-[var(--primary)] hover:underline">id.atlassian.com</a></li>
|
|
||||||
<li>Cliquez sur "Create API token"</li>
|
|
||||||
<li>Donnez un nom descriptif (ex: "TowerControl")</li>
|
|
||||||
<li>Copiez le token généré</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mt-3 text-xs">
|
|
||||||
<strong>Note:</strong> Ces variables doivent être configurées dans l'environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`p-4 rounded border ${
|
|
||||||
message.type === 'success'
|
|
||||||
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
|
||||||
: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
|
||||||
}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface SettingsIndexPageClientProps {
|
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPageClientProps) {
|
|
||||||
const settingsPages = [
|
|
||||||
{
|
|
||||||
href: '/settings/general',
|
|
||||||
icon: '⚙️',
|
|
||||||
title: 'Paramètres généraux',
|
|
||||||
description: 'Interface, thème, préférences d\'affichage',
|
|
||||||
status: 'En développement'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/settings/integrations',
|
|
||||||
icon: '🔌',
|
|
||||||
title: 'Intégrations',
|
|
||||||
description: 'Jira, GitHub, Slack et autres services externes',
|
|
||||||
status: 'Fonctionnel'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/settings/advanced',
|
|
||||||
icon: '🛠️',
|
|
||||||
title: 'Paramètres avancés',
|
|
||||||
description: 'Sauvegarde, logs, debug et maintenance',
|
|
||||||
status: 'Prochainement'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Configuration & Paramètres"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-3">
|
|
||||||
Paramètres
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--muted-foreground)] text-lg">
|
|
||||||
Configuration de TowerControl et de ses intégrations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">🎨</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
|
||||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.theme}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">🔌</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">📏</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
|
||||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.fontSize}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings Sections */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
|
||||||
Sections de configuration
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
|
||||||
{settingsPages.map((page) => (
|
|
||||||
<Link key={page.href} href={page.href}>
|
|
||||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<span className="text-3xl">{page.icon}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
|
||||||
{page.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--muted-foreground)] mb-2">
|
|
||||||
{page.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
page.status === 'Fonctionnel'
|
|
||||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
|
||||||
: page.status === 'En développement'
|
|
||||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
|
||||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
|
||||||
}`}>
|
|
||||||
{page.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
|
||||||
Actions rapides
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Créer une sauvegarde des données
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm">
|
|
||||||
Sauvegarder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Tester la connexion Jira
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm"
|
|
||||||
disabled={!initialPreferences.jiraConfig.enabled}
|
|
||||||
>
|
|
||||||
Tester
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Info */}
|
|
||||||
<Card className="mt-8">
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
|
||||||
<p className="font-medium">TowerControl v1.0.0</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
|
||||||
<p className="font-medium">Il y a 2 jours</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Env</p>
|
|
||||||
<p className="font-medium">Development</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
|
||||||
import { JiraSync } from '@/components/jira/JiraSync';
|
|
||||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
|
||||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
|
||||||
|
|
||||||
export function SettingsPageClient() {
|
|
||||||
const { config: jiraConfig } = useJiraConfig();
|
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
|
|
||||||
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
|
|
||||||
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Configuration & Paramètres"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
{/* En-tête compact */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
|
|
||||||
Paramètres
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Configuration de TowerControl et de ses intégrations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-6">
|
|
||||||
{/* Navigation latérale compacte */}
|
|
||||||
<div className="w-56 flex-shrink-0">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
|
|
||||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-base">{tab.icon}</span>
|
|
||||||
<span className="font-medium text-sm">{tab.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu principal */}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
{activeTab === 'general' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">Préférences générales</h2>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Les paramètres généraux seront disponibles dans une prochaine version.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'integrations' && (
|
|
||||||
<div className="h-full">
|
|
||||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
|
|
||||||
|
|
||||||
{/* Colonne 1: Configuration Jira */}
|
|
||||||
<div className="xl:col-span-2">
|
|
||||||
<Card className="h-fit">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Synchronisation automatique des tickets
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<JiraConfigForm />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Colonne 2: Actions et Logs */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{jiraConfig?.enabled && (
|
|
||||||
<>
|
|
||||||
<JiraSync />
|
|
||||||
<JiraLogs />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'advanced' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Les paramètres avancés seront disponibles dans une prochaine version.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
|
|
||||||
<li>• Configuration de la base de données</li>
|
|
||||||
<li>• Logs de debug</li>
|
|
||||||
<li>• Export/Import des données</li>
|
|
||||||
<li>• Réinitialisation</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { HTMLAttributes, forwardRef } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
||||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline';
|
|
||||||
size?: 'sm' | 'md';
|
|
||||||
}
|
|
||||||
|
|
||||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
|
||||||
({ className, variant = 'default', size = 'md', ...props }, ref) => {
|
|
||||||
const baseStyles = 'inline-flex items-center font-mono font-medium transition-all duration-200';
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
default: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)]',
|
|
||||||
primary: 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30',
|
|
||||||
success: 'bg-[var(--success)]/20 text-[var(--success)] border border-[var(--success)]/30',
|
|
||||||
warning: 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30',
|
|
||||||
danger: 'bg-[var(--destructive)]/20 text-[var(--destructive)] border border-[var(--destructive)]/30',
|
|
||||||
outline: 'bg-transparent text-[var(--muted-foreground)] border border-[var(--border)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizes = {
|
|
||||||
sm: 'px-1.5 py-0.5 text-xs rounded',
|
|
||||||
md: 'px-2 py-1 text-xs rounded-md'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
baseStyles,
|
|
||||||
variants[variant],
|
|
||||||
sizes[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Badge.displayName = 'Badge';
|
|
||||||
|
|
||||||
export { Badge };
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant = 'primary', size = 'md', ...props }, ref) => {
|
|
||||||
const baseStyles = 'inline-flex items-center justify-center font-mono font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--background)] disabled:opacity-50 disabled:cursor-not-allowed';
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
primary: 'bg-[var(--primary)] hover:bg-[var(--primary)]/80 text-[var(--primary-foreground)] border border-[var(--primary)]/30 shadow-[var(--primary)]/20 shadow-lg hover:shadow-[var(--primary)]/30 focus:ring-[var(--primary)]',
|
|
||||||
secondary: 'bg-[var(--card)] hover:bg-[var(--card-hover)] text-[var(--foreground)] border border-[var(--border)] shadow-[var(--muted)]/20 shadow-lg hover:shadow-[var(--muted)]/30 focus:ring-[var(--muted)]',
|
|
||||||
danger: 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/80 text-white border border-[var(--destructive)]/30 shadow-[var(--destructive)]/20 shadow-lg hover:shadow-[var(--destructive)]/30 focus:ring-[var(--destructive)]',
|
|
||||||
ghost: 'bg-transparent hover:bg-[var(--card)]/50 text-[var(--muted-foreground)] hover:text-[var(--foreground)] border border-[var(--border)]/50 hover:border-[var(--border)] focus:ring-[var(--muted)]'
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizes = {
|
|
||||||
sm: 'px-3 py-1.5 text-xs rounded-md',
|
|
||||||
md: 'px-4 py-2 text-sm rounded-lg',
|
|
||||||
lg: 'px-6 py-3 text-base rounded-lg'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
baseStyles,
|
|
||||||
variants[variant],
|
|
||||||
sizes[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
|
||||||
|
|
||||||
export { Button };
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { HTMLAttributes, forwardRef } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
variant?: 'default' | 'elevated' | 'bordered' | 'column';
|
|
||||||
}
|
|
||||||
|
|
||||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
|
||||||
({ className, variant = 'default', ...props }, ref) => {
|
|
||||||
const variants = {
|
|
||||||
default: 'bg-[var(--card)]/50 border border-[var(--border)]/50',
|
|
||||||
elevated: 'bg-[var(--card)]/80 border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20',
|
|
||||||
bordered: 'bg-[var(--card)]/50 border border-[var(--primary)]/30 shadow-[var(--primary)]/10 shadow-lg',
|
|
||||||
column: 'bg-[var(--card-column)] border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'rounded-lg backdrop-blur-sm transition-all duration-200',
|
|
||||||
variants[variant],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Card.displayName = 'Card';
|
|
||||||
|
|
||||||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn('p-4 border-b border-[var(--border)]/50', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CardHeader.displayName = 'CardHeader';
|
|
||||||
|
|
||||||
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<h3
|
|
||||||
ref={ref}
|
|
||||||
className={cn('font-mono font-semibold text-[var(--foreground)] tracking-wide', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CardTitle.displayName = 'CardTitle';
|
|
||||||
|
|
||||||
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn('p-4', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CardContent.displayName = 'CardContent';
|
|
||||||
|
|
||||||
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn('p-4 border-t border-[var(--border)]/50', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CardFooter.displayName = 'CardFooter';
|
|
||||||
|
|
||||||
export { Card, CardHeader, CardTitle, CardContent, CardFooter };
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
|
||||||
import { useJiraConfig } from '@/contexts/JiraConfigContext';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
syncing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header({ title = "TowerControl", subtitle = "Task Management", syncing = false }: HeaderProps) {
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
const { isConfigured: isJiraConfigured, config: jiraConfig } = useJiraConfig();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
// Fonction pour déterminer si un lien est actif
|
|
||||||
const isActiveLink = (href: string) => {
|
|
||||||
if (href === '/') {
|
|
||||||
return pathname === '/';
|
|
||||||
}
|
|
||||||
return pathname.startsWith(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour obtenir les classes CSS d'un lien (desktop)
|
|
||||||
const getLinkClasses = (href: string) => {
|
|
||||||
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-3 py-1.5 rounded-md";
|
|
||||||
|
|
||||||
if (isActiveLink(href)) {
|
|
||||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour obtenir les classes CSS d'un lien (mobile)
|
|
||||||
const getMobileLinkClasses = (href: string) => {
|
|
||||||
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-4 py-3 rounded-md block w-full text-left";
|
|
||||||
|
|
||||||
if (isActiveLink(href)) {
|
|
||||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Liste des liens de navigation
|
|
||||||
const navLinks = [
|
|
||||||
{ href: '/', label: 'Dashboard' },
|
|
||||||
{ href: '/kanban', label: 'Kanban' },
|
|
||||||
{ href: '/daily', label: 'Daily' },
|
|
||||||
{ href: '/weekly-summary', label: 'Résumé' },
|
|
||||||
{ href: '/tags', label: 'Tags' },
|
|
||||||
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
|
||||||
{ href: '/settings', label: 'Settings' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="relative z-50 bg-[var(--card)]/80 backdrop-blur-sm border-b border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20">
|
|
||||||
<div className="container mx-auto px-4 sm:px-6 py-4">
|
|
||||||
{/* Layout mobile/tablette */}
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{/* Titre et status */}
|
|
||||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0 flex-1">
|
|
||||||
<div className={`w-3 h-3 rounded-full shadow-lg flex-shrink-0 ${
|
|
||||||
syncing
|
|
||||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
|
||||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
|
||||||
}`}></div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h1 className="text-xl sm:text-2xl font-mono font-bold text-[var(--foreground)] tracking-wider truncate">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-xs sm:text-sm truncate">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls mobile/tablette */}
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{/* Theme Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
|
|
||||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Menu burger */}
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
||||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
|
|
||||||
title="Toggle menu"
|
|
||||||
>
|
|
||||||
{mobileMenuOpen ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Layout desktop - une seule ligne comme avant */}
|
|
||||||
<div className="hidden lg:flex items-center justify-between gap-6">
|
|
||||||
{/* Titre et status */}
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="flex items-center gap-4 w-[300px]">
|
|
||||||
<div className={`w-3 h-3 rounded-full shadow-lg ${
|
|
||||||
syncing
|
|
||||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
|
||||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
|
||||||
}`}></div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] tracking-wider">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-sm">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation desktop */}
|
|
||||||
<nav className="flex items-center gap-2">
|
|
||||||
{navLinks.map(({ href, label }) => (
|
|
||||||
<Link
|
|
||||||
key={href}
|
|
||||||
href={href}
|
|
||||||
className={getLinkClasses(href)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Theme Toggle desktop */}
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--card-hover)]"
|
|
||||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu mobile/tablette en overlay fixe */}
|
|
||||||
{mobileMenuOpen && (
|
|
||||||
<>
|
|
||||||
{/* Backdrop pour fermer le menu */}
|
|
||||||
<div
|
|
||||||
className="lg:hidden fixed inset-0 bg-black/20 backdrop-blur-sm z-[100]"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
{/* Menu */}
|
|
||||||
<div className="lg:hidden fixed top-[80px] left-0 right-0 bg-[var(--card)]/98 backdrop-blur-md border-b border-[var(--border)]/50 shadow-xl z-[101]">
|
|
||||||
<nav className="container mx-auto px-4 py-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{navLinks.map(({ href, label }) => (
|
|
||||||
<Link
|
|
||||||
key={href}
|
|
||||||
href={href}
|
|
||||||
className={getMobileLinkClasses(href)}
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Header } from './Header';
|
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
|
||||||
|
|
||||||
interface HeaderContainerProps {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
|
|
||||||
const { syncing } = useTasks();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
syncing={syncing}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, label, error, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{label && (
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
className={cn(
|
|
||||||
'w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg',
|
|
||||||
'text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)]',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50',
|
|
||||||
'hover:border-[var(--border)] transition-all duration-200',
|
|
||||||
'backdrop-blur-sm',
|
|
||||||
error && 'border-[var(--destructive)]/50 focus:ring-[var(--destructive)]/50 focus:border-[var(--destructive)]/50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs font-mono text-[var(--destructive)] flex items-center gap-1">
|
|
||||||
<span className="text-[var(--destructive)]">⚠</span>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
|
||||||
|
|
||||||
export { Input };
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import { Tag } from '@/lib/types';
|
|
||||||
import { useTagsAutocomplete } from '@/hooks/useTags';
|
|
||||||
import { Badge } from './Badge';
|
|
||||||
|
|
||||||
interface TagInputProps {
|
|
||||||
tags: string[];
|
|
||||||
onChange: (tags: string[]) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
maxTags?: number;
|
|
||||||
className?: string;
|
|
||||||
compactSuggestions?: boolean; // Pour adapter selon l'espace
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagInput({
|
|
||||||
tags,
|
|
||||||
onChange,
|
|
||||||
placeholder = "Ajouter des tags...",
|
|
||||||
maxTags = 10,
|
|
||||||
className = "",
|
|
||||||
compactSuggestions = false
|
|
||||||
}: TagInputProps) {
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const { suggestions, loading, searchTags, clearSuggestions, loadPopularTags } = useTagsAutocomplete();
|
|
||||||
|
|
||||||
// Rechercher des suggestions quand l'input change
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputValue.trim()) {
|
|
||||||
searchTags(inputValue);
|
|
||||||
setShowSuggestions(true);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
} else {
|
|
||||||
clearSuggestions();
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
}, [inputValue, searchTags, clearSuggestions]);
|
|
||||||
|
|
||||||
const addTag = (tagName: string) => {
|
|
||||||
const trimmedTag = tagName.trim();
|
|
||||||
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
|
||||||
onChange([...tags, trimmedTag]);
|
|
||||||
}
|
|
||||||
setInputValue('');
|
|
||||||
setShowSuggestions(false);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTag = (tagToRemove: string) => {
|
|
||||||
onChange(tags.filter(tag => tag !== tagToRemove));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
|
||||||
addTag(suggestions[selectedIndex].name);
|
|
||||||
} else if (inputValue.trim()) {
|
|
||||||
addTag(inputValue);
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex(prev =>
|
|
||||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
|
||||||
);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
|
||||||
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
|
||||||
// Supprimer le dernier tag si l'input est vide
|
|
||||||
removeTag(tags[tags.length - 1]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestionClick = (tag: Tag) => {
|
|
||||||
addTag(tag.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = (e: React.FocusEvent) => {
|
|
||||||
// Délai pour permettre le clic sur une suggestion
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!suggestionsRef.current?.contains(e.relatedTarget as Node)) {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocus = () => {
|
|
||||||
if (inputValue.trim()) {
|
|
||||||
// Si il y a du texte, afficher les suggestions existantes
|
|
||||||
setShowSuggestions(true);
|
|
||||||
} else {
|
|
||||||
// Si l'input est vide, charger les tags populaires
|
|
||||||
loadPopularTags(20);
|
|
||||||
setShowSuggestions(true);
|
|
||||||
}
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative ${className}`}>
|
|
||||||
{/* Container des tags et input */}
|
|
||||||
<div className="min-h-[42px] p-2 border border-[var(--border)] rounded-lg bg-[var(--input)] focus-within:border-[var(--primary)] focus-within:ring-1 focus-within:ring-[var(--primary)]/20 transition-colors">
|
|
||||||
<div className="flex flex-wrap gap-1 items-center">
|
|
||||||
{/* Tags existants */}
|
|
||||||
{tags.map((tag, index) => (
|
|
||||||
<Badge
|
|
||||||
key={index}
|
|
||||||
variant="default"
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
<span>{tag}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeTag(tag)}
|
|
||||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
|
|
||||||
aria-label={`Supprimer le tag ${tag}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Input pour nouveau tag */}
|
|
||||||
{tags.length < maxTags && (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
placeholder={tags.length === 0 ? placeholder : ""}
|
|
||||||
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-[var(--foreground)] placeholder-[var(--muted-foreground)] text-sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Suggestions dropdown */}
|
|
||||||
{showSuggestions && (suggestions.length > 0 || loading) && (
|
|
||||||
<div
|
|
||||||
ref={suggestionsRef}
|
|
||||||
className="absolute top-full left-0 right-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg z-[9999] max-h-64 overflow-y-auto"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-3 text-center text-[var(--muted-foreground)] text-sm">
|
|
||||||
Recherche...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={`gap-2 p-3 ${compactSuggestions ? 'grid grid-cols-1' : 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'}`}>
|
|
||||||
{suggestions.map((tag, index) => (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSuggestionClick(tag)}
|
|
||||||
className={`flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors ${
|
|
||||||
index === selectedIndex
|
|
||||||
? 'bg-[var(--card-hover)] text-[var(--primary)] ring-1 ring-[var(--primary)]'
|
|
||||||
: 'text-[var(--foreground)] hover:bg-[var(--card-hover)]'
|
|
||||||
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
disabled={tags.includes(tag.name)}
|
|
||||||
title={tags.includes(tag.name) ? 'Déjà ajouté' : `Ajouter ${tag.name}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{tag.name}</span>
|
|
||||||
{tags.includes(tag.name) && (
|
|
||||||
<span className="text-[var(--muted-foreground)] ml-auto">✓</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Indicateur de limite */}
|
|
||||||
{tags.length >= maxTags && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
Limite de {maxTags} tags atteinte
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Tag } from '@/lib/types';
|
|
||||||
|
|
||||||
interface TagListProps {
|
|
||||||
tags: (Tag & { usage?: number })[];
|
|
||||||
onTagEdit?: (tag: Tag) => void;
|
|
||||||
onTagDelete?: (tag: Tag) => void;
|
|
||||||
showActions?: boolean;
|
|
||||||
showUsage?: boolean;
|
|
||||||
deletingTagId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagList({
|
|
||||||
tags,
|
|
||||||
onTagEdit,
|
|
||||||
onTagDelete,
|
|
||||||
showActions = true,
|
|
||||||
deletingTagId
|
|
||||||
}: TagListProps) {
|
|
||||||
if (tags.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12 text-slate-400">
|
|
||||||
<div className="text-6xl mb-4">🏷️</div>
|
|
||||||
<p className="text-lg mb-2">Aucun tag trouvé</p>
|
|
||||||
<p className="text-sm">Créez votre premier tag pour commencer</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{tags.map((tag) => {
|
|
||||||
const isDeleting = deletingTagId === tag.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
|
|
||||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Contenu principal */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-5 h-5 rounded-full shadow-sm"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-slate-200 font-medium truncate">
|
|
||||||
{tag.name}
|
|
||||||
</h3>
|
|
||||||
{tag.usage !== undefined && (
|
|
||||||
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
|
|
||||||
{tag.usage}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Actions (apparaissent au hover) */}
|
|
||||||
{showActions && (onTagEdit || onTagDelete) && (
|
|
||||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{onTagEdit && (
|
|
||||||
<button
|
|
||||||
onClick={() => onTagEdit(tag)}
|
|
||||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onTagDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => onTagDelete(tag)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isDeleting ? '⏳' : '🗑️'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Indicateur de couleur en bas */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
116
data/README.md
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
# 📁 Dossier Data - TowerControl
|
||||||
|
|
||||||
|
Ce dossier contient toutes les données persistantes de l'application TowerControl.
|
||||||
|
|
||||||
|
## 📋 Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── README.md # Ce fichier
|
||||||
|
├── prod.db # Base de données production (Docker)
|
||||||
|
├── dev.db # Base de données développement (Docker)
|
||||||
|
├── backups/ # Sauvegardes automatiques et manuelles
|
||||||
|
│ ├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||||
|
│ ├── towercontrol_2025-01-15T11-30-00-000Z.db.gz
|
||||||
|
│ └── ...
|
||||||
|
└── uploads/ # Images et fichiers uploadés
|
||||||
|
└── notes/ # Images des notes markdown
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Utilisation
|
||||||
|
|
||||||
|
### En développement local
|
||||||
|
|
||||||
|
- La base de données principale est dans `prisma/dev.db`
|
||||||
|
- Ce dossier `data/` est utilisé uniquement par Docker
|
||||||
|
- Les sauvegardes locales sont dans `backups/` (racine du projet)
|
||||||
|
|
||||||
|
### En production Docker
|
||||||
|
|
||||||
|
- Base de données : `data/prod.db` ou `data/dev.db`
|
||||||
|
- Sauvegardes : `data/backups/`
|
||||||
|
- Tout ce dossier est mappé vers `/app/data` dans le conteneur
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
Les chemins sont configurés via les variables d'environnement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Base de données
|
||||||
|
DATABASE_URL="file:../data/prod.db"
|
||||||
|
|
||||||
|
# Chemin de la base pour les backups
|
||||||
|
BACKUP_DATABASE_PATH="./data/prod.db"
|
||||||
|
|
||||||
|
# Dossier de stockage des sauvegardes
|
||||||
|
BACKUP_STORAGE_PATH="./data/backups"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗂️ Fichiers
|
||||||
|
|
||||||
|
### Bases de données SQLite
|
||||||
|
|
||||||
|
- **prod.db** : Base de données de production
|
||||||
|
- **dev.db** : Base de données de développement Docker
|
||||||
|
- Format : SQLite 3
|
||||||
|
- Contient : Tasks, Tags, User Preferences, Sync Logs, etc.
|
||||||
|
|
||||||
|
### Sauvegardes
|
||||||
|
|
||||||
|
- **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz`
|
||||||
|
- **Compression** : gzip
|
||||||
|
- **Rétention** : Configurable (défaut: 5 sauvegardes)
|
||||||
|
- **Fréquence** : Configurable (défaut: horaire)
|
||||||
|
|
||||||
|
### Images uploadées
|
||||||
|
|
||||||
|
- **Dossier** : `data/uploads/notes/`
|
||||||
|
- **Format** : Images collées dans les notes markdown
|
||||||
|
- **Accès** : Via `/api/notes/images/[filename]`
|
||||||
|
- **Persistance** : Monté en volume Docker pour persistance
|
||||||
|
|
||||||
|
## 🚀 Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer une sauvegarde manuelle
|
||||||
|
pnpm run backup:create
|
||||||
|
|
||||||
|
# Lister les sauvegardes
|
||||||
|
pnpm run backup:list
|
||||||
|
|
||||||
|
# Voir la configuration
|
||||||
|
pnpm run backup:config
|
||||||
|
|
||||||
|
# Restaurer une sauvegarde (dev uniquement)
|
||||||
|
pnpm run backup:restore filename.db.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Important
|
||||||
|
|
||||||
|
- **Ne pas modifier** les fichiers `.db` directement
|
||||||
|
- **Ne pas supprimer** ce dossier en production
|
||||||
|
- **Sauvegarder régulièrement** le contenu de ce dossier
|
||||||
|
- **Vérifier l'espace disque** disponible pour les sauvegardes
|
||||||
|
|
||||||
|
## 🔒 Sécurité
|
||||||
|
|
||||||
|
- Ce dossier est ignoré par Git (`.gitignore`)
|
||||||
|
- Contient des données sensibles en production
|
||||||
|
- Accès restreint recommandé sur le serveur
|
||||||
|
- Chiffrement recommandé pour les sauvegardes externes
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
Pour surveiller l'espace disque :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Taille du dossier data
|
||||||
|
du -sh data/
|
||||||
|
|
||||||
|
# Taille des sauvegardes
|
||||||
|
du -sh data/backups/
|
||||||
|
|
||||||
|
# Nombre de sauvegardes
|
||||||
|
ls -1 data/backups/ | wc -l
|
||||||
|
```
|
||||||
0
data/uploads/notes/.gitkeep
Executable file
@@ -1,52 +1,76 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
towercontrol:
|
towercontrol:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: runner
|
||||||
ports:
|
ports:
|
||||||
- "3006:3000"
|
- '${PORT:-3007}:3000'
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
- DATABASE_URL=file:/app/data/prod.db
|
DATABASE_URL: ${DATABASE_URL:-file:/app/data/dev.db}
|
||||||
- TZ=Europe/Paris
|
BACKUP_DATABASE_PATH: ${BACKUP_DATABASE_PATH:-./data/dev.db}
|
||||||
|
BACKUP_STORAGE_PATH: ${BACKUP_STORAGE_PATH:-./data/backups}
|
||||||
|
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:
|
||||||
# Volume persistant pour la base SQLite
|
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||||
- sqlite_data:/app/data
|
|
||||||
# Monter ta DB locale (décommente pour utiliser tes données locales)
|
|
||||||
- ./prisma/dev.db:/app/data/prod.db
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"]
|
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# Service de développement (optionnel)
|
|
||||||
towercontrol-dev:
|
towercontrol-dev:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
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:/app/data/dev.db
|
DATABASE_URL: ${DATABASE_URL:-file:/app/data/dev.db}
|
||||||
|
BACKUP_DATABASE_PATH: ${BACKUP_DATABASE_PATH:-./data/dev.db}
|
||||||
|
BACKUP_STORAGE_PATH: ${BACKUP_STORAGE_PATH:-./data/backups}
|
||||||
|
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
|
- .:/app # code en live
|
||||||
- /app/node_modules
|
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
||||||
- /app/.next
|
- /app/.next
|
||||||
- sqlite_data_dev:/app/data
|
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||||
command: sh -c "npm install && npx prisma generate && npx prisma migrate deploy && npm run dev"
|
command: >
|
||||||
|
sh -c "pnpm install &&
|
||||||
|
pnpm prisma generate &&
|
||||||
|
(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)) &&
|
||||||
|
pnpm run dev"
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
|
# 📁 Structure des données :
|
||||||
volumes:
|
# ./data/ -> /app/data (bind mount)
|
||||||
sqlite_data:
|
# ├── prod.db -> Base de données production
|
||||||
driver: local
|
# ├── dev.db -> Base de données développement
|
||||||
sqlite_data_dev:
|
# └── backups/ -> Sauvegardes automatiques
|
||||||
driver: local
|
#
|
||||||
|
# 🔧 Configuration via variables d'environnement (.env ou .env.local)
|
||||||
|
# Les variables utilisent la syntaxe ${VAR:-default} pour les fallbacks
|
||||||
|
# 📚 Documentation : ./data/README.md et env.example
|
||||||
|
|||||||
14
env.example
@@ -1,11 +1,23 @@
|
|||||||
# Base de données (requis)
|
# Base de données (requis)
|
||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL="file:../data/dev.db"
|
||||||
|
|
||||||
|
# Chemin de la base de données pour les backups (optionnel)
|
||||||
|
# Si non défini, utilise DATABASE_URL ou le chemin par défaut
|
||||||
|
BACKUP_DATABASE_PATH="./data/dev.db"
|
||||||
|
|
||||||
|
# Dossier de stockage des sauvegardes (optionnel)
|
||||||
|
# Par défaut: ./backups en local, ./data/backups en production
|
||||||
|
BACKUP_STORAGE_PATH="./backups"
|
||||||
|
|
||||||
# Intégration Jira (optionnel)
|
# Intégration Jira (optionnel)
|
||||||
JIRA_BASE_URL="" # https://votre-domaine.atlassian.net
|
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,468 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useTransition } from 'react';
|
|
||||||
import { dailyClient, DailyHistoryFilters, DailySearchFilters, ReorderCheckboxesData } from '@/clients/daily-client';
|
|
||||||
import { DailyView, DailyCheckbox, UpdateDailyCheckboxData, DailyCheckboxType } from '@/lib/types';
|
|
||||||
import {
|
|
||||||
toggleCheckbox as toggleCheckboxAction,
|
|
||||||
addTodayCheckbox as addTodayCheckboxAction,
|
|
||||||
addYesterdayCheckbox as addYesterdayCheckboxAction,
|
|
||||||
updateCheckbox as updateCheckboxAction,
|
|
||||||
deleteCheckbox as deleteCheckboxAction,
|
|
||||||
reorderCheckboxes as reorderCheckboxesAction
|
|
||||||
} from '@/actions/daily';
|
|
||||||
|
|
||||||
interface UseDailyState {
|
|
||||||
dailyView: DailyView | null;
|
|
||||||
loading: boolean;
|
|
||||||
refreshing: boolean; // Pour les refresh silencieux
|
|
||||||
error: string | null;
|
|
||||||
saving: boolean; // Pour indiquer les opérations en cours
|
|
||||||
isPending: boolean; // Pour indiquer les server actions en cours
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseDailyActions {
|
|
||||||
refreshDaily: () => Promise<void>;
|
|
||||||
refreshDailySilent: () => Promise<void>;
|
|
||||||
addTodayCheckbox: (text: string, type?: DailyCheckboxType, taskId?: string) => Promise<DailyCheckbox | null>;
|
|
||||||
addYesterdayCheckbox: (text: string, type?: DailyCheckboxType, taskId?: string) => Promise<DailyCheckbox | null>;
|
|
||||||
updateCheckbox: (checkboxId: string, data: UpdateDailyCheckboxData) => Promise<DailyCheckbox | null>;
|
|
||||||
deleteCheckbox: (checkboxId: string) => Promise<void>;
|
|
||||||
toggleCheckbox: (checkboxId: string) => Promise<void>;
|
|
||||||
toggleAllToday: () => Promise<void>;
|
|
||||||
toggleAllYesterday: () => Promise<void>;
|
|
||||||
reorderCheckboxes: (data: ReorderCheckboxesData) => Promise<void>;
|
|
||||||
goToPreviousDay: () => Promise<void>;
|
|
||||||
goToNextDay: () => Promise<void>;
|
|
||||||
goToToday: () => Promise<void>;
|
|
||||||
setDate: (date: Date) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook pour la gestion d'une vue daily spécifique
|
|
||||||
*/
|
|
||||||
export function useDaily(initialDate?: Date, initialDailyView?: DailyView): UseDailyState & UseDailyActions & { currentDate: Date } {
|
|
||||||
const [currentDate, setCurrentDate] = useState<Date>(initialDate || new Date());
|
|
||||||
const [dailyView, setDailyView] = useState<DailyView | null>(initialDailyView || null);
|
|
||||||
const [loading, setLoading] = useState(!initialDailyView); // Pas de loading si on a des données SSR
|
|
||||||
const [refreshing, setRefreshing] = useState(false); // Pour les refresh silencieux
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const refreshDaily = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const view = await dailyClient.getDailyView(currentDate);
|
|
||||||
setDailyView(view);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement du daily');
|
|
||||||
console.error('Erreur refreshDaily:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentDate]);
|
|
||||||
|
|
||||||
const refreshDailySilent = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setRefreshing(true);
|
|
||||||
// Refresh silencieux sans setLoading(true) pour éviter le clignotement
|
|
||||||
const view = await dailyClient.getDailyView(currentDate);
|
|
||||||
setDailyView(view);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erreur refreshDailySilent:', err);
|
|
||||||
// On n'affiche pas l'erreur pour ne pas perturber l'UX
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [currentDate]);
|
|
||||||
|
|
||||||
const addTodayCheckbox = useCallback((text: string, type?: DailyCheckboxType, taskId?: string): Promise<DailyCheckbox | null> => {
|
|
||||||
if (!dailyView) return Promise.resolve(null);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await addTodayCheckboxAction(text, type, taskId);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
// Mise à jour optimiste
|
|
||||||
setDailyView(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
today: [...prev.today, result.data!].sort((a, b) => a.order - b.order)
|
|
||||||
} : null);
|
|
||||||
resolve(result.data);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors de l\'ajout de la checkbox');
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de l\'ajout de la checkbox');
|
|
||||||
console.error('Erreur addTodayCheckbox:', err);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [dailyView]);
|
|
||||||
|
|
||||||
const addYesterdayCheckbox = useCallback((text: string, type?: DailyCheckboxType, taskId?: string): Promise<DailyCheckbox | null> => {
|
|
||||||
if (!dailyView) return Promise.resolve(null);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await addYesterdayCheckboxAction(text, type, taskId);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
// Mise à jour optimiste
|
|
||||||
setDailyView(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
yesterday: [...prev.yesterday, result.data!].sort((a, b) => a.order - b.order)
|
|
||||||
} : null);
|
|
||||||
resolve(result.data);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors de l\'ajout de la checkbox');
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de l\'ajout de la checkbox');
|
|
||||||
console.error('Erreur addYesterdayCheckbox:', err);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [dailyView]);
|
|
||||||
|
|
||||||
const updateCheckbox = useCallback((checkboxId: string, data: UpdateDailyCheckboxData): Promise<DailyCheckbox | null> => {
|
|
||||||
if (!dailyView) return Promise.resolve(null);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await updateCheckboxAction(checkboxId, data);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
// Mise à jour optimiste
|
|
||||||
setDailyView(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
yesterday: prev.yesterday.map(cb => cb.id === checkboxId ? result.data! : cb),
|
|
||||||
today: prev.today.map(cb => cb.id === checkboxId ? result.data! : cb)
|
|
||||||
} : null);
|
|
||||||
resolve(result.data);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors de la mise à jour de la checkbox');
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour de la checkbox');
|
|
||||||
console.error('Erreur updateCheckbox:', err);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [dailyView]);
|
|
||||||
|
|
||||||
const deleteCheckbox = useCallback((checkboxId: string): Promise<void> => {
|
|
||||||
if (!dailyView) return Promise.resolve();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const previousDailyView = dailyView;
|
|
||||||
|
|
||||||
// Mise à jour optimiste IMMÉDIATE - supprimer la checkbox
|
|
||||||
setDailyView(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
yesterday: prev.yesterday.filter(cb => cb.id !== checkboxId),
|
|
||||||
today: prev.today.filter(cb => cb.id !== checkboxId)
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await deleteCheckboxAction(checkboxId);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
// Rollback en cas d'erreur
|
|
||||||
setDailyView(previousDailyView);
|
|
||||||
setError(result.error || 'Erreur lors de la suppression de la checkbox');
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
} catch (err) {
|
|
||||||
// Rollback en cas d'erreur
|
|
||||||
setDailyView(previousDailyView);
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la suppression de la checkbox');
|
|
||||||
console.error('Erreur deleteCheckbox:', err);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [dailyView]);
|
|
||||||
|
|
||||||
const toggleCheckbox = useCallback((checkboxId: string): Promise<void> => {
|
|
||||||
if (!dailyView) return Promise.resolve();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
// Trouver la checkbox dans yesterday ou today
|
|
||||||
let checkbox = dailyView.yesterday.find(cb => cb.id === checkboxId);
|
|
||||||
if (!checkbox) {
|
|
||||||
checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!checkbox) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mise à jour optimiste IMMÉDIATE
|
|
||||||
const newCheckedState = !checkbox.isChecked;
|
|
||||||
const previousDailyView = dailyView;
|
|
||||||
|
|
||||||
setDailyView(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
yesterday: prev.yesterday.map(cb =>
|
|
||||||
cb.id === checkboxId ? { ...cb, isChecked: newCheckedState } : cb
|
|
||||||
),
|
|
||||||
today: prev.today.map(cb =>
|
|
||||||
cb.id === checkboxId ? { ...cb, isChecked: newCheckedState } : cb
|
|
||||||
)
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await toggleCheckboxAction(checkboxId);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
// Rollback en cas d'erreur
|
|
||||||
setDailyView(previousDailyView);
|
|
||||||
setError(result.error || 'Erreur lors de la mise à jour de la checkbox');
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
} catch (err) {
|
|
||||||
// Rollback en cas d'erreur
|
|
||||||
setDailyView(previousDailyView);
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour de la checkbox');
|
|
||||||
console.error('Erreur toggleCheckbox:', err);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [dailyView]);
|
|
||||||
|
|
||||||
const toggleAllToday = useCallback(async (): Promise<void> => {
|
|
||||||
if (!dailyView) return;
|
|
||||||
|
|
||||||
const todayCheckboxes = dailyView.today;
|
|
||||||
if (todayCheckboxes.length === 0) return;
|
|
||||||
|
|
||||||
// Déterminer si on coche tout ou on décoche tout
|
|
||||||
const allChecked = todayCheckboxes.every(cb => cb.isChecked);
|
|
||||||
const newCheckedState = !allChecked;
|
|
||||||
|
|
||||||
const previousDailyView = dailyView;
|
|
||||||
|
|
||||||
// Mise à jour optimiste IMMÉDIATE
|
|
||||||
setDailyView(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
today: prev.today.map(cb => ({ ...cb, isChecked: newCheckedState }))
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Appeler l'API pour chaque checkbox en parallèle
|
|
||||||
await Promise.all(
|
|
||||||
todayCheckboxes.map(checkbox =>
|
|
||||||
updateCheckboxAction(checkbox.id, { isChecked: newCheckedState })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// Rollback en cas d'erreur
|
|
||||||
setDailyView(previousDailyView);
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour des checkboxes');
|
|
||||||
console.error('Erreur toggleAllToday:', err);
|
|
||||||
}
|
|
||||||
}, [dailyView]);
|
|
||||||
|
|
||||||
const toggleAllYesterday = useCallback(async (): Promise<void> => {
|
|
||||||
if (!dailyView) return;
|
|
||||||
|
|
||||||
const yesterdayCheckboxes = dailyView.yesterday;
|
|
||||||
if (yesterdayCheckboxes.length === 0) return;
|
|
||||||
|
|
||||||
// Déterminer si on coche tout ou on décoche tout
|
|
||||||
const allChecked = yesterdayCheckboxes.every(cb => cb.isChecked);
|
|
||||||
const newCheckedState = !allChecked;
|
|
||||||
|
|
||||||
const previousDailyView = dailyView;
|
|
||||||
|
|
||||||
// Mise à jour optimiste IMMÉDIATE
|
|
||||||
setDailyView(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
yesterday: prev.yesterday.map(cb => ({ ...cb, isChecked: newCheckedState }))
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Appeler l'API pour chaque checkbox en parallèle
|
|
||||||
await Promise.all(
|
|
||||||
yesterdayCheckboxes.map(checkbox =>
|
|
||||||
updateCheckboxAction(checkbox.id, { isChecked: newCheckedState })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// Rollback en cas d'erreur
|
|
||||||
setDailyView(previousDailyView);
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour des checkboxes');
|
|
||||||
console.error('Erreur toggleAllYesterday:', err);
|
|
||||||
}
|
|
||||||
}, [dailyView]);
|
|
||||||
|
|
||||||
const reorderCheckboxes = useCallback(async (data: ReorderCheckboxesData): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Convertir la date en string format YYYY-MM-DD pour la server action
|
|
||||||
const dailyId = data.date.toISOString().split('T')[0];
|
|
||||||
const result = await reorderCheckboxesAction(dailyId, data.checkboxIds);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error || 'Erreur lors du réordonnancement');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du réordonnancement');
|
|
||||||
console.error('Erreur reorderCheckboxes:', err);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const goToPreviousDay = useCallback(async (): Promise<void> => {
|
|
||||||
const previousDay = new Date(currentDate);
|
|
||||||
previousDay.setDate(previousDay.getDate() - 1);
|
|
||||||
setCurrentDate(previousDay);
|
|
||||||
}, [currentDate]);
|
|
||||||
|
|
||||||
const goToNextDay = useCallback(async (): Promise<void> => {
|
|
||||||
const nextDay = new Date(currentDate);
|
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
|
||||||
setCurrentDate(nextDay);
|
|
||||||
}, [currentDate]);
|
|
||||||
|
|
||||||
const goToToday = useCallback(async (): Promise<void> => {
|
|
||||||
setCurrentDate(new Date());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setDate = useCallback(async (date: Date): Promise<void> => {
|
|
||||||
setCurrentDate(date);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// État pour savoir si c'est le premier chargement
|
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(!initialDailyView);
|
|
||||||
|
|
||||||
// Charger le daily quand la date change
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialLoad) {
|
|
||||||
// Premier chargement : utiliser refreshDaily normal seulement si pas de données SSR
|
|
||||||
if (!initialDailyView) {
|
|
||||||
refreshDaily().finally(() => setIsInitialLoad(false));
|
|
||||||
} else {
|
|
||||||
setIsInitialLoad(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Changements suivants : utiliser refreshDailySilent
|
|
||||||
refreshDailySilent();
|
|
||||||
}
|
|
||||||
}, [refreshDaily, refreshDailySilent, isInitialLoad, initialDailyView]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
dailyView,
|
|
||||||
loading,
|
|
||||||
refreshing,
|
|
||||||
error,
|
|
||||||
saving,
|
|
||||||
isPending,
|
|
||||||
currentDate,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
refreshDaily,
|
|
||||||
refreshDailySilent,
|
|
||||||
addTodayCheckbox,
|
|
||||||
addYesterdayCheckbox,
|
|
||||||
updateCheckbox,
|
|
||||||
deleteCheckbox,
|
|
||||||
toggleCheckbox,
|
|
||||||
toggleAllToday,
|
|
||||||
toggleAllYesterday,
|
|
||||||
reorderCheckboxes,
|
|
||||||
goToPreviousDay,
|
|
||||||
goToNextDay,
|
|
||||||
goToToday,
|
|
||||||
setDate
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook pour l'historique des checkboxes
|
|
||||||
*/
|
|
||||||
export function useDailyHistory() {
|
|
||||||
const [history, setHistory] = useState<{ date: Date; checkboxes: DailyCheckbox[] }[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadHistory = useCallback(async (filters?: DailyHistoryFilters) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const historyData = await dailyClient.getCheckboxHistory(filters);
|
|
||||||
setHistory(historyData);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement de l\'historique');
|
|
||||||
console.error('Erreur loadHistory:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const searchCheckboxes = useCallback(async (filters: DailySearchFilters) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const checkboxes = await dailyClient.searchCheckboxes(filters);
|
|
||||||
// Grouper par date pour l'affichage
|
|
||||||
const groupedHistory = checkboxes.reduce((acc, checkbox) => {
|
|
||||||
const dateKey = checkbox.date.toDateString();
|
|
||||||
const existing = acc.find(item => item.date.toDateString() === dateKey);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.checkboxes.push(checkbox);
|
|
||||||
} else {
|
|
||||||
acc.push({ date: checkbox.date, checkboxes: [checkbox] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, [] as { date: Date; checkboxes: DailyCheckbox[] }[]);
|
|
||||||
|
|
||||||
setHistory(groupedHistory);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la recherche');
|
|
||||||
console.error('Erreur searchCheckboxes:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
history,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
loadHistory,
|
|
||||||
searchCheckboxes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { getAvailableJiraFilters, getFilteredJiraAnalytics } from '@/actions/jira-filters';
|
|
||||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
|
||||||
|
|
||||||
export function useJiraFilters() {
|
|
||||||
const [availableFilters, setAvailableFilters] = useState<AvailableFilters>({
|
|
||||||
components: [],
|
|
||||||
fixVersions: [],
|
|
||||||
issueTypes: [],
|
|
||||||
statuses: [],
|
|
||||||
assignees: [],
|
|
||||||
labels: [],
|
|
||||||
priorities: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const [activeFilters, setActiveFilters] = useState<Partial<JiraAnalyticsFilters>>(
|
|
||||||
JiraAdvancedFiltersService.createEmptyFilters()
|
|
||||||
);
|
|
||||||
|
|
||||||
const [filteredAnalytics, setFilteredAnalytics] = useState<JiraAnalytics | null>(null);
|
|
||||||
const [isLoadingFilters, setIsLoadingFilters] = useState(false);
|
|
||||||
const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Charger les filtres disponibles
|
|
||||||
const loadAvailableFilters = useCallback(async () => {
|
|
||||||
setIsLoadingFilters(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getAvailableJiraFilters();
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setAvailableFilters(result.data);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors du chargement des filtres');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Erreur de connexion');
|
|
||||||
} finally {
|
|
||||||
setIsLoadingFilters(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Appliquer les filtres et récupérer les analytics filtrées
|
|
||||||
const applyFilters = useCallback(async (filters: Partial<JiraAnalyticsFilters>) => {
|
|
||||||
setIsLoadingAnalytics(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getFilteredJiraAnalytics(filters);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setFilteredAnalytics(result.data);
|
|
||||||
setActiveFilters(filters);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors du filtrage');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Erreur de connexion');
|
|
||||||
} finally {
|
|
||||||
setIsLoadingAnalytics(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Effacer tous les filtres
|
|
||||||
const clearFilters = useCallback(() => {
|
|
||||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
|
||||||
setActiveFilters(emptyFilters);
|
|
||||||
setFilteredAnalytics(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Chargement initial des filtres disponibles
|
|
||||||
useEffect(() => {
|
|
||||||
loadAvailableFilters();
|
|
||||||
}, [loadAvailableFilters]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// État
|
|
||||||
availableFilters,
|
|
||||||
activeFilters,
|
|
||||||
filteredAnalytics,
|
|
||||||
isLoadingFilters,
|
|
||||||
isLoadingAnalytics,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadAvailableFilters,
|
|
||||||
applyFilters,
|
|
||||||
clearFilters,
|
|
||||||
|
|
||||||
// Helpers
|
|
||||||
hasActiveFilters: JiraAdvancedFiltersService.hasActiveFilters(activeFilters),
|
|
||||||
activeFiltersCount: JiraAdvancedFiltersService.countActiveFilters(activeFilters),
|
|
||||||
filtersSummary: JiraAdvancedFiltersService.getFiltersSummary(activeFilters)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { tasksClient, TaskFilters, CreateTaskData } from '@/clients/tasks-client';
|
|
||||||
import { updateTaskStatus, createTask as createTaskAction } from '@/actions/tasks';
|
|
||||||
import { Task, TaskStats, TaskStatus } from '@/lib/types';
|
|
||||||
|
|
||||||
interface UseTasksState {
|
|
||||||
tasks: Task[];
|
|
||||||
stats: TaskStats;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
syncing: boolean; // Pour indiquer les opérations optimistes en cours
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseTasksActions {
|
|
||||||
refreshTasks: () => Promise<void>;
|
|
||||||
createTask: (data: CreateTaskData) => Promise<Task | null>;
|
|
||||||
updateTaskOptimistic: (taskId: string, status: TaskStatus) => Promise<Task | null>;
|
|
||||||
setFilters: (filters: TaskFilters) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook pour la gestion des tâches
|
|
||||||
*/
|
|
||||||
export function useTasks(
|
|
||||||
initialFilters?: TaskFilters,
|
|
||||||
initialData?: { tasks: Task[]; stats?: TaskStats }
|
|
||||||
): UseTasksState & UseTasksActions {
|
|
||||||
const [state, setState] = useState<UseTasksState>({
|
|
||||||
tasks: initialData?.tasks || [],
|
|
||||||
stats: initialData?.stats || {
|
|
||||||
total: 0,
|
|
||||||
completed: 0,
|
|
||||||
inProgress: 0,
|
|
||||||
todo: 0,
|
|
||||||
backlog: 0,
|
|
||||||
cancelled: 0,
|
|
||||||
freeze: 0,
|
|
||||||
archived: 0,
|
|
||||||
completionRate: 0
|
|
||||||
},
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
syncing: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState<TaskFilters>(initialFilters || {});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère les tâches depuis l'API
|
|
||||||
*/
|
|
||||||
const refreshTasks = useCallback(async () => {
|
|
||||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await tasksClient.getTasks({ ...filters, limit: undefined });
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
tasks: response.data,
|
|
||||||
stats: response.stats,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crée une nouvelle tâche
|
|
||||||
*/
|
|
||||||
const createTask = useCallback(async (data: CreateTaskData): Promise<Task | null> => {
|
|
||||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await createTaskAction({
|
|
||||||
title: data.title,
|
|
||||||
description: data.description,
|
|
||||||
status: data.status,
|
|
||||||
priority: data.priority,
|
|
||||||
tags: data.tags
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Rafraîchir la liste après création
|
|
||||||
await refreshTasks();
|
|
||||||
return result.data as Task;
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Erreur lors de la création');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur lors de la création'
|
|
||||||
}));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [refreshTasks]);
|
|
||||||
|
|
||||||
// Note: updateTask et deleteTask ont été migrés vers Server Actions
|
|
||||||
// Voir /src/actions/tasks.ts pour updateTaskTitle, updateTaskStatus, deleteTask
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Met à jour le statut d'une tâche de manière optimiste (pour drag & drop)
|
|
||||||
*/
|
|
||||||
const updateTaskOptimistic = useCallback(async (taskId: string, status: TaskStatus): Promise<Task | null> => {
|
|
||||||
// 1. Sauvegarder l'état actuel pour rollback
|
|
||||||
const currentTasks = state.tasks;
|
|
||||||
const taskToUpdate = currentTasks.find(t => t.id === taskId);
|
|
||||||
|
|
||||||
if (!taskToUpdate) {
|
|
||||||
console.error('Tâche non trouvée pour mise à jour optimiste:', taskId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Mise à jour optimiste immédiate de l'état local
|
|
||||||
const updatedTask = { ...taskToUpdate, status };
|
|
||||||
const updatedTasks = currentTasks.map(task =>
|
|
||||||
task.id === taskId ? updatedTask : task
|
|
||||||
);
|
|
||||||
|
|
||||||
// Recalculer les stats
|
|
||||||
const newStats = {
|
|
||||||
total: updatedTasks.length,
|
|
||||||
completed: updatedTasks.filter(t => t.status === 'done').length,
|
|
||||||
inProgress: updatedTasks.filter(t => t.status === 'in_progress').length,
|
|
||||||
todo: updatedTasks.filter(t => t.status === 'todo').length,
|
|
||||||
backlog: updatedTasks.filter(t => t.status === 'backlog').length,
|
|
||||||
cancelled: updatedTasks.filter(t => t.status === 'cancelled').length,
|
|
||||||
freeze: updatedTasks.filter(t => t.status === 'freeze').length,
|
|
||||||
archived: updatedTasks.filter(t => t.status === 'archived').length,
|
|
||||||
completionRate: updatedTasks.length > 0
|
|
||||||
? Math.round((updatedTasks.filter(t => t.status === 'done').length / updatedTasks.length) * 100)
|
|
||||||
: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
tasks: updatedTasks,
|
|
||||||
stats: newStats,
|
|
||||||
error: null,
|
|
||||||
syncing: true // Indiquer qu'une synchronisation est en cours
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. Appel Server Action en arrière-plan
|
|
||||||
try {
|
|
||||||
const result = await updateTaskStatus(taskId, status);
|
|
||||||
|
|
||||||
// Si l'action réussit, la revalidation automatique se charge du reste
|
|
||||||
if (result.success) {
|
|
||||||
setState(prev => ({ ...prev, syncing: false }));
|
|
||||||
return result.data as Task;
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Erreur lors de la mise à jour');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 4. Rollback en cas d'erreur
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
tasks: currentTasks,
|
|
||||||
stats: {
|
|
||||||
total: currentTasks.length,
|
|
||||||
completed: currentTasks.filter(t => t.status === 'done').length,
|
|
||||||
inProgress: currentTasks.filter(t => t.status === 'in_progress').length,
|
|
||||||
todo: currentTasks.filter(t => t.status === 'todo').length,
|
|
||||||
backlog: currentTasks.filter(t => t.status === 'backlog').length,
|
|
||||||
cancelled: currentTasks.filter(t => t.status === 'cancelled').length,
|
|
||||||
freeze: currentTasks.filter(t => t.status === 'freeze').length,
|
|
||||||
archived: currentTasks.filter(t => t.status === 'archived').length,
|
|
||||||
completionRate: currentTasks.length > 0
|
|
||||||
? Math.round((currentTasks.filter(t => t.status === 'done').length / currentTasks.length) * 100)
|
|
||||||
: 0
|
|
||||||
},
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur lors de la mise à jour',
|
|
||||||
syncing: false // Arrêter l'indicateur de synchronisation
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.error('Erreur lors de la mise à jour optimiste:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [state.tasks]);
|
|
||||||
|
|
||||||
// Note: deleteTask a été migré vers Server Actions
|
|
||||||
// Utilisez directement deleteTask depuis /src/actions/tasks.ts dans les composants
|
|
||||||
|
|
||||||
// Charger les tâches au montage seulement si pas de données initiales
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialData?.tasks?.length) {
|
|
||||||
refreshTasks();
|
|
||||||
}
|
|
||||||
}, [refreshTasks, initialData?.tasks?.length]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
refreshTasks,
|
|
||||||
createTask,
|
|
||||||
updateTaskOptimistic,
|
|
||||||
setFilters
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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'],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
8976
package-lock.json
generated
82
package.json
@@ -1,48 +1,92 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"next": "15.5.3",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"emoji-regex": "^10.5.0",
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
|
"mermaid": "^11.12.0",
|
||||||
|
"next": "15.5.7",
|
||||||
|
"next-auth": "^4.24.12",
|
||||||
|
"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",
|
||||||
"sqlite3": "^5.1.7",
|
"rehype-raw": "^7.0.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"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.3.3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@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.7",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"husky": "^9.1.7",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"knip": "^5.71.0",
|
||||||
|
"lint-staged": "^15.5.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^4",
|
"sharp": "^0.34.5",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": ">=0.25.0",
|
||||||
|
"mdast-util-to-hast": ">=13.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,jsx,ts,tsx,json,css,md}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7959
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
prisma/data/dev.db
Normal file
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "folders" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tagId" TEXT,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "folders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "folders_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "folders_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "folders" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AlterTable Note - Add folderId column
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "folderId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "folders_userId_idx" ON "folders"("userId");
|
||||||
|
CREATE INDEX "folders_parentId_idx" ON "folders"("parentId");
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable Note - Add order column
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "isFavorite" BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
BIN
prisma/prisma/dev.db
Normal file
@@ -1,6 +1,3 @@
|
|||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -10,48 +7,83 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Task {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
email String @unique
|
||||||
description String?
|
name String?
|
||||||
status String @default("todo")
|
firstName String?
|
||||||
priority String @default("medium")
|
lastName String?
|
||||||
source String // "reminders" | "jira"
|
avatar String? // URL de l'avatar
|
||||||
sourceId String? // ID dans le système source
|
role String @default("user") // user, admin, etc.
|
||||||
dueDate DateTime?
|
isActive Boolean @default(true)
|
||||||
completedAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
password String // Hashé avec bcrypt
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
// Métadonnées Jira
|
preferences UserPreferences?
|
||||||
jiraProject String?
|
notes Note[]
|
||||||
jiraKey String?
|
|
||||||
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
|
||||||
assignee String?
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
taskTags TaskTag[]
|
|
||||||
dailyCheckboxes DailyCheckbox[]
|
dailyCheckboxes DailyCheckbox[]
|
||||||
|
tasks Task[] @relation("TaskOwner")
|
||||||
|
tags Tag[] @relation("TagOwner")
|
||||||
|
folders Folder[] @relation("FolderOwner")
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
status String @default("todo")
|
||||||
|
priority String @default("medium")
|
||||||
|
source String
|
||||||
|
sourceId String?
|
||||||
|
dueDate DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
jiraProject String?
|
||||||
|
jiraKey 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?
|
||||||
|
tfsProject String?
|
||||||
|
tfsPullRequestId Int?
|
||||||
|
tfsRepository String?
|
||||||
|
tfsSourceBranch String?
|
||||||
|
tfsTargetBranch String?
|
||||||
|
primaryTagId String?
|
||||||
|
primaryTag Tag? @relation("PrimaryTag", fields: [primaryTagId], references: [id])
|
||||||
|
dailyCheckboxes DailyCheckbox[]
|
||||||
|
taskTags TaskTag[]
|
||||||
|
notes Note[] // Notes associées à cette tâche
|
||||||
|
|
||||||
@@unique([source, sourceId])
|
@@unique([source, sourceId])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
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) // Tag pour objectifs principaux
|
isPinned Boolean @default(false)
|
||||||
taskTags TaskTag[]
|
ownerId String // Chaque tag appartient à un utilisateur
|
||||||
|
owner User @relation("TagOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
taskTags TaskTag[]
|
||||||
|
primaryTasks Task[] @relation("PrimaryTag")
|
||||||
|
noteTags NoteTag[]
|
||||||
|
folders Folder[]
|
||||||
|
|
||||||
|
@@unique([name, ownerId]) // Un nom de tag unique par utilisateur
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TaskTag {
|
model TaskTag {
|
||||||
taskId String
|
taskId String
|
||||||
tagId String
|
tagId String
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
|
||||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([taskId, tagId])
|
@@id([taskId, tagId])
|
||||||
@@map("task_tags")
|
@@map("task_tags")
|
||||||
@@ -59,8 +91,8 @@ model TaskTag {
|
|||||||
|
|
||||||
model SyncLog {
|
model SyncLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
source String // "reminders" | "jira"
|
source String
|
||||||
status String // "success" | "error"
|
status String
|
||||||
message String?
|
message String?
|
||||||
tasksSync Int @default(0)
|
tasksSync Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -70,39 +102,83 @@ model SyncLog {
|
|||||||
|
|
||||||
model DailyCheckbox {
|
model DailyCheckbox {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
date DateTime // Date de la checkbox (YYYY-MM-DD)
|
date DateTime
|
||||||
text String // Texte de la checkbox
|
text String
|
||||||
isChecked Boolean @default(false)
|
isChecked Boolean @default(false)
|
||||||
type String @default("task") // "task" | "meeting"
|
type String @default("task")
|
||||||
order Int @default(0) // Ordre d'affichage pour cette date
|
order Int @default(0)
|
||||||
taskId String? // Liaison optionnelle vers une tâche
|
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])
|
||||||
// Relations
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@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
|
||||||
// Filtres Kanban (JSON)
|
kanbanFilters Json?
|
||||||
kanbanFilters Json?
|
viewPreferences Json?
|
||||||
|
|
||||||
// Préférences de vue (JSON)
|
|
||||||
viewPreferences Json?
|
|
||||||
|
|
||||||
// Visibilité des colonnes (JSON)
|
|
||||||
columnVisibility Json?
|
columnVisibility Json?
|
||||||
|
jiraConfig Json?
|
||||||
// Configuration Jira (JSON)
|
jiraAutoSync Boolean @default(false)
|
||||||
jiraConfig Json?
|
jiraSyncInterval String @default("daily")
|
||||||
|
tfsConfig Json?
|
||||||
createdAt DateTime @default(now())
|
tfsAutoSync Boolean @default(false)
|
||||||
updatedAt DateTime @updatedAt
|
tfsSyncInterval String @default("daily")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
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
|
||||||
|
folderId String? // Dossier contenant la note
|
||||||
|
order Int @default(0) // Ordre manuel de la note dans son dossier
|
||||||
|
isFavorite Boolean @default(false) // Note favorite
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
|
folder Folder? @relation(fields: [folderId], references: [id])
|
||||||
|
noteTags NoteTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Folder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
userId String
|
||||||
|
tagId String? // Tag associé au dossier
|
||||||
|
parentId String? // Dossier parent pour sous-dossiers
|
||||||
|
order Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation("FolderOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag? @relation(fields: [tagId], references: [id])
|
||||||
|
parent Folder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Folder[] @relation("FolderHierarchy")
|
||||||
|
notes Note[]
|
||||||
|
|
||||||
|
@@map("folders")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 |