59 Commits

Author SHA1 Message Date
Julien Froidefond
c1a14f9196 feat: enhance JiraDashboardPage with new components and improved UI
- Integrated `PeriodSelector`, `SkeletonGrid`, and `MetricsGrid` for better data visualization and user interaction.
- Replaced legacy period selection and error display with new components for a cleaner UI.
- Updated `UIShowcaseClient` to demonstrate new Jira dashboard components, enhancing showcase functionality.
2025-09-29 16:47:35 +02:00
Julien Froidefond
6c0c353a4e style: update special card colors in globals.css
- Changed `--jira-card` and `--tfs-card` colors to more subtle shades of slate for improved visual consistency.
- Adjusted comments to reflect the new color choices.
2025-09-29 16:25:03 +02:00
Julien Froidefond
3fcada65f6 fix: update todosCount checks and refactor key accomplishments extraction
- Changed todosCount checks in AchievementCard and ChallengeCard to ensure proper handling of undefined values.
- Updated extractKeyAccomplishments method to be asynchronous and count all related todos using Prisma, improving accuracy in task completion metrics.
- Refactored relatedItems and todosCount handling for better clarity and functionality in ManagerSummaryService.
2025-09-29 16:20:35 +02:00
Julien Froidefond
d45a04d347 feat: refactor Daily components and enhance UI integration
- Replaced `DailyCalendar` with a new `Calendar` component for improved functionality and consistency.
- Introduced `AlertBanner` to replace `DeadlineReminder`, providing a more flexible way to display urgent tasks.
- Updated `DailyAddForm` to use new options for task types, enhancing user experience when adding tasks.
- Removed unused state and components, streamlining the DailyPageClient for better performance and maintainability.
- Enhanced `DailySection` to utilize new `CheckboxItem` format for better integration with the UI.
- Cleaned up imports and improved overall structure for better readability.
2025-09-29 09:47:13 +02:00
Julien Froidefond
41fdd0c5b5 style: refine dark theme colors in globals.css
- Updated background and card colors for the dark theme to enhance visual clarity and consistency.
- Adjusted comments for better understanding of color choices.
2025-09-29 08:53:16 +02:00
Julien Froidefond
8d657872c0 refactor: update theme management and enhance UI components
- Refactored theme imports in `preferences.ts` and `ThemeSelector.tsx` to use centralized `theme-config`.
- Added new CSS variables for special cards in `globals.css` to improve theme consistency.
- Enhanced `Header` and `TaskCard` components with theme dropdown functionality for better user experience.
- Updated `ThemeProvider` to support cycling through dark themes, improving theme selection flexibility.
- Cleaned up unused imports and streamlined component structures for better maintainability.
2025-09-29 08:51:20 +02:00
Julien Froidefond
641a009b34 refactor: streamline TaskCard component and enhance UI integration
- Removed unused state and effects in `TaskCard`, simplifying the component structure.
- Integrated `UITaskCard` for improved UI consistency and modularity.
- Updated event handlers for editing and deleting tasks to enhance user interaction.
- Enhanced props handling for better customization and flexibility in task display.
- Improved emoji handling and title editing functionality for a smoother user experience.
2025-09-28 22:36:22 +02:00
Julien Froidefond
687d02ff3a feat: enhance Kanban components with new UI elements
- Added `ColumnHeader`, `EmptyState`, and `DropZone` components to improve the Kanban UI structure and user experience.
- Refactored `KanbanColumn` to utilize the new components, enhancing readability and maintainability.
- Updated `Card` component to support flexible props for shadow, border, and background, allowing for better customization across the application.
- Adjusted `SwimlanesBase` to incorporate the new `ColumnHeader` for consistent column representation.
2025-09-28 22:10:12 +02:00
Julien Froidefond
5a3d825b8e refactor: simplify swimlane mode handling in KanbanFilters
- Removed swimlane mode toggle and dropdown, streamlining the swimlane mode functionality.
- Updated `handleSwimlanesToggle` to cycle through swimlane modes directly, enhancing clarity and usability.
- Cleaned up unused state and refs related to swimlane mode, improving component performance and maintainability.
2025-09-28 21:55:56 +02:00
Julien Froidefond
0fcd4d68c1 feat: unify CardHeader padding across components
- Updated `CardHeader` padding from `pb-3` to `pb-4` in `JiraLogs`, `JiraSync`, `KanbanColumn`, `ObjectivesBoard`, and `DesktopControls` for consistent spacing.
- Refactored `DesktopControls` and `KanbanFilters` to utilize new `ControlPanel`, `ControlSection`, and `ControlGroup` components, enhancing layout structure and maintainability.
- Replaced button elements with `ToggleButton` and `FilterChip` components in various filter sections for improved UI consistency and usability.
2025-09-28 21:53:22 +02:00
Julien Froidefond
bdf8ab9fb4 feat: add new dashboard components and enhance UI
- Introduced new CSS variables for light theme in `globals.css` to improve visual consistency.
- Replaced `Card` component with `StatCard`, `ProgressBar`, and `MetricCard` in `DashboardStats`, `ProductivityAnalytics`, and `RecentTasks` for better modularity and reusability.
- Updated `QuickActions` to use `ActionCard` for a more cohesive design.
- Enhanced `Badge` and `Button` components with new variants for improved styling options.
- Added new UI showcase section in `UIShowcaseClient` to demonstrate the new dashboard components.
2025-09-28 21:22:33 +02:00
Julien Froidefond
0e2eaf1052 feat: improve theme selector and UI components
- Updated `ThemeSelector` to use a new `ThemePreview` component for better theme visualization.
- Refactored button implementation in `ThemeSelector` to utilize the new `Button` component, enhancing consistency.
- Added a UI showcase section in `GeneralSettingsPageClient` to display available UI components with different themes.
- Enhanced `Badge`, `Button`, and `Input` components with new variants and improved styling for better usability and visual appeal.
- Updated CSS variables in `globals.css` for improved contrast and accessibility across themes.
2025-09-28 21:08:48 +02:00
Julien Froidefond
9ef23dbddc feat: enhance theme management and customization options
- Added support for multiple themes (dracula, monokai, nord, gruvbox, tokyo_night, catppuccin, rose_pine, one_dark, material, solarized) in the application.
- Updated `setTheme` function to accept the new `Theme` type, allowing for more flexible theme selection.
- Introduced `ThemeSelector` component in GeneralSettingsPage for user-friendly theme selection.
- Modified `ThemeProvider` to handle user preferred themes and improved theme toggling logic.
- Updated CSS variables in `globals.css` to support new themes, enhancing visual consistency across the app.
2025-09-28 20:47:26 +02:00
Julien Froidefond
7acb2d7e4e Revert "feat: update TODO and enhance design token integration"
This reverts commit aa348a0f82.
2025-09-28 12:10:43 +02:00
Julien Froidefond
aa348a0f82 feat: update TODO and enhance design token integration
- Marked hydration issues and design system tasks as complete in TODO.md, reflecting progress on theme optimization.
- Added documentation for CSS variables in globals.css to guide future color modifications using design tokens.
- Refactored QuickActions component to utilize StatusMessage for better message display and applied design tokens for button styles, improving UI consistency.
2025-09-28 10:21:39 +02:00
Julien Froidefond
b5d6967fcd feat: refactor theme management and enhance color customization
- Cleaned up theme architecture by consolidating CSS variables and removing redundant theme applications, ensuring a single source of truth for theming.
- Implemented a dark mode override and improved color management using CSS variables for better customization.
- Updated various components to utilize new color variables, enhancing maintainability and visual consistency across the application.
- Added detailed tasks in TODO.md for future enhancements related to user preferences and color customization features.
2025-09-28 10:14:25 +02:00
Julien Froidefond
97770917c1 fix: disable hover effect on taskCard
- Removed hover effect on taskCard for improved user experience and consistency in UI interactions.
- Updated TODO_ARCHIVE.md to reflect this change.
2025-09-28 07:30:58 +02:00
Julien Froidefond
58353a0dec feat: refactor service organization and enhance Daily task management
- Restructured service files into dedicated domains (core, analytics, data-management, integrations, task-management) for better organization and maintainability.
- Updated imports across services to reflect new structure, ensuring all references are correct.
- Added new features to the Daily page, including a section for uncompleted tasks, archiving options, and visual indicators for task age, improving task management experience.
2025-09-27 07:17:10 +02:00
Julien Froidefond
986f1732ea fix: update loadPendingTasks logic to include refreshTrigger condition
- Modified the condition in `PendingTasksSection` to reload tasks if `refreshTrigger` changes, ensuring data is refreshed after toggle/delete actions. This improves the accuracy of displayed pending tasks when filters are applied.
2025-09-27 07:12:53 +02:00
Julien Froidefond
b9f801c110 refactor: replace Jira and TFS filters with SourceQuickFilter in Desktop and Mobile controls
- Removed `JiraQuickFilter` and `TfsQuickFilter` components, consolidating functionality into `SourceQuickFilter`.
- Updated UI sections in `DesktopControls` and `MobileControls` to reflect the new filter structure, enhancing maintainability and reducing redundancy.
2025-09-26 15:05:39 +02:00
Julien Froidefond
6fccf20581 fix: clean up FilterBar title and improve useJiraFilters dependencies
- Simplified the title prop in `FilterBar` for better readability.
- Updated dependency array in `useJiraFilters` to include `filterAnalyticsLocally`, ensuring proper effect execution.
- Added new line at the end of `test-jira-fields.ts` and `test-story-points.ts` for consistency.
2025-09-26 14:00:41 +02:00
Julien Froidefond
7de060566f feat: enhance Jira filters and dashboard functionality
- Added new test scripts in `package.json` for story points and Jira fields validation.
- Updated `JiraDashboardPageClient` to utilize raw analytics for filtering, improving data handling with active filters.
- Introduced a loading state in `FilterBar` with visual feedback for filter application, enhancing user experience.
- Refactored `useJiraFilters` to support local filtering based on initial analytics, streamlining filter management.
- Enhanced `JiraAnalyticsService` to calculate story points based on issue types, improving accuracy in analytics.
2025-09-26 11:54:41 +02:00
Julien Froidefond
bd7ede412e feat: add cache monitoring scripts and enhance JiraAnalyticsCache
- Introduced `cache-monitor.ts` for real-time cache monitoring, providing stats and actions for managing Jira analytics cache.
- Updated `package.json` with new cache-related scripts for easy access.
- Enhanced `JiraAnalyticsCacheService` to support TTL for cache entries, automatic cleanup of expired entries, and improved logging for cache operations.
- Added methods for calculating time until expiry and formatting TTL for better visibility.
2025-09-26 11:42:18 +02:00
Julien Froidefond
350dbe6479 feat: enhance JiraDashboard with initial analytics support
- Updated `JiraDashboardPageClient` to accept `initialAnalytics`, allowing for server-side analytics retrieval.
- Modified `useJiraAnalytics` to initialize state with `initialAnalytics`, improving data handling.
- Adjusted `CollaborationMatrix` to manage client-side rendering and analytics data processing, preventing hydration errors.
- Enhanced `page.tsx` to fetch analytics based on Jira configuration, ensuring data is available for the dashboard.
2025-09-26 11:42:08 +02:00
Julien Froidefond
b87fa64d4d feat: implement optimistic UI for checkbox toggling in DailyCheckboxItem
- Added optimistic state handling in `DailyCheckboxItem` for immediate feedback on checkbox toggles, improving user experience.
- Updated `useDaily` hook to handle checkbox state updates without blocking UI, ensuring smoother interactions.
- Enhanced error handling to rollback state on toggle failures, maintaining data integrity.
2025-09-26 11:32:22 +02:00
Julien Froidefond
a01c0d83d0 style: adjust KanbanFilters layout for improved responsiveness
- Modified the grid layout in `KanbanFilters` to use specific column widths, enhancing the overall UI structure.
- This change optimizes the display of filters, ensuring better alignment and usability across different screen sizes.
2025-09-26 11:20:38 +02:00
Julien Froidefond
31541a11d4 refactor: switch from filteredTasks to regularTasks in filter components
- Updated `JiraFilters`, `PriorityFilters`, `TagFilters`, and `TfsFilters` to use `regularTasks` instead of `filteredTasks` for task counts and available options.
- This change ensures that all tasks are considered, improving the accuracy of project and type availability across filters.
- Adjusted related logic and comments for clarity and consistency.
2025-09-26 11:19:07 +02:00
Julien Froidefond
908f39bc5f feat: add project and type counters to Jira and TFS filters
- Implemented `jiraProjectCounts` and `jiraTypeCounts` in `JiraFilters` to display task counts per project and type, enhancing user visibility.
- Added similar functionality with `tfsProjectCounts` in `TfsFilters`, allowing users to see task distribution across TFS projects.
- Updated UI to show these counts next to project and type labels for better context.
2025-09-26 08:57:08 +02:00
Julien Froidefond
0253555fa4 refactor: update filters to use filteredTasks instead of regularTasks
- Modified `JiraFilters`, `PriorityFilters`, `TagFilters`, and `TfsFilters` components to utilize `filteredTasks` for better accuracy in task filtering.
- Adjusted logic to ensure that available projects, types, and counts are based on the currently filtered tasks, enhancing the relevance of displayed options.
2025-09-26 08:54:15 +02:00
Julien Froidefond
2e9cc4e667 feat: add search functionality and due date filter toggle in DesktopControls
- Implemented a debounced search input for filtering tasks, enhancing user experience with smooth input handling.
- Added a toggle button for filtering tasks by due date, improving task visibility options.
- Updated layout for better responsiveness and integrated new input components for a cleaner UI.
2025-09-26 08:44:05 +02:00
Julien Froidefond
a5199a8302 refactor: update Kanban component imports and streamline filters
- Replaced direct imports of `KanbanFilters` with type imports from `@/lib/types` across multiple components for consistency.
- Simplified `KanbanPageClient` by integrating `DesktopControls` for better organization and readability, removing redundant desktop control code.
- Ensured `compactView` is explicitly typed as boolean in relevant components to enhance type safety.
2025-09-26 08:32:07 +02:00
Julien Froidefond
c224c644b1 refactor: remove unused collapse icon from ObjectivesBoard
- Deleted the collapse icon SVG from the ObjectivesBoard component to clean up the code.
- This change simplifies the button layout and improves readability.
2025-09-26 08:32:00 +02:00
Julien Froidefond
65a307c8ac feat: enhance EditCheckboxModal with task tags display
- Updated the task status display to include tags in a flex container for better layout.
- Added logic to show up to 3 tags with a count for additional tags, improving task information visibility.
2025-09-26 08:17:01 +02:00
Julien Froidefond
a3a5be96a2 style: update text color in BackupTimelineChart for better visibility
- Changed error message text color from gray-500 to gray-600 for improved contrast.
- Updated labels in the backup stats section to use gray-700 for better readability in both light and dark modes.
2025-09-26 08:15:25 +02:00
Julien Froidefond
026a175681 feat: enhance RecentTasks component with task link and date formatting
- Wrapped the task updated date in a flex container for better layout.
- Added a link to the Kanban page for each task, allowing users to quickly access task details directly from the RecentTasks component.
2025-09-26 08:09:08 +02:00
Julien Froidefond
4e9d06896d feat: enhance Kanban navigation and task editing
- Updated `KanbanPageClient` to retrieve `taskId` from URL search parameters for direct task editing.
- Modified links in `DailyCheckboxItem` and `PendingTasksSection` to navigate to the Kanban page with the corresponding `taskId`, improving user experience by allowing quick access to task details.
- Added logic in `KanbanBoardContainer` to automatically open the edit modal if a `taskId` is present, streamlining the editing process.
2025-09-25 22:39:21 +02:00
Julien Froidefond
6ca24b9509 fix: clean up unused imports in KanbanFilters and backup
- Removed unused `getToday` import from `backup.ts` to streamline the code.
- Cleaned up imports in `KanbanFilters.tsx` by removing `useMemo`, which was not utilized, enhancing readability.
2025-09-25 22:35:51 +02:00
Julien Froidefond
b0e7a60308 feat: refactor KanbanFilters to use modular filter components
- Replaced inline priority and tag filtering logic with dedicated `PriorityFilters`, `TagFilters`, `GeneralFilters`, and `ColumnFilters` components for better organization and maintainability.
- Optimized layout to enhance responsiveness and user experience by restructuring the filter display into a grid format.
- Removed unused code related to previous filtering logic, streamlining the component.
2025-09-25 22:33:11 +02:00
Julien Froidefond
f2b18e4527 feat: implement backup management features
- Added `createBackupAction`, `verifyDatabaseAction`, and `refreshBackupStatsAction` for handling backup operations.
- Introduced `getBackupStats` method in `BackupClient` to retrieve daily backup statistics.
- Updated API route to support fetching backup stats.
- Integrated backup stats into the `BackupSettingsPage` and visualized them with `BackupTimelineChart`.
- Enhanced `BackupSettingsPageClient` to manage backup stats and actions more effectively.
2025-09-25 22:28:17 +02:00
Julien Froidefond
cd71824cc8 style: refine button styles and layout in KanbanFilters
- Changed button padding and layout from grid to flex for better responsiveness.
- Adjusted gap sizes for a more compact design.
- Ensured consistent styling across priority and tag buttons for improved UI coherence.
2025-09-25 22:10:00 +02:00
Julien Froidefond
551279efcb feat: add due date filter to KanbanFilters
- Introduced `showWithDueDate` option in `KanbanFilters` to filter tasks based on due dates.
- Added toggle button in the UI for users to easily enable/disable this filter.
- Updated `TasksContext` to handle the new filter state and applied filtering logic in task retrieval.
- Ensured user preferences are saved with the new filter option in `user-preferences.ts`.
2025-09-25 21:44:08 +02:00
Julien Froidefond
a870f7f3dc feat: add initial pending tasks support in DailyPage
- Updated `DailyPageClient` to accept and pass `initialPendingTasks` to the `PendingTasksSection`.
- Modified `page.tsx` to fetch pending tasks from the service and handle graceful fallbacks.
- Adjusted `PendingTasksSection` to initialize state with `initialPendingTasks` and prevent unnecessary loading when initial data is present.
2025-09-25 21:36:13 +02:00
Julien Froidefond
0f22ae7019 fix: update task filtering and layout in ObjectivesBoard
- Removed 'freeze' status from in-progress tasks filtering to improve accuracy.
- Added a new column for 'freeze' tasks, enhancing task visibility and organization on the board.
- Adjusted grid layout to accommodate the new column, ensuring a balanced display.
2025-09-25 09:18:16 +02:00
Julien Froidefond
9ec775acbf fix: enhance TaskCard opacity handling for task statuses
- Updated opacity logic in `TaskCard` to include 'archived' status alongside 'done', improving visual feedback for completed tasks.
- Added specific styling for 'freeze' status to differentiate it visually, enhancing user experience and clarity in task representation.
2025-09-25 08:58:33 +02:00
Julien Froidefond
cff9ad10f0 fix: update task status filtering in ObjectivesBoard
- Modified task filtering logic to include 'freeze' status in in-progress tasks and 'archived' status in completed tasks, enhancing task categorization and improving board accuracy.
2025-09-25 08:31:47 +02:00
Julien Froidefond
6db5e2ef00 fix: ensure default search value in KanbanFilters
- Updated `setKanbanFilters` to set a default empty string for the search filter when no value is provided, preventing potential undefined behavior and improving filter consistency.
2025-09-24 14:03:19 +02:00
Julien Froidefond
167f90369b feat: enhance date handling in TaskBasicFields and date-utils
- Integrated `ensureDate` and `formatDateForDateTimeInput` in `TaskBasicFields` for improved due date management.
- Updated `date-utils` functions to accept both `Date` and `string` types, ensuring robust date validation and parsing.
- Added `ensureDate` utility to handle various date inputs, improving error handling and consistency across date-related functions.
2025-09-24 13:53:18 +02:00
Julien Froidefond
75aa60cb83 style: update DeadlineReminder component styles
- Refactored styles in `DeadlineReminder` for improved visual consistency and clarity.
- Changed card structure and applied new background and border colors using CSS color-mix for better aesthetics.
- Simplified text formatting and ensured proper opacity settings for better readability.
2025-09-24 08:22:16 +02:00
Julien Froidefond
ea21df13c7 fix: improve local search synchronization in KanbanFilters
- Added a ref to track user typing state to prevent overwriting local search when filters change externally.
- Ensured local search updates only occur when the user is not actively typing, enhancing user experience and reducing unnecessary updates.
2025-09-24 08:21:56 +02:00
Julien Froidefond
9c8d19fb09 feat: implement debounced search functionality in KanbanFilters
- Added local state for search input to improve user experience with immediate feedback.
- Introduced a debounced search function to optimize filter updates, reducing unnecessary renders.
- Ensured synchronization of local search state with external filter changes and cleaned up timeouts on component unmount.
2025-09-24 08:11:46 +02:00
Julien Froidefond
7ebc0af3c7 feat: expand multi-tenant architecture and role management in TODO
- Updated migration plan to include a complete user model with roles (ADMIN, MANAGER, USER) and hierarchical relationships.
- Added detailed phases for implementing role-based permissions and collaborative features, enhancing user management and task assignment.
- Structured UI/UX considerations for different user roles, ensuring tailored experiences and improved navigation.
2025-09-24 06:13:48 +02:00
Julien Froidefond
11ebe5cd00 refactor: remove unused analytics actions and integrate metrics directly
- Deleted `analytics.ts` and `deadline-analytics.ts` as they were no longer needed.
- Integrated `AnalyticsService` and `DeadlineAnalyticsService` directly into `HomePage` and `DailyPage`, streamlining data fetching.
- Updated components to utilize the new metrics structure, ensuring proper data flow and rendering.
2025-09-23 22:07:52 +02:00
Julien Froidefond
21e1f68921 fix: clean up imports and improve text formatting
- Removed unused `DeadlineMetrics` import from `deadline-analytics.ts`.
- Updated text in `DeadlineReminder` component to use HTML entity for apostrophe, enhancing rendering consistency.
2025-09-23 21:55:02 +02:00
Julien Froidefond
8a227aec36 feat: update analytics services for improved task handling
- Removed unused `parseDate` import from `analytics.ts`.
- Refactored `ManagerSummaryService` to handle standalone todos with a new priority rule, ensuring todos without tasks default to low priority.
- Updated logic in `MetricsService` to calculate total tasks by including in-progress tasks, enhancing completion rate accuracy.
- Adjusted comments for clarity on new functionality and priority determination.
2025-09-23 21:54:55 +02:00
Julien Froidefond
7ac961f6c7 feat: add DeadlineReminder component for urgent task notifications
- Introduced `DeadlineReminder` component to display urgent tasks based on deadlines.
- Integrated the component into `DailyPageClient` for desktop view, enhancing user awareness of critical tasks.
- Implemented logic to fetch and sort urgent tasks by urgency level and remaining days.
2025-09-23 21:52:56 +02:00
Julien Froidefond
34b9aff6e7 fix: light mode : review some styles 2025-09-23 21:36:50 +02:00
Julien Froidefond
fd3827214f feat: update dashboard components and analytics for 7-day summaries
- Modified `ManagerWeeklySummary`, `MetricsTab`, and `ProductivityAnalytics` to reflect a focus on the last 7 days instead of the current week.
- Enhanced `ManagerSummaryService` and `MetricsService` to calculate metrics over a sliding 7-day window, improving data relevance.
- Added a new utility function `formatDistanceToNow` for better date formatting in French.
- Updated comments and documentation to clarify changes in timeframes.
2025-09-23 21:22:59 +02:00
Julien Froidefond
336b5c1006 feat: integrate Jira and TFS filters into KanbanFilters
- Replaced existing Jira and TFS toggle handlers with `JiraFilters` and `TfsFilters` components for improved modularity and maintainability.
- Streamlined filter management by encapsulating logic within dedicated components, enhancing readability and future extensibility.
2025-09-23 20:53:04 +02:00
Julien Froidefond
db8ff88a4c feat: add TFS filters and integration
- Introduced TFS filtering capabilities in `KanbanFilters` with options to show/hide TFS tasks and filter by TFS projects.
- Integrated `TfsQuickFilter` component into `KanbanPageClient` and `MobileControls` for enhanced task management.
- Updated `TasksContext` to support new TFS filter states and ensure proper task filtering based on TFS criteria.
- Enhanced type definitions in `types.ts` to accommodate new TFS filter properties.
2025-09-23 11:07:24 +02:00
133 changed files with 10000 additions and 3008 deletions

View 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.**

411
TODO.md
View File

@@ -1,67 +1,45 @@
# TowerControl v2.0 - Gestionnaire de tâches moderne
## Autre Todos
- [x] Désactiver le hover sur les taskCard
## 🔧 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
## Idées à developper
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut <!-- Diagnostic terminé -->
- [ ] Personnalisation : couleurs
- [ ] Optimisations Perf : requetes DB
- [ ] PWA et mode offline
---
## 🎨 **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
### **Phase 2: Système Couleurs Personnalisées**
- [ ] **Étendre le modèle UserPreferences** pour supporter des couleurs personnalisées
- [ ] **Créer un service de gestion** des couleurs personnalisées
- [ ] **Créer une interface de configuration** des couleurs personnalisées
- [ ] **Implémenter le système CSS** pour les couleurs personnalisées dynamiques
- [ ] **Créer un système de presets** de thèmes (Tech Dark, Corporate Light, etc.)
- [ ] **Ajouter la validation des contrastes** pour les couleurs personnalisées
- [ ] **Permettre export/import** des configurations de thème personnalisées
### **Problèmes identifiés actuellement :**
- ❌ Approche hybride incohérente (CSS Variables + Tailwind `dark:` + classes conditionnelles)
- ❌ Double application du thème (3 endroits différents)
- ❌ Pas de configuration Tailwind pour `darkMode`
- ❌ Hydration mismatch avec flashs
- ❌ CSS Variables mal optimisées (`:root` contient le thème sombre)
- ❌ Couleurs hardcodées dans certains composants
---
## 🚀 Nouvelles idées & fonctionnalités futures
### 🔄 Intégration TFS/Azure DevOps
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
- [x] PR arrivent en backlog avec filtrage par team project
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
- [x] Filtrage par team project, repository, auteur
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
- [x] Refactoriser pour interfaces génériques d'intégration
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
- [x] UI générique de configuration des intégrations
- [x] Système de plugins pour ajouter facilement de nouveaux services
### 📋 Daily - Gestion des tâches non cochées
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
- [x] Liste de toutes les todos non cochées (historique complet)
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
- [x] Indicateurs visuels d'ancienneté (couleurs vert→rouge)
- [x] Actions par tâche : Cocher, Archiver, Supprimer
- [x] **Statut "Archivé" basique** <!-- Implémenté le 21/09/2025 -->
- [x] Marquage textuel [ARCHIVÉ] dans le texte de la tâche
- [x] Interface pour voir les tâches archivées (visuellement distinctes)
- [ ] Possibilité de désarchiver une tâche
- [ ] Champ dédié en base de données (actuellement via texte)
### 🎯 Jira - Suivi des demandes en attente
- [ ] **Page "Jiras en attente"**
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
@@ -72,157 +50,93 @@
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
- [ ] Notifications quand une demande change de statut
### 🏗️ Architecture & technique
- [ ] **Système d'intégrations modulaire**
- [ ] Interface `IntegrationProvider` standardisée
- [ ] Configuration dynamique des intégrations
- [ ] Gestion des credentials par intégration
- [ ] **Modèles de données étendus**
- [ ] `PullRequest` pour TFS/GitHub
- [ ] `PendingRequest` pour les demandes Jira
- [ ] `ArchivedTask` pour les daily archivées
- [ ] **UI générique**
- [ ] Composants réutilisables pour toutes les intégrations
- [ ] Configuration unifiée des filtres et synchronisations
- [ ] Dashboard multi-intégrations
## 🔄 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
- [ ] **Isolation et organisation des types & interfaces**
- [ ] **Analytics types** (`src/services/analytics/types.ts`)
- [ ] Extraire `TaskType`, `CheckboxType` de `manager-summary.ts`
- [ ] Extraire `KeyAccomplishment`, `UpcomingChallenge`, `ManagerSummary` de `manager-summary.ts`
- [ ] Créer `types.ts` centralisé pour le dossier analytics
- [ ] Remplacer tous les imports par `import type { ... } from './types'`
- [ ] **Task Management types** (`src/services/task-management/types.ts`)
- [ ] Analyser quels types spécifiques manquent aux services tasks/tags/daily
- [ ] Créer `types.ts` pour les types métier spécifiques au task-management
- [ ] Uniformiser les imports avec `import type { ... } from './types'`
- [ ] **Jira Integration types** (`src/services/integrations/jira/types.ts`)
- [ ] Extraire `CacheEntry` de `analytics-cache.ts`
- [ ] Créer types spécifiques aux services Jira (configs, cache, anomalies)
- [ ] Centraliser les types d'intégration Jira
- [ ] Uniformiser les imports avec `import type { ... } from './types'`
- [ ] **TFS Integration types** (`src/services/integrations/types.ts`)
- [ ] Analyser les types spécifiques à TFS dans `tfs.ts`
- [ ] Créer types d'intégration TFS si nécessaire
- [ ] Préparer structure extensible pour futures intégrations
- [ ] **Core services types** (`src/services/core/types.ts`)
- [ ] Analyser si des types spécifiques aux services core sont nécessaires
- [ ] Types pour database, system-info, user-preferences
- [ ] **Conversion des imports en `import type`**
- [ ] Analyser tous les imports de types depuis `@/lib/types` dans services
- [ ] Remplacer par `import type { ... } from '@/lib/types'` quand applicable
- [ ] Vérifier que les imports de valeurs restent normaux (sans `type`)
### 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
```
### 👥 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
- **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 ou custom)
- [ ] 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**
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
- [ ] Migration des données existantes vers un utilisateur par défaut
- [ ] Contraintes de base de données pour l'isolation
- [ ] Index sur `userId` pour les performances
- [ ] **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: Services et API**
- [ ] Modifier tous les services pour filtrer par `userId`
- [ ] Middleware d'injection automatique du `userId` dans les requêtes
- [ ] Validation que chaque utilisateur ne voit que ses données
- [ ] API d'administration (optionnel)
- [ ] **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: UI et UX**
- [ ] Header avec profil utilisateur et déconnexion
- [ ] Onboarding pour nouveaux utilisateurs
- [ ] Gestion du profil utilisateur
- [ ] Partage optionnel entre utilisateurs (équipes)
- [ ] **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
@@ -232,4 +146,113 @@ src/services/
---
## 🤖 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.*

View File

@@ -368,4 +368,114 @@ src/
- [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] 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
## 🔄 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)

106
UI_COMPONENTS_GUIDE.md Normal file
View File

@@ -0,0 +1,106 @@
# 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>
```
## 🔄 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

View File

@@ -13,7 +13,13 @@
"backup:config": "npx tsx scripts/backup-manager.ts config",
"backup:start": "npx tsx scripts/backup-manager.ts scheduler-start",
"backup:stop": "npx tsx scripts/backup-manager.ts scheduler-stop",
"backup:status": "npx tsx scripts/backup-manager.ts scheduler-status"
"backup:status": "npx tsx scripts/backup-manager.ts scheduler-status",
"cache:monitor": "npx tsx scripts/cache-monitor.ts",
"cache:stats": "npx tsx scripts/cache-monitor.ts stats",
"cache:cleanup": "npx tsx scripts/cache-monitor.ts cleanup",
"cache:clear": "npx tsx scripts/cache-monitor.ts clear",
"test:story-points": "npx tsx scripts/test-story-points.ts",
"test:jira-fields": "npx tsx scripts/test-jira-fields.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

137
scripts/cache-monitor.ts Normal file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env tsx
/**
* Script de monitoring du cache Jira Analytics
* Usage: npm run cache:monitor
*/
import { jiraAnalyticsCache } from '../src/services/integrations/jira/analytics-cache';
import * as readline from 'readline';
function displayCacheStats() {
console.log('\n📊 === STATISTIQUES DU CACHE JIRA ANALYTICS ===');
const stats = jiraAnalyticsCache.getStats();
console.log(`\n📈 Total des entrées: ${stats.totalEntries}`);
if (stats.projects.length === 0) {
console.log('📭 Aucune donnée en cache');
return;
}
console.log('\n📋 Projets en cache:');
stats.projects.forEach(project => {
const status = project.isExpired ? '❌ EXPIRÉ' : '✅ VALIDE';
console.log(`${project.projectKey}:`);
console.log(` - Âge: ${project.age}`);
console.log(` - TTL: ${project.ttl}`);
console.log(` - Expire dans: ${project.expiresIn}`);
console.log(` - Taille: ${Math.round(project.size / 1024)}KB`);
console.log(` - Statut: ${status}`);
console.log('');
});
}
function displayCacheActions() {
console.log('\n🔧 === ACTIONS DISPONIBLES ===');
console.log('1. Afficher les statistiques');
console.log('2. Forcer le nettoyage');
console.log('3. Invalider tout le cache');
console.log('4. Surveiller en temps réel (Ctrl+C pour arrêter)');
console.log('5. Quitter');
}
async function monitorRealtime() {
console.log('\n👀 Surveillance en temps réel (Ctrl+C pour arrêter)...');
const interval = setInterval(() => {
console.clear();
displayCacheStats();
console.log('\n⏰ Mise à jour toutes les 5 secondes...');
}, 5000);
// Gérer l'arrêt propre
process.on('SIGINT', () => {
clearInterval(interval);
console.log('\n\n👋 Surveillance arrêtée');
process.exit(0);
});
}
async function main() {
console.log('🚀 Cache Monitor Jira Analytics');
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'stats':
displayCacheStats();
break;
case 'cleanup':
console.log('\n🧹 Nettoyage forcé du cache...');
const cleaned = jiraAnalyticsCache.forceCleanup();
console.log(`${cleaned} entrées supprimées`);
break;
case 'clear':
console.log('\n🗑 Invalidation de tout le cache...');
jiraAnalyticsCache.invalidateAll();
console.log('✅ Cache vidé');
break;
case 'monitor':
await monitorRealtime();
break;
default:
displayCacheStats();
displayCacheActions();
// Interface interactive simple
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const askAction = () => {
rl.question('\nChoisissez une action (1-5): ', async (answer: string) => {
switch (answer.trim()) {
case '1':
displayCacheStats();
askAction();
break;
case '2':
const cleaned = jiraAnalyticsCache.forceCleanup();
console.log(`${cleaned} entrées supprimées`);
askAction();
break;
case '3':
jiraAnalyticsCache.invalidateAll();
console.log('✅ Cache vidé');
askAction();
break;
case '4':
rl.close();
await monitorRealtime();
break;
case '5':
console.log('👋 Au revoir !');
rl.close();
process.exit(0);
break;
default:
console.log('❌ Action invalide');
askAction();
}
});
};
askAction();
}
}
// Exécution du script
main().catch(console.error);

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env tsx
/**
* Script pour identifier les champs personnalisés disponibles dans Jira
* Usage: npm run test:jira-fields
*/
import { JiraService } from '../src/services/integrations/jira/jira';
import { userPreferencesService } from '../src/services/core/user-preferences';
async function testJiraFields() {
console.log('🔍 Identification des champs personnalisés Jira\n');
try {
// Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
console.log('❌ Configuration Jira manquante');
return;
}
if (!jiraConfig.projectKey) {
console.log('❌ Aucun projet configuré');
return;
}
console.log(`📋 Analyse du projet: ${jiraConfig.projectKey}`);
// Créer le service Jira
const jiraService = new JiraService(jiraConfig);
// Récupérer un seul ticket pour analyser tous ses champs
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
const issues = await jiraService.searchIssues(jql);
if (issues.length === 0) {
console.log('❌ Aucun ticket trouvé');
return;
}
const firstIssue = issues[0];
console.log(`\n📄 Analyse du ticket: ${firstIssue.key}`);
console.log(`Titre: ${firstIssue.summary}`);
console.log(`Type: ${firstIssue.issuetype.name}`);
// Afficher les story points actuels
console.log(`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`);
console.log('\n💡 Pour identifier le bon champ story points:');
console.log('1. Connectez-vous à votre instance Jira');
console.log('2. Allez dans Administration > Projets > [Votre projet]');
console.log('3. Regardez dans "Champs" ou "Story Points"');
console.log('4. Notez le nom du champ personnalisé (ex: customfield_10003)');
console.log('5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167');
console.log('\n🔧 Champs couramment utilisés pour les story points:');
console.log('• customfield_10002 (par défaut)');
console.log('• customfield_10003');
console.log('• customfield_10004');
console.log('• customfield_10005');
console.log('• customfield_10006');
console.log('• customfield_10007');
console.log('• customfield_10008');
console.log('• customfield_10009');
console.log('• customfield_10010');
console.log('\n📝 Alternative: Utiliser les estimations par type');
console.log('Le système utilise déjà des estimations intelligentes:');
console.log('• Epic: 13 points');
console.log('• Story: 5 points');
console.log('• Task: 3 points');
console.log('• Bug: 2 points');
console.log('• Subtask: 1 point');
} catch (error) {
console.error('❌ Erreur lors du test:', error);
}
}
// Exécution du script
testJiraFields().catch(console.error);

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env tsx
/**
* Script de test pour vérifier la récupération des story points Jira
* Usage: npm run test:story-points
*/
import { JiraService } from '../src/services/integrations/jira/jira';
import { userPreferencesService } from '../src/services/core/user-preferences';
async function testStoryPoints() {
console.log('🧪 Test de récupération des story points Jira\n');
try {
// Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
console.log('❌ Configuration Jira manquante');
return;
}
if (!jiraConfig.projectKey) {
console.log('❌ Aucun projet configuré');
return;
}
console.log(`📋 Test sur le projet: ${jiraConfig.projectKey}`);
// Créer le service Jira
const jiraService = new JiraService(jiraConfig);
// Récupérer quelques tickets pour tester
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
const issues = await jiraService.searchIssues(jql);
console.log(`\n📊 Analyse de ${issues.length} tickets:\n`);
let totalStoryPoints = 0;
let ticketsWithStoryPoints = 0;
let ticketsWithoutStoryPoints = 0;
const storyPointsDistribution: Record<number, number> = {};
const typeDistribution: Record<string, { count: number; totalPoints: number }> = {};
issues.slice(0, 20).forEach((issue, index) => {
const storyPoints = issue.storyPoints || 0;
const issueType = issue.issuetype.name;
console.log(`${index + 1}. ${issue.key} (${issueType})`);
console.log(` Titre: ${issue.summary.substring(0, 50)}...`);
console.log(` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`);
console.log(` Statut: ${issue.status.name}`);
console.log('');
if (storyPoints > 0) {
ticketsWithStoryPoints++;
totalStoryPoints += storyPoints;
storyPointsDistribution[storyPoints] = (storyPointsDistribution[storyPoints] || 0) + 1;
} else {
ticketsWithoutStoryPoints++;
}
// Distribution par type
if (!typeDistribution[issueType]) {
typeDistribution[issueType] = { count: 0, totalPoints: 0 };
}
typeDistribution[issueType].count++;
typeDistribution[issueType].totalPoints += storyPoints;
});
console.log('📈 === RÉSUMÉ ===\n');
console.log(`Total tickets analysés: ${issues.length}`);
console.log(`Tickets avec story points: ${ticketsWithStoryPoints}`);
console.log(`Tickets sans story points: ${ticketsWithoutStoryPoints}`);
console.log(`Total story points: ${totalStoryPoints}`);
console.log(`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`);
console.log('\n📊 Distribution des story points:');
Object.entries(storyPointsDistribution)
.sort(([a], [b]) => parseInt(a) - parseInt(b))
.forEach(([points, count]) => {
console.log(` ${points} points: ${count} tickets`);
});
console.log('\n🏷 Distribution par type:');
Object.entries(typeDistribution)
.sort(([,a], [,b]) => b.count - a.count)
.forEach(([type, stats]) => {
const avgPoints = stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0';
console.log(` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`);
});
if (ticketsWithoutStoryPoints > 0) {
console.log('\n⚠ Recommandations:');
console.log('• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira');
console.log('• Le champ par défaut est "customfield_10002"');
console.log('• Si votre projet utilise un autre champ, modifiez le code dans jira.ts');
console.log('• En attendant, le système utilise des estimations basées sur le type de ticket');
}
} catch (error) {
console.error('❌ Erreur lors du test:', error);
}
}
// Exécution du script
testStoryPoints().catch(console.error);

View File

@@ -1,25 +0,0 @@
'use server';
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics/analytics';
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
success: boolean;
data?: ProductivityMetrics;
error?: string;
}> {
try {
const metrics = await AnalyticsService.getProductivityMetrics(timeRange);
return {
success: true,
data: metrics
};
} catch (error) {
console.error('Erreur lors de la récupération des métriques:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}

60
src/actions/backup.ts Normal file
View File

@@ -0,0 +1,60 @@
'use server';
import { backupService } from '@/services/data-management/backup';
import { revalidatePath } from 'next/cache';
export async function createBackupAction(force: boolean = false) {
try {
const result = await backupService.createBackup('manual', force);
// Invalider le cache de la page pour forcer le rechargement des données SSR
revalidatePath('/settings/backup');
if (result === null) {
return {
success: true,
skipped: true,
message: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.'
};
}
return {
success: true,
data: result,
message: `Sauvegarde créée : ${result.filename}`
};
} catch (error) {
console.error('Failed to create backup:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur lors de la création de la sauvegarde'
};
}
}
export async function verifyDatabaseAction() {
try {
await backupService.verifyDatabaseHealth();
return {
success: true,
message: 'Intégrité vérifiée'
};
} catch (error) {
console.error('Database verification failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Vérification échouée'
};
}
}
export async function refreshBackupStatsAction() {
try {
// Cette action sert juste à revalider le cache
revalidatePath('/settings/backup');
return { success: true };
} catch (error) {
console.error('Failed to refresh backup stats:', error);
return { success: false };
}
}

View File

@@ -2,6 +2,7 @@
import { userPreferencesService } from '@/services/core/user-preferences';
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
import { Theme } from '@/lib/theme-config';
import { revalidatePath } from 'next/cache';
/**
@@ -117,9 +118,9 @@ export async function toggleObjectivesCollapse(): Promise<{
}
/**
* Change le thème (light/dark)
* Change le thème (light/dark/dracula/monokai/nord)
*/
export async function setTheme(theme: 'light' | 'dark'): Promise<{
export async function setTheme(theme: Theme): Promise<{
success: boolean;
error?: string;
}> {

View File

@@ -17,6 +17,16 @@ export async function GET(request: NextRequest) {
});
}
if (action === 'stats') {
const days = parseInt(searchParams.get('days') || '30');
const stats = await backupService.getBackupStats(days);
return NextResponse.json({
success: true,
data: stats
});
}
console.log('🔄 API GET /api/backups called');
// Test de la configuration d'abord

View File

@@ -3,10 +3,12 @@
import { useState, useEffect } from 'react';
import React from 'react';
import { useDaily } from '@/hooks/useDaily';
import { DailyView, DailyCheckboxType } from '@/lib/types';
import { DailyView, DailyCheckboxType, DailyCheckbox } from '@/lib/types';
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { DailyCalendar } from '@/components/daily/DailyCalendar';
import { Calendar } from '@/components/ui/Calendar';
import { AlertBanner, AlertItem } from '@/components/ui/AlertBanner';
import { DailySection } from '@/components/daily/DailySection';
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
import { dailyClient } from '@/clients/daily-client';
@@ -17,12 +19,16 @@ interface DailyPageClientProps {
initialDailyView?: DailyView;
initialDailyDates?: string[];
initialDate?: Date;
initialDeadlineMetrics?: DeadlineMetrics | null;
initialPendingTasks?: DailyCheckbox[];
}
export function DailyPageClient({
initialDailyView,
initialDailyDates = [],
initialDate
initialDate,
initialDeadlineMetrics,
initialPendingTasks = []
}: DailyPageClientProps = {}) {
const {
dailyView,
@@ -47,7 +53,6 @@ export function DailyPageClient({
} = useDaily(initialDate, initialDailyView);
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Fonction pour rafraîchir la liste des dates avec des dailies
const refreshDailyDates = async () => {
@@ -82,14 +87,12 @@ export function DailyPageClient({
const handleToggleCheckbox = async (checkboxId: string) => {
await toggleCheckbox(checkboxId);
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
};
const handleDeleteCheckbox = async (checkboxId: string) => {
await deleteCheckbox(checkboxId);
// Refresh dates après suppression pour mettre à jour le calendrier
await refreshDailyDates();
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
};
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
@@ -104,6 +107,7 @@ export function DailyPageClient({
await reorderCheckboxes({ date, checkboxIds });
};
const getYesterdayDate = () => {
return getPreviousWorkday(currentDate);
};
@@ -136,6 +140,40 @@ export function DailyPageClient({
return `📋 ${formatDateShort(yesterdayDate)}`;
};
// Convertir les métriques de deadline en AlertItem
const convertDeadlineMetricsToAlertItems = (metrics: DeadlineMetrics | null): AlertItem[] => {
if (!metrics) return [];
const urgentTasks = [
...metrics.overdue,
...metrics.critical,
...metrics.warning
].sort((a, b) => {
const urgencyOrder: Record<string, number> = { 'overdue': 0, 'critical': 1, 'warning': 2 };
if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) {
return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
}
return a.daysRemaining - b.daysRemaining;
});
return urgentTasks.map(task => ({
id: task.id,
title: task.title,
icon: task.urgencyLevel === 'overdue' ? '🔴' :
task.urgencyLevel === 'critical' ? '🟠' : '🟡',
urgency: task.urgencyLevel as 'low' | 'medium' | 'high' | 'critical',
source: task.source,
metadata: task.urgencyLevel === 'overdue' ?
(task.daysRemaining === -1 ? 'En retard de 1 jour' : `En retard de ${Math.abs(task.daysRemaining)} jours`) :
task.urgencyLevel === 'critical' ?
(task.daysRemaining === 0 ? 'Échéance aujourd\'hui' :
task.daysRemaining === 1 ? 'Échéance demain' :
`Dans ${task.daysRemaining} jours`) :
`Dans ${task.daysRemaining} jours`
}));
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
@@ -211,8 +249,22 @@ export function DailyPageClient({
</div>
</div>
{/* Rappel des échéances urgentes - Desktop uniquement */}
<div className="hidden sm:block container mx-auto px-4 pt-4 pb-2">
<AlertBanner
title="Rappel - Tâches urgentes"
items={convertDeadlineMetricsToAlertItems(initialDeadlineMetrics || null)}
icon="⚠️"
variant="warning"
onItemClick={(item) => {
// Rediriger vers la page Kanban avec la tâche sélectionnée
window.location.href = `/kanban?taskId=${item.id}`;
}}
/>
</div>
{/* Contenu principal */}
<main className="container mx-auto px-4 py-8">
<main className="container mx-auto px-4 py-6 sm:py-4">
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
<div className="block sm:hidden">
{dailyView && (
@@ -233,10 +285,12 @@ export function DailyPageClient({
/>
{/* Calendrier en bas sur mobile */}
<DailyCalendar
<Calendar
currentDate={currentDate}
onDateSelect={handleDateSelect}
dailyDates={dailyDates}
markedDates={dailyDates}
showTodayButton={true}
showLegend={true}
/>
</div>
)}
@@ -247,10 +301,12 @@ export function DailyPageClient({
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Calendrier - Desktop */}
<div className="xl:col-span-1">
<DailyCalendar
<Calendar
currentDate={currentDate}
onDateSelect={handleDateSelect}
dailyDates={dailyDates}
markedDates={dailyDates}
showTodayButton={true}
showLegend={true}
/>
</div>
@@ -296,7 +352,8 @@ export function DailyPageClient({
onToggleCheckbox={handleToggleCheckbox}
onDeleteCheckbox={handleDeleteCheckbox}
onRefreshDaily={refreshDailySilent}
refreshTrigger={refreshTrigger}
refreshTrigger={0}
initialPendingTasks={initialPendingTasks}
/>
{/* Footer avec stats - dans le flux normal */}

View File

@@ -1,6 +1,7 @@
import { Metadata } from 'next';
import { DailyPageClient } from './DailyPageClient';
import { dailyService } from '@/services/task-management/daily';
import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
import { getToday } from '@/lib/date-utils';
// Force dynamic rendering (no static generation)
@@ -16,9 +17,15 @@ export default async function DailyPage() {
const today = getToday();
try {
const [dailyView, dailyDates] = await Promise.all([
const [dailyView, dailyDates, deadlineMetrics, pendingTasks] = await Promise.all([
dailyService.getDailyView(today),
dailyService.getDailyDates()
dailyService.getDailyDates(),
DeadlineAnalyticsService.getDeadlineMetrics().catch(() => null), // Graceful fallback
dailyService.getPendingCheckboxes({
maxDays: 7,
excludeToday: true,
limit: 50
}).catch(() => []) // Graceful fallback
]);
return (
@@ -26,6 +33,8 @@ export default async function DailyPage() {
initialDailyView={dailyView}
initialDailyDates={dailyDates}
initialDate={today}
initialDeadlineMetrics={deadlineMetrics}
initialPendingTasks={pendingTasks}
/>
);
} catch (error) {

View File

@@ -1,25 +1,7 @@
@import "tailwindcss";
:root {
/* Dark theme (default) */
--background: #1e293b; /* slate-800 - encore plus clair */
--foreground: #f1f5f9; /* slate-100 */
--card: #334155; /* slate-700 - beaucoup plus clair pour contraste fort */
--card-hover: #475569; /* slate-600 */
--card-column: #0f172a; /* slate-900 - plus foncé que les cartes */
--border: #64748b; /* slate-500 - encore plus clair */
--input: #334155; /* slate-700 - plus clair */
--primary: #06b6d4; /* cyan-500 */
--primary-foreground: #f1f5f9; /* slate-100 */
--muted: #64748b; /* slate-500 */
--muted-foreground: #94a3b8; /* slate-400 */
--accent: #f59e0b; /* amber-500 */
--destructive: #ef4444; /* red-500 */
--success: #10b981; /* emerald-500 */
}
.light {
/* Light theme */
/* Valeurs par défaut (Light theme) */
--background: #f1f5f9; /* slate-100 */
--foreground: #0f172a; /* slate-900 */
--card: #ffffff; /* white */
@@ -34,6 +16,404 @@
--accent: #d97706; /* amber-600 */
--destructive: #dc2626; /* red-600 */
--success: #059669; /* emerald-600 */
--purple: #8b5cf6; /* purple-500 */
--yellow: #eab308; /* yellow-500 */
--green: #059669; /* emerald-600 */
--blue: #2563eb; /* blue-600 */
--gray: #6b7280; /* gray-500 */
--gray-light: #e5e7eb; /* gray-200 */
/* Cartes spéciales */
--jira-card: #dbeafe; /* blue-100 - clair */
--tfs-card: #fed7aa; /* orange-200 - clair */
--jira-border: #3b82f6; /* blue-500 */
--tfs-border: #f59e0b; /* amber-500 */
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
}
.light {
/* Light theme explicit */
--background: #f1f5f9; /* slate-100 */
--foreground: #0f172a; /* slate-900 */
--card: #ffffff; /* white */
--card-hover: #f8fafc; /* slate-50 */
--card-column: #f8fafc; /* slate-50 */
--border: #cbd5e1; /* slate-300 */
--input: #ffffff; /* white */
--primary: #0891b2; /* cyan-600 */
--primary-foreground: #ffffff; /* white */
--muted: #94a3b8; /* slate-400 */
--muted-foreground: #64748b; /* slate-500 */
--accent: #d97706; /* amber-600 */
--destructive: #dc2626; /* red-600 */
--success: #059669; /* emerald-600 */
--purple: #8b5cf6; /* purple-500 */
--yellow: #eab308; /* yellow-500 */
--green: #059669; /* emerald-600 */
--blue: #2563eb; /* blue-600 */
--gray: #6b7280; /* gray-500 */
--gray-light: #e5e7eb; /* gray-200 */
/* Cartes spéciales */
--jira-card: #dbeafe; /* blue-100 - clair */
--tfs-card: #fed7aa; /* orange-200 - clair */
--jira-border: #3b82f6; /* blue-500 */
--tfs-border: #f59e0b; /* amber-500 */
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
}
.dark {
/* Dark theme override */
--background: #1e293b; /* slate-800 - background principal */
--foreground: #f1f5f9; /* slate-100 */
--card: #334155; /* slate-700 - plus clair que le background */
--card-hover: #475569; /* slate-600 */
--card-column: #0f172a; /* slate-900 - plus foncé que les cartes */
--border: #64748b; /* slate-500 - encore plus clair */
--input: #334155; /* slate-700 - plus clair */
--primary: #06b6d4; /* cyan-500 */
--primary-foreground: #ffffff; /* white - better contrast with cyan */
--muted: #64748b; /* slate-500 */
--muted-foreground: #94a3b8; /* slate-400 */
--accent: #f59e0b; /* amber-500 */
--destructive: #ef4444; /* red-500 */
--success: #10b981; /* emerald-500 */
--purple: #8b5cf6; /* purple-500 */
--yellow: #eab308; /* yellow-500 */
--green: #10b981; /* emerald-500 */
--blue: #3b82f6; /* blue-500 */
--gray: #9ca3af; /* gray-400 */
--gray-light: #374151; /* gray-700 */
/* Cartes spéciales */
--jira-card: #475569; /* slate-700 - plus subtil */
--tfs-card: #475569; /* slate-600 - plus subtil */
--jira-border: #60a5fa; /* blue-400 - plus clair pour contraste */
--tfs-border: #fb923c; /* orange-400 - plus clair pour contraste */
--jira-text: #93c5fd; /* blue-300 - clair pour contraste */
--tfs-text: #fdba74; /* orange-300 - clair pour contraste */
}
.dracula {
/* Dracula theme */
--background: #282a36; /* dracula background */
--foreground: #f8f8f2; /* dracula foreground */
--card: #44475a; /* dracula current line */
--card-hover: #6272a4; /* dracula comment */
--card-column: #21222c; /* darker background */
--border: #6272a4; /* dracula comment */
--input: #44475a; /* dracula current line */
--primary: #ff79c6; /* dracula pink */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #6272a4; /* dracula comment */
--muted-foreground: #50fa7b; /* dracula green */
--accent: #ffb86c; /* dracula orange */
--destructive: #ff5555; /* dracula red */
--success: #50fa7b; /* dracula green */
--purple: #bd93f9; /* dracula purple */
--yellow: #f1fa8c; /* dracula yellow */
--green: #50fa7b; /* dracula green */
--blue: #8be9fd; /* dracula cyan */
--gray: #6272a4; /* dracula comment */
--gray-light: #44475a; /* dracula current line */
/* Cartes spéciales */
--jira-card: #44475a; /* dracula current line - fond neutre */
--tfs-card: #44475a; /* dracula current line - fond neutre */
--jira-border: #8be9fd; /* dracula cyan */
--tfs-border: #ffb86c; /* dracula orange */
--jira-text: #f8f8f2; /* dracula foreground - texte principal */
--tfs-text: #f8f8f2; /* dracula foreground - texte principal */
}
.monokai {
/* Monokai theme */
--background: #272822; /* monokai background */
--foreground: #f8f8f2; /* monokai foreground */
--card: #3e3d32; /* monokai selection */
--card-hover: #49483e; /* monokai line */
--card-column: #1e1f1c; /* darker background */
--border: #49483e; /* monokai line */
--input: #3e3d32; /* monokai selection */
--primary: #f92672; /* monokai pink */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #75715e; /* monokai comment */
--muted-foreground: #a6e22e; /* monokai green */
--accent: #fd971f; /* monokai orange */
--destructive: #f92672; /* monokai red */
--success: #a6e22e; /* monokai green */
--purple: #ae81ff; /* monokai purple */
--yellow: #e6db74; /* monokai yellow */
--green: #a6e22e; /* monokai green */
--blue: #66d9ef; /* monokai cyan */
--gray: #75715e; /* monokai comment */
--gray-light: #3e3d32; /* monokai selection */
/* Cartes spéciales */
--jira-card: #3e3d32; /* monokai selection - fond neutre */
--tfs-card: #3e3d32; /* monokai selection - fond neutre */
--jira-border: #66d9ef; /* monokai cyan */
--tfs-border: #fd971f; /* monokai orange */
--jira-text: #f8f8f2; /* monokai foreground */
--tfs-text: #f8f8f2; /* monokai foreground */
}
.nord {
/* Nord theme */
--background: #2e3440; /* nord0 */
--foreground: #d8dee9; /* nord4 */
--card: #3b4252; /* nord1 */
--card-hover: #434c5e; /* nord2 */
--card-column: #242831; /* darker nord0 */
--border: #4c566a; /* nord3 */
--input: #3b4252; /* nord1 */
--primary: #88c0d0; /* nord7 */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #4c566a; /* nord3 */
--muted-foreground: #81a1c1; /* nord9 */
--accent: #d08770; /* nord12 */
--destructive: #bf616a; /* nord11 */
--success: #a3be8c; /* nord14 */
--purple: #b48ead; /* nord13 */
--yellow: #ebcb8b; /* nord15 */
--green: #a3be8c; /* nord14 */
--blue: #5e81ac; /* nord10 */
--gray: #4c566a; /* nord3 */
--gray-light: #3b4252; /* nord1 */
/* Cartes spéciales */
--jira-card: #3b4252; /* nord1 - fond neutre */
--tfs-card: #3b4252; /* nord1 - fond neutre */
--jira-border: #5e81ac; /* nord10 - bleu */
--tfs-border: #d08770; /* nord12 - orange */
--jira-text: #d8dee9; /* nord4 - texte principal */
--tfs-text: #d8dee9; /* nord4 - texte principal */
}
.gruvbox {
/* Gruvbox theme */
--background: #282828; /* gruvbox bg0 */
--foreground: #ebdbb2; /* gruvbox fg */
--card: #3c3836; /* gruvbox bg1 */
--card-hover: #504945; /* gruvbox bg2 */
--card-column: #1d2021; /* gruvbox bg0_h */
--border: #665c54; /* gruvbox bg3 */
--input: #3c3836; /* gruvbox bg1 */
--primary: #fe8019; /* gruvbox orange */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #665c54; /* gruvbox bg3 */
--muted-foreground: #a89984; /* gruvbox gray */
--accent: #fabd2f; /* gruvbox yellow */
--destructive: #fb4934; /* gruvbox red */
--success: #b8bb26; /* gruvbox green */
--purple: #d3869b; /* gruvbox purple */
--yellow: #fabd2f; /* gruvbox yellow */
--green: #b8bb26; /* gruvbox green */
--blue: #83a598; /* gruvbox blue */
--gray: #a89984; /* gruvbox gray */
--gray-light: #3c3836; /* gruvbox bg1 */
/* Cartes spéciales */
--jira-card: #3c3836; /* gruvbox bg1 - fond neutre */
--tfs-card: #3c3836; /* gruvbox bg1 - fond neutre */
--jira-border: #83a598; /* gruvbox blue */
--tfs-border: #fe8019; /* gruvbox orange */
--jira-text: #ebdbb2; /* gruvbox fg */
--tfs-text: #ebdbb2; /* gruvbox fg */
}
.tokyo_night {
/* Tokyo Night theme */
--background: #1a1b26; /* tokyo-night bg */
--foreground: #a9b1d6; /* tokyo-night fg */
--card: #24283b; /* tokyo-night bg_highlight */
--card-hover: #2f3349; /* tokyo-night bg_visual */
--card-column: #16161e; /* tokyo-night bg_dark */
--border: #565f89; /* tokyo-night comment */
--input: #24283b; /* tokyo-night bg_highlight */
--primary: #7aa2f7; /* tokyo-night blue */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #565f89; /* tokyo-night comment */
--muted-foreground: #9aa5ce; /* tokyo-night fg_dark */
--accent: #ff9e64; /* tokyo-night orange */
--destructive: #f7768e; /* tokyo-night red */
--success: #9ece6a; /* tokyo-night green */
--purple: #bb9af7; /* tokyo-night purple */
--yellow: #e0af68; /* tokyo-night yellow */
--green: #9ece6a; /* tokyo-night green */
--blue: #7aa2f7; /* tokyo-night blue */
--gray: #565f89; /* tokyo-night comment */
--gray-light: #24283b; /* tokyo-night bg_highlight */
/* Cartes spéciales */
--jira-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
--tfs-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
--jira-border: #7aa2f7; /* tokyo-night blue */
--tfs-border: #ff9e64; /* tokyo-night orange */
--jira-text: #a9b1d6; /* tokyo-night fg */
--tfs-text: #a9b1d6; /* tokyo-night fg */
}
.catppuccin {
/* Catppuccin Mocha theme */
--background: #1e1e2e; /* catppuccin base */
--foreground: #cdd6f4; /* catppuccin text */
--card: #313244; /* catppuccin surface0 */
--card-hover: #45475a; /* catppuccin surface1 */
--card-column: #181825; /* catppuccin mantle */
--border: #6c7086; /* catppuccin overlay0 */
--input: #313244; /* catppuccin surface0 */
--primary: #cba6f7; /* catppuccin mauve */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #6c7086; /* catppuccin overlay0 */
--muted-foreground: #a6adc8; /* catppuccin subtext0 */
--accent: #fab387; /* catppuccin peach */
--destructive: #f38ba8; /* catppuccin red */
--success: #a6e3a1; /* catppuccin green */
--purple: #cba6f7; /* catppuccin mauve */
--yellow: #f9e2af; /* catppuccin yellow */
--green: #a6e3a1; /* catppuccin green */
--blue: #89b4fa; /* catppuccin blue */
--gray: #6c7086; /* catppuccin overlay0 */
--gray-light: #313244; /* catppuccin surface0 */
/* Cartes spéciales */
--jira-card: #313244; /* catppuccin surface0 - fond neutre */
--tfs-card: #313244; /* catppuccin surface0 - fond neutre */
--jira-border: #89b4fa; /* catppuccin blue */
--tfs-border: #fab387; /* catppuccin peach */
--jira-text: #cdd6f4; /* catppuccin text */
--tfs-text: #cdd6f4; /* catppuccin text */
}
.rose_pine {
/* Rose Pine theme */
--background: #191724; /* rose-pine base */
--foreground: #e0def4; /* rose-pine text */
--card: #26233a; /* rose-pine surface */
--card-hover: #312f44; /* rose-pine overlay */
--card-column: #16141f; /* rose-pine base */
--border: #6e6a86; /* rose-pine muted */
--input: #26233a; /* rose-pine surface */
--primary: #c4a7e7; /* rose-pine iris */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #6e6a86; /* rose-pine muted */
--muted-foreground: #908caa; /* rose-pine subtle */
--accent: #f6c177; /* rose-pine gold */
--destructive: #eb6f92; /* rose-pine love */
--success: #9ccfd8; /* rose-pine foam */
--purple: #c4a7e7; /* rose-pine iris */
--yellow: #f6c177; /* rose-pine gold */
--green: #9ccfd8; /* rose-pine foam */
--blue: #3e8fb0; /* rose-pine pine */
--gray: #6e6a86; /* rose-pine muted */
--gray-light: #26233a; /* rose-pine surface */
/* Cartes spéciales */
--jira-card: #26233a; /* rose-pine surface - fond neutre */
--tfs-card: #26233a; /* rose-pine surface - fond neutre */
--jira-border: #3e8fb0; /* rose-pine pine - bleu */
--tfs-border: #f6c177; /* rose-pine gold - orange/jaune */
--jira-text: #e0def4; /* rose-pine text */
--tfs-text: #e0def4; /* rose-pine text */
}
.one_dark {
/* One Dark theme */
--background: #282c34; /* one-dark bg */
--foreground: #abb2bf; /* one-dark fg */
--card: #3e4451; /* one-dark bg1 */
--card-hover: #4f5666; /* one-dark bg2 */
--card-column: #21252b; /* one-dark bg0 */
--border: #5c6370; /* one-dark bg3 */
--input: #3e4451; /* one-dark bg1 */
--primary: #61afef; /* one-dark blue */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #5c6370; /* one-dark bg3 */
--muted-foreground: #828997; /* one-dark gray */
--accent: #e06c75; /* one-dark red */
--destructive: #e06c75; /* one-dark red */
--success: #98c379; /* one-dark green */
--purple: #c678dd; /* one-dark purple */
--yellow: #e5c07b; /* one-dark yellow */
--green: #98c379; /* one-dark green */
--blue: #61afef; /* one-dark blue */
--gray: #5c6370; /* one-dark bg3 */
--gray-light: #3e4451; /* one-dark bg1 */
/* Cartes spéciales */
--jira-card: #3e4451; /* one-dark bg1 - fond neutre */
--tfs-card: #3e4451; /* one-dark bg1 - fond neutre */
--jira-border: #61afef; /* one-dark blue */
--tfs-border: #e5c07b; /* one-dark yellow */
--jira-text: #abb2bf; /* one-dark fg */
--tfs-text: #abb2bf; /* one-dark fg */
}
.material {
/* Material Design Dark theme */
--background: #121212; /* material bg */
--foreground: #ffffff; /* material on-bg */
--card: #1e1e1e; /* material surface */
--card-hover: #2c2c2c; /* material surface-variant */
--card-column: #0f0f0f; /* material surface-container */
--border: #3c3c3c; /* material outline */
--input: #1e1e1e; /* material surface */
--primary: #bb86fc; /* material primary */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #3c3c3c; /* material outline */
--muted-foreground: #b3b3b3; /* material on-surface-variant */
--accent: #ffab40; /* material secondary */
--destructive: #cf6679; /* material error */
--success: #4caf50; /* material success */
--purple: #bb86fc; /* material primary */
--yellow: #ffab40; /* material secondary */
--green: #4caf50; /* material success */
--blue: #2196f3; /* material info */
--gray: #3c3c3c; /* material outline */
--gray-light: #1e1e1e; /* material surface */
/* Cartes spéciales */
--jira-card: #1e1e1e; /* material surface - fond neutre */
--tfs-card: #1e1e1e; /* material surface - fond neutre */
--jira-border: #2196f3; /* material info - bleu */
--tfs-border: #ffab40; /* material secondary - orange */
--jira-text: #ffffff; /* material on-bg */
--tfs-text: #ffffff; /* material on-bg */
}
.solarized {
/* Solarized Dark theme */
--background: #002b36; /* solarized base03 */
--foreground: #93a1a1; /* solarized base1 */
--card: #073642; /* solarized base02 */
--card-hover: #0a4b5a; /* solarized base01 */
--card-column: #001e26; /* solarized base03 darker */
--border: #586e75; /* solarized base01 */
--input: #073642; /* solarized base02 */
--primary: #268bd2; /* solarized blue */
--primary-foreground: #ffffff; /* white for contrast */
--muted: #586e75; /* solarized base01 */
--muted-foreground: #657b83; /* solarized base00 */
--accent: #b58900; /* solarized yellow */
--destructive: #dc322f; /* solarized red */
--success: #859900; /* solarized green */
--purple: #6c71c4; /* solarized violet */
--yellow: #b58900; /* solarized yellow */
--green: #859900; /* solarized green */
--blue: #268bd2; /* solarized blue */
--gray: #586e75; /* solarized base01 */
--gray-light: #073642; /* solarized base02 */
/* Cartes spéciales */
--jira-card: #073642; /* solarized base02 - fond neutre */
--tfs-card: #073642; /* solarized base02 - fond neutre */
--jira-border: #268bd2; /* solarized blue */
--tfs-border: #b58900; /* solarized yellow */
--jira-text: #93a1a1; /* solarized base1 */
--tfs-text: #93a1a1; /* solarized base1 */
}
@theme inline {
@@ -69,10 +449,96 @@ body {
background: var(--primary);
}
/* Outline card styles pour meilleure lisibilité */
.outline-card-blue {
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
border-color: color-mix(in srgb, var(--primary) 25%, var(--border));
}
.outline-card-green {
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--success);
background-color: color-mix(in srgb, var(--success) 8%, transparent);
border-color: color-mix(in srgb, var(--success) 25%, var(--border));
}
.outline-card-orange {
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--accent);
background-color: color-mix(in srgb, var(--accent) 8%, transparent);
border-color: color-mix(in srgb, var(--accent) 25%, var(--border));
}
.outline-card-red {
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--destructive);
background-color: color-mix(in srgb, var(--destructive) 8%, transparent);
border-color: color-mix(in srgb, var(--destructive) 25%, var(--border));
}
.outline-card-purple {
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--purple);
background-color: color-mix(in srgb, var(--purple) 8%, transparent);
border-color: color-mix(in srgb, var(--purple) 25%, var(--border));
}
.outline-card-yellow {
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--yellow);
background-color: color-mix(in srgb, var(--yellow) 8%, transparent);
border-color: color-mix(in srgb, var(--yellow) 25%, var(--border));
}
.outline-card-gray {
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--muted-foreground);
background-color: color-mix(in srgb, var(--muted) 8%, transparent);
border-color: color-mix(in srgb, var(--muted) 25%, var(--border));
}
/* Variantes pour les métriques (padding plus large) */
.outline-metric-blue {
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
border-color: color-mix(in srgb, var(--primary) 25%, var(--border));
}
.outline-metric-green {
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--success);
background-color: color-mix(in srgb, var(--success) 8%, transparent);
border-color: color-mix(in srgb, var(--success) 25%, var(--border));
}
.outline-metric-orange {
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--accent);
background-color: color-mix(in srgb, var(--accent) 8%, transparent);
border-color: color-mix(in srgb, var(--accent) 25%, var(--border));
}
.outline-metric-purple {
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--purple);
background-color: color-mix(in srgb, var(--purple) 8%, transparent);
border-color: color-mix(in srgb, var(--purple) 25%, var(--border));
}
.outline-metric-gray {
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
color: var(--muted-foreground);
background-color: color-mix(in srgb, var(--muted) 8%, transparent);
border-color: color-mix(in srgb, var(--muted) 25%, var(--border));
}
/* Animations tech */
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px rgba(6, 182, 212, 0.3); }
50% { box-shadow: 0 0 20px rgba(6, 182, 212, 0.6); }
0%, 100% { box-shadow: 0 0 5px var(--primary); }
50% { box-shadow: 0 0 20px var(--primary); }
}
.animate-glow {

View File

@@ -1,13 +1,17 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { JiraConfig } from '@/lib/types';
import { JiraConfig, JiraAnalytics } from '@/lib/types';
import { useJiraAnalytics } from '@/hooks/useJiraAnalytics';
import { useJiraExport } from '@/hooks/useJiraExport';
import { filterAnalyticsByPeriod, getPeriodInfo, type PeriodFilter } from '@/lib/jira-period-filter';
import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { PeriodSelector, SkeletonGrid, MetricsGrid } from '@/components/ui';
import { AlertBanner } from '@/components/ui/AlertBanner';
import { Tabs } from '@/components/ui/Tabs';
import { VelocityChart } from '@/components/jira/VelocityChart';
import { TeamDistributionChart } from '@/components/jira/TeamDistributionChart';
import { CycleTimeChart } from '@/components/jira/CycleTimeChart';
@@ -28,10 +32,11 @@ import Link from 'next/link';
interface JiraDashboardPageClientProps {
initialJiraConfig: JiraConfig;
initialAnalytics?: JiraAnalytics | null;
}
export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) {
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics();
export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }: JiraDashboardPageClientProps) {
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(initialAnalytics);
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport();
const {
availableFilters,
@@ -39,7 +44,7 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
filteredAnalytics,
applyFilters,
hasActiveFilters
} = useJiraFilters();
} = useJiraFilters(rawAnalytics);
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null);
const [showSprintModal, setShowSprintModal] = useState(false);
@@ -47,6 +52,9 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
// Filtrer les analytics selon la période sélectionnée et les filtres avancés
const analytics = useMemo(() => {
// Si on a des filtres actifs ET des analytics filtrées, utiliser celles-ci
// Sinon utiliser les analytics brutes
// Si on est en train de charger les filtres, garder les données originales
const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
if (!baseAnalytics) return null;
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
@@ -56,11 +64,11 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
const periodInfo = getPeriodInfo(selectedPeriod);
useEffect(() => {
// Charger les analytics au montage si Jira est configuré avec un projet
if (initialJiraConfig.enabled && initialJiraConfig.projectKey) {
// Charger les analytics au montage seulement si Jira est configuré ET qu'on n'a pas déjà des données
if (initialJiraConfig.enabled && initialJiraConfig.projectKey && !initialAnalytics) {
loadAnalytics();
}
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics]);
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics, initialAnalytics]);
// Gestion du clic sur un sprint
const handleSprintClick = (sprint: SprintVelocity) => {
@@ -192,26 +200,16 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<div className="flex items-center gap-3">
{/* Sélecteur de période */}
<div className="flex bg-[var(--card)] border border-[var(--border)] rounded-lg p-1">
{[
<PeriodSelector
options={[
{ value: '7d', label: '7j' },
{ value: '30d', label: '30j' },
{ value: '3m', label: '3m' },
{ value: 'current', label: 'Sprint' }
].map((period: { value: string; label: string }) => (
<button
key={period.value}
onClick={() => setSelectedPeriod(period.value as PeriodFilter)}
className={`px-3 py-1 text-sm rounded transition-all ${
selectedPeriod === period.value
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
{period.label}
</button>
))}
</div>
]}
selectedValue={selectedPeriod}
onValueChange={(value) => setSelectedPeriod(value as PeriodFilter)}
/>
<div className="flex items-center gap-2">
{analytics && (
@@ -255,40 +253,27 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
{/* Contenu principal */}
{error && (
<Card className="mb-6 border-red-500/20 bg-red-500/10">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<span></span>
<span>{error}</span>
</div>
</CardContent>
</Card>
<AlertBanner
title="Erreur"
items={[{ id: 'error', title: error }]}
icon="❌"
variant="error"
className="mb-6"
/>
)}
{exportError && (
<Card className="mb-6 border-orange-500/20 bg-orange-500/10">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
<span></span>
<span>Erreur d&apos;export: {exportError}</span>
</div>
</CardContent>
</Card>
<AlertBanner
title="Erreur d'export"
items={[{ id: 'export-error', title: exportError }]}
icon="⚠️"
variant="warning"
className="mb-6"
/>
)}
{isLoading && !analytics && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Skeleton loading */}
{[1, 2, 3, 4, 5, 6].map(i => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-4 bg-[var(--muted)] rounded mb-4"></div>
<div className="h-8 bg-[var(--muted)] rounded mb-2"></div>
<div className="h-4 bg-[var(--muted)] rounded w-2/3"></div>
</CardContent>
</Card>
))}
</div>
<SkeletonGrid count={6} />
)}
{analytics && (
@@ -302,41 +287,36 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<span className="text-sm font-normal text-[var(--muted-foreground)]">
({periodInfo.label})
</span>
{hasActiveFilters && (
<Badge className="bg-purple-100 text-purple-800 text-xs">
🔍 Filtré
</Badge>
)}
</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-xl font-bold text-[var(--primary)]">
{analytics.project.totalIssues}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Tickets
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-blue-500">
{analytics.teamMetrics.totalAssignees}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Équipe
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-green-500">
{analytics.teamMetrics.activeAssignees}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Actifs
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-orange-500">
{analytics.velocityMetrics.currentSprintPoints}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Points
</div>
</div>
</div>
<MetricsGrid
metrics={[
{
title: 'Tickets',
value: analytics.project.totalIssues,
color: 'primary'
},
{
title: 'Équipe',
value: analytics.teamMetrics.totalAssignees,
color: 'default'
},
{
title: 'Actifs',
value: analytics.teamMetrics.activeAssignees,
color: 'success'
},
{
title: 'Points',
value: analytics.velocityMetrics.currentSprintPoints,
color: 'warning'
}
]}
/>
</div>
</CardHeader>
</Card>
@@ -346,34 +326,23 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
availableFilters={availableFilters}
activeFilters={activeFilters}
onFiltersChange={applyFilters}
isLoading={false}
/>
{/* Détection d'anomalies */}
<AnomalyDetectionPanel />
{/* Onglets de navigation */}
<div className="border-b border-[var(--border)]">
<nav className="flex space-x-8">
{[
{ id: 'overview', label: '📊 Vue d\'ensemble' },
{ id: 'velocity', label: '🚀 Vélocité & Sprints' },
{ id: 'analytics', label: '📈 Analytics avancées' },
{ id: 'quality', label: '🎯 Qualité & Collaboration' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as 'overview' | 'velocity' | 'analytics' | 'quality')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:border-[var(--border)]'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<Tabs
items={[
{ id: 'overview', label: '📊 Vue d\'ensemble' },
{ id: 'velocity', label: '🚀 Vélocité & Sprints' },
{ id: 'analytics', label: '📈 Analytics avancées' },
{ id: 'quality', label: '🎯 Qualité & Collaboration' }
]}
activeTab={activeTab}
onTabChange={(tabId) => setActiveTab(tabId as 'overview' | 'velocity' | 'analytics' | 'quality')}
/>
{/* Contenu des onglets */}
{activeTab === 'overview' && (

View File

@@ -1,4 +1,5 @@
import { userPreferencesService } from '@/services/core/user-preferences';
import { getJiraAnalytics } from '@/actions/jira-analytics';
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
// Force dynamic rendering
@@ -7,8 +8,20 @@ export const dynamic = 'force-dynamic';
export default async function JiraDashboardPage() {
// Récupérer la config Jira côté serveur
const jiraConfig = await userPreferencesService.getJiraConfig();
// Récupérer les analytics côté serveur (utilise le cache du service)
let initialAnalytics = null;
if (jiraConfig.enabled && jiraConfig.projectKey) {
const analyticsResult = await getJiraAnalytics(false); // Utilise le cache
if (analyticsResult.success) {
initialAnalytics = analyticsResult.data;
}
}
return (
<JiraDashboardPageClient initialJiraConfig={jiraConfig} />
<JiraDashboardPageClient
initialJiraConfig={jiraConfig}
initialAnalytics={initialAnalytics}
/>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
import { Header } from '@/components/ui/Header';
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
@@ -8,10 +9,8 @@ import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { Task, Tag } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
import { Button } from '@/components/ui/Button';
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
import { MobileControls } from '@/components/kanban/MobileControls';
import { DesktopControls } from '@/components/kanban/DesktopControls';
import { useIsMobile } from '@/hooks/useIsMobile';
interface KanbanPageClientProps {
@@ -24,6 +23,8 @@ function KanbanPageContent() {
const { preferences, updateViewPreferences } = useUserPreferences();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const isMobile = useIsMobile(768); // Tailwind md breakpoint
const searchParams = useSearchParams();
const taskIdFromUrl = searchParams.get('taskId');
// Extraire les préférences du context
const showFilters = preferences.viewPreferences.showFilters;
@@ -77,112 +78,27 @@ function KanbanPageContent() {
onCreateTask={() => setIsCreateModalOpen(true)}
/>
) : (
/* Barre de contrôles desktop */
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
<div className="container mx-auto px-6 py-2">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<button
onClick={handleToggleFilters}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
showFilters
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
Filtres{activeFiltersCount > 0 && ` (${activeFiltersCount})`}
</button>
<button
onClick={handleToggleObjectives}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
showObjectives
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--accent)]/50'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
Objectifs
</button>
</div>
<div className="flex items-center gap-2 border-l border-[var(--border)] pl-4">
{/* Raccourcis Jira */}
<JiraQuickFilter
filters={kanbanFilters}
onFiltersChange={setKanbanFilters}
/>
<button
onClick={handleToggleCompactView}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
compactView
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--secondary)]/50'
}`}
title={compactView ? "Vue détaillée" : "Vue compacte"}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{compactView ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
{compactView ? 'Détaillée' : 'Compacte'}
</button>
<button
onClick={handleToggleSwimlanes}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
swimlanesByTags
? 'bg-[var(--warning)]/20 text-[var(--warning)] border border-[var(--warning)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--warning)]/50'
}`}
title={swimlanesByTags ? "Vue standard" : "Vue swimlanes"}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{swimlanesByTags ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14-7H5m14 14H5" />
)}
</svg>
{swimlanesByTags ? 'Standard' : 'Swimlanes'}
</button>
{/* Font Size Toggle */}
<FontSizeToggle />
</div>
</div>
{/* Bouton d'ajout de tâche */}
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
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="M12 4v16m8-8H4" />
</svg>
Nouvelle tâche
</Button>
</div>
</div>
</div>
<DesktopControls
showFilters={showFilters}
showObjectives={showObjectives}
compactView={compactView}
swimlanesByTags={swimlanesByTags}
activeFiltersCount={activeFiltersCount}
kanbanFilters={kanbanFilters}
onToggleFilters={handleToggleFilters}
onToggleObjectives={handleToggleObjectives}
onToggleCompactView={handleToggleCompactView}
onToggleSwimlanes={handleToggleSwimlanes}
onFiltersChange={setKanbanFilters}
onCreateTask={() => setIsCreateModalOpen(true)}
/>
)}
<main className="h-[calc(100vh-160px)]">
<KanbanBoardContainer
showFilters={showFilters}
showObjectives={showObjectives}
initialTaskIdToEdit={taskIdFromUrl}
/>
</main>

View File

@@ -5,6 +5,7 @@ import { ThemeProvider } from "@/contexts/ThemeContext";
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
import { userPreferencesService } from "@/services/core/user-preferences";
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -30,11 +31,15 @@ export default async function RootLayout({
const initialPreferences = await userPreferencesService.getAllPreferences();
return (
<html lang="en" className={initialPreferences.viewPreferences.theme}>
<html lang="fr">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
<ThemeProvider
initialTheme={initialPreferences.viewPreferences.theme}
userPreferredTheme={initialPreferences.viewPreferences.theme === 'light' ? 'dark' : initialPreferences.viewPreferences.theme}
>
<KeyboardShortcuts />
<JiraConfigProvider config={initialPreferences.jiraConfig}>
<UserPreferencesProvider initialPreferences={initialPreferences}>
{children}

View File

@@ -1,5 +1,7 @@
import { tasksService } from '@/services/task-management/tasks';
import { tagsService } from '@/services/task-management/tags';
import { AnalyticsService } from '@/services/analytics/analytics';
import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
import { HomePageClient } from '@/components/HomePageClient';
// Force dynamic rendering (no static generation)
@@ -7,10 +9,12 @@ export const dynamic = 'force-dynamic';
export default async function HomePage() {
// SSR - Récupération des données côté serveur
const [initialTasks, initialTags, initialStats] = await Promise.all([
const [initialTasks, initialTags, initialStats, productivityMetrics, deadlineMetrics] = await Promise.all([
tasksService.getTasks(),
tagsService.getTags(),
tasksService.getTaskStats()
tasksService.getTaskStats(),
AnalyticsService.getProductivityMetrics(),
DeadlineAnalyticsService.getDeadlineMetrics()
]);
return (
@@ -18,6 +22,8 @@ export default async function HomePage() {
initialTasks={initialTasks}
initialTags={initialTags}
initialStats={initialStats}
productivityMetrics={productivityMetrics}
deadlineMetrics={deadlineMetrics}
/>
);
}

View File

@@ -10,6 +10,7 @@ export default async function BackupSettingsPage() {
const backups = await backupService.listBackups();
const schedulerStatus = backupScheduler.getStatus();
const config = backupService.getConfig();
const backupStats = await backupService.getBackupStats(30);
const initialData = {
backups,
@@ -18,6 +19,7 @@ export default async function BackupSettingsPage() {
nextBackup: schedulerStatus.nextBackup ? schedulerStatus.nextBackup.toISOString() : null,
},
config,
backupStats,
};
return (

View File

@@ -0,0 +1,5 @@
import { UIShowcaseClient } from '@/components/ui-showcase/UIShowcaseClient';
export default function UIShowcasePage() {
return <UIShowcaseClient />;
}

View File

@@ -109,6 +109,24 @@ export class BackupClient {
const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`);
return response.data.logs;
}
/**
* Récupère les statistiques de sauvegarde par jour
*/
async getBackupStats(days: number = 30): Promise<Array<{
date: string;
manual: number;
automatic: number;
total: number;
}>> {
const response = await httpClient.get<{ data: Array<{
date: string;
manual: number;
automatic: number;
total: number;
}> }>(`${this.baseUrl}?action=stats&days=${days}`);
return response.data;
}
}
export const backupClient = new BackupClient();

View File

@@ -8,15 +8,22 @@ import { DashboardStats } from '@/components/dashboard/DashboardStats';
import { QuickActions } from '@/components/dashboard/QuickActions';
import { RecentTasks } from '@/components/dashboard/RecentTasks';
import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics';
import { ProductivityMetrics } from '@/services/analytics/analytics';
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
interface HomePageClientProps {
initialTasks: Task[];
initialTags: (Tag & { usage: number })[];
initialStats: TaskStats;
productivityMetrics: ProductivityMetrics;
deadlineMetrics: DeadlineMetrics;
}
function HomePageContent() {
function HomePageContent({ productivityMetrics, deadlineMetrics }: {
productivityMetrics: ProductivityMetrics;
deadlineMetrics: DeadlineMetrics;
}) {
const { stats, syncing, createTask, tasks } = useTasksContext();
// Handler pour la création de tâche
@@ -40,7 +47,10 @@ function HomePageContent() {
<QuickActions onCreateTask={handleCreateTask} />
{/* Analytics et métriques */}
<ProductivityAnalytics />
<ProductivityAnalytics
metrics={productivityMetrics}
deadlineMetrics={deadlineMetrics}
/>
{/* Tâches récentes */}
<RecentTasks tasks={tasks} />
@@ -49,14 +59,23 @@ function HomePageContent() {
);
}
export function HomePageClient({ initialTasks, initialTags, initialStats }: HomePageClientProps) {
export function HomePageClient({
initialTasks,
initialTags,
initialStats,
productivityMetrics,
deadlineMetrics
}: HomePageClientProps) {
return (
<TasksProvider
initialTasks={initialTasks}
initialTags={initialTags}
initialStats={initialStats}
>
<HomePageContent />
<HomePageContent
productivityMetrics={productivityMetrics}
deadlineMetrics={deadlineMetrics}
/>
</TasksProvider>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
export function KeyboardShortcuts() {
useKeyboardShortcuts();
return null; // Ce composant ne rend rien, il gère juste les raccourcis
}

View File

@@ -0,0 +1,116 @@
'use client';
import { useTheme } from '@/contexts/ThemeContext';
import { Theme } from '@/lib/theme-config';
import { Button } from '@/components/ui/Button';
import { THEME_CONFIG, getThemeMetadata } from '@/lib/theme-config';
// Génération des thèmes à partir de la configuration centralisée
const themes: { id: Theme; name: string; description: string }[] = THEME_CONFIG.allThemes.map(themeId => {
const metadata = getThemeMetadata(themeId);
return {
id: themeId,
name: metadata.name,
description: metadata.description
};
});
// Composant pour l'aperçu du thème
function ThemePreview({ themeId, isSelected }: { themeId: Theme; isSelected: boolean }) {
return (
<div
className={`w-16 h-12 rounded-lg border-2 overflow-hidden ${themeId}`}
style={{
borderColor: isSelected ? 'var(--primary)' : 'var(--border)',
backgroundColor: 'var(--background)'
}}
>
{/* Barre de titre */}
<div
className="h-3 w-full"
style={{ backgroundColor: 'var(--card)' }}
/>
{/* Contenu avec couleurs du thème */}
<div className="p-1 h-9 flex flex-col gap-0.5">
{/* Ligne de texte */}
<div
className="h-1 rounded-sm"
style={{ backgroundColor: 'var(--foreground)' }}
/>
{/* Couleurs d'accent */}
<div className="flex gap-0.5">
<div
className="h-1 flex-1 rounded-sm"
style={{ backgroundColor: 'var(--primary)' }}
/>
<div
className="h-1 flex-1 rounded-sm"
style={{ backgroundColor: 'var(--accent)' }}
/>
<div
className="h-1 flex-1 rounded-sm"
style={{ backgroundColor: 'var(--success)' }}
/>
</div>
</div>
</div>
);
}
export function ThemeSelector() {
const { theme, setTheme } = useTheme();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-mono font-semibold text-[var(--foreground)]">Thème de l&apos;interface</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
Choisissez l&apos;apparence de TowerControl
</p>
</div>
<div className="text-sm text-[var(--muted-foreground)]">
Actuel: <span className="font-medium text-[var(--primary)] capitalize">{theme}</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{themes.map((themeOption) => (
<Button
key={themeOption.id}
onClick={() => setTheme(themeOption.id)}
variant={theme === themeOption.id ? 'selected' : 'secondary'}
className="p-4 h-auto text-left justify-start"
>
<div className="flex items-start gap-3">
{/* Aperçu du thème */}
<div className="flex-shrink-0">
<ThemePreview
themeId={themeOption.id}
isSelected={theme === themeOption.id}
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-[var(--foreground)] mb-1">
{themeOption.name}
</div>
<div className="text-xs text-[var(--muted-foreground)] leading-relaxed">
{themeOption.description}
</div>
{theme === themeOption.id && (
<div className="mt-2 text-xs text-[var(--primary)] font-medium">
Sélectionné
</div>
)}
</div>
</div>
</Button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
interface BackupStats {
date: string;
manual: number;
automatic: number;
total: number;
}
interface BackupTimelineChartProps {
stats?: BackupStats[];
className?: string;
}
export function BackupTimelineChart({ stats = [], className = '' }: BackupTimelineChartProps) {
// Protection contre les stats non-array
const safeStats = Array.isArray(stats) ? stats : [];
const error = safeStats.length === 0 ? 'Aucune donnée disponible' : null;
// Convertir les stats en map pour accès rapide
const statsMap = new Map(safeStats.map(s => [s.date, s]));
// Générer les 30 derniers jours
const days = Array.from({ length: 30 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (29 - i));
// Utiliser la date locale pour éviter les décalages UTC
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().split('T')[0];
});
// Organiser en semaines (5 semaines de 6 jours + quelques jours)
const weeks = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
const formatDateFull = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
};
if (error) {
return (
<div className={`p-4 sm:p-6 ${className}`}>
<div className="text-gray-500 text-sm text-center py-8">
{error}
</div>
</div>
);
}
return (
<div className={`p-4 sm:p-6 w-full ${className}`}>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
💾 Activité de sauvegarde (30 derniers jours)
</h3>
{/* Vue en ligne avec indicateurs clairs */}
<div className="mb-6">
{/* En-têtes des jours */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => (
<div key={day} className="text-xs text-center text-gray-500 font-medium py-1">
{day}
</div>
))}
</div>
{/* Grille des jours avec indicateurs visuels */}
<div className="space-y-1">
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="grid grid-cols-7 gap-1">
{week.map((day) => {
const stat = statsMap.get(day) || { date: day, manual: 0, automatic: 0, total: 0 };
const hasManual = stat.manual > 0;
const hasAuto = stat.automatic > 0;
const dayNumber = new Date(day).getDate();
return (
<div key={day} className="group relative">
<div className={`
relative h-8 rounded border-2 transition-all duration-200 cursor-pointer flex items-center justify-center text-xs font-medium
${stat.total === 0
? 'border-[var(--border)] text-[var(--muted-foreground)]'
: 'border-transparent'
}
`}>
{/* Jour du mois */}
<span className={`relative z-10 ${stat.total > 0 ? 'text-white font-bold' : ''}`}>
{dayNumber}
</span>
{/* Fond selon le type */}
{stat.total > 0 && (
<div className={`
absolute inset-0 rounded
${hasManual && hasAuto
? 'bg-gradient-to-br from-blue-500 to-green-500'
: hasManual
? 'bg-blue-500'
: 'bg-green-500'
}
`}></div>
)}
{/* Indicateurs visuels pour l'intensité */}
{stat.total > 0 && stat.total > 1 && (
<div className="absolute -top-1 -right-1 bg-orange-500 text-white rounded-full w-4 h-4 flex items-center justify-center text-xs font-bold">
{stat.total > 9 ? '9+' : stat.total}
</div>
)}
</div>
{/* Tooltip détaillé */}
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-20">
<div className="font-semibold">{formatDateFull(day)}</div>
{stat.total > 0 ? (
<div className="mt-1 space-y-1">
{stat.manual > 0 && (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-400 rounded-full"></div>
<span>Manuel: {stat.manual}</span>
</div>
)}
{stat.automatic > 0 && (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span>Auto: {stat.automatic}</span>
</div>
)}
<div className="font-semibold border-t border-gray-600 pt-1">
Total: {stat.total}
</div>
</div>
) : (
<div className="text-gray-300 mt-1">Aucune sauvegarde</div>
)}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
{/* Légende claire */}
<div className="mb-6 p-3 rounded-lg" style={{ backgroundColor: 'var(--card-hover)' }}>
<h4 className="text-sm font-medium mb-3 text-[var(--foreground)]">Légende</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div>
<span className="text-[var(--foreground)]">Manuel seul</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div>
<span className="text-[var(--foreground)]">Auto seul</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gradient-to-br from-blue-500 to-green-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div>
<span className="text-[var(--foreground)]">Manuel + Auto</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 border-2 rounded flex items-center justify-center text-xs" style={{ backgroundColor: 'var(--gray-light)', borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>15</div>
<span className="text-[var(--foreground)]">Aucune</span>
</div>
</div>
</div>
<div className="mt-3 text-xs text-[var(--muted-foreground)]">
💡 Le badge orange indique le nombre total quand &gt; 1
</div>
</div>
{/* Statistiques résumées */}
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--blue) 10%, transparent)' }}>
<div className="text-xl font-bold" style={{ color: 'var(--blue)' }}>
{safeStats.reduce((sum, s) => sum + s.manual, 0)}
</div>
<div className="text-xs font-medium" style={{ color: 'var(--blue)' }}>Manuelles</div>
</div>
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 10%, transparent)' }}>
<div className="text-xl font-bold" style={{ color: 'var(--green)' }}>
{safeStats.reduce((sum, s) => sum + s.automatic, 0)}
</div>
<div className="text-xs font-medium" style={{ color: 'var(--green)' }}>Automatiques</div>
</div>
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--purple) 10%, transparent)' }}>
<div className="text-xl font-bold" style={{ color: 'var(--purple)' }}>
{safeStats.reduce((sum, s) => sum + s.total, 0)}
</div>
<div className="text-xs font-medium" style={{ color: 'var(--purple)' }}>Total</div>
</div>
</div>
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
import { Input } from '@/components/ui/Input';
@@ -24,6 +24,36 @@ export function DailyCheckboxItem({
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
const [inlineEditingText, setInlineEditingText] = useState('');
const [editingCheckbox, setEditingCheckbox] = useState<DailyCheckbox | null>(null);
const [optimisticChecked, setOptimisticChecked] = useState<boolean | null>(null);
// État optimiste local pour une réponse immédiate
const isChecked = optimisticChecked !== null ? optimisticChecked : checkbox.isChecked;
// Synchroniser l'état optimiste avec les changements externes
useEffect(() => {
if (optimisticChecked !== null && optimisticChecked === checkbox.isChecked) {
// L'état serveur a été mis à jour, on peut reset l'optimiste
setOptimisticChecked(null);
}
}, [checkbox.isChecked, optimisticChecked]);
// Handler optimiste pour le toggle
const handleOptimisticToggle = async () => {
const newCheckedState = !isChecked;
// Mise à jour optimiste immédiate
setOptimisticChecked(newCheckedState);
try {
await onToggle(checkbox.id);
// Reset l'état optimiste après succès
setOptimisticChecked(null);
} catch (error) {
// Rollback en cas d'erreur
setOptimisticChecked(null);
console.error('Erreur lors du toggle:', error);
}
};
// Édition inline simple
const handleStartInlineEdit = () => {
@@ -82,8 +112,8 @@ export function DailyCheckboxItem({
{/* Checkbox */}
<input
type="checkbox"
checked={checkbox.isChecked}
onChange={() => onToggle(checkbox.id)}
checked={isChecked}
onChange={handleOptimisticToggle}
disabled={saving}
className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
/>
@@ -128,7 +158,7 @@ export function DailyCheckboxItem({
{/* Lien vers la tâche si liée */}
{checkbox.task && (
<Link
href={`/?highlight=${checkbox.task.id}`}
href={`/kanban?taskId=${checkbox.task.id}`}
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono truncate max-w-[100px]"
title={`Tâche: ${checkbox.task.title}`}
>

View File

@@ -4,8 +4,8 @@ import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { DailyCheckboxSortable } from './DailyCheckboxSortable';
import { DailyCheckboxItem } from './DailyCheckboxItem';
import { DailyAddForm } from './DailyAddForm';
import { CheckboxItem, CheckboxItemData } from '@/components/ui/CheckboxItem';
import { DailyAddForm, AddFormOption } from '@/components/ui/DailyAddForm';
import { DndContext, closestCenter, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { useState } from 'react';
@@ -80,6 +80,22 @@ export function DailySection({
const activeCheckbox = activeId ? items.find(item => item.id === activeId) : null;
// Options pour le formulaire d'ajout
const addFormOptions: AddFormOption[] = [
{ value: 'task', label: 'Tâche', icon: '✅', color: 'green' },
{ value: 'meeting', label: 'Réunion', icon: '🗓️', color: 'blue' }
];
// Convertir les checkboxes en format CheckboxItemData
const convertToCheckboxItemData = (checkbox: DailyCheckbox): CheckboxItemData => ({
id: checkbox.id,
text: checkbox.text,
isChecked: checkbox.isChecked,
type: checkbox.type,
taskId: checkbox.taskId,
task: checkbox.task
});
return (
<DndContext
collisionDetection={closestCenter}
@@ -145,8 +161,11 @@ export function DailySection({
{/* Footer - Formulaire d'ajout toujours en bas */}
<div className="p-4 pt-2 border-t border-[var(--border)]/30 bg-[var(--card)]/50">
<DailyAddForm
onAdd={onAddCheckbox}
onAdd={(text, option) => onAddCheckbox(text, option as DailyCheckboxType)}
disabled={saving}
placeholder="Ajouter une tâche..."
options={addFormOptions}
defaultOption="task"
/>
</div>
</Card>
@@ -160,12 +179,14 @@ export function DailySection({
{activeCheckbox ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-md shadow-xl opacity-95 transform rotate-3 scale-105">
<div className="pl-4">
<DailyCheckboxItem
checkbox={activeCheckbox}
<CheckboxItem
item={convertToCheckboxItemData(activeCheckbox)}
onToggle={() => Promise.resolve()}
onUpdate={() => Promise.resolve()}
onDelete={() => Promise.resolve()}
saving={false}
showEditButton={false}
showDeleteButton={false}
/>
</div>
</div>

View File

@@ -168,13 +168,27 @@ export function EditCheckboxModal({
{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 className="flex items-center gap-2 mt-1">
<span className={`inline-block px-1 py-0.5 rounded text-xs ${
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>
{selectedTask.tags && selectedTask.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedTask.tags.map((tag, index) => (
<span
key={index}
className="inline-block px-1.5 py-0.5 rounded text-xs bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30"
>
#{tag}
</span>
))}
</div>
)}
</div>
</div>
<Button
type="button"
@@ -225,13 +239,32 @@ export function EditCheckboxModal({
{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>
<div className="flex items-center gap-2 mt-1">
<span className={`inline-block px-1 py-0.5 rounded text-xs ${
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>
{task.tags && task.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{task.tags.slice(0, 3).map((tag, index) => (
<span
key={index}
className="inline-block px-1.5 py-0.5 rounded text-xs bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30"
>
#{tag}
</span>
))}
{task.tags.length > 3 && (
<span className="text-xs text-[var(--muted-foreground)]">
+{task.tags.length - 3}
</span>
)}
</div>
)}
</div>
</button>
))
)}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useTransition } from 'react';
import Link from 'next/link';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
@@ -13,16 +14,18 @@ interface PendingTasksSectionProps {
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
onRefreshDaily?: () => Promise<void>; // Pour rafraîchir la vue daily principale
refreshTrigger?: number; // Pour forcer le refresh depuis le parent
initialPendingTasks?: DailyCheckbox[]; // Données SSR
}
export function PendingTasksSection({
onToggleCheckbox,
onDeleteCheckbox,
onRefreshDaily,
refreshTrigger
refreshTrigger,
initialPendingTasks = []
}: PendingTasksSectionProps) {
const [isCollapsed, setIsCollapsed] = useState(true);
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>([]);
const [isCollapsed, setIsCollapsed] = useState(false); // Open by default
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>(initialPendingTasks);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const [filters, setFilters] = useState({
@@ -52,9 +55,16 @@ export function PendingTasksSection({
// Charger au montage et quand les filtres changent
useEffect(() => {
if (!isCollapsed) {
loadPendingTasks();
// Si on a des données initiales et qu'on utilise les filtres par défaut, ne pas recharger
// SAUF si refreshTrigger a changé (pour recharger après toggle/delete)
const hasInitialData = initialPendingTasks.length > 0;
const usingDefaultFilters = filters.maxDays === 7 && filters.type === 'all' && filters.limit === 50;
if (!hasInitialData || !usingDefaultFilters || (refreshTrigger && refreshTrigger > 0)) {
loadPendingTasks();
}
}
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks]);
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks, initialPendingTasks.length]);
// Gérer l'archivage d'une tâche
const handleArchiveTask = async (checkboxId: string) => {
@@ -217,9 +227,12 @@ export function PendingTasksSection({
`Il y a ${daysAgo} jours`}
</span>
{task.task && (
<span className="text-[var(--primary)]">
<Link
href={`/kanban?taskId=${task.task.id}`}
className="text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
>
🔗 {task.task.title}
</span>
</Link>
)}
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { TaskStats } from '@/lib/types';
import { Card } from '@/components/ui/Card';
import { StatCard, ProgressBar } from '@/components/ui';
import { getDashboardStatColors } from '@/lib/status-config';
interface DashboardStatsProps {
@@ -18,77 +19,55 @@ export function DashboardStats({ stats }: DashboardStatsProps) {
title: 'Total Tâches',
value: stats.total,
icon: '📋',
type: 'total' as const,
...getDashboardStatColors('total')
color: 'default' as const
},
{
title: 'À Faire',
value: stats.todo,
icon: '⏳',
type: 'todo' as const,
...getDashboardStatColors('todo')
color: 'warning' as const
},
{
title: 'En Cours',
value: stats.inProgress,
icon: '🔄',
type: 'inProgress' as const,
...getDashboardStatColors('inProgress')
color: 'primary' as const
},
{
title: 'Terminées',
value: stats.completed,
icon: '✅',
type: 'completed' as const,
...getDashboardStatColors('completed')
color: 'success' as const
}
];
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>
<StatCard
key={index}
title={stat.title}
value={stat.value}
icon={stat.icon}
color={stat.color}
/>
))}
{/* 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>
<ProgressBar
value={completionRate}
label="Terminées"
color="success"
/>
<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>
<ProgressBar
value={inProgressRate}
label="En Cours"
color="primary"
/>
</div>
</Card>

View File

@@ -4,12 +4,15 @@ import { useState } from 'react';
import { ManagerSummary } from '@/services/analytics/manager-summary';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { getPriorityConfig } from '@/lib/status-config';
import { MetricCard } from '@/components/ui/MetricCard';
import { Tabs, TabItem } from '@/components/ui/Tabs';
import { AchievementCard } from '@/components/ui/AchievementCard';
import { ChallengeCard } from '@/components/ui/ChallengeCard';
import { useTasksContext } from '@/contexts/TasksContext';
import { MetricsTab } from './MetricsTab';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Tag } from '@/lib/types';
interface ManagerWeeklySummaryProps {
initialSummary: ManagerSummary;
@@ -20,6 +23,10 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
const { tags: availableTags } = useTasksContext();
const handleTabChange = (tabId: string) => {
setActiveView(tabId as 'narrative' | 'accomplishments' | 'challenges' | 'metrics');
};
const handleRefresh = () => {
// SSR - refresh via page reload
window.location.reload();
@@ -27,26 +34,16 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
const formatPeriod = () => {
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })}`;
return `7 derniers jours (${format(summary.period.start, 'dd MMM', { locale: fr })} - ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })})`;
};
const getPriorityBadgeStyle = (priority: 'low' | 'medium' | 'high') => {
const config = getPriorityConfig(priority);
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium';
switch (config.color) {
case 'blue':
return `${baseClasses} bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400`;
case 'yellow':
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400`;
case 'purple':
return `${baseClasses} bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400`;
case 'red':
return `${baseClasses} bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400`;
default:
return `${baseClasses} bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400`;
}
};
// Configuration des onglets
const tabItems: TabItem[] = [
{ id: 'narrative', label: 'Vue Executive', icon: '📝' },
{ id: 'accomplishments', label: 'Accomplissements', icon: '✅', count: summary.keyAccomplishments.length },
{ id: 'challenges', label: 'Enjeux à venir', icon: '🎯', count: summary.upcomingChallenges.length },
{ id: 'metrics', label: 'Métriques', icon: '📊' }
];
return (
@@ -67,50 +64,11 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
</div>
{/* Navigation des vues */}
<div className="border-b border-[var(--border)]">
<nav className="flex space-x-8">
<button
onClick={() => setActiveView('narrative')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'narrative'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
📝 Vue Executive
</button>
<button
onClick={() => setActiveView('accomplishments')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'accomplishments'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
Accomplissements ({summary.keyAccomplishments.length})
</button>
<button
onClick={() => setActiveView('challenges')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'challenges'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
🎯 Enjeux à venir ({summary.upcomingChallenges.length})
</button>
<button
onClick={() => setActiveView('metrics')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'metrics'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
📊 Métriques
</button>
</nav>
</div>
<Tabs
items={tabItems}
activeTab={activeView}
onTabChange={handleTabChange}
/>
{/* Vue Executive / Narrative */}
{activeView === 'narrative' && (
@@ -123,19 +81,19 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-400">
<h3 className="font-medium text-blue-900 mb-2">🎯 Points clés accomplis</h3>
<p className="text-blue-800">{summary.narrative.weekHighlight}</p>
<div className="outline-card-blue p-4">
<h3 className="font-medium mb-2">🎯 Points clés accomplis</h3>
<p>{summary.narrative.weekHighlight}</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
<h3 className="font-medium text-yellow-900 mb-2"> Défis traités</h3>
<p className="text-yellow-800">{summary.narrative.mainChallenges}</p>
<div className="outline-card-yellow p-4">
<h3 className="font-medium mb-2"> Défis traités</h3>
<p>{summary.narrative.mainChallenges}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg border-l-4 border-green-400">
<h3 className="font-medium text-green-900 mb-2">🔮 Focus semaine prochaine</h3>
<p className="text-green-800">{summary.narrative.nextWeekFocus}</p>
<div className="outline-card-green p-4">
<h3 className="font-medium mb-2">🔮 Focus 7 prochains jours</h3>
<p>{summary.narrative.nextWeekFocus}</p>
</div>
</CardContent>
</Card>
@@ -147,45 +105,33 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{summary.metrics.totalTasksCompleted}
</div>
<div className="text-sm text-blue-600">Tâches complétées</div>
<div className="text-xs text-blue-500">
dont {summary.metrics.highPriorityTasksCompleted} priorité haute
</div>
</div>
<MetricCard
title="Tâches complétées"
value={summary.metrics.totalTasksCompleted}
subtitle={`dont ${summary.metrics.highPriorityTasksCompleted} priorité haute`}
color="primary"
/>
<div className="text-center p-4 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{summary.metrics.totalCheckboxesCompleted}
</div>
<div className="text-sm text-green-600">Todos complétés</div>
<div className="text-xs text-green-500">
dont {summary.metrics.meetingCheckboxesCompleted} meetings
</div>
</div>
<MetricCard
title="Todos complétés"
value={summary.metrics.totalCheckboxesCompleted}
subtitle={`dont ${summary.metrics.meetingCheckboxesCompleted} meetings`}
color="success"
/>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{summary.keyAccomplishments.filter(a => a.impact === 'high').length}
</div>
<div className="text-sm text-purple-600">Items à fort impact</div>
<div className="text-xs text-purple-500">
/ {summary.keyAccomplishments.length} accomplissements
</div>
</div>
<MetricCard
title="Items à fort impact"
value={summary.keyAccomplishments.filter(a => a.impact === 'high').length}
subtitle={`/ ${summary.keyAccomplishments.length} accomplissements`}
color="warning"
/>
<div className="text-center p-4 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{summary.upcomingChallenges.filter(c => c.priority === 'high').length}
</div>
<div className="text-sm text-orange-600">Priorités critiques</div>
<div className="text-xs text-orange-500">
/ {summary.upcomingChallenges.length} enjeux
</div>
</div>
<MetricCard
title="Priorités critiques"
value={summary.upcomingChallenges.filter(c => c.priority === 'high').length}
subtitle={`/ ${summary.upcomingChallenges.length} enjeux`}
color="destructive"
/>
</div>
</CardContent>
</Card>
@@ -202,64 +148,18 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
<p>Aucun accomplissement significatif trouvé cette semaine.</p>
<p className="text-sm mt-2">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p>
</div>
) : (
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
<div
key={accomplishment.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
{getPriorityConfig(accomplishment.impact).label}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
</span>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{accomplishment.title}
</h4>
{/* Tags */}
{accomplishment.tags && accomplishment.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={accomplishment.tags}
availableTags={availableTags}
size="sm"
maxTags={2}
/>
</div>
)}
{/* Description si disponible */}
{accomplishment.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{accomplishment.description}
</p>
)}
{/* Count de todos */}
{accomplishment.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))
)}
) : (
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
<AchievementCard
key={accomplishment.id}
achievement={accomplishment}
availableTags={availableTags as (Tag & { usage: number })[]}
index={index}
showDescription={true}
maxTags={2}
/>
))
)}
</div>
</CardContent>
</Card>
@@ -276,64 +176,16 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
<p>Aucun enjeu prioritaire trouvé.</p>
<p className="text-sm mt-2">Ajoutez des tâches non complétées avec priorité haute/medium.</p>
</div>
) : (
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
<div
key={challenge.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(challenge.priority)}>
{getPriorityConfig(challenge.priority).label}
</span>
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags}
size="sm"
maxTags={2}
/>
</div>
)}
{/* Description si disponible */}
{challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
) : (
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
availableTags={availableTags as (Tag & { usage: number })[]}
index={index}
showDescription={true}
maxTags={2}
/>
))
)}
</div>
@@ -346,7 +198,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
{activeView === 'accomplishments' && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold"> Accomplissements de la semaine</h2>
<h2 className="text-lg font-semibold"> Accomplissements des 7 derniers jours</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.keyAccomplishments.length} accomplissements significatifs {summary.metrics.totalTasksCompleted} tâches {summary.metrics.totalCheckboxesCompleted} todos complétés
</p>
@@ -354,60 +206,14 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.keyAccomplishments.map((accomplishment, index) => (
<div
key={accomplishment.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
{getPriorityConfig(accomplishment.impact).label}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
</span>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{accomplishment.title}
</h4>
{/* Tags */}
{accomplishment.tags && accomplishment.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={accomplishment.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
/>
</div>
)}
{/* Description si disponible */}
{accomplishment.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
{accomplishment.description}
</p>
)}
{/* Count de todos */}
{accomplishment.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
<AchievementCard
key={accomplishment.id}
achievement={accomplishment}
availableTags={availableTags as (Tag & { usage: number })[]}
index={index}
showDescription={true}
maxTags={3}
/>
))}
</div>
</CardContent>
@@ -426,62 +232,14 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.upcomingChallenges.map((challenge, index) => (
<div
key={challenge.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(challenge.priority)}>
{getPriorityConfig(challenge.priority).label}
</span>
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
/>
</div>
)}
{/* Description si disponible */}
{challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
<ChallengeCard
key={challenge.id}
challenge={challenge}
availableTags={availableTags as (Tag & { usage: number })[]}
index={index}
showDescription={true}
maxTags={3}
/>
))}
</div>
</CardContent>

View File

@@ -31,7 +31,7 @@ export function MetricsTab({ className }: MetricsTabProps) {
const formatPeriod = () => {
if (!metrics) return '';
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
return `7 derniers jours (${format(metrics.period.start, 'dd MMM', { locale: fr })} - ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })})`;
};

View File

@@ -1,73 +1,25 @@
'use client';
import { useState, useEffect, useTransition } from 'react';
import { ProductivityMetrics } from '@/services/analytics/analytics';
import { getProductivityMetrics } from '@/actions/analytics';
import { DeadlineMetrics } from '@/services/analytics/deadline-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';
import { Card, MetricCard } from '@/components/ui';
import { DeadlineOverview } from '@/components/deadline/DeadlineOverview';
export function ProductivityAnalytics() {
const [metrics, setMetrics] = useState<ProductivityMetrics | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
interface ProductivityAnalyticsProps {
metrics: ProductivityMetrics;
deadlineMetrics: DeadlineMetrics;
}
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;
}
export function ProductivityAnalytics({ metrics, deadlineMetrics }: ProductivityAnalyticsProps) {
return (
<div className="space-y-8">
{/* Titre de section */}
{/* Section Échéances Critiques */}
<DeadlineOverview metrics={deadlineMetrics} />
{/* Titre de section Analytics */}
<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)]">
@@ -119,42 +71,33 @@ export function ProductivityAnalytics() {
<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>
<MetricCard
title="Vélocité Moyenne"
value={`${metrics.velocityData.length > 0
? Math.round(metrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / metrics.velocityData.length)
: 0
} tâches/sem`}
color="primary"
/>
<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>
<MetricCard
title="Priorité Principale"
value={metrics.priorityDistribution.reduce((max, item) =>
item.count > max.count ? item : max,
metrics.priorityDistribution[0]
)?.priority || 'N/A'}
color="success"
/>
<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>
<MetricCard
title="Taux de Completion"
value={`${(() => {
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;
})()}%`}
color="warning"
/>
</div>
</Card>
</div>

View File

@@ -1,10 +1,9 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { ActionCard } from '@/components/ui';
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
import { CreateTaskData } from '@/clients/tasks-client';
import Link from 'next/link';
interface QuickActionsProps {
onCreateTask: (data: CreateTaskData) => Promise<void>;
@@ -21,65 +20,54 @@ export function QuickActions({ onCreateTask }: QuickActionsProps) {
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Button
variant="primary"
<ActionCard
title="Nouvelle Tâche"
description="Créer une nouvelle tâche"
icon={
<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>
}
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>
variant="primary"
/>
<Link href="/kanban">
<Button
variant="secondary"
className="flex items-center gap-2 p-6 h-auto w-full"
>
<ActionCard
title="Kanban Board"
description="Gérer les tâches"
icon={
<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>
}
href="/kanban"
variant="secondary"
/>
<Link href="/daily">
<Button
variant="secondary"
className="flex items-center gap-2 p-6 h-auto w-full"
>
<ActionCard
title="Daily"
description="Checkboxes quotidiennes"
icon={
<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>
}
href="/daily"
variant="secondary"
/>
<Link href="/settings">
<Button
variant="secondary"
className="flex items-center gap-2 p-6 h-auto w-full"
>
<ActionCard
title="Paramètres"
description="Configuration"
icon={
<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>
}
href="/settings"
variant="secondary"
/>
</div>
<CreateTaskForm

View File

@@ -2,12 +2,8 @@
import { Task } from '@/lib/types';
import { Card } from '@/components/ui/Card';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { formatDateShort } from '@/lib/date-utils';
import { Badge } from '@/components/ui/Badge';
import { TaskCard } from '@/components/ui';
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 {
@@ -22,17 +18,6 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
.sort((a, b) => b.updatedAt.getTime() - 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">
@@ -56,70 +41,43 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
) : (
<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 key={task.id} className="relative group">
<TaskCard
variant="detailed"
source={task.source || 'manual'}
title={task.title}
description={task.description}
status={task.status}
priority={task.priority as 'low' | 'medium' | 'high' | 'urgent'}
tags={task.tags || []}
dueDate={task.dueDate}
completedAt={task.completedAt}
jiraKey={task.jiraKey}
jiraProject={task.jiraProject}
jiraType={task.jiraType}
tfsPullRequestId={task.tfsPullRequestId}
tfsProject={task.tfsProject}
tfsRepository={task.tfsRepository}
availableTags={availableTags}
fontSize="small"
onTitleClick={() => {
// Navigation vers le kanban avec la tâche sélectionnée
window.location.href = `/kanban?taskId=${task.id}`;
}}
/>
{/* Overlay avec lien vers le kanban */}
<Link
href={`/kanban?taskId=${task.id}`}
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-[var(--primary)]/5 rounded-lg flex items-center justify-center"
title="Ouvrir dans le Kanban"
>
<div className="bg-[var(--primary)]/20 backdrop-blur-sm rounded-full p-2 border border-[var(--primary)]/30">
<svg className="w-4 h-4 text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
{formatDateShort(task.updatedAt)}
</div>
</div>
</Link>
</div>
))}
</div>

View File

@@ -33,41 +33,41 @@ export function MetricsOverview({ metrics }: MetricsOverviewProps) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<div className="text-2xl font-bold text-green-600">
<div className="outline-metric-green">
<div className="text-2xl font-bold">
{metrics.summary.totalTasksCompleted}
</div>
<div className="text-sm text-green-600">Terminées</div>
<div className="text-sm">Terminées</div>
</div>
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
<div className="outline-metric-blue">
<div className="text-2xl font-bold">
{metrics.summary.totalTasksCreated}
</div>
<div className="text-sm text-blue-600">Créées</div>
<div className="text-sm">Créées</div>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
<div className="outline-metric-purple">
<div className="text-2xl font-bold">
{metrics.summary.averageCompletionRate.toFixed(1)}%
</div>
<div className="text-sm text-purple-600">Taux moyen</div>
<div className="text-sm">Taux moyen</div>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
<div className="outline-metric-orange">
<div className="text-2xl font-bold">
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
</div>
<div className="text-sm text-orange-600 capitalize">
<div className="text-sm capitalize">
{metrics.summary.trendsAnalysis.completionTrend}
</div>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
<div className="text-2xl font-bold text-gray-600">
<div className="outline-metric-gray">
<div className="text-2xl font-bold">
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
</div>
<div className="text-sm text-gray-600">
<div className="text-sm">
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
</div>

View File

@@ -76,37 +76,37 @@ export function ProductivityInsights({ data, className }: ProductivityInsightsPr
{/* Insights principaux */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Jour le plus productif */}
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<div className="outline-card-green p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-green-900 dark:text-green-100">
<h4 className="font-medium">
🏆 Jour champion
</h4>
<span className="text-2xl font-bold text-green-600">
<span className="text-2xl font-bold">
{mostProductiveDay.completed}
</span>
</div>
<p className="text-sm text-green-800 dark:text-green-200">
<p className="text-sm">
{mostProductiveDay.dayName} - {mostProductiveDay.completed} tâches terminées
</p>
<p className="text-xs text-green-600 mt-1">
<p className="text-xs opacity-75 mt-1">
Taux: {mostProductiveDay.completionRate.toFixed(1)}%
</p>
</div>
{/* Jour le plus créatif */}
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
<div className="outline-card-blue p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-blue-900 dark:text-blue-100">
<h4 className="font-medium">
💡 Jour créatif
</h4>
<span className="text-2xl font-bold text-blue-600">
<span className="text-2xl font-bold">
{mostCreativeDay.newTasks}
</span>
</div>
<p className="text-sm text-blue-800 dark:text-blue-200">
<p className="text-sm">
{mostCreativeDay.dayName} - {mostCreativeDay.newTasks} nouvelles tâches
</p>
<p className="text-xs text-blue-600 mt-1">
<p className="text-xs opacity-75 mt-1">
{mostCreativeDay.dayName === mostProductiveDay.dayName ?
'Également jour le plus productif!' :
'Journée de planification'}
@@ -162,11 +162,11 @@ export function ProductivityInsights({ data, className }: ProductivityInsightsPr
</div>
{/* Recommandations */}
<div className="p-4 bg-yellow-50 dark:bg-yellow-950/20 rounded-lg">
<h4 className="font-medium text-yellow-900 dark:text-yellow-100 mb-2 flex items-center gap-2">
<div className="outline-card-yellow p-4">
<h4 className="font-medium mb-2 flex items-center gap-2">
💡 Recommandations
</h4>
<div className="space-y-1 text-sm text-yellow-800 dark:text-yellow-200">
<div className="space-y-1 text-sm">
{trend === 'down' && (
<p> Essayez de retrouver votre rythme du début de semaine</p>
)}

View File

@@ -19,13 +19,13 @@ export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmap
};
// Obtenir la couleur basée sur l'intensité
const getColorClass = (intensity: number) => {
if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800';
if (intensity < 0.2) return 'bg-green-100 dark:bg-green-900/30';
if (intensity < 0.4) return 'bg-green-200 dark:bg-green-800/50';
if (intensity < 0.6) return 'bg-green-300 dark:bg-green-700/70';
if (intensity < 0.8) return 'bg-green-400 dark:bg-green-600/80';
return 'bg-green-500 dark:bg-green-500';
const getColorStyle = (intensity: number) => {
if (intensity === 0) return { backgroundColor: 'var(--gray-light)' };
if (intensity < 0.2) return { backgroundColor: 'color-mix(in srgb, var(--green) 20%, transparent)' };
if (intensity < 0.4) return { backgroundColor: 'color-mix(in srgb, var(--green) 40%, transparent)' };
if (intensity < 0.6) return { backgroundColor: 'color-mix(in srgb, var(--green) 60%, transparent)' };
if (intensity < 0.8) return { backgroundColor: 'color-mix(in srgb, var(--green) 80%, transparent)' };
return { backgroundColor: 'var(--green)' };
};
return (
@@ -46,14 +46,15 @@ export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmap
<div className="flex gap-1">
{data.map((day, index) => {
const intensity = getIntensity(day);
const colorClass = getColorClass(intensity);
const colorStyle = getColorStyle(intensity);
const totalActivity = day.completed + day.newTasks;
return (
<div key={index} className="text-center">
{/* Carré de couleur */}
<div
className={`w-8 h-8 rounded ${colorClass} border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative`}
className="w-8 h-8 rounded border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative"
style={colorStyle}
title={`${day.dayName}: ${totalActivity} activités (${day.completed} complétées, ${day.newTasks} créées)`}
>
{/* Tooltip au hover */}
@@ -87,12 +88,12 @@ export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmap
<div className="flex items-center justify-center gap-2 text-xs text-[var(--muted-foreground)]">
<span>Moins</span>
<div className="flex gap-1">
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-800 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-100 dark:bg-green-900/30 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-200 dark:bg-green-800/50 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-300 dark:bg-green-700/70 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-400 dark:bg-green-600/80 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 bg-green-500 dark:bg-green-500 border border-[var(--border)] rounded"></div>
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'var(--gray-light)' }}></div>
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 20%, transparent)' }}></div>
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 40%, transparent)' }}></div>
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 60%, transparent)' }}></div>
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 80%, transparent)' }}></div>
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'var(--green)' }}></div>
</div>
<span>Plus</span>
</div>

View File

@@ -0,0 +1,179 @@
'use client';
import { DeadlineTask } from '@/services/analytics/deadline-analytics';
import { Card } from '@/components/ui/Card';
interface CriticalDeadlinesCardProps {
overdue: DeadlineTask[];
critical: DeadlineTask[];
warning: DeadlineTask[];
}
export function CriticalDeadlinesCard({ overdue, critical, warning }: CriticalDeadlinesCardProps) {
// Combiner toutes les tâches urgentes et trier par urgence
const urgentTasks = [...overdue, ...critical, ...warning]
.sort((a, b) => {
// En retard d'abord, puis critique, puis attention
const urgencyOrder: Record<string, number> = { 'overdue': 0, 'critical': 1, 'warning': 2 };
if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) {
return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
}
// Si même urgence, trier par jours restants
return a.daysRemaining - b.daysRemaining;
});
const getUrgencyStyle = (task: DeadlineTask) => {
if (task.urgencyLevel === 'overdue') {
return {
icon: '🔴',
text: task.daysRemaining === -1 ? 'En retard de 1 jour' : `En retard de ${Math.abs(task.daysRemaining)} jours`,
style: 'border-[var(--destructive)]/60'
};
} else if (task.urgencyLevel === 'critical') {
return {
icon: '🟠',
text: task.daysRemaining === 0 ? 'Échéance aujourd\'hui' :
task.daysRemaining === 1 ? 'Échéance demain' :
`Dans ${task.daysRemaining} jours`,
style: 'border-[var(--accent)]/60'
};
} else {
return {
icon: '🟡',
text: `Dans ${task.daysRemaining} jours`,
style: 'border-[var(--yellow)]/60'
};
}
};
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'urgent': return '🔥';
case 'high': return '⬆️';
case 'medium': return '➡️';
case 'low': return '⬇️';
default: return '❓';
}
};
const getSourceIcon = (source: string) => {
switch (source.toLowerCase()) {
case 'jira': return '🔵';
case 'tfs': return '🟣';
case 'manual': return '✏️';
default: return '📋';
}
};
if (urgentTasks.length === 0) {
return (
<Card className="p-6 hover:shadow-lg transition-shadow">
<h3 className="text-lg font-semibold mb-4">Tâches Urgentes</h3>
<div className="text-center py-8">
<div className="text-4xl mb-2">🎉</div>
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--green)' }}>Excellent !</h4>
<p className="text-sm text-[var(--muted-foreground)]">
Aucune tâche urgente ou critique
</p>
</div>
</Card>
);
}
return (
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Tâches Urgentes</h3>
<div className="text-sm text-[var(--muted-foreground)]">
{urgentTasks.length} tâche{urgentTasks.length > 1 ? 's' : ''}
</div>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto scrollbar-thin scrollbar-track-transparent pr-2" style={{ scrollbarColor: 'var(--muted) transparent' }}>
{urgentTasks.map((task) => {
const urgencyStyle = getUrgencyStyle(task);
const getStyleClass = (urgencyLevel: string) => {
if (urgencyLevel === 'overdue') {
return 'outline-card-red';
} else if (urgencyLevel === 'critical') {
return 'outline-card-orange';
} else {
return 'outline-card-yellow';
}
};
return (
<div
key={task.id}
className={getStyleClass(task.urgencyLevel)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm">{urgencyStyle.icon}</span>
<span className="text-sm">{getSourceIcon(task.source)}</span>
<span className="text-sm">{getPriorityIcon(task.priority)}</span>
{task.jiraKey && (
<span className="text-xs px-1.5 py-0.5 bg-[var(--border)] rounded font-mono">
{task.jiraKey}
</span>
)}
</div>
<h4 className="font-medium text-sm leading-tight mb-0.5 truncate" title={task.title}>
{task.title}
</h4>
<div className="text-xs opacity-75">
{urgencyStyle.text}
</div>
{task.tags.length > 0 && (
<div className="flex gap-1 mt-1.5 flex-wrap">
{task.tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="text-xs px-1.5 py-0.5 bg-[var(--accent)]/60 text-[var(--accent-foreground)] rounded"
>
{tag}
</span>
))}
{task.tags.length > 2 && (
<span className="text-xs text-[var(--muted-foreground)] opacity-70">
+{task.tags.length - 2}
</span>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{urgentTasks.length > 0 && (
<div className="pt-3 border-t border-[var(--border)] mt-4">
<div className="flex flex-wrap gap-3 text-xs text-[var(--muted-foreground)] justify-center">
{overdue.length > 0 && (
<span className="font-medium" style={{ color: 'var(--destructive)' }}>
{overdue.length} en retard
</span>
)}
{critical.length > 0 && (
<span className="font-medium" style={{ color: 'var(--accent)' }}>
{critical.length} critique{critical.length > 1 ? 's' : ''}
</span>
)}
{warning.length > 0 && (
<span className="font-medium" style={{ color: 'var(--yellow)' }}>
{warning.length} attention
</span>
)}
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
import { DeadlineRiskCard } from './DeadlineRiskCard';
import { CriticalDeadlinesCard } from './CriticalDeadlinesCard';
import { DeadlineSummaryCard } from './DeadlineSummaryCard';
interface DeadlineOverviewProps {
metrics: DeadlineMetrics;
}
export function DeadlineOverview({ metrics }: DeadlineOverviewProps) {
return (
<div className="space-y-6">
{/* Titre de section */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">🚨 Échéances Critiques</h2>
<div className="text-sm text-[var(--muted-foreground)]">
Surveillance temps réel
</div>
</div>
{/* Cards principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<DeadlineRiskCard metrics={metrics} />
<DeadlineSummaryCard metrics={metrics} />
<CriticalDeadlinesCard
overdue={metrics.overdue}
critical={metrics.critical}
warning={metrics.warning}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { DeadlineMetrics, DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
import { Card } from '@/components/ui/Card';
interface DeadlineRiskCardProps {
metrics: DeadlineMetrics;
}
export function DeadlineRiskCard({ metrics }: DeadlineRiskCardProps) {
const riskAnalysis = DeadlineAnalyticsService.calculateRiskMetrics(metrics);
const getRiskIcon = (level: string) => {
switch (level) {
case 'critical': return '🔴';
case 'high': return '🟠';
case 'medium': return '🟡';
case 'low': return '🟢';
default: return '⚪';
}
};
const getRiskColor = (level: string) => {
switch (level) {
case 'critical': return { color: 'var(--destructive)' };
case 'high': return { color: 'var(--accent)' };
case 'medium': return { color: 'var(--yellow)' };
case 'low': return { color: 'var(--green)' };
default: return { color: 'var(--muted-foreground)' };
}
};
const getRiskBgColor = (level: string) => {
switch (level) {
case 'critical': return { backgroundColor: 'color-mix(in srgb, var(--destructive) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--destructive) 30%, var(--border))' };
case 'high': return { backgroundColor: 'color-mix(in srgb, var(--accent) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--accent) 30%, var(--border))' };
case 'medium': return { backgroundColor: 'color-mix(in srgb, var(--yellow) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--yellow) 30%, var(--border))' };
case 'low': return { backgroundColor: 'color-mix(in srgb, var(--green) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--green) 30%, var(--border))' };
default: return { backgroundColor: 'color-mix(in srgb, var(--muted) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--muted) 30%, var(--border))' };
}
};
return (
<Card className={`p-6 ${getRiskBgColor(riskAnalysis.riskLevel)} transition-all hover:shadow-lg`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">{getRiskIcon(riskAnalysis.riskLevel)}</span>
<h3 className="text-lg font-semibold">Niveau de Risque</h3>
</div>
<div className="text-3xl font-bold" style={getRiskColor(riskAnalysis.riskLevel)}>
{riskAnalysis.riskScore}
</div>
</div>
<div className="space-y-3">
{/* Barre de risque */}
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`h-3 rounded-full transition-all duration-500 ${
riskAnalysis.riskLevel === 'critical' ? 'bg-red-500/80' :
riskAnalysis.riskLevel === 'high' ? 'bg-orange-500/80' :
riskAnalysis.riskLevel === 'medium' ? 'bg-yellow-500/80' : 'bg-green-500/80'
}`}
style={{ width: `${Math.min(riskAnalysis.riskScore, 100)}%` }}
/>
</div>
{/* Détails des risques */}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--muted-foreground)]">En retard:</span>
<span className="font-medium" style={{ color: 'var(--destructive)' }}>{metrics.summary.overdueCount}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--muted-foreground)]">Critique:</span>
<span className="font-medium" style={{ color: 'var(--accent)' }}>{metrics.summary.criticalCount}</span>
</div>
</div>
{/* Recommandation */}
<div className="pt-2 border-t border-[var(--border)]">
<p className="text-xs text-[var(--muted-foreground)] leading-relaxed">
{riskAnalysis.recommendation}
</p>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
import { Card } from '@/components/ui/Card';
interface DeadlineSummaryCardProps {
metrics: DeadlineMetrics;
}
export function DeadlineSummaryCard({ metrics }: DeadlineSummaryCardProps) {
const { summary } = metrics;
const summaryItems = [
{
label: 'En retard',
count: summary.overdueCount,
icon: '⏰',
color: 'var(--destructive)',
bgColor: 'color-mix(in srgb, var(--destructive) 10%, transparent)'
},
{
label: 'Critique (0-2j)',
count: summary.criticalCount,
icon: '🚨',
color: 'var(--accent)',
bgColor: 'color-mix(in srgb, var(--accent) 10%, transparent)'
},
{
label: 'Attention (3-7j)',
count: summary.warningCount,
icon: '⚠️',
color: 'var(--yellow)',
bgColor: 'color-mix(in srgb, var(--yellow) 10%, transparent)'
},
{
label: 'À venir (8-14j)',
count: summary.upcomingCount,
icon: '📅',
color: 'var(--blue)',
bgColor: 'color-mix(in srgb, var(--blue) 10%, transparent)'
}
];
return (
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Répartition des Échéances</h3>
<div className="text-sm text-[var(--muted-foreground)]">
{summary.totalWithDeadlines} total
</div>
</div>
<div className="space-y-3">
{summaryItems.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm" style={{ backgroundColor: item.bgColor }}>
{item.icon}
</div>
<span className="text-sm font-medium">{item.label}</span>
</div>
<div className="text-lg font-bold" style={{ color: item.color }}>
{item.count}
</div>
</div>
))}
{/* Indicateur de performance */}
<div className="pt-3 border-t border-[var(--border)]">
<div className="flex items-center justify-between text-sm">
<span className="text-[var(--muted-foreground)]">Tâches sous contrôle:</span>
<span className="font-medium">
{summary.totalWithDeadlines - summary.overdueCount - summary.criticalCount}/{summary.totalWithDeadlines}
</span>
</div>
<div className="w-full rounded-full h-2 mt-2" style={{ backgroundColor: 'var(--gray-light)' }}>
<div
className="bg-green-500/80 h-2 rounded-full transition-all duration-300"
style={{
width: `${summary.totalWithDeadlines > 0
? Math.round(((summary.totalWithDeadlines - summary.overdueCount - summary.criticalCount) / summary.totalWithDeadlines) * 100)
: 100
}%`
}}
/>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { DeadlineOverview } from './DeadlineOverview';
export { DeadlineRiskCard } from './DeadlineRiskCard';
export { CriticalDeadlinesCard } from './CriticalDeadlinesCard';
export { DeadlineSummaryCard } from './DeadlineSummaryCard';

View File

@@ -3,6 +3,7 @@
import { Input } from '@/components/ui/Input';
import { TaskPriority, TaskStatus } from '@/lib/types';
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
import { ensureDate, formatDateForDateTimeInput } from '@/lib/date-utils';
interface TaskBasicFieldsProps {
title: string;
@@ -109,7 +110,10 @@ export function TaskBasicFields({
<Input
label="Date d'échéance"
type="datetime-local"
value={dueDate ? new Date(dueDate.getTime() - dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
value={(() => {
const date = ensureDate(dueDate);
return date ? formatDateForDateTimeInput(date) : '';
})()}
onChange={(e) => onDueDateChange(e.target.value ? new Date(e.target.value) : undefined)}
disabled={loading}
/>

View File

@@ -131,19 +131,19 @@ export function BurndownChart({ sprintHistory, className }: BurndownChartProps)
{/* Légende visuelle */}
<div className="mb-4 flex justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-green-600 dark:bg-green-500 border-dashed border-t-2 border-green-600 dark:border-green-500"></div>
<span className="text-green-600 dark:text-green-500">Idéal</span>
<div className="w-4 h-0.5 border-dashed border-t-2" style={{ backgroundColor: 'var(--green)', borderColor: 'var(--green)' }}></div>
<span style={{ color: 'var(--green)' }}>Idéal</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-blue-600 dark:bg-blue-500"></div>
<span className="text-blue-600 dark:text-blue-500">Réel</span>
<div className="w-4 h-0.5" style={{ backgroundColor: 'var(--blue)' }}></div>
<span style={{ color: 'var(--blue)' }}>Réel</span>
</div>
</div>
{/* Métriques */}
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-sm font-medium text-green-500">
<div className="text-sm font-medium" style={{ color: 'var(--green)' }}>
{currentSprint.plannedPoints}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
@@ -151,7 +151,7 @@ export function BurndownChart({ sprintHistory, className }: BurndownChartProps)
</div>
</div>
<div>
<div className="text-sm font-medium text-blue-500">
<div className="text-sm font-medium" style={{ color: 'var(--blue)' }}>
{currentSprint.completedPoints}
</div>
<div className="text-xs text-[var(--muted-foreground)]">

View File

@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { JiraAnalytics } from '@/lib/types';
import { Card } from '@/components/ui/Card';
@@ -23,10 +23,16 @@ interface CollaborationData {
}
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;
const [collaborationData, setCollaborationData] = useState<CollaborationData[]>([]);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
// Analyser les patterns de collaboration basés sur les données existantes
const data: 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);
@@ -67,20 +73,32 @@ export function CollaborationMatrix({ analytics, className }: CollaborationMatri
};
});
// 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]);
setCollaborationData(data);
}, [analytics]);
// Ne pas rendre côté serveur pour éviter l'erreur d'hydratation
if (!isClient) {
return (
<div className={className}>
<div className="animate-pulse rounded-lg h-96" style={{ backgroundColor: 'var(--gray-light)' }} />
</div>
);
}
// 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';
case 'high': return { backgroundColor: 'var(--green)' };
case 'medium': return { backgroundColor: 'var(--yellow)' };
case 'low': return { backgroundColor: 'var(--gray)' };
}
};
@@ -99,10 +117,13 @@ export function CollaborationMatrix({ analytics, className }: CollaborationMatri
<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
className="w-3 h-3 rounded-full"
style={{
backgroundColor: person.isolation < 30 ? 'var(--green)' :
person.isolation < 60 ? 'var(--yellow)' : 'var(--destructive)'
}}
/>
</div>
</div>
<div className="space-y-1">
@@ -114,7 +135,7 @@ export function CollaborationMatrix({ analytics, className }: CollaborationMatri
</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 className="w-2 h-2 rounded-full" style={getIntensityColor(dep.intensity)} />
</div>
</div>
))
@@ -141,11 +162,11 @@ export function CollaborationMatrix({ analytics, className }: CollaborationMatri
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'];
const colors = ['var(--green)', 'var(--blue)', 'var(--yellow)', 'var(--destructive)'];
return (
<div key={level} className="flex items-center gap-2 text-xs">
<div className={`w-3 h-3 rounded-sm ${colors[index]}`} />
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: colors[index] }} />
<span className="flex-1 truncate">{level}</span>
<span className="font-mono text-xs">{count}</span>
</div>
@@ -185,7 +206,7 @@ export function CollaborationMatrix({ analytics, className }: CollaborationMatri
{ 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)}`} />
<div className="w-2 h-2 rounded-full" style={getIntensityColor(item.intensity)} />
<span>{item.label}</span>
</div>
))}
@@ -239,25 +260,25 @@ export function CollaborationMatrix({ analytics, className }: CollaborationMatri
<h4 className="text-sm font-medium mb-2">Recommandations d&apos;é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">
<div className="flex items-center gap-2" style={{ color: 'var(--destructive)' }}>
<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">
<div className="flex items-center gap-2" style={{ color: 'var(--green)' }}>
<span></span>
<span>Excellente collaboration - L&apos;équipe travaille bien ensemble</span>
</div>
)}
{mostIsolated && mostIsolated.isolation > 80 && (
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
<div className="flex items-center gap-2" style={{ color: 'var(--accent)' }}>
<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">
<div className="flex items-center gap-2" style={{ color: 'var(--blue)' }}>
<span>🔗</span>
<span>Quelques membres travaillent en silo - Organiser des sessions de partage</span>
</div>

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
import { Button } from '@/components/ui/Button';
@@ -11,6 +11,7 @@ interface FilterBarProps {
availableFilters: AvailableFilters;
activeFilters: Partial<JiraAnalyticsFilters>;
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
isLoading?: boolean;
className?: string;
}
@@ -18,18 +19,35 @@ export default function FilterBar({
availableFilters,
activeFilters,
onFiltersChange,
isLoading = false,
className = ''
}: FilterBarProps) {
const [showModal, setShowModal] = useState(false);
const [pendingFilters, setPendingFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
const clearAllFilters = () => {
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
onFiltersChange(emptyFilters);
setPendingFilters(emptyFilters);
};
const applyPendingFilters = () => {
onFiltersChange(pendingFilters);
setShowModal(false);
};
const cancelFilters = () => {
setPendingFilters(activeFilters);
setShowModal(false);
};
// Synchroniser pendingFilters avec activeFilters quand ils changent
useEffect(() => {
setPendingFilters(activeFilters);
}, [activeFilters]);
const removeFilter = (filterType: keyof JiraAnalyticsFilters, value: string) => {
const currentValues = activeFilters[filterType];
if (!currentValues || !Array.isArray(currentValues)) return;
@@ -47,7 +65,12 @@ export default function FilterBar({
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--foreground)]">🔍 Filtres</span>
{hasActiveFilters && (
{isLoading && (
<Badge className="bg-yellow-100 text-yellow-800 text-xs">
Chargement...
</Badge>
)}
{hasActiveFilters && !isLoading && (
<Badge className="bg-blue-100 text-blue-800 text-xs">
{activeFiltersCount}
</Badge>
@@ -175,14 +198,14 @@ export default function FilterBar({
>
<input
type="checkbox"
checked={activeFilters.issueTypes?.includes(option.value) || false}
checked={pendingFilters.issueTypes?.includes(option.value) || false}
onChange={(e) => {
const current = activeFilters.issueTypes || [];
const current = pendingFilters.issueTypes || [];
const newValues = e.target.checked
? [...current, option.value]
: current.filter(v => v !== option.value);
onFiltersChange({
...activeFilters,
setPendingFilters({
...pendingFilters,
issueTypes: newValues
});
}}
@@ -206,14 +229,14 @@ export default function FilterBar({
>
<input
type="checkbox"
checked={activeFilters.statuses?.includes(option.value) || false}
checked={pendingFilters.statuses?.includes(option.value) || false}
onChange={(e) => {
const current = activeFilters.statuses || [];
const current = pendingFilters.statuses || [];
const newValues = e.target.checked
? [...current, option.value]
: current.filter(v => v !== option.value);
onFiltersChange({
...activeFilters,
setPendingFilters({
...pendingFilters,
statuses: newValues
});
}}
@@ -237,14 +260,14 @@ export default function FilterBar({
>
<input
type="checkbox"
checked={activeFilters.assignees?.includes(option.value) || false}
checked={pendingFilters.assignees?.includes(option.value) || false}
onChange={(e) => {
const current = activeFilters.assignees || [];
const current = pendingFilters.assignees || [];
const newValues = e.target.checked
? [...current, option.value]
: current.filter(v => v !== option.value);
onFiltersChange({
...activeFilters,
setPendingFilters({
...pendingFilters,
assignees: newValues
});
}}
@@ -268,14 +291,14 @@ export default function FilterBar({
>
<input
type="checkbox"
checked={activeFilters.components?.includes(option.value) || false}
checked={pendingFilters.components?.includes(option.value) || false}
onChange={(e) => {
const current = activeFilters.components || [];
const current = pendingFilters.components || [];
const newValues = e.target.checked
? [...current, option.value]
: current.filter(v => v !== option.value);
onFiltersChange({
...activeFilters,
setPendingFilters({
...pendingFilters,
components: newValues
});
}}
@@ -291,10 +314,11 @@ export default function FilterBar({
<div className="flex gap-2 pt-6 border-t">
<Button
onClick={() => setShowModal(false)}
onClick={cancelFilters}
variant="secondary"
className="flex-1"
>
Fermer
Annuler
</Button>
<Button
onClick={clearAllFilters}
@@ -303,6 +327,13 @@ export default function FilterBar({
>
🗑 Effacer tout
</Button>
<Button
onClick={applyPendingFilters}
className="flex-1"
disabled={isLoading}
>
{isLoading ? '⏳ Application...' : '✅ Appliquer'}
</Button>
</div>
</Modal>
)}

View File

@@ -62,7 +62,7 @@ export function JiraLogs({ className = "" }: JiraLogsProps) {
return (
<Card className={className}>
<CardHeader className="pb-3">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-gray-400 animate-pulse"></div>

View File

@@ -147,10 +147,10 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
return (
<Card className={`${className}`}>
<CardHeader className="pb-3">
<CardHeader className="pb-4">
<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>
<div className="w-2 h-2 rounded-full animate-pulse" style={{ backgroundColor: 'var(--blue)' }}></div>
<h3 className="font-mono text-sm font-bold text-blue-400 uppercase tracking-wider">
JIRA SYNC
</h3>

View File

@@ -205,31 +205,31 @@ export function PredictabilityMetrics({ sprintHistory, className }: Predictabili
<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">
<div className="flex items-center gap-2" style={{ color: 'var(--green)' }}>
<span></span>
<span>Excellente predictabilité - L&apos;équipe estime bien sa capacité</span>
</div>
)}
{averageAccuracy < 60 && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<div className="flex items-center gap-2" style={{ color: 'var(--destructive)' }}>
<span></span>
<span>Predictabilité faible - Revoir les méthodes d&apos;estimation</span>
</div>
)}
{averageVariance > 25 && (
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
<div className="flex items-center gap-2" style={{ color: 'var(--accent)' }}>
<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">
<div className="flex items-center gap-2" style={{ color: 'var(--green)' }}>
<span>📈</span>
<span>Tendance positive - L&apos;équipe s&apos;améliore dans ses estimations</span>
</div>
)}
{trend < -10 && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<div className="flex items-center gap-2" style={{ color: 'var(--destructive)' }}>
<span>📉</span>
<span>Tendance négative - Attention aux changements récents (équipe, processus)</span>
</div>

View File

@@ -190,19 +190,19 @@ export function QualityMetrics({ analytics, className }: QualityMetricsProps) {
<h4 className="text-sm font-medium mb-2">Analyse qualité</h4>
<div className="space-y-2 text-sm">
{bugRatio > 25 && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<div className="flex items-center gap-2" style={{ color: 'var(--destructive)' }}>
<span></span>
<span>Ratio de bugs élevé ({bugRatio}%) - Attention à la dette technique</span>
</div>
)}
{bugRatio <= 15 && (
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<div className="flex items-center gap-2" style={{ color: 'var(--green)' }}>
<span></span>
<span>Excellent ratio de bugs ({bugRatio}%) - Bonne qualité du code</span>
</div>
)}
{issueTypes.stories > issueTypes.bugs * 3 && (
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<div className="flex items-center gap-2" style={{ color: 'var(--blue)' }}>
<span>🚀</span>
<span>Focus positif sur les fonctionnalités - Bon équilibre produit</span>
</div>

View File

@@ -138,16 +138,16 @@ export function ThroughputChart({ sprintHistory, className }: ThroughputChartPro
{/* Légende visuelle */}
<div className="mb-4 flex justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-3 bg-blue-600 dark:bg-blue-500 rounded-sm"></div>
<span className="text-blue-600 dark:text-blue-500">Points complétés</span>
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: 'var(--blue)' }}></div>
<span style={{ color: 'var(--blue)' }}>Points complétés</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-green-600 dark:bg-green-500"></div>
<span className="text-green-600 dark:text-green-500">Throughput</span>
<div className="w-4 h-0.5" style={{ backgroundColor: 'var(--green)' }}></div>
<span style={{ color: 'var(--green)' }}>Throughput</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-orange-600 dark:bg-orange-500 border-dashed border-t-2 border-orange-600 dark:border-orange-500"></div>
<span className="text-orange-600 dark:text-orange-500">Tendance</span>
<div className="w-4 h-0.5 border-dashed border-t-2" style={{ backgroundColor: 'var(--accent)', borderColor: 'var(--accent)' }}></div>
<span style={{ color: 'var(--accent)' }}>Tendance</span>
</div>
</div>

View File

@@ -86,7 +86,7 @@ export function KanbanBoard({ tasks, onCreateTask, onEditTask, onUpdateStatus, c
<div className="pt-4"></div>
{/* Board tech dark */}
<div className="flex-1 flex gap-3 overflow-x-auto p-6">
<div className="flex-1 flex gap-3 overflow-x-auto p-6 min-w-0">
{visibleColumns.map((column) => (
<KanbanColumn
key={column.id}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { EditTaskForm } from '@/components/forms/EditTaskForm';
import { useTasksContext } from '@/contexts/TasksContext';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
@@ -14,11 +14,13 @@ import { BoardRouter } from './BoardRouter';
interface KanbanBoardContainerProps {
showFilters?: boolean;
showObjectives?: boolean;
initialTaskIdToEdit?: string | null;
}
export function KanbanBoardContainer({
showFilters = true,
showObjectives = true
showObjectives = true,
initialTaskIdToEdit = null
}: KanbanBoardContainerProps = {}) {
const {
filteredTasks,
@@ -37,6 +39,33 @@ export function KanbanBoardContainer({
const visibleStatuses = allStatuses.filter(status => isColumnVisible(status.key)).map(s => s.key);
const [editingTask, setEditingTask] = useState<Task | null>(null);
// Callback memoized pour appliquer le filtre de recherche
const applyTaskFilter = useCallback((taskToEdit: Task) => {
if (kanbanFilters.search !== taskToEdit.title) {
setKanbanFilters({
...kanbanFilters,
search: taskToEdit.title
});
// Nettoyer l'URL pour éviter les répétitions
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.delete('taskId');
window.history.replaceState({}, '', url.toString());
}
}
}, [kanbanFilters, setKanbanFilters]);
// Effet pour appliquer un filtre de recherche si un taskId est fourni dans l'URL
useEffect(() => {
if (initialTaskIdToEdit && filteredTasks.length > 0) {
const taskToEdit = filteredTasks.find(task => task.id === initialTaskIdToEdit);
if (taskToEdit) {
applyTaskFilter(taskToEdit);
}
}
}, [initialTaskIdToEdit, filteredTasks, applyTaskFilter]);
const handleEditTask = (task: Task) => {
setEditingTask(task);
};

View File

@@ -5,7 +5,7 @@ import { SwimlanesBoard } from './SwimlanesBoard';
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
import { Task, TaskStatus } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
import { KanbanFilters } from './KanbanFilters';
import type { KanbanFilters } from '@/lib/types';
import { useIsMobile } from '@/hooks/useIsMobile';
interface BoardRouterProps {
@@ -41,7 +41,7 @@ export function BoardRouter({
onCreateTask={onCreateTask}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
compactView={kanbanFilters.compactView as boolean}
visibleStatuses={visibleStatuses}
loading={loading}
/>
@@ -53,7 +53,7 @@ export function BoardRouter({
onCreateTask={onCreateTask}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
compactView={kanbanFilters.compactView as boolean}
visibleStatuses={visibleStatuses}
loading={loading}
/>
@@ -68,7 +68,7 @@ export function BoardRouter({
onCreateTask={onCreateTask}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
compactView={kanbanFilters.compactView as boolean}
visibleStatuses={visibleStatuses}
/>
);

View File

@@ -1,12 +1,11 @@
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 { Card, CardHeader, CardContent, ColumnHeader, EmptyState, DropZone } from '@/components/ui';
import { CreateTaskData } from '@/clients/tasks-client';
import { useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { getStatusConfig, getTechStyle, getBadgeVariant } from '@/lib/status-config';
import { getStatusConfig, getTechStyle } from '@/lib/status-config';
interface KanbanColumnProps {
id: TaskStatus;
@@ -27,41 +26,23 @@ export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView
// 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>
<div className="flex-shrink-0 w-72 md:w-72 lg:w-80 h-full">
<DropZone ref={setNodeRef} isOver={isOver}>
<Card variant="column" className="h-full flex flex-col">
<CardHeader className="pb-4">
<ColumnHeader
title={statusConfig.label}
icon={statusConfig.icon}
count={tasks.length}
color={style.accent.replace('text-', '')}
accentColor={style.accent}
borderColor={style.border}
showAddButton={!!onCreateTask}
onAddClick={() => setShowQuickAdd(true)}
/>
</CardHeader>
<CardContent className="flex-1 p-4 h-[calc(100vh-220px)] overflow-y-auto">
<div className="space-y-3">
@@ -78,15 +59,11 @@ export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView
)}
{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>
<EmptyState
icon={statusConfig.icon}
accentColor={style.accent}
borderColor={style.border}
/>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} onEdit={onEditTask} compactView={compactView} />
@@ -94,7 +71,8 @@ export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView
)}
</div>
</CardContent>
</Card>
</Card>
</DropZone>
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Button, ToggleButton, SearchInput, ControlPanel, ControlSection, ControlGroup } from '@/components/ui';
import { SourceQuickFilter } from '@/components/kanban/SourceQuickFilter';
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
import type { KanbanFilters } from '@/lib/types';
interface DesktopControlsProps {
showFilters: boolean;
showObjectives: boolean;
compactView: boolean;
swimlanesByTags: boolean;
activeFiltersCount: number;
kanbanFilters: KanbanFilters;
onToggleFilters: () => void;
onToggleObjectives: () => void;
onToggleCompactView: () => void;
onToggleSwimlanes: () => void;
onFiltersChange: (filters: KanbanFilters) => void;
onCreateTask: () => void;
}
export function DesktopControls({
showFilters,
showObjectives,
compactView,
swimlanesByTags,
activeFiltersCount,
kanbanFilters,
onToggleFilters,
onToggleObjectives,
onToggleCompactView,
onToggleSwimlanes,
onFiltersChange,
onCreateTask,
}: DesktopControlsProps) {
// État local pour la recherche pour une saisie fluide
const [localSearch, setLocalSearch] = useState(kanbanFilters.search || '');
const searchTimeoutRef = useRef<number | undefined>(undefined);
// Fonction debouncée pour mettre à jour les filtres
const debouncedSearchChange = useCallback((search: string) => {
if (searchTimeoutRef.current) {
window.clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = window.setTimeout(() => {
onFiltersChange({ ...kanbanFilters, search: search || undefined });
}, 300);
}, [kanbanFilters, onFiltersChange]);
const handleSearchChange = (search: string) => {
setLocalSearch(search);
debouncedSearchChange(search);
};
// Synchroniser l'état local quand les filtres changent de l'extérieur
useEffect(() => {
setLocalSearch(kanbanFilters.search || '');
}, [kanbanFilters.search]);
// Nettoyer le timeout au démontage
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
window.clearTimeout(searchTimeoutRef.current);
}
};
}, []);
const handleDueDateFilterToggle = () => {
onFiltersChange({
...kanbanFilters,
showWithDueDate: !kanbanFilters.showWithDueDate
});
};
return (
<ControlPanel>
{/* Layout responsive : deux lignes sur tablette, une ligne sur desktop */}
<div className="flex flex-col lg:flex-row lg:items-center gap-4 lg:gap-0 w-full">
{/* Section gauche : Recherche + Boutons principaux */}
<ControlSection>
{/* Champ de recherche */}
<SearchInput
value={localSearch}
onChange={handleSearchChange}
placeholder="Rechercher des tâches..."
/>
<ControlGroup>
<ToggleButton
variant="primary"
isActive={showFilters}
count={activeFiltersCount}
onClick={onToggleFilters}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
}
>
Filtres
</ToggleButton>
<ToggleButton
variant="accent"
isActive={showObjectives}
onClick={onToggleObjectives}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
}
>
Objectifs
</ToggleButton>
<ToggleButton
variant="cyan"
isActive={kanbanFilters.showWithDueDate}
onClick={handleDueDateFilterToggle}
title={kanbanFilters.showWithDueDate ? "Afficher toutes les tâches" : "Afficher seulement les tâches avec date de fin"}
icon={
<svg className="w-4 h-4" 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 002 2v12a2 2 0 002 2z" />
</svg>
}
/>
</ControlGroup>
</ControlSection>
{/* Section droite : Raccourcis + Bouton Nouvelle tâche */}
<ControlSection className="justify-between lg:justify-start">
<ControlGroup className="border-l border-[var(--border)] ml-2 pl-2 pr-4">
{/* Raccourcis Sources (Jira & TFS) */}
<SourceQuickFilter
filters={kanbanFilters}
onFiltersChange={onFiltersChange}
/>
<ToggleButton
variant="secondary"
isActive={compactView}
onClick={onToggleCompactView}
title={compactView ? "Vue détaillée" : "Vue compacte"}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{compactView ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
}
>
{compactView ? 'Détaillée' : 'Compacte'}
</ToggleButton>
<ToggleButton
variant="warning"
isActive={swimlanesByTags}
onClick={onToggleSwimlanes}
title={swimlanesByTags ? "Vue standard" : "Vue swimlanes"}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{swimlanesByTags ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14-7H5m14 14H5" />
)}
</svg>
}
>
{swimlanesByTags ? 'Standard' : 'Swimlanes'}
</ToggleButton>
{/* Font Size Toggle */}
<FontSizeToggle />
</ControlGroup>
{/* Bouton d'ajout de tâche */}
<Button
variant="primary"
size="sm"
onClick={onCreateTask}
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="M12 4v16m8-8H4" />
</svg>
Nouvelle tâche
</Button>
</ControlSection>
</div>
</ControlPanel>
);
}

View File

@@ -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>
);
}

View File

@@ -1,33 +1,21 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef } 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 { Button, SearchInput, ToggleButton, ControlPanel, ControlSection, ControlGroup, FilterSummary } from '@/components/ui';
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';
import { useIsMobile } from '@/hooks/useIsMobile';
import { JiraFilters } from './filters/JiraFilters';
import { TfsFilters } from './filters/TfsFilters';
import { PriorityFilters } from './filters/PriorityFilters';
import { TagFilters } from './filters/TagFilters';
import { GeneralFilters } from './filters/GeneralFilters';
import { ColumnFilters } from './filters/ColumnFilters';
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.)
}
import type { KanbanFilters } from '@/lib/types';
interface KanbanFiltersProps {
filters: KanbanFilters;
@@ -37,17 +25,15 @@ interface KanbanFiltersProps {
}
export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsHiddenStatuses, onToggleStatusVisibility }: KanbanFiltersProps) {
const { tags: availableTags, regularTasks, activeFiltersCount } = useTasksContext();
const { 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 isMobile = useIsMobile(768); // Tailwind md breakpoint
const sortDropdownRef = useRef<HTMLDivElement>(null);
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
const sortButtonRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
@@ -57,17 +43,15 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
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) {
if (isSortExpanded) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isSortExpanded, isSwimlaneModeExpanded]);
}, [isSortExpanded]);
// Handler pour la recherche avec debounce intégré
const handleSearchChange = (search: string) => {
onFiltersChange({ ...filters, search: search || undefined });
};
@@ -98,30 +82,31 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
const handleSwimlanesToggle = () => {
onFiltersChange({
...filters,
swimlanesByTags: !filters.swimlanesByTags
});
// Cycle entre les 3 modes : Normal → Par tags → Par priorité → Normal
if (!filters.swimlanesByTags) {
// Normal → Par tags
onFiltersChange({
...filters,
swimlanesByTags: true,
swimlanesMode: 'tags'
});
} else if (filters.swimlanesMode === 'tags') {
// Par tags → Par priorité
onFiltersChange({
...filters,
swimlanesByTags: true,
swimlanesMode: 'priority'
});
} else {
// Par priorité → Normal
onFiltersChange({
...filters,
swimlanesByTags: false,
swimlanesMode: undefined
});
}
};
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({
@@ -141,176 +126,63 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
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);
}
const handleDueDateFilterToggle = () => {
onFiltersChange({
...filters,
showWithDueDate: !filters.showWithDueDate
});
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">
<ControlPanel 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">
<ControlSection>
<div className="flex-1 max-w-md">
<Input
type="text"
<SearchInput
value={filters.search || ''}
onChange={(e) => handleSearchChange(e.target.value)}
onChange={handleSearchChange}
placeholder="Rechercher des tâches..."
className="bg-[var(--card)] border-[var(--border)]"
/>
</div>
{/* Menu swimlanes - masqué sur mobile */}
{!isMobile && (
<div className="flex gap-1">
<Button
variant={filters.swimlanesByTags ? "primary" : "ghost"}
<ControlGroup>
<ToggleButton
variant="warning"
isActive={!!filters.swimlanesByTags}
onClick={handleSwimlanesToggle}
className="flex items-center gap-2"
title="Mode d'affichage"
icon={
<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>
}
>
<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>
</ToggleButton>
{/* 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>
</ControlGroup>
)}
@@ -343,227 +215,61 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
onClick={handleClearFilters}
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
>
Effacer
</Button>
)}
</div>
</ControlSection>
{/* 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>
{/* Layout optimisé : 3 colonnes avec Tags très large à droite */}
<div className="grid grid-cols-1 xl:grid-cols-[280px_1fr_300px] gap-4 lg:gap-6 items-start">
{/* Colonne 1 : Priorités + Généraux */}
<div className="space-y-4">
<PriorityFilters
selectedPriorities={filters.priorities}
onPriorityToggle={handlePriorityToggle}
/>
<GeneralFilters
showWithDueDate={filters.showWithDueDate}
onDueDateFilterToggle={handleDueDateFilterToggle}
/>
</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>
{/* Colonne 2 : Tags - Espace restant maximum */}
<TagFilters
selectedTags={filters.tags}
onTagToggle={handleTagToggle}
/>
{/* 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
{/* Colonne 3 : Visibilité des colonnes */}
<ColumnFilters
hiddenStatuses={hiddenStatuses}
onToggleStatus={toggleStatusVisibility}
tasks={regularTasks}
className="text-xs"
/>
</div>
{/* Deuxième ligne : TFS et Jira côte à côte */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-2">
{/* Filtres TFS */}
<TfsFilters
filters={filters}
onFiltersChange={onFiltersChange}
/>
{/* Filtres Jira */}
<JiraFilters
filters={filters}
onFiltersChange={onFiltersChange}
/>
</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">&ldquo;{filters.search}&rdquo;</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>
)}
<FilterSummary
filters={filters}
activeFiltersCount={activeFiltersCount}
onClearFilters={handleClearFilters}
className="mt-6"
/>
</div>
</div>
@@ -604,35 +310,6 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
document.body
)}
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index - masqué sur mobile */}
{!isMobile && 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>
</ControlPanel>
);
}

View File

@@ -1,10 +1,10 @@
'use client';
import { KanbanFilters } from './KanbanFilters';
import type { KanbanFilters as KanbanFiltersType } from '@/lib/types';
import { ObjectivesBoard } from './ObjectivesBoard';
import { Task, TaskStatus } from '@/lib/types';
import { KanbanFilters as KanbanFiltersType } from './KanbanFilters';
import { UserPreferences } from '@/lib/types';
import { KanbanFilters } from './KanbanFilters';
interface KanbanHeaderProps {
showFilters: boolean;
@@ -49,7 +49,7 @@ export function KanbanHeader({
tasks={pinnedTasks}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
compactView={kanbanFilters.compactView as boolean}
pinnedTagName={pinnedTagName}
/>
)}

View File

@@ -1,10 +1,10 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
import { Button, ToggleButton, ControlPanel } from '@/components/ui';
import { SourceQuickFilter } from '@/components/kanban/SourceQuickFilter';
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
import { KanbanFilters } from '@/components/kanban/KanbanFilters';
import type { KanbanFilters } from '@/lib/types';
interface MobileControlsProps {
showFilters: boolean;
@@ -34,102 +34,96 @@ export function MobileControls({
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
<div className="px-4 py-2">
{/* Barre principale mobile */}
<div className="flex items-center justify-between">
{/* Bouton menu hamburger */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--primary)]/50 transition-all"
>
<ControlPanel className="px-4 py-2">
{/* Barre principale mobile */}
<div className="flex items-center justify-between">
{/* Bouton menu hamburger */}
<ToggleButton
variant="primary"
isActive={isMenuOpen}
count={activeFiltersCount}
onClick={() => setIsMenuOpen(!isMenuOpen)}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span className="text-sm font-mono">Options</span>
{activeFiltersCount > 0 && (
<span className="bg-[var(--primary)]/20 text-[var(--primary)] text-xs px-1.5 py-0.5 rounded-full font-mono">
{activeFiltersCount}
</span>
)}
</button>
}
>
Options
</ToggleButton>
{/* Bouton d'ajout de tâche */}
<Button
variant="primary"
onClick={onCreateTask}
className="flex items-center gap-2"
size="sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="hidden xs:inline">Nouvelle</span>
</Button>
</div>
{/* Bouton d'ajout de tâche */}
<Button
variant="primary"
onClick={onCreateTask}
className="flex items-center gap-2"
size="sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="hidden xs:inline">Nouvelle</span>
</Button>
</div>
{/* Menu déroulant */}
{isMenuOpen && (
<div className="mt-3 p-3 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg">
{/* Section Affichage */}
<div className="mb-4">
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Affichage
</h3>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => {
onToggleFilters();
setIsMenuOpen(false);
}}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
showFilters
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
}`}
>
{/* Menu déroulant */}
{isMenuOpen && (
<div className="mt-3 p-3 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg">
{/* Section Affichage */}
<div className="mb-4">
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Affichage
</h3>
<div className="grid grid-cols-2 gap-2">
<ToggleButton
variant="primary"
isActive={showFilters}
onClick={() => {
onToggleFilters();
setIsMenuOpen(false);
}}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
Filtres
</button>
<button
onClick={() => {
onToggleObjectives();
setIsMenuOpen(false);
}}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
showObjectives
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
}`}
>
}
>
Filtres
</ToggleButton>
<ToggleButton
variant="accent"
isActive={showObjectives}
onClick={() => {
onToggleObjectives();
setIsMenuOpen(false);
}}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
Objectifs
</button>
</div>
}
>
Objectifs
</ToggleButton>
</div>
</div>
{/* Section Paramètres */}
<div className="mb-4">
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Paramètres
</h3>
<div className="space-y-2">
<button
onClick={() => {
onToggleCompactView();
setIsMenuOpen(false);
}}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
compactView
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
}`}
>
{/* Section Paramètres */}
<div className="mb-4">
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Paramètres
</h3>
<div className="space-y-2">
<ToggleButton
variant="secondary"
isActive={compactView}
onClick={() => {
onToggleCompactView();
setIsMenuOpen(false);
}}
className="w-full"
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{compactView ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
@@ -137,29 +131,32 @@ export function MobileControls({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
Vue {compactView ? 'détaillée' : 'compacte'}
</button>
}
>
Vue {compactView ? 'détaillée' : 'compacte'}
</ToggleButton>
<div className="flex items-center justify-between px-3 py-2 bg-[var(--muted)]/30 border border-[var(--border)] rounded-md">
<span className="text-sm font-mono text-[var(--muted-foreground)]">Taille police</span>
<FontSizeToggle />
</div>
<div className="flex items-center justify-between px-3 py-2 bg-[var(--muted)]/30 border border-[var(--border)] rounded-md">
<span className="text-sm font-mono text-[var(--muted-foreground)]">Taille police</span>
<FontSizeToggle />
</div>
</div>
</div>
{/* Section Jira */}
<div>
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Raccourcis Jira
</h3>
<JiraQuickFilter
{/* Section Sources */}
<div>
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Sources
</h3>
<div className="space-y-2">
<SourceQuickFilter
filters={kanbanFilters}
onFiltersChange={onFiltersChange}
/>
</div>
</div>
)}
</div>
</div>
</div>
)}
</ControlPanel>
);
}

View File

@@ -128,7 +128,7 @@ export function ObjectivesBoard({
<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">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<button
onClick={toggleObjectivesCollapse}
@@ -143,18 +143,6 @@ export function ObjectivesBoard({
{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">
@@ -184,15 +172,16 @@ export function ObjectivesBoard({
</CardHeader>
{!isCollapsed && (
<CardContent className="pt-0">
<CardContent className="pt-3">
{(() => {
// 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');
const completedTasks = tasks.filter(task => task.status === 'done' || task.status === 'archived');
const frozenTasks = tasks.filter(task => task.status === 'freeze');
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<DroppableColumn
status="todo"
tasks={todoTasks}
@@ -213,6 +202,16 @@ export function ObjectivesBoard({
compactView={compactView}
/>
<DroppableColumn
status="freeze"
tasks={frozenTasks}
title="Gelé"
color="bg-purple-400"
icon="🧊"
onEditTask={onEditTask}
compactView={compactView}
/>
<DroppableColumn
status="done"
tasks={completedTasks}

View File

@@ -0,0 +1,206 @@
'use client';
import { useState, useMemo, useRef, useEffect } from 'react';
import { useTasksContext } from '@/contexts/TasksContext';
import type { KanbanFilters } from '@/lib/types';
interface SourceQuickFilterProps {
filters: KanbanFilters;
onFiltersChange: (filters: KanbanFilters) => void;
}
interface SourceOption {
id: 'jira' | 'tfs';
label: string;
icon: string;
hasTasks: boolean;
}
type FilterMode = 'all' | 'show' | 'hide';
export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilterProps) {
const { regularTasks } = useTasksContext();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Vérifier quelles sources ont des tâches
const sources = useMemo((): SourceOption[] => {
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
const hasTfsTasks = regularTasks.some(task => task.source === 'tfs');
return [
{
id: 'jira' as const,
label: 'Jira',
icon: '🔹',
hasTasks: hasJiraTasks
},
{
id: 'tfs' as const,
label: 'TFS',
icon: '🔷',
hasTasks: hasTfsTasks
}
].filter(source => source.hasTasks);
}, [regularTasks]);
// Fermer le dropdown quand on clique ailleurs
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Si aucune source disponible, on n'affiche rien
if (sources.length === 0) {
return null;
}
// Déterminer l'état actuel de chaque source
const getSourceMode = (sourceId: 'jira' | 'tfs'): FilterMode => {
if (sourceId === 'jira') {
return filters.showJiraOnly ? 'show' : filters.hideJiraTasks ? 'hide' : 'all';
} else {
return filters.showTfsOnly ? 'show' : filters.hideTfsTasks ? 'hide' : 'all';
}
};
const handleModeChange = (sourceId: 'jira' | 'tfs', mode: FilterMode) => {
const updates: Partial<KanbanFilters> = {};
if (sourceId === 'jira') {
updates.showJiraOnly = mode === 'show';
updates.hideJiraTasks = mode === 'hide';
} else {
updates.showTfsOnly = mode === 'show';
updates.hideTfsTasks = mode === 'hide';
}
onFiltersChange({ ...filters, ...updates });
};
// Déterminer le texte du bouton principal
const getMainButtonText = () => {
const activeFilters = sources.filter(source => {
const mode = getSourceMode(source.id);
return mode !== 'all';
});
if (activeFilters.length === 0) {
return 'All sources';
} else if (activeFilters.length === 1) {
const source = activeFilters[0];
const mode = getSourceMode(source.id);
return mode === 'show' ? `${source.label} only` : `No ${source.label}`;
} else {
return `${activeFilters.length} filters`;
}
};
const getMainButtonStyle = () => {
const activeFilters = sources.filter(source => {
const mode = getSourceMode(source.id);
return mode !== 'all';
});
if (activeFilters.length === 0) {
return 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50';
} else {
return 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30';
}
};
return (
<div className="relative" ref={dropdownRef}>
{/* Bouton principal */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${getMainButtonStyle()}`}
title="Filtrer par source"
>
<span>🔌</span>
{getMainButtonText()}
<svg
className={`w-4 h-4 transition-transform ${isOpen ? '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>
{/* Dropdown */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-md shadow-lg z-50 min-w-[240px]">
<div className="p-3 space-y-3">
{sources.map((source) => {
const currentMode = getSourceMode(source.id);
return (
<div key={source.id} className="space-y-2">
<div className="flex items-center gap-2 text-sm font-mono text-[var(--muted-foreground)]">
<span>{source.icon}</span>
<span>{source.label}</span>
</div>
<div className="space-y-1 ml-6">
{[
{ mode: 'all' as FilterMode, label: 'Afficher tout', icon: '👁️' },
{ mode: 'show' as FilterMode, label: 'Seulement cette source', icon: '✅' },
{ mode: 'hide' as FilterMode, label: 'Masquer cette source', icon: '🚫' }
].map(({ mode, label, icon }) => (
<label
key={mode}
className="flex items-center gap-2 text-sm cursor-pointer hover:text-[var(--foreground)] transition-colors"
>
<input
type="radio"
name={`source-${source.id}`}
checked={currentMode === mode}
onChange={() => handleModeChange(source.id, mode)}
className="w-3 h-3 text-[var(--primary)] bg-[var(--background)] border-[var(--border)] focus:ring-[var(--primary)]/20"
/>
<span className="flex items-center gap-1">
<span>{icon}</span>
<span>{label}</span>
</span>
</label>
))}
</div>
</div>
);
})}
{/* Option pour réinitialiser tous les filtres */}
<div className="border-t border-[var(--border)] pt-2 mt-2">
<button
onClick={() => {
const updates: Partial<KanbanFilters> = {
showJiraOnly: false,
hideJiraTasks: false,
showTfsOnly: false,
hideTfsTasks: false
};
onFiltersChange({ ...filters, ...updates });
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all text-left bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50 hover:text-[var(--foreground)]"
title="Réinitialiser tous les filtres de source"
>
<span>🔄</span>
<span className="flex-1">Réinitialiser tout</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { useState } from 'react';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { useDragAndDrop } from '@/hooks/useDragAndDrop';
import { getAllStatuses, getTechStyle } from '@/lib/status-config';
import { Card, CardHeader, ColumnHeader, DropZone } from '@/components/ui';
import {
DndContext,
DragEndEvent,
@@ -49,7 +50,7 @@ function DroppableColumn({
});
return (
<div ref={setNodeRef} className="min-h-[100px] relative group/column">
<DropZone ref={setNodeRef} className="min-h-[100px] relative group/column">
<SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{tasks.map(task => (
@@ -90,7 +91,7 @@ function DroppableColumn({
)}
</>
)}
</div>
</DropZone>
);
}
@@ -197,11 +198,18 @@ export function SwimlanesBase({
{statusesToShow.map(status => {
const statusConfig = allStatuses.find(s => s.key === status);
const techStyle = statusConfig ? getTechStyle(statusConfig.color) : null;
const tasksInStatus = tasks.filter(task => task.status === status);
return (
<div key={status} className="text-center">
<h3 className={`text-sm font-mono font-bold uppercase tracking-wider ${techStyle?.accent || 'text-[var(--foreground)]'}`}>
{statusConfig?.icon} {statusConfig?.label}
</h3>
<ColumnHeader
title={statusConfig?.label || status}
icon={statusConfig?.icon}
count={tasksInStatus.length}
color={techStyle?.accent.replace('text-', '')}
accentColor={techStyle?.accent}
className="justify-center gap-4"
/>
</div>
);
})}
@@ -214,12 +222,12 @@ export function SwimlanesBase({
const isCollapsed = collapsedSwimlanes.has(swimlane.key);
return (
<div key={swimlane.key} className="border border-[var(--border)]/50 rounded-lg bg-[var(--card-column)]">
<Card key={swimlane.key} background="column" className="overflow-hidden">
{/* Header de la swimlane */}
<div className="flex items-center p-2 border-b border-[var(--border)]/50">
<CardHeader padding="sm" separator={false}>
<button
onClick={() => toggleSwimlane(swimlane.key)}
className="flex items-center gap-2 hover:bg-[var(--card-hover)] rounded p-1 -m-1 transition-colors"
className="flex items-center gap-2 hover:bg-[var(--card-hover)] rounded p-1 -m-1 transition-colors w-full"
>
<svg
className={`w-4 h-4 text-[var(--muted-foreground)] transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
@@ -240,7 +248,7 @@ export function SwimlanesBase({
{swimlane.label} ({swimlane.tasks.length})
</span>
</button>
</div>
</CardHeader>
{/* Contenu de la swimlane */}
{!isCollapsed && (
@@ -272,7 +280,7 @@ export function SwimlanesBase({
})}
</div>
)}
</div>
</Card>
);
})}
</div>

View File

@@ -1,15 +1,9 @@
import { useState, useEffect, useRef, useTransition } from 'react';
import { useTransition } from 'react';
import { Task } from '@/lib/types';
import { TfsConfig } from '@/services/integrations/tfs';
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 { TaskCard as UITaskCard } from '@/components/ui/TaskCard';
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 {
@@ -19,146 +13,44 @@ interface TaskCardProps {
}
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState(task.title);
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}`;
};
// Helper pour construire l'URL TFS Pull Request
const getTfsPullRequestUrl = (
tfsPullRequestId: number,
tfsProject: string,
tfsRepository: string
): string => {
const tfsConfig = preferences.tfsConfig as TfsConfig;
const baseUrl = tfsConfig?.organizationUrl;
if (!baseUrl || !tfsPullRequestId || !tfsProject || !tfsRepository)
return '';
return `${baseUrl}/${encodeURIComponent(tfsProject)}/_git/${tfsRepository}/pullrequest/${tfsPullRequestId}`;
};
// 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(() => {
const currentTimeout = timeoutRef.current;
return () => {
if (currentTimeout) {
clearTimeout(currentTimeout);
}
};
}, []);
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const handleDelete = async () => {
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();
const handleEdit = () => {
if (onEdit) {
onEdit(task);
}
};
const handleTitleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging && !isPending) {
setIsEditingTitle(true);
}
};
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);
};
const handleTitleCancel = () => {
setEditTitle(task.title);
setIsEditingTitle(false);
};
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTitleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleTitleCancel();
}
const handleTitleSave = async (newTitle: string) => {
startTransition(async () => {
const result = await updateTaskTitle(task.id, newTitle);
if (!result.success) {
console.error('Error updating task title:', result.error);
} else {
task.title = newTitle;
}
});
};
// Style de transformation pour le drag
@@ -168,385 +60,36 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
}
: 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}
title="Cliquer pour éditer"
>
{titleWithoutEmojis}
</h4>
</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%)',
}
: {};
// Styles spéciaux pour les tâches TFS
const isTfsTask = task.source === 'tfs';
const tfsStyles = isTfsTask
? {
border: '1px solid rgba(255, 165, 0, 0.3)',
borderLeft: '3px solid #FFA500',
background:
'linear-gradient(135deg, rgba(255, 165, 0, 0.05) 0%, rgba(255, 165, 0, 0.02) 100%)',
}
: {};
// Combiner les styles spéciaux
const specialStyles = { ...jiraStyles, ...tfsStyles };
// Vue compacte : seulement le titre
if (compactView) {
return (
<Card
ref={setNodeRef}
style={{ ...style, ...specialStyles }}
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' : ''
} ${
isTfsTask ? 'tfs-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
<UITaskCard
ref={setNodeRef}
style={{ ...style, ...specialStyles }}
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' : ''
} ${
isTfsTask ? 'tfs-task' : ''
} ${isPending ? 'opacity-70 pointer-events-none' : ''}`}
style={style}
variant={compactView ? 'compact' : 'detailed'}
source={task.source || 'manual'}
title={task.title}
description={task.description}
tags={task.tags}
priority={task.priority}
status={task.status}
dueDate={task.dueDate}
completedAt={task.completedAt}
jiraKey={task.jiraKey}
jiraProject={task.jiraProject}
jiraType={task.jiraType}
tfsPullRequestId={task.tfsPullRequestId}
tfsProject={task.tfsProject}
tfsRepository={task.tfsRepository}
isDragging={isDragging}
isPending={isPending}
onEdit={handleEdit}
onDelete={handleDelete}
onTitleSave={handleTitleSave}
fontSize={preferences.viewPreferences.fontSize}
availableTags={availableTags}
jiraConfig={preferences.jiraConfig}
tfsConfig={preferences.tfsConfig}
{...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(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>
)
) : task.source === 'tfs' && task.tfsPullRequestId ? (
preferences.tfsConfig &&
(preferences.tfsConfig as TfsConfig).organizationUrl ? (
<a
href={getTfsPullRequestUrl(
task.tfsPullRequestId,
task.tfsProject || '',
task.tfsRepository || ''
)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="hover:scale-105 transition-transform"
>
<Badge
variant="outline"
size="sm"
className="hover:bg-orange-500/10 hover:border-orange-400/50 cursor-pointer"
>
PR-{task.tfsPullRequestId}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
PR-{task.tfsPullRequestId}
</Badge>
)
) : (
<Badge variant="outline" size="sm">
{task.source}
</Badge>
))}
{/* Badges spécifiques TFS */}
{task.tfsRepository && (
<Badge
variant="outline"
size="sm"
className="text-orange-400 border-orange-400/30"
>
{task.tfsRepository}
</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>
{...listeners}
/>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { TaskStatus, Task } from '@/lib/types';
import { getAllStatuses } from '@/lib/status-config';
import { FilterChip } from '@/components/ui';
interface ColumnFiltersProps {
hiddenStatuses: Set<TaskStatus>;
onToggleStatus: (status: TaskStatus) => void;
tasks: Task[];
}
export function ColumnFilters({ hiddenStatuses, onToggleStatus, tasks }: ColumnFiltersProps) {
return (
<div className="space-y-2">
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Colonnes
</label>
<div className="flex flex-wrap gap-1">
{getAllStatuses().map(statusConfig => {
const statusCount = tasks.filter(task => task.status === statusConfig.key).length;
return (
<FilterChip
key={statusConfig.key}
onClick={() => onToggleStatus(statusConfig.key)}
variant={hiddenStatuses.has(statusConfig.key) ? 'hidden' : 'default'}
count={statusCount}
icon={
hiddenStatuses.has(statusConfig.key) ? '👁️‍🗨️' : '👁️'
}
title={hiddenStatuses.has(statusConfig.key) ? `Afficher ${statusConfig.label}` : `Masquer ${statusConfig.label}`}
>
{statusConfig.label}
</FilterChip>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { FilterChip } from '@/components/ui';
interface GeneralFiltersProps {
showWithDueDate?: boolean;
onDueDateFilterToggle: () => void;
}
export function GeneralFilters({ showWithDueDate = false, onDueDateFilterToggle }: GeneralFiltersProps) {
return (
<div className="space-y-2">
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Généraux
</label>
<div className="flex flex-wrap gap-1">
<FilterChip
onClick={onDueDateFilterToggle}
variant={showWithDueDate ? 'selected' : 'default'}
icon={
<svg
className="w-4 h-4"
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 002 2v12a2 2 0 002 2z" />
</svg>
}
>
Avec date de fin
</FilterChip>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useMemo } from 'react';
import { Button, FilterChip } from '@/components/ui';
import { useTasksContext } from '@/contexts/TasksContext';
import type { KanbanFilters } from '@/lib/types';
interface JiraFiltersProps {
filters: KanbanFilters;
onFiltersChange: (filters: KanbanFilters) => void;
}
export function JiraFilters({ filters, onFiltersChange }: JiraFiltersProps) {
const { regularTasks } = useTasksContext();
// 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');
// Récupérer les projets et types Jira disponibles dans toutes les tâches
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]);
// Calculer les compteurs pour les projets Jira
const jiraProjectCounts = useMemo(() => {
const counts: Record<string, number> = {};
availableJiraProjects.forEach(project => {
counts[project] = regularTasks.filter(task =>
task.source === 'jira' && task.jiraProject === project
).length;
});
return counts;
}, [regularTasks, availableJiraProjects]);
// Calculer les compteurs pour les types Jira
const jiraTypeCounts = useMemo(() => {
const counts: Record<string, number> = {};
availableJiraTypes.forEach(type => {
counts[type] = regularTasks.filter(task =>
task.source === 'jira' && task.jiraType === type
).length;
});
return counts;
}, [regularTasks, availableJiraTypes]);
const handleJiraToggle = (mode: 'show' | 'hide' | 'all') => {
const updates: Partial<KanbanFilters> = {};
switch (mode) {
case 'show':
updates.showJiraOnly = true;
updates.hideJiraTasks = false;
// Désactiver les filtres TFS conflictuels
updates.showTfsOnly = false;
break;
case 'hide':
updates.showJiraOnly = false;
updates.hideJiraTasks = true;
// Désactiver les filtres TFS conflictuels
updates.hideTfsTasks = false;
break;
case 'all':
updates.showJiraOnly = false;
updates.hideJiraTasks = false;
// Ne pas toucher aux filtres TFS
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
});
};
// Si pas de tâches Jira, on n'affiche rien
if (!hasJiraTasks) {
return null;
}
return (
<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) => (
<FilterChip
key={project}
onClick={() => handleJiraProjectToggle(project)}
variant={filters.jiraProjects?.includes(project) ? 'selected' : 'default'}
count={jiraProjectCounts[project]}
icon="📋"
>
{project}
</FilterChip>
))}
</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) => {
const getTypeIcon = (type: string) => {
switch (type) {
case 'Feature': return '✨';
case 'Story': return '📖';
case 'Task': return '📝';
case 'Bug': return '🐛';
case 'Support': return '🛠️';
case 'Enabler': return '🔧';
default: return '📋';
}
};
return (
<FilterChip
key={type}
onClick={() => handleJiraTypeToggle(type)}
variant={filters.jiraTypes?.includes(type) ? 'selected' : 'default'}
count={jiraTypeCounts[type]}
icon={getTypeIcon(type)}
>
{type}
</FilterChip>
);
})}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useMemo } from 'react';
import { TaskPriority } from '@/lib/types';
import { useTasksContext } from '@/contexts/TasksContext';
import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
import { FilterChip } from '@/components/ui';
interface PriorityFiltersProps {
selectedPriorities?: TaskPriority[];
onPriorityToggle: (priority: TaskPriority) => void;
}
export function PriorityFilters({ selectedPriorities = [], onPriorityToggle }: PriorityFiltersProps) {
const { regularTasks } = useTasksContext();
// Calculer les compteurs pour les priorités basés sur toutes les tâches
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]);
const priorityOptions = getAllPriorities().map(priorityConfig => ({
value: priorityConfig.key,
label: priorityConfig.label,
color: priorityConfig.color,
count: priorityCounts[priorityConfig.key] || 0
}));
// Filtrer les priorités qui ont des tâches visibles
const visiblePriorities = priorityOptions.filter(priority => priority.count > 0);
return (
<div className="space-y-2">
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Priorités
</label>
{visiblePriorities.length === 0 ? (
<div className="text-xs text-[var(--muted-foreground)] italic">
Aucune priorité disponible dans les tâches filtrées
</div>
) : (
<div className="flex flex-wrap gap-1">
{visiblePriorities.map((priority) => (
<FilterChip
key={priority.value}
onClick={() => onPriorityToggle(priority.value)}
variant={selectedPriorities.includes(priority.value) ? 'selected' : 'priority'}
color={getPriorityColorHex(priority.color)}
count={priority.count}
>
{priority.label}
</FilterChip>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useMemo } from 'react';
import { useTasksContext } from '@/contexts/TasksContext';
import { FilterChip } from '@/components/ui';
interface TagFiltersProps {
selectedTags?: string[];
onTagToggle: (tagName: string) => void;
}
export function TagFilters({ selectedTags = [], onTagToggle }: TagFiltersProps) {
const { tags: availableTags, regularTasks } = useTasksContext();
// Calculer les compteurs pour les tags basés sur toutes les tâches
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]);
// 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]);
// Montrer tous les tags disponibles (pas seulement ceux avec des tâches visibles)
const visibleTags = sortedTags;
if (availableTags.length === 0) {
return null;
}
return (
<div className="space-y-2">
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Tags
</label>
{visibleTags.length === 0 ? (
<div className="text-xs text-[var(--muted-foreground)] italic">
Aucun tag disponible
</div>
) : (
<div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto">
{visibleTags.map((tag) => (
<FilterChip
key={tag.id}
onClick={() => onTagToggle(tag.name)}
variant={selectedTags.includes(tag.name) ? 'selected' : 'tag'}
color={tag.color}
count={tagCounts[tag.name]}
>
{tag.name}
</FilterChip>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useMemo } from 'react';
import { Button, FilterChip } from '@/components/ui';
import { useTasksContext } from '@/contexts/TasksContext';
import type { KanbanFilters } from '@/lib/types';
interface TfsFiltersProps {
filters: KanbanFilters;
onFiltersChange: (filters: KanbanFilters) => void;
}
export function TfsFilters({ filters, onFiltersChange }: TfsFiltersProps) {
const { regularTasks } = useTasksContext();
// Vérifier s'il y a des tâches TFS dans le système (même masquées)
const hasTfsTasks = regularTasks.some(task => task.source === 'tfs');
// Récupérer les projets TFS disponibles dans toutes les tâches
const availableTfsProjects = useMemo(() => {
const projects = new Set<string>();
regularTasks.forEach(task => {
if (task.source === 'tfs' && task.tfsProject) {
projects.add(task.tfsProject);
}
});
return Array.from(projects).sort();
}, [regularTasks]);
// Calculer les compteurs pour les projets TFS
const tfsProjectCounts = useMemo(() => {
const counts: Record<string, number> = {};
availableTfsProjects.forEach(project => {
counts[project] = regularTasks.filter(task =>
task.source === 'tfs' && task.tfsProject === project
).length;
});
return counts;
}, [regularTasks, availableTfsProjects]);
const handleTfsToggle = (mode: 'show' | 'hide' | 'all') => {
const updates: Partial<KanbanFilters> = {};
switch (mode) {
case 'show':
updates.showTfsOnly = true;
updates.hideTfsTasks = false;
// Désactiver les filtres Jira conflictuels
updates.showJiraOnly = false;
break;
case 'hide':
updates.showTfsOnly = false;
updates.hideTfsTasks = true;
// Désactiver les filtres Jira conflictuels
updates.hideJiraTasks = false;
break;
case 'all':
updates.showTfsOnly = false;
updates.hideTfsTasks = false;
// Ne pas toucher aux filtres Jira
break;
}
onFiltersChange({ ...filters, ...updates });
};
const handleTfsProjectToggle = (project: string) => {
const currentProjects = filters.tfsProjects || [];
const newProjects = currentProjects.includes(project)
? currentProjects.filter(p => p !== project)
: [...currentProjects, project];
onFiltersChange({
...filters,
tfsProjects: newProjects
});
};
// Si pas de tâches TFS, on n'affiche rien
if (!hasTfsTasks) {
return null;
}
return (
<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">
📦 TFS
</label>
{/* Toggle TFS Show/Hide - inline avec le titre */}
<div className="flex gap-1">
<Button
variant={filters.showTfsOnly ? "primary" : "ghost"}
onClick={() => handleTfsToggle('show')}
size="sm"
className="text-xs px-2 py-1 h-auto"
>
🔷 Seul
</Button>
<Button
variant={filters.hideTfsTasks ? "danger" : "ghost"}
onClick={() => handleTfsToggle('hide')}
size="sm"
className="text-xs px-2 py-1 h-auto"
>
🚫 Mask
</Button>
<Button
variant={(!filters.showTfsOnly && !filters.hideTfsTasks) ? "primary" : "ghost"}
onClick={() => handleTfsToggle('all')}
size="sm"
className="text-xs px-2 py-1 h-auto"
>
📋 All
</Button>
</div>
</div>
{/* Projets TFS */}
{availableTfsProjects.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">
{availableTfsProjects.map((project) => (
<FilterChip
key={project}
onClick={() => handleTfsProjectToggle(project)}
variant={filters.tfsProjects?.includes(project) ? 'selected' : 'default'}
count={tfsProjectCounts[project]}
icon="📦"
>
{project}
</FilterChip>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useTransition } from 'react';
import { backupClient, BackupListResponse } from '@/clients/backup-client';
import { BackupConfig } from '@/services/data-management/backup';
import { Button } from '@/components/ui/Button';
@@ -8,18 +8,27 @@ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Header } from '@/components/ui/Header';
import { formatDateForDisplay, parseDate, getToday } from '@/lib/date-utils';
import { BackupTimelineChart } from '@/components/backup/BackupTimelineChart';
import { createBackupAction, verifyDatabaseAction } from '@/actions/backup';
import { parseDate, getToday } from '@/lib/date-utils';
import Link from 'next/link';
interface BackupSettingsPageClientProps {
initialData?: BackupListResponse;
initialData?: BackupListResponse & {
backupStats?: Array<{
date: string;
manual: number;
automatic: number;
total: number;
}>;
};
}
export default function BackupSettingsPageClient({ initialData }: BackupSettingsPageClientProps) {
const [data, setData] = useState<BackupListResponse | null>(initialData || null);
const [backupStats, setBackupStats] = useState(initialData?.backupStats || []);
const [isLoading, setIsLoading] = useState(!initialData);
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [isPending, startTransition] = useTransition();
const [showRestoreConfirm, setShowRestoreConfirm] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [config, setConfig] = useState<BackupConfig | null>(initialData?.config || null);
@@ -56,10 +65,18 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
const loadData = async () => {
try {
console.log('🔄 Loading backup data...');
const response = await backupClient.listBackups();
const [response, newBackupStats] = await Promise.all([
backupClient.listBackups(),
backupClient.getBackupStats(30)
]);
console.log('✅ Backup data loaded:', response);
console.log('✅ Backup stats loaded:', newBackupStats);
setData(response);
setBackupStats(newBackupStats);
setConfig(response.config);
console.log('✅ States updated - backups count:', response.backups.length, 'stats count:', newBackupStats.length);
} catch (error) {
console.error('❌ Failed to load backup data:', error);
// Afficher l'erreur spécifique à l'utilisateur
@@ -71,59 +88,79 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
}
};
const handleCreateBackup = async (force: boolean = false) => {
setIsCreatingBackup(true);
try {
const result = await backupClient.createBackup(force);
if (result === null) {
const handleCreateBackup = (force: boolean = false) => {
startTransition(async () => {
try {
console.log('🔄 Creating backup...');
const result = await createBackupAction(force);
console.log('✅ Backup action result:', result);
if (!result.success) {
setMessage('backup', {
type: 'error',
text: result.error || 'Erreur lors de la création de la sauvegarde'
});
return;
}
if (result.skipped) {
setMessage('backup', {
type: 'success',
text: result.message || 'Sauvegarde sautée : aucun changement détecté.'
});
} else {
setMessage('backup', {
type: 'success',
text: result.message || 'Sauvegarde créée avec succès'
});
}
// Petit délai pour être sûr que la sauvegarde est bien créée
await new Promise(resolve => setTimeout(resolve, 500));
// Recharger les données manuellement pour être sûr
console.log('🔄 Reloading data after backup...');
await loadData();
console.log('✅ Data reloaded manually');
} catch (error) {
console.error('❌ Error in handleCreateBackup:', error);
setMessage('backup', {
type: 'success',
text: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.'
});
} else {
setMessage('backup', {
type: 'success',
text: `Sauvegarde créée : ${result.filename}`
type: 'error',
text: 'Erreur lors de la création de la sauvegarde'
});
}
});
};
const handleVerifyDatabase = () => {
startTransition(async () => {
setMessage('verify', null);
const result = await verifyDatabaseAction();
await loadData();
} catch (error) {
console.error('Failed to create backup:', error);
setMessage('backup', {
type: 'error',
text: 'Erreur lors de la création de la sauvegarde'
});
} finally {
setIsCreatingBackup(false);
}
if (result.success) {
setMessage('verify', {type: 'success', text: result.message || 'Intégrité vérifiée'});
} else {
setMessage('verify', {type: 'error', text: result.error || 'Vérification échouée'});
}
});
};
const handleVerifyDatabase = async () => {
setIsVerifying(true);
setMessage('verify', null);
try {
await backupClient.verifyDatabase();
setMessage('verify', {type: 'success', text: 'Intégrité vérifiée'});
} catch (error) {
console.error('Database verification failed:', error);
setMessage('verify', {type: 'error', text: 'Vérification échouée'});
} finally {
setIsVerifying(false);
}
};
const handleDeleteBackup = async (filename: string) => {
try {
await backupClient.deleteBackup(filename);
setShowDeleteConfirm(null);
setMessage('restore', {type: 'success', text: `Sauvegarde ${filename} supprimée`});
await loadData();
} catch (error) {
console.error('Failed to delete backup:', error);
setMessage('restore', {type: 'error', text: 'Suppression échouée'});
}
const handleDeleteBackup = (filename: string) => {
startTransition(async () => {
try {
console.log('🔄 Deleting backup:', filename);
await backupClient.deleteBackup(filename);
setShowDeleteConfirm(null);
setMessage('restore', {type: 'success', text: `Sauvegarde ${filename} supprimée`});
console.log('🔄 Reloading data after deletion...');
await loadData();
console.log('✅ Data reloaded after deletion');
} catch (error) {
console.error('Failed to delete backup:', error);
setMessage('restore', {type: 'error', text: 'Suppression échouée'});
}
});
};
const handleRestoreBackup = async (filename: string) => {
@@ -192,10 +229,16 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
const formatDate = (date: string | Date): string => {
// Format cohérent serveur/client pour éviter les erreurs d'hydratation
const formatDateWithTime = (date: string | Date): string => {
const d = typeof date === 'string' ? parseDate(date) : date;
return formatDateForDisplay(d, 'DISPLAY_MEDIUM');
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (isLoading) {
@@ -228,11 +271,14 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
if (!message) return null;
return (
<div className={`text-xs mt-2 px-2 py-1 rounded transition-all inline-block ${
message.type === 'success'
? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/20'
: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/20'
}`}>
<div
className="text-xs mt-2 px-2 py-1 rounded transition-all inline-block border"
style={{
color: message.type === 'success' ? 'var(--success)' : 'var(--destructive)',
backgroundColor: message.type === 'success' ? 'color-mix(in srgb, var(--success) 10%, transparent)' : 'color-mix(in srgb, var(--destructive) 10%, transparent)',
borderColor: message.type === 'success' ? 'color-mix(in srgb, var(--success) 20%, var(--border))' : 'color-mix(in srgb, var(--destructive) 20%, var(--border))'
}}
>
{message.text}
</div>
);
@@ -391,17 +437,17 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
<div className="flex gap-2">
<Button
onClick={() => handleCreateBackup(false)}
disabled={isCreatingBackup}
disabled={isPending}
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
>
{isCreatingBackup ? 'Création...' : 'Créer sauvegarde'}
{isPending ? 'Création...' : 'Créer sauvegarde'}
</Button>
<Button
onClick={() => handleCreateBackup(true)}
disabled={isCreatingBackup}
disabled={isPending}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{isCreatingBackup ? 'Création...' : 'Forcer'}
{isPending ? 'Création...' : 'Forcer'}
</Button>
</div>
<div className="text-xs text-[var(--muted-foreground)]">
@@ -413,10 +459,10 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
<div className="flex gap-2">
<Button
onClick={handleVerifyDatabase}
disabled={isVerifying}
disabled={isPending}
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
>
{isVerifying ? 'Vérification...' : 'Vérifier l\'intégrité'}
{isPending ? 'Vérification...' : 'Vérifier l\'intégrité'}
</Button>
<InlineMessage messageKey="verify" />
@@ -433,6 +479,13 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
</div>
</CardContent>
</Card>
{/* Graphique timeline des sauvegardes */}
<Card>
<CardContent className="p-0">
<BackupTimelineChart stats={Array.isArray(backupStats) ? backupStats : []} />
</CardContent>
</Card>
</div>
{/* Colonne latérale: Statut et historique */}
@@ -511,16 +564,18 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
<span className="text-xs text-[var(--muted-foreground)]">
{formatFileSize(backup.size)}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
backup.type === 'manual'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
color: backup.type === 'manual' ? 'var(--blue)' : 'var(--muted-foreground)',
backgroundColor: backup.type === 'manual' ? 'color-mix(in srgb, var(--blue) 10%, transparent)' : 'color-mix(in srgb, var(--muted) 10%, transparent)'
}}
>
{backup.type === 'manual' ? 'Manuel' : 'Auto'}
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{formatDate(backup.createdAt)}
{formatDateWithTime(backup.createdAt)}
</p>
</div>

View File

@@ -5,6 +5,7 @@ import { useTags } from '@/hooks/useTags';
import { Header } from '@/components/ui/Header';
import { Card, CardContent } from '@/components/ui/Card';
import { TagsManagement } from './tags/TagsManagement';
import { ThemeSelector } from '@/components/ThemeSelector';
import Link from 'next/link';
interface GeneralSettingsPageClientProps {
@@ -46,7 +47,34 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
</p>
</div>
<div className="space-y-6">
<div className="space-y-8">
{/* Sélection de thème */}
<div className="bg-[var(--card)]/30 border border-[var(--border)]/50 rounded-lg p-6 backdrop-blur-sm">
<ThemeSelector />
</div>
{/* UI Showcase */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-[var(--foreground)] mb-2">
🎨 UI Components Showcase
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
Visualisez tous les composants UI disponibles avec différents thèmes
</p>
</div>
<Link
href="/ui-showcase"
className="inline-flex items-center px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[color-mix(in_srgb,var(--primary)_90%,transparent)] transition-colors font-medium"
>
Voir la démo
</Link>
</div>
</CardContent>
</Card>
{/* Gestion des tags */}
<TagsManagement
tags={tags}

View File

@@ -341,8 +341,8 @@ export function JiraConfigForm() {
{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'
? 'border border-[var(--success)]/20'
: 'border border-[var(--destructive)]/20'
}`}>
{validationResult.text}
</div>
@@ -433,11 +433,14 @@ export function JiraConfigForm() {
)}
{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'
}`}>
<div
className="p-4 rounded border"
style={{
color: message.type === 'success' ? 'var(--success)' : 'var(--destructive)',
backgroundColor: message.type === 'success' ? 'color-mix(in srgb, var(--success) 10%, transparent)' : 'color-mix(in srgb, var(--destructive) 10%, transparent)',
borderColor: message.type === 'success' ? 'color-mix(in srgb, var(--success) 20%, var(--border))' : 'color-mix(in srgb, var(--destructive) 20%, var(--border))'
}}
>
{message.text}
</div>
)}

View File

@@ -339,16 +339,16 @@ export function TfsConfigForm() {
{/* Actions de gestion des données TFS */}
{isTfsConfigured && (
<div className="p-4 bg-[var(--card)] rounded border border-orange-200 dark:border-orange-800">
<div className="p-4 bg-[var(--card)] rounded border" style={{ borderColor: 'color-mix(in srgb, var(--accent) 30%, var(--border))', backgroundColor: 'color-mix(in srgb, var(--accent) 5%, var(--card))', color: 'var(--accent)' }}>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-orange-800 dark:text-orange-200">
<h3 className="font-medium" style={{ color: 'var(--accent)' }}>
Gestion des données
</h3>
<p className="text-sm text-orange-600 dark:text-orange-300">
<p className="text-sm" style={{ color: 'var(--accent)' }}>
Supprimez toutes les tâches TFS synchronisées de la base locale
</p>
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1">
<p className="text-xs mt-1" style={{ color: 'var(--accent)' }}>
<strong>Attention:</strong> Cette action est irréversible et
supprimera définitivement toutes les tâches importées depuis
Azure DevOps.
@@ -624,11 +624,12 @@ export function TfsConfigForm() {
{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'
}`}
className="p-4 rounded border"
style={{
color: message.type === 'success' ? 'var(--success)' : 'var(--destructive)',
backgroundColor: message.type === 'success' ? 'color-mix(in srgb, var(--success) 10%, transparent)' : 'color-mix(in srgb, var(--destructive) 10%, transparent)',
borderColor: message.type === 'success' ? 'color-mix(in srgb, var(--success) 20%, var(--border))' : 'color-mix(in srgb, var(--destructive) 20%, var(--border))'
}}
>
{message.text}
</div>

View File

@@ -43,11 +43,9 @@ export function QuickActions({
Créer une sauvegarde des données
</p>
{messages.backup && (
<p className={`text-xs mt-1 ${
messages.backup.type === 'success'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
<p className="text-xs mt-1" style={{
color: messages.backup.type === 'success' ? 'var(--success)' : 'var(--destructive)'
}}>
{messages.backup.text}
</p>
)}
@@ -72,11 +70,9 @@ export function QuickActions({
Tester la connexion Jira
</p>
{messages.jira && (
<p className={`text-xs mt-1 ${
messages.jira.type === 'success'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
<p className="text-xs mt-1" style={{
color: messages.jira.type === 'success' ? 'var(--success)' : 'var(--destructive)'
}}>
{messages.jira.text}
</p>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
'use client';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { PriorityBadge } from '@/components/ui/PriorityBadge';
import { Tag } from '@/lib/types';
export interface AchievementData {
id: string;
title: string;
description?: string;
impact: 'low' | 'medium' | 'high';
completedAt: Date;
tags?: string[];
todosCount?: number;
}
interface AchievementCardProps {
achievement: AchievementData;
availableTags: (Tag & { usage: number })[];
index: number;
showDescription?: boolean;
maxTags?: number;
className?: string;
}
export function AchievementCard({
achievement,
availableTags,
index,
showDescription = true,
maxTags = 2,
className = ''
}: AchievementCardProps) {
return (
<div className={`relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group ${className}`}>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center text-[var(--success)] bg-[var(--success)]/15 border border-[var(--success)]/25">
#{index + 1}
</span>
<PriorityBadge priority={achievement.impact} />
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{format(achievement.completedAt, 'dd/MM', { locale: fr })}
</span>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{achievement.title}
</h4>
{/* Tags */}
{achievement.tags && achievement.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={achievement.tags}
availableTags={availableTags as Tag[]}
size="sm"
maxTags={maxTags}
/>
</div>
)}
{/* Description si disponible */}
{showDescription && achievement.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{achievement.description}
</p>
)}
{/* Count de todos */}
{achievement.todosCount !== undefined && achievement.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{achievement.todosCount} todo{achievement.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import { Button } from './Button';
import { cn } from '@/lib/utils';
interface ActionCardProps {
title: string;
description?: string;
icon?: ReactNode;
onClick?: () => void;
href?: string;
variant?: 'primary' | 'secondary' | 'ghost';
className?: string;
}
export function ActionCard({
title,
description,
icon,
onClick,
href,
variant = 'secondary',
className
}: ActionCardProps) {
const content = (
<Button
variant={variant}
onClick={onClick}
className={cn("flex items-center gap-3 p-6 h-auto text-left justify-start w-full", className)}
>
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
<div className="flex-1 min-w-0">
<div className={`font-semibold ${
variant === 'primary'
? 'text-[var(--primary-foreground)]'
: 'text-[var(--foreground)]'
}`}>
{title}
</div>
{description && (
<div className={`text-sm ${
variant === 'primary'
? 'text-[var(--primary-foreground)] opacity-60'
: 'text-[var(--muted-foreground)]'
}`}>
{description}
</div>
)}
</div>
</Button>
);
if (href) {
return (
<a href={href} className="block">
{content}
</a>
);
}
return content;
}

View File

@@ -0,0 +1,49 @@
'use client';
import { Card } from '@/components/ui/Card';
interface AlertProps {
variant?: 'default' | 'destructive' | 'success' | 'warning' | 'info';
className?: string;
children: React.ReactNode;
}
export function Alert({ variant = 'default', className = '', children }: AlertProps) {
const getVariantClasses = () => {
switch (variant) {
case 'destructive':
return 'outline-card-red';
case 'success':
return 'outline-card-green';
case 'info':
return 'outline-card-blue';
case 'warning':
return 'outline-card-yellow';
case 'default':
default:
return 'outline-card-gray';
}
};
return (
<Card className={`${getVariantClasses()} ${className}`}>
{children}
</Card>
);
}
export function AlertTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<h3 className={`text-sm font-semibold mb-2 ${className}`}>
{children}
</h3>
);
}
export function AlertDescription({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<div className={`text-sm ${className}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { Card } from '@/components/ui/Card';
export interface AlertItem {
id: string;
title: string;
icon?: string;
urgency?: 'low' | 'medium' | 'high' | 'critical';
source?: string;
metadata?: string;
}
interface AlertProps {
title: string;
items: AlertItem[];
icon?: string;
variant?: 'info' | 'warning' | 'error' | 'success';
className?: string;
onItemClick?: (item: AlertItem) => void;
}
export function AlertBanner({
title,
items,
icon = '⚠️',
variant = 'warning',
className = '',
onItemClick
}: AlertProps) {
// Ne rien afficher si pas d'éléments
if (!items || items.length === 0) {
return null;
}
const getVariantClasses = () => {
switch (variant) {
case 'error':
return 'outline-card-red';
case 'success':
return 'outline-card-green';
case 'info':
return 'outline-card-blue';
case 'warning':
default:
return 'outline-card-yellow';
}
};
const getUrgencyColor = (urgency?: string) => {
switch (urgency) {
case 'critical':
return 'text-red-600';
case 'high':
return 'text-orange-600';
case 'medium':
return 'text-yellow-600';
case 'low':
return 'text-green-600';
default:
return 'text-gray-600';
}
};
const getSourceIcon = (source?: string) => {
switch (source) {
case 'jira':
return '🔗';
case 'reminder':
return '📱';
case 'tfs':
return '🔧';
default:
return '📋';
}
};
return (
<Card className={`mb-2 ${className}`}>
<div className={`${getVariantClasses()} p-4`}>
<div className="flex items-start gap-3">
<div className="text-2xl">{icon}</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold mb-2">
{title} ({items.length})
</h3>
<div className="flex flex-wrap gap-2">
{items.map((item, index) => (
<div
key={item.id}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity ${
onItemClick ? 'hover:bg-[var(--card)]/50' : ''
}`}
style={{
backgroundColor: 'color-mix(in srgb, var(--primary) 15%, var(--card))',
borderColor: 'color-mix(in srgb, var(--primary) 35%, var(--border))',
border: '1px solid',
color: 'color-mix(in srgb, var(--primary) 85%, var(--foreground))'
}}
onClick={() => onItemClick?.(item)}
title={item.title}
>
<span>{item.icon || getSourceIcon(item.source)}</span>
<span className="truncate max-w-[200px]">
{item.title}
</span>
{item.metadata && (
<span className="text-[10px] opacity-75">
({item.metadata})
</span>
)}
{item.urgency && (
<span className={`text-[10px] ${getUrgencyColor(item.urgency)}`}>
{item.urgency === 'critical' ? '🔴' :
item.urgency === 'high' ? '🟠' :
item.urgency === 'medium' ? '🟡' : '🟢'}
</span>
)}
{index < items.length - 1 && (
<span className="opacity-50"></span>
)}
</div>
))}
</div>
{items.length > 0 && (
<div className="mt-2 text-xs opacity-75">
Cliquez sur un élément pour plus de détails
</div>
)}
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,38 +1,44 @@
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';
interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'primary' | 'success' | 'destructive' | 'accent' | 'purple' | 'yellow' | 'green' | 'blue' | 'gray' | 'outline' | 'danger' | 'warning';
size?: 'sm' | 'md' | 'lg';
}
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
const Badge = forwardRef<HTMLDivElement, 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)]'
default: 'bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]',
primary: 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)] text-[var(--primary)] border border-[color-mix(in_srgb,var(--primary)_25%,var(--border))]',
success: 'bg-[color-mix(in_srgb,var(--success)_10%,transparent)] text-[var(--success)] border border-[color-mix(in_srgb,var(--success)_25%,var(--border))]',
destructive: 'bg-[color-mix(in_srgb,var(--destructive)_10%,transparent)] text-[var(--destructive)] border border-[color-mix(in_srgb,var(--destructive)_25%,var(--border))]',
accent: 'bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] text-[var(--accent)] border border-[color-mix(in_srgb,var(--accent)_25%,var(--border))]',
purple: 'bg-[color-mix(in_srgb,var(--purple)_10%,transparent)] text-[var(--purple)] border border-[color-mix(in_srgb,var(--purple)_25%,var(--border))]',
yellow: 'bg-[color-mix(in_srgb,var(--yellow)_10%,transparent)] text-[var(--yellow)] border border-[color-mix(in_srgb,var(--yellow)_25%,var(--border))]',
green: 'bg-[color-mix(in_srgb,var(--green)_10%,transparent)] text-[var(--green)] border border-[color-mix(in_srgb,var(--green)_25%,var(--border))]',
blue: 'bg-[color-mix(in_srgb,var(--blue)_10%,transparent)] text-[var(--blue)] border border-[color-mix(in_srgb,var(--blue)_25%,var(--border))]',
gray: 'bg-[color-mix(in_srgb,var(--gray)_10%,transparent)] text-[var(--gray)] border border-[color-mix(in_srgb,var(--gray)_25%,var(--border))]',
outline: 'bg-transparent text-[var(--foreground)] border border-[var(--border)]',
danger: 'bg-[color-mix(in_srgb,var(--destructive)_10%,transparent)] text-[var(--destructive)] border border-[color-mix(in_srgb,var(--destructive)_25%,var(--border))]',
warning: 'bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] text-[var(--accent)] border border-[color-mix(in_srgb,var(--accent)_25%,var(--border))]'
};
const sizes = {
sm: 'px-1.5 py-0.5 text-xs rounded',
md: 'px-2 py-1 text-xs rounded-md'
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-0.5 text-xs',
lg: 'px-3 py-1 text-sm'
};
return (
<span
<div
ref={ref}
className={cn(
baseStyles,
'inline-flex items-center rounded-md font-medium transition-colors',
variants[variant],
sizes[size],
className
)}
ref={ref}
{...props}
/>
);
@@ -41,4 +47,4 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
Badge.displayName = 'Badge';
export { Badge };
export { Badge };

View File

@@ -2,36 +2,37 @@ import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'success' | 'selected' | 'danger';
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)]'
primary: 'bg-[var(--primary)] text-[var(--primary-foreground)] hover:bg-[color-mix(in_srgb,var(--primary)_90%,transparent)]',
secondary: 'bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--card-hover)]',
ghost: 'text-[var(--foreground)] hover:bg-[var(--card-hover)]',
destructive: 'bg-[var(--destructive)] text-white hover:bg-[color-mix(in_srgb,var(--destructive)_90%,transparent)]',
success: 'bg-[var(--success)] text-white hover:bg-[color-mix(in_srgb,var(--success)_90%,transparent)]',
selected: 'bg-[color-mix(in_srgb,var(--primary)_15%,transparent)] text-[var(--foreground)] border border-[var(--primary)] hover:bg-[color-mix(in_srgb,var(--primary)_20%,transparent)]',
danger: 'bg-[var(--destructive)] text-white hover:bg-[color-mix(in_srgb,var(--destructive)_90%,transparent)]'
};
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'
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
return (
<button
ref={ref}
className={cn(
baseStyles,
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
variants[variant],
sizes[size],
className
)}
ref={ref}
{...props}
/>
);
@@ -40,4 +41,4 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
Button.displayName = 'Button';
export { Button };
export { Button };

View File

@@ -7,17 +7,23 @@ import { formatDateForAPI, createDate, getToday } from '@/lib/date-utils';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface DailyCalendarProps {
interface CalendarProps {
currentDate: Date;
onDateSelect: (date: Date) => void;
dailyDates: string[]; // Liste des dates qui ont des dailies (format YYYY-MM-DD)
markedDates?: string[]; // Liste des dates marquées (format YYYY-MM-DD)
showTodayButton?: boolean;
showLegend?: boolean;
className?: string;
}
export function DailyCalendar({
export function Calendar({
currentDate,
onDateSelect,
dailyDates,
}: DailyCalendarProps) {
markedDates = [],
showTodayButton = true,
showLegend = true,
className = ''
}: CalendarProps) {
const [viewDate, setViewDate] = useState(createDate(currentDate));
// Formatage des dates pour comparaison (éviter le décalage timezone)
@@ -90,8 +96,8 @@ export function DailyCalendar({
return date.getMonth() === viewDate.getMonth();
};
const hasDaily = (date: Date) => {
return dailyDates.includes(formatDateKey(date));
const hasMarkedDate = (date: Date) => {
return markedDates.includes(formatDateKey(date));
};
const isSelected = (date: Date) => {
@@ -105,7 +111,7 @@ export function DailyCalendar({
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
return (
<Card className="p-4">
<Card className={`p-4 ${className}`}>
{/* Header avec navigation */}
<div className="flex items-center justify-between mb-4">
<Button
@@ -132,11 +138,13 @@ export function DailyCalendar({
</div>
{/* Bouton Aujourd'hui */}
<div className="mb-4 text-center">
<Button onClick={goToToday} variant="primary" size="sm">
Aujourd&apos;hui
</Button>
</div>
{showTodayButton && (
<div className="mb-4 text-center">
<Button onClick={goToToday} variant="primary" size="sm">
Aujourd&apos;hui
</Button>
</div>
)}
{/* Jours de la semaine */}
<div className="grid grid-cols-7 gap-1 mb-2">
@@ -155,7 +163,7 @@ export function DailyCalendar({
{days.map((date, index) => {
const isCurrentMonthDay = isCurrentMonth(date);
const isTodayDay = isTodayDate(date);
const hasCheckboxes = hasDaily(date);
const hasMarked = hasMarkedDate(date);
const isSelectedDay = isSelected(date);
return (
@@ -175,13 +183,13 @@ export function DailyCalendar({
: ''
}
${isSelectedDay ? 'bg-[var(--primary)] text-white' : ''}
${hasCheckboxes ? 'font-bold' : ''}
${hasMarked ? 'font-bold' : ''}
`}
>
{date.getDate()}
{/* Indicateur de daily existant */}
{hasCheckboxes && (
{/* Indicateur de date marquée */}
{hasMarked && (
<div
className={`
absolute bottom-1 right-1 w-2 h-2 rounded-full
@@ -195,16 +203,18 @@ export function DailyCalendar({
</div>
{/* Légende */}
<div className="mt-4 text-xs text-[var(--muted-foreground)] space-y-1">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--primary)]"></div>
<span>Jour avec des tâches</span>
{showLegend && (
<div className="mt-4 text-xs text-[var(--muted-foreground)] space-y-1">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--primary)]"></div>
<span>Jour avec des éléments</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-[var(--primary)] bg-[var(--primary)]/20"></div>
<span>Aujourd&apos;hui</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-[var(--primary)] bg-[var(--primary)]/20"></div>
<span>Aujourd&apos;hui</span>
</div>
</div>
)}
</Card>
);
}

View File

@@ -3,23 +3,54 @@ import { cn } from '@/lib/utils';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'bordered' | 'column';
shadow?: 'none' | 'sm' | 'md' | 'lg';
border?: 'none' | 'default' | 'primary' | 'accent';
background?: 'default' | 'column' | 'muted';
}
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'
({ className, variant = 'default', shadow = 'sm', border = 'default', background = 'default', ...props }, ref) => {
const backgrounds = {
default: 'bg-[var(--card)]',
column: 'bg-[var(--card-column)]',
muted: 'bg-[var(--muted)]/10'
};
const borders = {
none: '',
default: 'border border-[var(--border)]',
primary: 'border border-[var(--primary)]/30',
accent: 'border border-[var(--accent)]/30'
};
const shadows = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg'
};
// Variants prédéfinis pour la rétrocompatibilité
const variantStyles = {
default: '',
elevated: 'shadow-lg',
bordered: 'border-[var(--primary)]/30 shadow-lg',
column: 'bg-[var(--card-column)] shadow-lg'
};
// Appliquer le variant si spécifié, sinon utiliser les props individuelles
const finalShadow = variant !== 'default' ? variantStyles[variant] : shadows[shadow];
const finalBorder = variant !== 'default' ? variantStyles[variant] : borders[border];
const finalBackground = variant !== 'default' ? variantStyles[variant] : backgrounds[background];
return (
<div
ref={ref}
className={cn(
'rounded-lg backdrop-blur-sm transition-all duration-200',
variants[variant],
finalBackground,
finalBorder,
finalShadow,
className
)}
{...props}
@@ -30,50 +61,113 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
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}
/>
)
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
separator?: boolean;
padding?: 'sm' | 'md' | 'lg';
}
const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, separator = true, padding = 'md', ...props }, ref) => {
const paddings = {
sm: 'p-2',
md: 'p-4',
lg: 'p-6'
};
return (
<div
ref={ref}
className={cn(
paddings[padding],
separator && 'border-b border-[var(--border)]',
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}
/>
)
interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
size?: 'sm' | 'md' | 'lg';
}
const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className, size = 'md', ...props }, ref) => {
const sizes = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
};
return (
<h3
ref={ref}
className={cn(
'font-mono font-semibold text-[var(--foreground)] tracking-wide',
sizes[size],
className
)}
{...props}
/>
);
}
);
CardTitle.displayName = 'CardTitle';
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('p-4', className)}
{...props}
/>
)
interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
padding?: 'sm' | 'md' | 'lg' | 'none';
}
const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className, padding = 'md', ...props }, ref) => {
const paddings = {
none: '',
sm: 'p-2',
md: 'p-4',
lg: 'p-6'
};
return (
<div
ref={ref}
className={cn(paddings[padding], 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}
/>
)
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
separator?: boolean;
padding?: 'sm' | 'md' | 'lg';
}
const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className, separator = true, padding = 'md', ...props }, ref) => {
const paddings = {
sm: 'p-2',
md: 'p-4',
lg: 'p-6'
};
return (
<div
ref={ref}
className={cn(
paddings[padding],
separator && 'border-t border-[var(--border)]',
className
)}
{...props}
/>
);
}
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,90 @@
'use client';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { PriorityBadge } from '@/components/ui/PriorityBadge';
import { Tag } from '@/lib/types';
export interface ChallengeData {
id: string;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high';
deadline?: Date;
tags?: string[];
todosCount?: number;
blockers?: string[];
}
interface ChallengeCardProps {
challenge: ChallengeData;
availableTags: (Tag & { usage: number })[];
index: number;
showDescription?: boolean;
maxTags?: number;
className?: string;
}
export function ChallengeCard({
challenge,
availableTags,
index,
showDescription = true,
maxTags = 2,
className = ''
}: ChallengeCardProps) {
return (
<div className={`relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group ${className}`}>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center text-[var(--accent)] bg-[var(--accent)]/15 border border-[var(--accent)]/25">
#{index + 1}
</span>
<PriorityBadge priority={challenge.priority} />
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags as Tag[]}
size="sm"
maxTags={maxTags}
/>
</div>
)}
{/* Description si disponible */}
{showDescription && challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount !== undefined && challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Input } from '@/components/ui/Input';
export interface CheckboxItemData {
id: string;
text: string;
isChecked: boolean;
type?: 'task' | 'meeting' | string;
taskId?: string;
task?: {
id: string;
title: string;
};
}
interface CheckboxItemProps {
item: CheckboxItemData;
onToggle: (itemId: string) => Promise<void>;
onUpdate: (itemId: string, text: string, type?: string, taskId?: string) => Promise<void>;
onDelete: (itemId: string) => Promise<void>;
saving?: boolean;
showTypeIndicator?: boolean;
showTaskLink?: boolean;
showEditButton?: boolean;
showDeleteButton?: boolean;
className?: string;
}
export function CheckboxItem({
item,
onToggle,
onUpdate,
onDelete,
saving = false,
showTaskLink = true,
showEditButton = true,
showDeleteButton = true,
className = ''
}: CheckboxItemProps) {
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
const [inlineEditingText, setInlineEditingText] = useState('');
const [optimisticChecked, setOptimisticChecked] = useState<boolean | null>(null);
// État optimiste local pour une réponse immédiate
const isChecked = optimisticChecked !== null ? optimisticChecked : item.isChecked;
// Synchroniser l'état optimiste avec les changements externes
useEffect(() => {
if (optimisticChecked !== null && optimisticChecked === item.isChecked) {
// L'état serveur a été mis à jour, on peut reset l'optimiste
setOptimisticChecked(null);
}
}, [item.isChecked, optimisticChecked]);
// Handler optimiste pour le toggle
const handleOptimisticToggle = async () => {
const newCheckedState = !isChecked;
// Mise à jour optimiste immédiate
setOptimisticChecked(newCheckedState);
try {
await onToggle(item.id);
// Reset l'état optimiste après succès
setOptimisticChecked(null);
} catch (error) {
// Rollback en cas d'erreur
setOptimisticChecked(null);
console.error('Erreur lors du toggle:', error);
}
};
// Édition inline simple
const handleStartInlineEdit = () => {
setInlineEditingId(item.id);
setInlineEditingText(item.text);
};
const handleSaveInlineEdit = async () => {
if (!inlineEditingText.trim()) return;
try {
await onUpdate(item.id, inlineEditingText.trim(), item.type, item.taskId);
setInlineEditingId(null);
setInlineEditingText('');
} catch (error) {
console.error('Erreur lors de la modification:', error);
}
};
const handleCancelInlineEdit = () => {
setInlineEditingId(null);
setInlineEditingText('');
};
const handleInlineEditKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveInlineEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelInlineEdit();
}
};
// Obtenir la couleur de bordure selon le type
const getTypeBorderColor = () => {
if (item.type === 'meeting') return 'border-l-blue-500';
return 'border-l-green-500';
};
return (
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group border-l-4 ${getTypeBorderColor()} border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)] ${className}`}>
{/* Checkbox */}
<input
type="checkbox"
checked={isChecked}
onChange={handleOptimisticToggle}
disabled={saving}
className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
/>
{/* Contenu principal */}
{inlineEditingId === item.id ? (
<Input
value={inlineEditingText}
onChange={(e) => setInlineEditingText(e.target.value)}
onKeyDown={handleInlineEditKeyPress}
onBlur={handleSaveInlineEdit}
autoFocus
className="flex-1 h-7 text-sm"
/>
) : (
<div className="flex-1 flex items-center gap-2">
{/* Texte cliquable pour édition inline */}
<span
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
item.isChecked
? 'line-through text-[var(--muted-foreground)]'
: 'text-[var(--foreground)]'
}`}
onClick={handleStartInlineEdit}
title="Cliquer pour éditer le texte"
>
{item.text}
</span>
{/* Icône d'édition avancée */}
{showEditButton && (
<button
onClick={() => {
// Pour l'instant, on utilise l'édition inline
// Plus tard, on pourra ajouter une modal d'édition avancée
handleStartInlineEdit();
}}
disabled={saving}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--muted)]/50 hover:bg-[var(--muted)] border border-[var(--border)]/30 hover:border-[var(--border)] flex items-center justify-center transition-all duration-200 text-[var(--foreground)] text-xs"
title="Éditer le texte"
>
</button>
)}
</div>
)}
{/* Lien vers la tâche si liée */}
{showTaskLink && item.task && (
<Link
href={`/kanban?taskId=${item.task.id}`}
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono truncate max-w-[100px]"
title={`Tâche: ${item.task.title}`}
>
{item.task.title}
</Link>
)}
{/* Bouton de suppression */}
{showDeleteButton && (
<button
onClick={() => onDelete(item.id)}
disabled={saving}
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)] text-xs"
title="Supprimer"
>
×
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState } from 'react';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
export interface CollapsibleItem {
id: string;
title: string;
subtitle?: string;
metadata?: string;
isChecked?: boolean;
isArchived?: boolean;
icon?: string;
actions?: Array<{
label: string;
icon: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'destructive';
disabled?: boolean;
}>;
}
interface CollapsibleSectionProps {
title: string;
items: CollapsibleItem[];
icon?: string;
defaultCollapsed?: boolean;
loading?: boolean;
emptyMessage?: string;
filters?: Array<{
label: string;
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}>;
onRefresh?: () => void;
onItemToggle?: (itemId: string) => void;
className?: string;
}
export function CollapsibleSection({
title,
items,
icon = '📋',
defaultCollapsed = false,
loading = false,
emptyMessage = 'Aucun élément',
filters = [],
onRefresh,
onItemToggle,
className = ''
}: CollapsibleSectionProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const handleItemToggle = (itemId: string) => {
onItemToggle?.(itemId);
};
const getItemClasses = (item: CollapsibleItem) => {
let classes = 'flex items-center gap-3 p-3 rounded-lg border border-[var(--border)]';
if (item.isArchived) {
classes += ' opacity-60 bg-[var(--muted)]/20';
} else {
classes += ' bg-[var(--card)]';
}
return classes;
};
const getCheckboxClasses = (item: CollapsibleItem) => {
let classes = 'w-5 h-5 rounded border-2 flex items-center justify-center transition-colors';
if (item.isArchived) {
classes += ' border-[var(--muted)] cursor-not-allowed';
} else {
classes += ' border-[var(--border)] hover:border-[var(--primary)]';
}
return classes;
};
const getActionClasses = (action: NonNullable<CollapsibleItem['actions']>[0]) => {
let classes = 'text-xs px-2 py-1';
switch (action.variant) {
case 'destructive':
classes += ' text-[var(--destructive)] hover:text-[var(--destructive)]';
break;
case 'primary':
classes += ' text-[var(--primary)] hover:text-[var(--primary)]';
break;
default:
classes += ' text-[var(--foreground)]';
}
return classes;
};
return (
<Card className={`mt-6 ${className}`}>
<CardHeader>
<div className="flex items-center justify-between">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors"
>
<span className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}>
</span>
{icon} {title}
{items.length > 0 && (
<span className="bg-[var(--warning)] text-[var(--warning-foreground)] px-2 py-1 rounded-full text-xs font-medium">
{items.length}
</span>
)}
</button>
{!isCollapsed && (
<div className="flex items-center gap-2">
{/* Filtres */}
{filters.map((filter, index) => (
<select
key={index}
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
>
{filter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
))}
{/* Bouton refresh */}
{onRefresh && (
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
>
{loading ? '🔄' : '↻'}
</Button>
)}
</div>
)}
</div>
</CardHeader>
{!isCollapsed && (
<CardContent>
{loading ? (
<div className="text-center py-4 text-[var(--muted-foreground)]">
Chargement...
</div>
) : items.length === 0 ? (
<div className="text-center py-4 text-[var(--muted-foreground)]">
🎉 {emptyMessage} ! Excellent travail.
</div>
) : (
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
className={getItemClasses(item)}
>
{/* Checkbox */}
{item.isChecked !== undefined && (
<button
onClick={() => handleItemToggle(item.id)}
disabled={item.isArchived}
className={getCheckboxClasses(item)}
>
{item.isChecked && <span className="text-[var(--primary)]"></span>}
</button>
)}
{/* Contenu */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{item.icon && <span>{item.icon}</span>}
<span className={`text-sm font-medium ${item.isArchived ? 'line-through' : ''}`}>
{item.title}
</span>
</div>
{(item.subtitle || item.metadata) && (
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{item.subtitle && <span>{item.subtitle}</span>}
{item.metadata && <span>{item.metadata}</span>}
</div>
)}
</div>
{/* Actions */}
{item.actions && (
<div className="flex items-center gap-1">
{item.actions.map((action, index) => (
<Button
key={index}
variant="ghost"
size="sm"
onClick={action.onClick}
disabled={action.disabled}
title={action.label}
className={getActionClasses(action)}
>
{action.icon}
</Button>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
)}
</Card>
);
}

View File

@@ -0,0 +1,77 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
import { Badge } from './Badge';
interface ColumnHeaderProps extends HTMLAttributes<HTMLDivElement> {
title: string;
icon?: string;
count: number;
color?: string;
accentColor?: string;
borderColor?: string;
onAddClick?: () => void;
showAddButton?: boolean;
}
const ColumnHeader = forwardRef<HTMLDivElement, ColumnHeaderProps>(
({
className,
title,
icon,
count,
color,
accentColor,
borderColor,
onAddClick,
showAddButton = false,
...props
}, ref) => {
return (
<div
ref={ref}
className={cn("flex items-center justify-between", className)}
{...props}
>
<div className="flex items-center gap-3">
<div
className={cn(
"w-2 h-2 rounded-full animate-pulse",
color ? `bg-${color}` : "bg-[var(--primary)]"
)}
/>
<h3
className={cn(
"font-mono text-sm font-bold uppercase tracking-wider",
accentColor || "text-[var(--foreground)]"
)}
>
{title} {icon}
</h3>
</div>
<div className="flex items-center gap-2">
<Badge variant="default" size="sm">
{String(count).padStart(2, '0')}
</Badge>
{showAddButton && onAddClick && (
<button
onClick={onAddClick}
className={cn(
"w-5 h-5 rounded-full border border-dashed hover:bg-[var(--card-hover)] transition-colors flex items-center justify-center text-xs font-mono",
borderColor || "border-[var(--border)]",
accentColor || "text-[var(--muted-foreground)]"
)}
title="Ajouter une tâche rapide"
>
+
</button>
)}
</div>
</div>
);
}
);
ColumnHeader.displayName = 'ColumnHeader';
export { ColumnHeader };

View File

@@ -0,0 +1,46 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface ControlPanelProps {
children: ReactNode;
className?: string;
}
export function ControlPanel({ children, className }: ControlPanelProps) {
return (
<div className={cn(
'bg-[var(--card)]/30 border-b border-[var(--border)]/30 w-full',
className
)}>
<div className="w-full px-6 py-2">
{children}
</div>
</div>
);
}
interface ControlSectionProps {
children: ReactNode;
className?: string;
}
export function ControlSection({ children, className }: ControlSectionProps) {
return (
<div className={cn('flex items-center gap-4', className)}>
{children}
</div>
);
}
interface ControlGroupProps {
children: ReactNode;
className?: string;
}
export function ControlGroup({ children, className }: ControlGroupProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
export interface AddFormOption {
value: string;
label: string;
icon?: string;
color?: string;
}
interface AddFormProps {
onAdd: (text: string, option?: string) => Promise<void>;
disabled?: boolean;
placeholder?: string;
options?: AddFormOption[];
defaultOption?: string;
className?: string;
}
export function DailyAddForm({
onAdd,
disabled = false,
placeholder = "Ajouter un élément...",
options = [],
defaultOption,
className = ''
}: AddFormProps) {
const [newItemText, setNewItemText] = useState('');
const [selectedOption, setSelectedOption] = useState<string>(defaultOption || (options.length > 0 ? options[0].value : ''));
const inputRef = useRef<HTMLInputElement>(null);
const handleAddItem = () => {
if (!newItemText.trim()) return;
const text = newItemText.trim();
// Vider et refocus IMMÉDIATEMENT pour l'UX optimiste
setNewItemText('');
inputRef.current?.focus();
// Lancer l'ajout en arrière-plan (fire and forget)
onAdd(text, selectedOption).catch(error => {
console.error('Erreur lors de l\'ajout:', error);
// En cas d'erreur, on pourrait restaurer le texte
// setNewItemText(text);
});
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddItem();
}
};
const getPlaceholder = () => {
if (placeholder !== "Ajouter un élément...") return placeholder;
if (options.length > 0) {
const selectedOptionData = options.find(opt => opt.value === selectedOption);
if (selectedOptionData) {
return `Ajouter ${selectedOptionData.label.toLowerCase()}...`;
}
}
return placeholder;
};
const getOptionColor = (option: AddFormOption) => {
if (option.color) return option.color;
// Couleurs par défaut selon le type
switch (option.value) {
case 'task':
return 'green';
case 'meeting':
return 'blue';
default:
return 'gray';
}
};
const getOptionClasses = (option: AddFormOption) => {
const color = getOptionColor(option);
const isSelected = selectedOption === option.value;
if (isSelected) {
switch (color) {
case 'green':
return 'border-l-green-500 bg-green-500/30 text-white font-medium';
case 'blue':
return 'border-l-blue-500 bg-blue-500/30 text-white font-medium';
default:
return 'border-l-gray-500 bg-gray-500/30 text-white font-medium';
}
} else {
switch (color) {
case 'green':
return 'border-l-green-300 hover:border-l-green-400 opacity-70 hover:opacity-90';
case 'blue':
return 'border-l-blue-300 hover:border-l-blue-400 opacity-70 hover:opacity-90';
default:
return 'border-l-gray-300 hover:border-l-gray-400 opacity-70 hover:opacity-90';
}
}
};
return (
<div className={`space-y-2 ${className}`}>
{/* Sélecteur d'options */}
{options.length > 0 && (
<div className="flex gap-2">
{options.map((option) => (
<Button
key={option.value}
type="button"
onClick={() => setSelectedOption(option.value)}
variant="ghost"
size="sm"
className={`flex items-center gap-1 text-xs border-l-4 ${getOptionClasses(option)}`}
disabled={disabled}
>
{option.icon && <span>{option.icon}</span>}
{option.label}
</Button>
))}
</div>
)}
{/* Champ de saisie et bouton d'ajout */}
<div className="flex gap-2">
<Input
ref={inputRef}
type="text"
placeholder={getPlaceholder()}
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
onKeyDown={handleKeyPress}
disabled={disabled}
className="flex-1 min-w-[300px]"
/>
<Button
onClick={handleAddItem}
disabled={!newItemText.trim() || disabled}
variant="primary"
size="sm"
className="min-w-[40px]"
>
+
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface DropZoneProps extends HTMLAttributes<HTMLDivElement> {
isOver?: boolean;
children: React.ReactNode;
}
const DropZone = forwardRef<HTMLDivElement, DropZoneProps>(
({ className, isOver = false, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"transition-all duration-200",
isOver
? "ring-2 ring-[var(--primary)]/50 bg-[var(--card-hover)]"
: "",
className
)}
{...props}
>
{children}
</div>
);
}
);
DropZone.displayName = 'DropZone';
export { DropZone };

View File

@@ -0,0 +1,107 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface EmptyStateProps extends HTMLAttributes<HTMLDivElement> {
icon?: string;
title?: string;
description?: string;
accentColor?: string;
borderColor?: string;
size?: 'sm' | 'md' | 'lg';
}
const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
({
className,
icon,
title = "NO DATA",
description,
accentColor,
borderColor,
size = 'md',
...props
}, ref) => {
const sizes = {
sm: {
container: 'py-8',
icon: 'w-8 h-8 text-lg',
title: 'text-xs',
divider: 'w-4 h-0.5'
},
md: {
container: 'py-20',
icon: 'w-16 h-16 text-2xl',
title: 'text-xs',
divider: 'w-8 h-0.5'
},
lg: {
container: 'py-32',
icon: 'w-24 h-24 text-3xl',
title: 'text-sm',
divider: 'w-12 h-0.5'
}
};
const currentSize = sizes[size];
return (
<div
ref={ref}
className={cn("text-center", currentSize.container, className)}
{...props}
>
<div
className={cn(
"mx-auto mb-4 rounded-full bg-[var(--card)] border-2 border-dashed flex items-center justify-center",
currentSize.icon,
borderColor || "border-[var(--border)]"
)}
>
<span
className={cn(
"opacity-50",
accentColor || "text-[var(--muted-foreground)]"
)}
>
{icon || "📋"}
</span>
</div>
<p
className={cn(
"font-mono uppercase tracking-wide",
currentSize.title,
accentColor || "text-[var(--muted-foreground)]"
)}
>
{title}
</p>
{description && (
<p
className={cn(
"mt-2 text-xs text-[var(--muted-foreground)]",
accentColor || "text-[var(--muted-foreground)]"
)}
>
{description}
</p>
)}
<div className="mt-2 flex justify-center">
<div
className={cn(
"opacity-30",
currentSize.divider,
accentColor ? accentColor.replace('text-', 'bg-') : "bg-[var(--muted-foreground)]"
)}
/>
</div>
</div>
);
}
);
EmptyState.displayName = 'EmptyState';
export { EmptyState };

View File

@@ -0,0 +1,75 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface FilterChipProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'selected' | 'hidden' | 'priority' | 'tag';
color?: string;
count?: number;
icon?: React.ReactNode;
size?: 'sm' | 'md';
}
const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(
({
className,
variant = 'default',
color,
count,
icon,
size = 'sm',
children,
...props
}, ref) => {
const variants = {
default: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)] hover:bg-[var(--card)]/80',
selected: 'border-cyan-400 bg-cyan-400/10 text-cyan-400',
hidden: 'bg-[var(--muted)]/20 text-[var(--muted)] border-[var(--muted)]/30 hover:bg-[var(--muted)]/30',
priority: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]',
tag: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
};
const sizes = {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1.5 text-sm'
};
return (
<button
ref={ref}
className={cn(
'flex items-center gap-2 rounded border transition-all font-medium cursor-pointer',
variants[variant],
sizes[size],
className
)}
{...props}
>
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
{color && (
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
)}
<span className="flex-1 text-left">
{children}
{count !== undefined && count > 0 && (
<span className="ml-1 opacity-75">
({count})
</span>
)}
</span>
</button>
);
}
);
FilterChip.displayName = 'FilterChip';
export { FilterChip };

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from './Card';
import { Badge } from './Badge';
import { Button } from './Button';
interface FilterSummaryProps {
filters: {
search?: string;
priorities?: string[];
tags?: string[];
showWithDueDate?: boolean;
showJiraOnly?: boolean;
hideJiraTasks?: boolean;
jiraProjects?: string[];
jiraTypes?: string[];
showTfsOnly?: boolean;
hideTfsTasks?: boolean;
tfsProjects?: string[];
};
activeFiltersCount: number;
onClearFilters?: () => void;
className?: string;
}
export function FilterSummary({
filters,
activeFiltersCount,
onClearFilters,
className
}: FilterSummaryProps) {
if (activeFiltersCount === 0) return null;
const filterItems: Array<{
label: string;
value: string;
variant: 'default' | 'primary' | 'success' | 'destructive' | 'accent' | 'purple' | 'yellow' | 'green' | 'blue' | 'gray' | 'outline' | 'danger' | 'warning';
}> = [];
// Recherche
if (filters.search) {
filterItems.push({
label: 'Recherche',
value: `"${filters.search}"`,
variant: 'primary'
});
}
// Priorités
if (filters.priorities?.filter(Boolean).length) {
filterItems.push({
label: 'Priorités',
value: filters.priorities.filter(Boolean).join(', '),
variant: 'accent'
});
}
// Tags
if (filters.tags?.filter(Boolean).length) {
filterItems.push({
label: 'Tags',
value: filters.tags.filter(Boolean).join(', '),
variant: 'purple'
});
}
// Affichage avec date de fin
if (filters.showWithDueDate) {
filterItems.push({
label: 'Affichage',
value: 'Avec date de fin',
variant: 'success'
});
}
// Jira
if (filters.showJiraOnly) {
filterItems.push({
label: 'Affichage',
value: 'Jira seulement',
variant: 'blue'
});
}
if (filters.hideJiraTasks) {
filterItems.push({
label: 'Affichage',
value: 'Masquer Jira',
variant: 'destructive'
});
}
if (filters.jiraProjects?.filter(Boolean).length) {
filterItems.push({
label: 'Projets Jira',
value: filters.jiraProjects.filter(Boolean).join(', '),
variant: 'blue'
});
}
if (filters.jiraTypes?.filter(Boolean).length) {
filterItems.push({
label: 'Types Jira',
value: filters.jiraTypes.filter(Boolean).join(', '),
variant: 'purple'
});
}
// TFS
if (filters.showTfsOnly) {
filterItems.push({
label: 'Affichage',
value: 'TFS seulement',
variant: 'yellow'
});
}
if (filters.hideTfsTasks) {
filterItems.push({
label: 'Affichage',
value: 'Masquer TFS',
variant: 'destructive'
});
}
if (filters.tfsProjects?.filter(Boolean).length) {
filterItems.push({
label: 'Projets TFS',
value: filters.tfsProjects.filter(Boolean).join(', '),
variant: 'yellow'
});
}
return (
<Card className={className}>
<CardHeader className="py-2 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xs font-mono uppercase tracking-wider text-[var(--muted-foreground)]">
Filtres actifs ({activeFiltersCount})
</CardTitle>
{onClearFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)] text-xs"
>
Effacer
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-3">
<div className="space-y-2">
{filterItems.map((item, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<span className="text-[var(--muted-foreground)] font-medium">
{item.label}:
</span>
<Badge variant={item.variant} size="sm">
{item.value}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -5,6 +5,8 @@ import { useJiraConfig } from '@/contexts/JiraConfigContext';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { useState } from 'react';
import { Theme } from '@/lib/theme-config';
import { THEME_CONFIG, getThemeMetadata } from '@/lib/theme-config';
interface HeaderProps {
title?: string;
@@ -13,10 +15,22 @@ interface HeaderProps {
}
export function Header({ title = "TowerControl", subtitle = "Task Management", syncing = false }: HeaderProps) {
const { theme, toggleTheme } = useTheme();
const { theme, setTheme } = useTheme();
const { isConfigured: isJiraConfigured, config: jiraConfig } = useJiraConfig();
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [themeDropdownOpen, setThemeDropdownOpen] = useState(false);
// Liste des thèmes disponibles avec leurs labels et icônes
const themes: { value: Theme; label: string; icon: string }[] = THEME_CONFIG.allThemes.map(themeValue => {
const metadata = getThemeMetadata(themeValue);
return {
value: themeValue,
label: metadata.name,
icon: metadata.icon
};
});
// Fonction pour déterminer si un lien est actif
const isActiveLink = (href: string) => {
@@ -83,22 +97,53 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
{/* 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>
{/* Theme Dropdown */}
<div className="relative">
<button
onClick={() => setThemeDropdownOpen(!themeDropdownOpen)}
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
title="Select theme"
>
{themes.find(t => t.value === theme)?.icon || '🎨'}
</button>
{themeDropdownOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[200]"
onClick={() => setThemeDropdownOpen(false)}
/>
{/* Dropdown */}
<div className="absolute right-0 top-full mt-2 w-48 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg z-[201] overflow-hidden">
<div className="py-2">
{themes.map((themeOption) => (
<button
key={themeOption.value}
onClick={() => {
setTheme(themeOption.value);
setThemeDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors flex items-center gap-3 ${
theme === themeOption.value
? 'text-[var(--primary)] bg-[var(--primary)]/10'
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--card-hover)]'
}`}
>
<span className="text-base">{themeOption.icon}</span>
<span className="font-mono">{themeOption.label}</span>
{theme === themeOption.value && (
<svg className="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
</div>
</>
)}
</button>
</div>
{/* Menu burger */}
<button
@@ -152,22 +197,53 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
</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>
{/* Theme Dropdown desktop */}
<div className="relative">
<button
onClick={() => setThemeDropdownOpen(!themeDropdownOpen)}
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--card-hover)]"
title="Select theme"
>
{themes.find(t => t.value === theme)?.icon || '🎨'}
</button>
{themeDropdownOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[200]"
onClick={() => setThemeDropdownOpen(false)}
/>
{/* Dropdown */}
<div className="absolute right-0 top-full mt-2 w-48 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg z-[201] overflow-hidden">
<div className="py-2">
{themes.map((themeOption) => (
<button
key={themeOption.value}
onClick={() => {
setTheme(themeOption.value);
setThemeDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors flex items-center gap-3 ${
theme === themeOption.value
? 'text-[var(--primary)] bg-[var(--primary)]/10'
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--card-hover)]'
}`}
>
<span className="text-base">{themeOption.icon}</span>
<span className="font-mono">{themeOption.label}</span>
{theme === themeOption.value && (
<svg className="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
</div>
</>
)}
</button>
</div>
</nav>
</div>

View File

@@ -2,43 +2,33 @@ import { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
variant?: 'default' | 'error';
label?: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, ...props }, ref) => {
({ className, variant = 'default', type, ...props }, ref) => {
const variants = {
default: 'border border-[var(--border)]/50 bg-[var(--input)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:border-[var(--primary)]/70 focus:ring-1 focus:ring-[var(--primary)]/20',
error: 'border border-[var(--destructive)]/50 bg-[var(--input)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:border-[var(--destructive)]/70 focus:ring-1 focus:ring-[var(--destructive)]/20'
};
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
type={type}
className={cn(
'flex h-10 w-full rounded-md px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
variants[variant],
className
)}
<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>
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
export { Input };

Some files were not shown because too many files have changed in this diff Show More