98 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
Julien Froidefond
f9c92f9efd doc: todo.md completion 2025-09-23 10:45:57 +02:00
Julien Froidefond
bbb4e543c4 feat: enhance type organization and import structure
- Added detailed tasks in `TODO.md` for isolating and organizing types/interfaces across various services, including analytics, task management, and integrations.
- Updated imports in multiple files to use the new `@/services/core/database` path for consistency.
- Ensured all type imports are converted to `import type { ... }` where applicable for better clarity and performance.
2025-09-23 10:35:52 +02:00
Julien Froidefond
88ab8c9334 feat: complete Phase 5 of service refactoring
- Marked tasks in `TODO.md` as completed for moving TFS and Jira services to the `integrations` directory and correcting imports across the codebase.
- Updated imports in various action files, API routes, and components to reflect the new structure.
- Removed obsolete `jira-advanced-filters.ts`, `jira-analytics.ts`, `jira-analytics-cache.ts`, `jira-anomaly-detection.ts`, `jira-scheduler.ts`, `jira.ts`, and `tfs.ts` files to streamline the codebase.
- Added new tasks in `TODO.md` for future cleaning and organization of service imports.
2025-09-23 10:32:25 +02:00
Julien Froidefond
f5417040fd feat: complete Phase 4 of service refactoring
- Marked tasks in `TODO.md` as completed for moving task-related files to the `task-management` directory and correcting imports across the codebase.
- Updated imports in `seed-data.ts`, `seed-tags.ts`, API routes, and various components to reflect the new structure.
- Removed obsolete `daily.ts`, `tags.ts`, and `tasks.ts` files to streamline the codebase.
- Added new tasks in `TODO.md` for future cleaning and organization of service imports.
2025-09-23 10:25:41 +02:00
Julien Froidefond
b8e0307f03 feat: complete Phase 3 of service refactoring
- Marked tasks in `TODO.md` as completed for moving backup-related files to the `data-management` directory and correcting imports across the codebase.
- Updated imports in `backup-manager.ts`, API routes, and various components to reflect the new structure.
- Removed obsolete `backup.ts` and `backup-scheduler.ts` files to streamline the codebase.
- Added new tasks in `TODO.md` for future cleaning and organization of service imports.
2025-09-23 10:20:56 +02:00
Julien Froidefond
ed16e2bb80 feat: complete Phase 2 of service refactoring
- Marked tasks in `TODO.md` as completed for moving analytics-related files to the `analytics` directory and correcting imports across the codebase.
- Updated imports in `src/actions/analytics.ts`, `src/actions/metrics.ts`, and various components to reflect the new structure.
- Removed unused `analytics.ts`, `manager-summary.ts`, and `metrics.ts` files to streamline the codebase.
2025-09-23 10:15:13 +02:00
Julien Froidefond
f88954bf81 feat: refactor service organization and update imports
- Introduced a new structure for services in `src/services/` to improve organization by domain, including core, analytics, data management, integrations, and task management.
- Moved relevant files to their new locations and updated all internal and external imports accordingly.
- Updated `TODO.md` to reflect the new service organization and outlined phases for further refactoring.
2025-09-23 10:10:34 +02:00
Julien Froidefond
ee64fe2ff3 chore : remove unused methods 2025-09-23 08:30:25 +02:00
Julien Froidefond
e36291a552 chore: Unused package and entire files 2025-09-23 08:21:53 +02:00
Julien Froidefond
723a44df32 feat: TFS Sync 2025-09-22 21:51:12 +02:00
Julien Froidefond
472135a97f fix: remove tooltip functionality from TaskCard component
- Disabled hover tooltip on task cards by removing related state and event handlers.
- Updated TODO.md to reflect the completion of disabling hover on task cards.
2025-09-22 09:09:50 +02:00
Julien Froidefond
b5d53ef0f1 feat: add "Move to Today" functionality for pending tasks
- Implemented a new button in the `PendingTasksSection` to move unchecked tasks to today's date.
- Created `moveCheckboxToToday` action in `daily.ts` to handle the logic for moving tasks.
- Updated `DailyPageClient` and `PendingTasksSection` to integrate the new functionality and refresh the daily view after moving tasks.
- Marked the feature as completed in `TODO.md`.
2025-09-22 08:51:59 +02:00
Julien Froidefond
f9d0641d77 fix: improve text truncation in EditCheckboxModal
- Added `min-w-0` to the title container to prevent overflow in the `EditCheckboxModal`.
- Updated task title and description elements to use `truncate` for better text handling and prevent layout issues.
2025-09-22 08:49:47 +02:00
Julien Froidefond
361fc0eaac feat: enhance mobile and desktop layouts in Daily and Kanban pages
- Refactored `DailyPageClient` to prioritize mobile layout with today's section first and calendar at the bottom for better usability.
- Updated `KanbanPageClient` to include responsive controls for mobile, improving task management experience.
- Adjusted `DailyCheckboxItem` and `DailySection` for better touch targets and responsive design.
- Cleaned up `TODO.md` to reflect changes in mobile interface considerations and task management features.
2025-09-21 21:37:30 +02:00
Julien Froidefond
2194744eef chore: clean up TODO.md by removing outdated mobile component examples
- Deleted specific mobile component examples that are no longer relevant to the current project scope.
- Updated UX considerations for mobile to focus on simplicity and touch optimization.
2025-09-21 21:13:06 +02:00
Julien Froidefond
8be5cb6f70 feat: update TODO.md with completed tasks and new features
- Marked the "Pending Tasks Section" and "Archived Status" as implemented with detailed descriptions.
- Added visual indicators for task age and actions for each task in the Daily page.
- Updated mobile task management features to improve navigation and usability.
2025-09-21 19:58:23 +02:00
Julien Froidefond
3cfed60f43 feat: refactor daily task management with new pending tasks section
- Added `PendingTasksSection` to `DailyPageClient` for displaying uncompleted tasks.
- Implemented `getPendingCheckboxes` method in `DailyClient` and `DailyService` to fetch pending tasks.
- Introduced `getDaysAgo` utility function for calculating elapsed days since a date.
- Updated `TODO.md` to reflect the new task management features and adjustments.
- Cleaned up and organized folder structure to align with Next.js 13+ best practices.
2025-09-21 19:55:04 +02:00
Julien Froidefond
0a03e40469 feat: enhance metrics dashboard with new components and data handling
- Introduced `MetricsOverview`, `MetricsMainCharts`, `MetricsDistributionCharts`, `MetricsVelocitySection`, and `MetricsProductivitySection` for improved metrics visualization.
- Updated `MetricsTab` to integrate new components and streamline data presentation.
- Added compatibility fields in `JiraTask` and `AssigneeDistribution` for better data handling.
- Refactored `calculateAssigneeDistribution` to include a count for total issues.
- Enhanced `JiraAnalyticsService` and `JiraAdvancedFiltersService` to support new metrics calculations.
- Cleaned up unused imports and components for a more maintainable codebase.
2025-09-21 15:55:11 +02:00
Julien Froidefond
c650c67627 feat: integrate UserPreferencesContext for improved preference management
- Added `UserPreferencesProvider` to `RootLayout` for centralized user preferences handling.
- Updated components to remove direct user preferences fetching, relying on context instead.
- Enhanced SSR data fetching by consolidating user preferences retrieval into a single service call.
- Cleaned up unused props in various components to streamline the codebase.
2025-09-21 15:03:19 +02:00
Julien Froidefond
4ba6ba2c0b refactor: unify date handling with utility functions
- Replaced direct date manipulations with utility functions like `getToday`, `parseDate`, and `createDateFromParts` across various components and services for consistency.
- Updated date initialization in `JiraAnalyticsService`, `BackupService`, and `DailyClient` to improve clarity and maintainability.
- Enhanced date parsing in forms and API routes to ensure proper handling of date strings.
2025-09-21 13:04:34 +02:00
Julien Froidefond
c3c1d24fa2 refactor: enhance date handling across components
- Replaced direct date manipulations with utility functions for consistency and readability.
- Updated date formatting in `DailyCalendar`, `RecentTasks`, `CompletionRateChart`, and other components to use `formatDateShort` and `formatDateForDisplay`.
- Improved date parsing in `JiraLogs`, `JiraSchedulerConfig`, and `BackupSettingsPageClient` to ensure proper handling of date strings.
- Streamlined date initialization in `useDaily` and `DailyService` to utilize `getToday` and `getYesterday` for better clarity.
2025-09-21 12:02:06 +02:00
Julien Froidefond
557cdebc13 refactor: date utils and all calls 2025-09-21 11:41:17 +02:00
Julien Froidefond
799a21df5c feat: implement Jira auto-sync scheduler and UI configuration
- Added `jiraAutoSync` and `jiraSyncInterval` fields to user preferences for scheduler configuration.
- Created `JiraScheduler` service to manage automatic synchronization with Jira based on user settings.
- Updated API route to handle scheduler actions and configuration updates.
- Introduced `JiraSchedulerConfig` component for user interface to control scheduler settings.
- Enhanced `TODO.md` to reflect completed tasks related to Jira synchronization features.
2025-09-21 11:30:41 +02:00
Julien Froidefond
a0e2a78372 feat: update Daily and Jira dashboard pages with dynamic titles and improved UI
- Implemented `getTodayTitle` and `getYesterdayTitle` functions in `DailyPageClient` to dynamically set section titles based on the current date.
- Updated `TODO.md` to mark completed tasks related to the Jira dashboard UI consistency.
- Enhanced card content in `JiraDashboardPageClient` to ensure charts are responsive and maintain consistent styling.
- Removed unused date formatting function in `DailySection` for cleaner code.
2025-09-21 10:49:39 +02:00
Julien Froidefond
4152b0bdfc chore: refactor project structure and clean up unused components
- Updated `TODO.md` to reflect new testing tasks and final structure expectations.
- Simplified TypeScript path mappings in `tsconfig.json` for better clarity.
- Revised business logic separation rules in `.cursor/rules` to align with new directory structure.
- Deleted unused client components and services to streamline the codebase.
- Adjusted import paths in scripts to match the new structure.
2025-09-21 10:26:35 +02:00
Julien Froidefond
9dc1fafa76 feat: expand TODO.md with multi-user and mobile interface plans
- Added detailed sections for transitioning to a multi-tenant architecture, including authentication, data model adjustments, and service modifications.
- Introduced a comprehensive migration plan for user data isolation and security considerations.
- Outlined phases for developing a dedicated mobile interface, addressing current usability issues and enhancing user experience on mobile devices.
- Included specific tasks for mobile components and UX optimizations.
2025-09-21 10:12:54 +02:00
Julien Froidefond
d7140507e5 chore: update TODO.md with new feature ideas and refactoring plans
- Added sections for future features including TFS/Azure DevOps integration, task management, and modular architecture.
- Detailed a migration plan for restructuring the project directory to align with Next.js 13+ best practices.
- Included specific tasks for improving integration interfaces and enhancing the user experience.
2025-09-21 09:14:52 +02:00
Julien Froidefond
43998425e6 feat: enhance backup functionality and logging
- Updated `createBackup` method to accept a `force` parameter, allowing backups to be created even if no changes are detected.
- Added user alerts in `AdvancedSettingsPageClient` and `BackupSettingsPageClient` for backup status feedback.
- Implemented `getBackupLogs` method in `BackupService` to retrieve backup logs, with a new API route for accessing logs.
- Enhanced UI in `BackupSettingsPageClient` to display backup logs and provide a refresh option.
- Updated `BackupManagerCLI` to support forced backups via command line.
2025-09-21 07:27:23 +02:00
Julien Froidefond
618e774a30 fix: update database path in README and remove backup link
- Changed `DATABASE_URL` in `data/README.md` to use a relative path for better compatibility.
- Removed the reference to `BACKUP.md` in `DOCKER.md` as it is no longer relevant.
2025-09-21 07:18:49 +02:00
Julien Froidefond
c5bfcc50f8 fix: refine loading states in MetricsTab
- Simplified loading logic by removing unnecessary trends loading check.
- Enhanced UI feedback by disabling the weeks selection during trends loading and added a loading state for the trends chart.
- Improved user experience by displaying a message when no velocity data is available.
2025-09-21 06:42:26 +02:00
Julien Froidefond
6e2b0abc8d chore: update TODO list with new tasks
- Added tasks for backup changes, date refactoring, and component splitting to the TODO.md file.
2025-09-21 06:42:18 +02:00
Julien Froidefond
9da824993d fix: update fs import in SystemInfoService for eslint compliance
- Changed the fs import in `system-info.ts` to comply with eslint rules by adding a comment to disable the specific linting error.
2025-09-21 06:36:13 +02:00
Julien Froidefond
e88b1aad32 chore: remove unused system info functionality
- Deleted `system-info.ts` as it is no longer needed in the codebase.
- No changes made to `workday-utils.ts`, just added a new line for consistency.
2025-09-21 06:34:43 +02:00
Julien Froidefond
3c20df95d9 feat: add system info and backup functionalities to settings page
- Integrated system info fetching in `SettingsPage` for improved user insights.
- Enhanced `SettingsIndexPageClient` with manual backup creation and Jira connection testing features.
- Added loading states and auto-dismiss messages for user feedback during actions.
- Updated UI to display system info and backup statistics dynamically.
2025-09-20 16:38:33 +02:00
Julien Froidefond
da0565472d feat: enhance settings and backup functionality
- Updated status descriptions in `SettingsIndexPageClient` to reflect current functionality.
- Added a new backup management section in settings for better user access.
- Modified `BackupService` to include backup type in filenames, improving clarity and organization.
- Enhanced backup file parsing to support both new and old filename formats, ensuring backward compatibility.
2025-09-20 16:21:50 +02:00
Julien Froidefond
9a33d1ee48 fix: improve date formatting and backup path handling
- Updated `formatTimeAgo` in `AdvancedSettingsPageClient` to use a fixed format for hydration consistency.
- Refined `formatDate` in `BackupSettingsPageClient` for consistent server/client formatting.
- Refactored `BackupService` to use `getCurrentBackupPath` for all backup path references, ensuring up-to-date paths and avoiding caching issues.
- Added `getCurrentBackupPath` method to dynamically retrieve the current backup path based on environment variables.
2025-09-20 16:12:01 +02:00
Julien Froidefond
ee442de773 chore: refine database and backup paths in configuration
- Updated `.gitignore` to only exclude `.db` files in the `/data/` directory and preserve backups.
- Modified `docker-compose.yml` to switch database and backup paths to `dev.db`, aligning with the current development setup.
2025-09-20 15:58:53 +02:00
Julien Froidefond
329018161c chore: update backup configurations and directory structure
- Modified `.gitignore` to exclude all files in the `/data/` directory.
- Enhanced `BACKUP.md` with customization options for backup storage paths and updated database path configurations.
- Updated `docker-compose.yml` to reflect new paths for database and backup storage.
- Adjusted `Dockerfile` to create a dedicated backups directory.
- Refactored `BackupService` to utilize environment variables for backup paths, improving flexibility and reliability.
- Deleted `dev.db` as it is no longer needed in the repository.
2025-09-20 15:45:56 +02:00
Julien Froidefond
dfa8d34855 feat: add workday utility functions
- Introduced utility functions for workday calculations in `workday-utils.ts`, including `getPreviousWorkday`, `getNextWorkday`, `isWorkday`, and `getDayName`.
- Updated `DailyService` and `DailyPageClient` to utilize `getPreviousWorkday` for accurate date handling instead of simple date subtraction.
2025-09-20 15:43:38 +02:00
303 changed files with 21692 additions and 10392 deletions

View File

@@ -9,7 +9,7 @@ description: Enforce business logic separation between frontend and backend
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction. All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
## ✅ ALLOWED in Frontend ([components/](mdc:components/), [hooks/](mdc:hooks/), [clients/](mdc:clients/)) ## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
### Components ### Components
- UI rendering and presentation logic - UI rendering and presentation logic
@@ -73,7 +73,7 @@ const calculateTeamVelocity = (sprints) => {
// This belongs in services/team-analytics.ts // This belongs in services/team-analytics.ts
``` ```
## ✅ REQUIRED in Backend ([services/](mdc:services/), [app/api/](mdc:app/api/)) ## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
### Services Layer ### Services Layer
- All business rules and domain logic - All business rules and domain logic

View File

@@ -1,10 +1,10 @@
--- ---
globs: components/**/*.tsx globs: src/components/**/*.tsx
--- ---
# Components Rules # Components Rules
1. UI components MUST be in components/ui/ 1. UI components MUST be in src/components/ui/
2. Feature components MUST be in their feature folder 2. Feature components MUST be in their feature folder
3. Components MUST use clients for data fetching 3. Components MUST use clients for data fetching
4. Components MUST be properly typed 4. Components MUST be properly typed

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

View File

@@ -5,26 +5,26 @@ alwaysApply: true
# Project Structure Rules # Project Structure Rules
1. Backend: 1. Backend:
- [services/](mdc:services/) - ALL database access - [src/services/](mdc:src/services/) - ALL database access
- [app/api/](mdc:app/api/) - API routes using services - [src/app/api/](mdc:src/app/api/) - API routes using services
2. Frontend: 2. Frontend:
- [clients/](mdc:clients/) - HTTP clients - [src/clients/](mdc:src/clients/) - HTTP clients
- [components/](mdc:components/) - React components (organized by domain) - [src/components/](mdc:src/components/) - React components (organized by domain)
- [hooks/](mdc:hooks/) - React hooks - [src/hooks/](mdc:src/hooks/) - React hooks
3. Shared: 3. Shared:
- [lib/](mdc:lib/) - Types and utilities - [src/lib/](mdc:src/lib/) - Types and utilities
- [scripts/](mdc:scripts/) - Utility scripts - [scripts/](mdc:scripts/) - Utility scripts
Key Files: Key Files:
- [services/database.ts](mdc:services/database.ts) - Database pool - [src/services/database.ts](mdc:src/services/database.ts) - Database pool
- [clients/base/http-client.ts](mdc:clients/base/http-client.ts) - Base HTTP client - [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
- [lib/types.ts](mdc:lib/types.ts) - Shared types - [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
❌ FORBIDDEN: ❌ FORBIDDEN:
- Database access outside services/ - Database access outside src/services/
- HTTP calls outside clients/ - HTTP calls outside src/clients/
- Business logic in components/ - Business logic in src/components/

View File

@@ -1,5 +1,5 @@
--- ---
globs: services/*.ts globs: src/services/*.ts
--- ---
# Services Rules # Services Rules
@@ -7,7 +7,7 @@ globs: services/*.ts
1. Services MUST contain ALL PostgreSQL queries 1. Services MUST contain ALL PostgreSQL queries
2. Services are the ONLY layer allowed to communicate with the database 2. Services are the ONLY layer allowed to communicate with the database
3. Each service MUST: 3. Each service MUST:
- Use the pool from [services/database.ts](mdc:services/database.ts) - Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
- Implement proper transaction management - Implement proper transaction management
- Handle errors and logging - Handle errors and logging
- Validate data before insertion - Validate data before insertion
@@ -37,6 +37,6 @@ export class MyService {
❌ FORBIDDEN: ❌ FORBIDDEN:
- Direct database queries outside services - Direct database queries outside src/services
- Raw SQL in API routes - Raw SQL in API routes
- Database logic in components - Database logic in components

3
.gitignore vendored
View File

@@ -43,4 +43,5 @@ next-env.d.ts
/src/generated/prisma /src/generated/prisma
/prisma/dev.db /prisma/dev.db
backups/ /data/*.db
/data/backups/*

View File

@@ -52,6 +52,19 @@ tsx scripts/backup-manager.ts config-set maxBackups=10
tsx scripts/backup-manager.ts config-set compression=true tsx scripts/backup-manager.ts config-set compression=true
``` ```
### Personnalisation du dossier de sauvegarde
```bash
# Via variable d'environnement permanente (.env)
BACKUP_STORAGE_PATH="./custom-backups"
# Via variable temporaire (une seule fois)
BACKUP_STORAGE_PATH="./my-backups" npm run backup:create
# Exemple avec un chemin absolu
BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
```
## Utilisation ## Utilisation
### Interface graphique ### Interface graphique
@@ -272,8 +285,34 @@ export const prisma = globalThis.__prisma || new PrismaClient({
### Variables d'environnement ### Variables d'environnement
```bash ```bash
# Optionnel : personnaliser le chemin de la base # Configuration des chemins de base de données
DATABASE_URL="file:./custom/path/dev.db" DATABASE_URL="file:./prisma/dev.db" # Pour Prisma
BACKUP_DATABASE_PATH="./prisma/dev.db" # Base à sauvegarder (optionnel)
BACKUP_STORAGE_PATH="./backups" # Dossier des sauvegardes (optionnel)
```
### Docker
En environnement Docker, tout est centralisé dans le dossier `data/` :
```yaml
# docker-compose.yml
environment:
DATABASE_URL: "file:./data/prod.db" # Base de données Prisma
BACKUP_DATABASE_PATH: "./data/prod.db" # Base à sauvegarder
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
volumes:
- ./data:/app/data # Bind mount vers dossier local
```
**Structure des dossiers :**
```
./data/ # Dossier local mappé
├── prod.db # Base de données production
├── dev.db # Base de données développement
└── backups/ # Sauvegardes (créé automatiquement)
├── towercontrol_*.db.gz
└── ...
``` ```
## API ## API

201
DOCKER.md Normal file
View File

@@ -0,0 +1,201 @@
# 🐳 Docker - TowerControl
Guide d'utilisation de TowerControl avec Docker.
## 🚀 Démarrage rapide
### Production
```bash
# Démarrer le service de production
docker-compose up -d towercontrol
# Accéder à l'application
open http://localhost:3006
```
### Développement
```bash
# Démarrer le service de développement avec live reload
docker-compose --profile dev up towercontrol-dev
# Accéder à l'application
open http://localhost:3005
```
## 📋 Services disponibles
### 🚀 `towercontrol` (Production)
- **Port** : 3006
- **Base de données** : `./data/prod.db`
- **Sauvegardes** : `./data/backups/`
- **Mode** : Optimisé, standalone
- **Restart** : Automatique
### 🛠️ `towercontrol-dev` (Développement)
- **Port** : 3005
- **Base de données** : `./data/dev.db`
- **Sauvegardes** : `./data/backups/` (partagées)
- **Mode** : Live reload, debug
- **Profile** : `dev`
## 📁 Structure des données
```
./data/ # Mappé vers /app/data dans les conteneurs
├── README.md # Documentation du dossier data
├── prod.db # Base SQLite production
├── dev.db # Base SQLite développement
└── backups/ # Sauvegardes automatiques
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
└── ...
```
## 🔧 Configuration
### Variables d'environnement
| Variable | Production | Développement | Description |
|----------|------------|---------------|-------------|
| `NODE_ENV` | `production` | `development` | Mode d'exécution |
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup |
| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire |
### Ports
- **Production** : `3006:3000`
- **Développement** : `3005:3000`
## 📚 Commandes utiles
### Gestion des conteneurs
```bash
# Voir les logs
docker-compose logs -f towercontrol
docker-compose logs -f towercontrol-dev
# Arrêter les services
docker-compose down
# Reconstruire les images
docker-compose build
# Nettoyer tout
docker-compose down -v --rmi all
```
### Gestion des données
```bash
# Sauvegarder les données
docker-compose exec towercontrol npm run backup:create
# Lister les sauvegardes
docker-compose exec towercontrol npm run backup:list
# Accéder au shell du conteneur
docker-compose exec towercontrol sh
```
### Base de données
```bash
# Migrations Prisma
docker-compose exec towercontrol npx prisma migrate deploy
# Reset de la base (dev uniquement)
docker-compose exec towercontrol-dev npx prisma migrate reset
# Studio Prisma (dev)
docker-compose exec towercontrol-dev npx prisma studio
```
## 🔍 Debugging
### Vérifier la santé
```bash
# Health check
curl http://localhost:3006/api/health
curl http://localhost:3005/api/health
# Vérifier les variables d'env
docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)"
```
### Logs détaillés
```bash
# Logs avec timestamps
docker-compose logs -f -t towercontrol
# Logs des 100 dernières lignes
docker-compose logs --tail=100 towercontrol
```
## 🚨 Dépannage
### Problèmes courants
**Port déjà utilisé**
```bash
# Trouver le processus qui utilise le port
lsof -i :3006
kill -9 <PID>
```
**Base de données corrompue**
```bash
# Restaurer depuis une sauvegarde
docker-compose exec towercontrol npm run backup:restore filename.db.gz
```
**Permissions**
```bash
# Corriger les permissions du dossier data
sudo chown -R $USER:$USER ./data
```
## 📊 Monitoring
### Espace disque
```bash
# Taille du dossier data
du -sh ./data
# Espace libre
df -h .
```
### Performance
```bash
# Stats des conteneurs
docker stats
# Utilisation mémoire
docker-compose exec towercontrol free -h
```
## 🔒 Production
### Recommandations
- Utiliser un reverse proxy (nginx, traefik)
- Configurer HTTPS
- Sauvegarder régulièrement `./data/`
- Monitorer l'espace disque
- Logs centralisés
### Exemple nginx
```nginx
server {
listen 80;
server_name towercontrol.example.com;
location / {
proxy_pass http://localhost:3006;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
---
📚 **Voir aussi** : [data/README.md](./data/README.md)

View File

@@ -35,8 +35,8 @@ RUN npm run build
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM base AS runner FROM base AS runner
# Set timezone to Europe/Paris # Set timezone to Europe/Paris and install sqlite3 for backups
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata sqlite
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
WORKDIR /app WORKDIR /app
@@ -64,8 +64,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Create data directory for SQLite # Create data directory for SQLite and backups
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data
# Set all ENV vars before switching user # Set all ENV vars before switching user
ENV PORT=3000 ENV PORT=3000

103
TFS_UPGRADE_SUMMARY.md Normal file
View File

@@ -0,0 +1,103 @@
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
## 🎯 Objectif
Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique.
## ⚡ Changements apportés
### 1. Service TFS (`src/services/tfs.ts`)
#### Nouvelles méthodes ajoutées :
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
- **`filterPullRequests()`** : Applique les filtres de configuration
#### Méthode syncTasks refactorisée :
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
- Plus efficace et centrée sur l'utilisateur
- Récupération directe via l'API Azure DevOps avec critères `@me`
#### Configuration mise à jour :
- **`projectName`** devient **optionnel**
- Validation assouplie dans les factories
- Comportement adaptatif : projet spécifique OU toute l'organisation
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
#### Modifications du formulaire :
- Champ "Nom du projet" marqué comme **optionnel**
- Validation `required` supprimée
- Placeholder mis à jour : *"laisser vide pour toute l'organisation"*
- Affichage du statut : *"Toute l'organisation"* si pas de projet
#### Instructions mises à jour :
- Explique le nouveau comportement **synchronisation intelligente**
- Précise que les PRs sont récupérées automatiquement selon l'assignation
- Note sur la portée projet vs organisation
### 3. Endpoints API
#### `/api/tfs/test/route.ts`
- Validation mise à jour (projectName optionnel)
- Message de réponse enrichi avec portée (projet/organisation)
- Retour détaillé du scope de synchronisation
#### `/api/tfs/sync/route.ts`
- Validation assouplie pour les deux méthodes GET/POST
- Configuration adaptative selon la présence du projectName
## 🔧 API Azure DevOps utilisées
### Nouvelles requêtes :
```typescript
// PRs créées par l'utilisateur
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
// PRs où je suis reviewer
/_apis/git/pullrequests?searchCriteria.reviewerId=@me&searchCriteria.status=active
```
### Comportement intelligent :
- **Fusion automatique** des deux types de PRs
- **Déduplication** basée sur `pullRequestId`
- **Filtrage** selon la configuration (repositories, branches, projet)
## 📊 Avantages
1. **Centré utilisateur** : Récupère seulement les PRs pertinentes
2. **Performance améliorée** : Une seule requête API au lieu de parcourir tous les repos
3. **Flexibilité** : Projet spécifique OU toute l'organisation
4. **Scalabilité** : Fonctionne avec des organisations de grande taille
5. **Simplicité** : Configuration minimale requise
## 🎨 Interface utilisateur
### Avant :
- Champ projet **obligatoire**
- Synchronisation limitée à UN projet
- Configuration rigide
### Après :
- Champ projet **optionnel**
- Synchronisation intelligente de TOUTES les PRs assignées
- Configuration flexible et adaptative
- Instructions claires sur le comportement
## ✅ Tests recommandés
1. **Configuration avec projet spécifique** : Vérifier le filtrage par projet
2. **Configuration sans projet** : Vérifier la récupération organisation complète
3. **Test de connexion** : Valider le nouveau comportement API
4. **Synchronisation** : Contrôler que seules les PRs assignées sont récupérées
## 🚀 Déploiement
La migration est **transparente** :
- Les configurations existantes continuent à fonctionner
- Possibilité de supprimer le `projectName` pour étendre la portée
- Pas de rupture de compatibilité
---
*Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps.* 🎯

640
TODO.md
View File

@@ -1,394 +1,258 @@
# TowerControl v2.0 - Gestionnaire de tâches moderne # TowerControl v2.0 - Gestionnaire de tâches moderne
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ) ## Idées à developper
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut <!-- Diagnostic terminé -->
### 1.1 Configuration projet Next.js - [ ] Personnalisation : couleurs
- [x] Initialiser Next.js avec TypeScript - [ ] Optimisations Perf : requetes DB
- [x] Configurer ESLint, Prettier
- [x] Setup structure de dossiers selon les règles du workspace
- [x] Configurer base de données (SQLite local)
- [x] Setup Prisma ORM
### 1.2 Architecture backend standalone
- [x] Créer `services/database.ts` - Pool de connexion DB
- [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
- [x] Nettoyer l'ancien code de synchronisation
### 1.3 API moderne et propre
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
- [x] Supprimer les routes de synchronisation obsolètes
- [x] Configuration moderne dans `lib/config.ts`
**Architecture finale** : App standalone avec backend propre et API REST moderne
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
### 2.1 Système de design et composants UI
- [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge)
- [x] Implémenter le système de design tech dark (couleurs, typographie, spacing)
- [x] Setup Tailwind CSS avec classes utilitaires personnalisées
- [x] Créer une palette de couleurs tech/cyberpunk
### 2.2 Composants Kanban existants (à améliorer)
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
- [x] `components/ui/Header.tsx` - Header avec statistiques
- [x] Refactoriser les composants pour utiliser le nouveau système UI
### 2.3 Gestion des tâches (CRUD)
- [x] Formulaire de création de tâche (Modal + Form)
- [x] Création rapide inline dans les colonnes (QuickAddTask)
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
- [x] Édition inline du titre des tâches (clic sur titre → input)
- [x] Suppression de tâche (icône discrète + API call)
- [x] Changement de statut par drag & drop (@dnd-kit)
- [x] Validation des formulaires et gestion d'erreurs
### 2.4 Gestion des tags
- [x] Créer/éditer des tags avec sélecteur de couleur
- [x] Autocomplete pour les tags existants
- [x] Suppression de tags (avec vérification des dépendances)
- [x] Affichage des tags avec couleurs personnalisées
- [x] Service tags avec CRUD complet (Prisma)
- [x] API routes /api/tags avec validation
- [x] Client HTTP et hook useTags
- [x] Composants UI (TagInput, TagDisplay, TagForm)
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
- [x] Contexte global pour partager les tags
- [x] Page de gestion des tags (/tags) avec interface complète
- [x] Navigation dans le Header (Kanban ↔ Tags)
- [x] Filtrage par tags (intégration dans Kanban)
- [x] Interface de filtrage complète (recherche, priorités, tags)
- [x] Logique de filtrage temps réel dans le contexte
- [x] Intégration des filtres dans KanbanBoard
### 2.5 Clients HTTP et hooks
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
- [x] `clients/tags-client.ts` - Client pour les tags
- [x] `clients/base/http-client.ts` - Client HTTP de base
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
- [x] Gestion des erreurs et loading states
- [x] Architecture SSR + hydratation client optimisée
### 2.6 Fonctionnalités Kanban avancées
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
- [x] Filtrage par statut/priorité/assigné
- [x] Recherche en temps réel dans les tâches
- [x] Interface de filtrage complète (KanbanFilters.tsx)
- [x] Logique de filtrage dans TasksContext
- [x] Tri des tâches (date, priorité, alphabétique)
### 2.7 Système de thèmes (clair/sombre)
- [x] Créer le contexte de thème (ThemeContext + ThemeProvider)
- [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune)
- [x] Définir les variables CSS pour le thème clair
- [x] Adapter tous les composants UI pour supporter les deux thèmes
- [x] Modifier la palette de couleurs pour le mode clair
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
- [x] Adapter les formulaires et modales
- [x] Adapter la page de gestion des tags
- [x] Sauvegarder la préférence de thème (localStorage)
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
### 3.1 Gestion du Daily
- [x] Créer `services/daily.ts` - Service de gestion des daily notes
- [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui)
- [x] Interface Daily avec sections "Hier" et "Aujourd'hui"
- [x] Checkboxes interactives avec état coché/non-coché
- [x] Liaison optionnelle checkbox ↔ tâche existante
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
- [x] Navigation par date (daily précédent/suivant)
- [x] Auto-création du daily du jour si inexistant
- [x] UX améliorée : édition au clic, focus persistant, input large
- [x] Vue calendar/historique des dailies
### 3.2 Intégration Jira Cloud
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
- [x] Authentification Basic Auth (email + API token)
- [x] Récupération des tickets assignés à l'utilisateur
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
- [x] Gestion des diffs - ne pas écraser les modifications locales
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
- [x] Interface de configuration dans les paramètres
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
- [x] Logs de synchronisation pour debug
- [x] Gestion des erreurs et timeouts API
### 3.3 Page d'accueil/dashboard
- [x] Créer une page d'accueil moderne avec vue d'ensemble
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
- [x] Actions rapides vers les différentes sections
- [x] Affichage des tâches récentes
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
- [x] Indicateurs de performance personnels
- [x] Intégration des analytics dans le dashboard
### 3.4 Analytics et métriques
- [x] `services/analytics.ts` - Calculs statistiques
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
- [x] Insights automatiques et métriques visuelles
## Autre Todo
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
- [x] Refactorer les couleurs des priorités dans un seul endroit
- [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
- [x] Système de sauvegarde automatique base de données
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
- [x] Configuration complète dans les paramètres avec interface dédiée
- [x] Rotation automatique des sauvegardes (configurable)
- [x] Format de sauvegarde avec timestamp + compression optionnelle
- [x] Interface complète pour visualiser et gérer les sauvegardes
- [x] CLI d'administration pour les opérations avancées
- [x] API REST complète pour la gestion programmatique
- [x] Vérification d'intégrité et restauration sécurisée
- [x] Option de restauration depuis une sauvegarde sélectionnée
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
### 4.1 Migration vers Server Actions - Actions rapides
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
#### Actions TaskCard (Priorité 1)
- [x] Créer `actions/tasks.ts` avec server actions de base
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
- [x] `deleteTask(taskId)` - Suppression de tâche
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
- [x] Remplacer les props callbacks par calls directs aux actions
- [x] Intégrer `useTransition` pour les loading states natifs
- [x] Tester la revalidation automatique du cache
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
#### Actions Daily (Priorité 2)
- [x] Créer `actions/daily.ts` pour les checkboxes
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
- [x] Modifier les composants Daily pour utiliser server actions
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
#### Actions User Preferences (Priorité 3)
- [x] Créer `actions/preferences.ts` pour les toggles
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
- [x] `updateTheme(theme)` - Changement de thème
- [x] Remplacer les hooks par server actions directes
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
#### Actions Tags (Priorité 4)
- [x] Créer `actions/tags.ts` pour la gestion tags
- [x] `createTag(name, color)` - Création tag
- [x] `updateTag(tagId, data)` - Modification tag
- [x] `deleteTag(tagId)` - Suppression tag
- [x] Modifier les formulaires tags pour server actions
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
#### Migration progressive avec nettoyage immédiat
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
### 4.2 Conservation API Routes - Endpoints complexes
**À GARDER en API routes** (pas de migration)
#### Endpoints de fetching initial
-`GET /api/tasks` - Récupération avec filtres complexes
-`GET /api/daily` - Vue daily avec logique métier
-`GET /api/tags` - Liste tags avec recherche
-`GET /api/user-preferences` - Préférences initiales
#### Endpoints d'intégration externe
-`POST /api/jira/sync` - Synchronisation Jira complexe
-`GET /api/jira/logs` - Logs de synchronisation
- ✅ Configuration Jira (formulaires complexes)
#### Raisons de conservation
- **API publique** : Réutilisable depuis mobile/externe
- **Logique complexe** : Synchronisation, analytics, rapports
- **Monitoring** : Besoin de logs HTTP séparés
- **Real-time futur** : WebSockets/SSE non compatibles server actions
### 4.3 Architecture hybride cible
```
Actions rapides → Server Actions directes
├── TaskCard actions (status, title, delete)
├── Daily checkboxes (toggle, add, edit)
├── Preferences toggles (theme, filters)
└── Tags CRUD (create, update, delete)
Endpoints complexes → API Routes conservées
├── Fetching initial avec filtres
├── Intégrations externes (Jira, webhooks)
├── Analytics et rapports
└── Future real-time features
```
### 4.4 Avantages attendus
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
- **🔄 Cache intelligent** : `revalidatePath()` automatique
- **📦 Bundle reduction** : Moins de code client HTTP
- **⚡ UX** : `useTransition` loading states natifs
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
### 5.1 Configuration projet Jira
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
- [x] Interface pour sélectionner le projet à surveiller
- [x] Validation de l'existence du projet via API Jira
- [x] Sauvegarde de la configuration projet dans les préférences
- [x] Test de connexion spécifique au projet configuré
### 5.2 Service d'analytics Jira
- [x] Créer `services/jira-analytics.ts` - Métriques avancées
- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés)
- [x] Calculs de vélocité d'équipe (story points par sprint)
- [x] Métriques de cycle time (temps entre statuts)
- [x] Analyse de la répartition des tâches par assignee
- [x] Détection des goulots d'étranglement (tickets bloqués)
- [x] Historique des sprints et burndown charts
- [x] Cache intelligent des métriques (éviter API rate limits)
### 5.3 Page de surveillance `/jira-dashboard`
- [x] Créer page dédiée avec navigation depuis settings Jira
- [x] Vue d'ensemble du projet (nom, lead, statut global)
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
- [x] Graphiques de vélocité avec Recharts
- [x] Heatmap d'activité de l'équipe
- [x] Timeline des releases et milestones
- [x] Alertes visuelles (tickets en retard, sprints déviants)
### 5.4 Métriques et graphiques avancés
- [x] **Vélocité** : Story points complétés par sprint
- [x] **Burndown chart** : Progression vs planifié
- [x] **Cycle time** : Temps moyen par type de ticket
- [x] **Throughput** : Nombre de tickets complétés par période
- [x] **Work in Progress** : Répartition par statut et assignee
- [x] **Quality metrics** : Ratio bugs/features, retours clients
- [x] **Predictability** : Variance entre estimé et réel
- [x] **Collaboration** : Matrice d'interactions entre assignees
### 5.5 Fonctionnalités de surveillance
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
- [x] Détection automatique d'anomalies (alertes)
- [x] Filtrage par composant, version, type de ticket
- [x] Vue détaillée par sprint avec drill-down
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
## Autre Todos #2
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
- [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
### 6.1 Gestion avancée des tâches
- [ ] Actions en lot (sélection multiple)
- [ ] Sous-tâches et hiérarchie
- [ ] Dates d'échéance et rappels
- [ ] Assignation et collaboration
- [ ] Templates de tâches
### 6.2 Personnalisation et thèmes
- [ ] Mode sombre/clair
- [ ] Personnalisation des couleurs
- [ ] Configuration des colonnes Kanban
- [ ] Préférences utilisateur
## 🚀 Phase 7: Intégrations futures (Priorité 7)
### 7.1 Intégrations externes (optionnel)
- [ ] Import/Export depuis d'autres outils
- [ ] API webhooks pour intégrations
- [ ] Synchronisation cloud (optionnel)
- [ ] Notifications push
### 7.2 Optimisations et performance
- [ ] Optimisation des requêtes DB
- [ ] Pagination et virtualisation
- [ ] Cache côté client
- [ ] PWA et mode offline - [ ] PWA et mode offline
## 🛠️ Configuration technique
### Stack moderne
- **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
- **Backend**: Next.js API Routes, Prisma ORM
- **Database**: SQLite (local) → PostgreSQL (production future)
- **UI**: Composants custom + Shadcn/ui, React Beautiful DnD
- **Charts**: Recharts ou Chart.js pour les analytics
### Architecture respectée
```
src/app/
├── api/tasks/ # API CRUD complète
├── page.tsx # Page principale
└── layout.tsx
services/
├── database.ts # Pool Prisma
└── tasks.ts # Service tâches standalone
components/
├── kanban/ # Board Kanban
├── ui/ # Composants UI de base
└── dashboard/ # Widgets dashboard (futur)
clients/ # Clients HTTP (à créer)
hooks/ # Hooks React (à créer)
lib/
├── types.ts # Types TypeScript
└── config.ts # Config app moderne
```
## 🎯 Prochaines étapes immédiates
1. **Drag & drop entre colonnes** - react-beautiful-dnd pour changer les statuts
2. **Gestion avancée des tags** - Couleurs, autocomplete, filtrage
3. **Recherche et filtres** - Filtrage temps réel par titre, tags, statut
4. **Dashboard et analytics** - Graphiques de productivité
## ✅ **Fonctionnalités terminées (Phase 2.1-2.3)**
- ✅ Système de design tech dark complet
- ✅ Composants UI de base (Button, Input, Card, Modal, Badge)
- ✅ Architecture SSR + hydratation client
- ✅ CRUD tâches complet (création, édition, suppression)
- ✅ Création rapide inline (QuickAddTask)
- ✅ Édition inline du titre (clic sur titre → input éditable)
- ✅ Drag & drop entre colonnes (@dnd-kit) + optimiste
- ✅ Client HTTP et hooks React
- ✅ Refactoring Kanban avec nouveaux composants
--- ---
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer.* ## 🎨 **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
### 🎯 Jira - Suivi des demandes en attente
- [ ] **Page "Jiras en attente"**
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
- [ ] Suivi des demandes formulées à d'autres équipes
- [ ] Filtrage par projet, équipe cible, ancienneté
- [ ] **Nouveau modèle de données**
- [ ] Table séparée pour les "demandes en attente" (différent des tâches Kanban)
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
- [ ] Notifications quand une demande change de statut
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
#### **Architecture actuelle → Multi-tenant**
- **Problème** : App mono-utilisateur avec données globales
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données + système de rôles
#### **Plan de migration**
- [ ] **Phase 1: Authentification**
- [ ] Système de login/mot de passe (NextAuth.js)
- [ ] Gestion des sessions sécurisées
- [ ] Pages de connexion/inscription/mot de passe oublié
- [ ] Middleware de protection des routes
- [ ] **Phase 2: Modèle de données multi-tenant + Rôles**
- [ ] **Modèle User complet**
- [ ] Table `users` (id, email, password, name, role, createdAt, updatedAt)
- [ ] Enum `UserRole` : `ADMIN`, `MANAGER`, `USER`
- [ ] Champs optionnels : avatar, timezone, language
- [ ] **Relations hiérarchiques**
- [ ] Table `user_teams` pour les relations manager → users
- [ ] Champ `managerId` dans users (optionnel, référence vers un manager)
- [ ] Support des équipes multiples par utilisateur
- [ ] **Migration des données existantes**
- [ ] Créer un utilisateur admin par défaut avec toutes les données actuelles
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
- [ ] Contraintes de base de données pour l'isolation
- [ ] Index sur `userId` pour les performances
- [ ] **Phase 3: Système de rôles et permissions**
- [ ] **Rôle ADMIN**
- [ ] Gestion complète des utilisateurs (CRUD)
- [ ] Assignation/modification des rôles
- [ ] Accès à toutes les données système (analytics globales)
- [ ] Configuration système (intégrations Jira/TFS globales)
- [ ] Gestion des équipes et hiérarchies
- [ ] **Rôle MANAGER**
- [ ] Vue sur les tâches/daily de ses équipiers
- [ ] Assignation de tâches à ses équipiers
- [ ] Analytics d'équipe (métriques, deadlines, performance)
- [ ] Création de tâches pour son équipe
- [ ] Accès aux rapports de son équipe
- [ ] **Rôle USER**
- [ ] Accès uniquement à ses propres données
- [ ] Réception de tâches assignées par son manager
- [ ] Gestion de son daily/kanban personnel
- [ ] **Middleware de permissions**
- [ ] Validation des droits d'accès par route
- [ ] Helper functions `canAccess()`, `canManage()`, `isAdmin()`
- [ ] Protection automatique des API routes
- [ ] **Phase 4: Services et API avec rôles**
- [ ] **Services utilisateurs**
- [ ] `user-management.ts` : CRUD utilisateurs (admin only)
- [ ] `team-management.ts` : Gestion des équipes (admin/manager)
- [ ] `role-permissions.ts` : Logique des permissions
- [ ] **Modification des services existants**
- [ ] Tous les services filtrent par `userId` OU permissions manager
- [ ] Middleware d'injection automatique du `userId` + `userRole`
- [ ] Services analytics étendus pour les managers
- [ ] Validation que chaque utilisateur ne voit que ses données autorisées
- [ ] **Phase 5: UI et UX multi-rôles**
- [ ] **Interface Admin**
- [ ] Page de gestion des utilisateurs (/admin/users)
- [ ] Création/modification/suppression d'utilisateurs
- [ ] Assignation des rôles et équipes
- [ ] Dashboard admin avec métriques globales
- [ ] **Interface Manager**
- [ ] Vue équipe avec tâches de tous les équipiers
- [ ] Assignation de tâches à l'équipe
- [ ] Dashboard manager avec analytics d'équipe
- [ ] Gestion des deadlines et priorités d'équipe
- [ ] **Interface commune**
- [ ] Header avec profil utilisateur, rôle et déconnexion
- [ ] Onboarding différencié par rôle
- [ ] Navigation adaptée aux permissions
- [ ] Indicateurs visuels du rôle actuel
- [ ] **Phase 6: Fonctionnalités collaboratives**
- [ ] **Assignation de tâches**
- [ ] Managers peuvent créer et assigner des tâches
- [ ] Notifications de nouvelles tâches assignées
- [ ] Suivi du statut des tâches assignées
- [ ] **Partage et visibilité**
- [ ] Tâches partagées entre équipiers
- [ ] Commentaires et collaboration sur les tâches
- [ ] Historique des modifications par utilisateur
#### **Considérations techniques**
- **Base de données** : Ajouter `userId` partout + contraintes
- **Sécurité** : Validation côté serveur de l'isolation des données
- **Performance** : Index sur `userId`, pagination pour gros volumes
- **Migration** : Script de migration des données existantes
---
## 🤖 Intégration IA avec Mistral (Phase IA)
### **Socle technique**
- [ ] **Phase 1: Infrastructure Mistral**
- [ ] Configuration du client Mistral local
- [ ] Service `mistral-client.ts` avec connexion au modèle local
- [ ] Configuration des endpoints et paramètres (température, tokens, etc.)
- [ ] Gestion des erreurs et timeouts
- [ ] Cache des réponses pour éviter les appels répétés
- [ ] **Système de prompts**
- [ ] Template engine pour les prompts structurés
- [ ] Prompts spécialisés par fonctionnalité (analyse, génération, classification)
- [ ] Versioning des prompts pour A/B testing
- [ ] Logging des interactions pour amélioration continue
- [ ] **Sécurité et performance**
- [ ] Rate limiting pour éviter la surcharge du modèle local
- [ ] Validation des inputs avant envoi au modèle
- [ ] Sanitization des réponses IA
- [ ] Monitoring des performances (latence, tokens utilisés)
- [ ] **Phase 2: Services IA développés avec les features**
- [ ] Services créés au fur et à mesure des besoins des fonctionnalités
- [ ] Pas de développement anticipé - implémentation juste-à-temps
- [ ] Architecture modulaire pour faciliter l'ajout de nouveaux services
- [ ] **Phase 3: Configuration et gestion de l'assistant**
- [ ] **Page de configuration IA (/settings/ai-assistant)**
- [ ] Configuration du modèle Mistral (endpoint, température, max tokens)
- [ ] Activation/désactivation des fonctionnalités IA par catégorie
- [ ] Paramètres de personnalisation (style de réponses, niveau d'agressivité)
- [ ] Configuration des seuils (confiance minimale, fréquence des suggestions)
- [ ] **Gestion des prompts personnalisés**
- [ ] Interface pour modifier les prompts par fonctionnalité
- [ ] Aperçu en temps réel des modifications
- [ ] Sauvegarde/restauration des configurations
- [ ] Templates de prompts prédéfinis
- [ ] **Monitoring et analytics IA**
- [ ] Dashboard des performances IA (latence, tokens utilisés, coût)
- [ ] Historique des interactions et taux de succès
- [ ] Métriques d'utilisation par fonctionnalité
- [ ] Logs des erreurs et suggestions d'amélioration
- [ ] **Système de feedback**
- [ ] Boutons "👍/👎" sur chaque suggestion IA
- [ ] Collecte des retours utilisateur pour amélioration
- [ ] A/B testing des différents prompts
- [ ] Apprentissage des préférences utilisateur
### **Fonctionnalités IA concrètes**
#### 🎯 **Smart Task Creation**
- [ ] **Bouton "Créer avec IA" dans le Kanban**
- [ ] Input libre : "Préparer présentation client pour vendredi"
- [ ] IA génère : titre, description, estimation durée, sous-tâches
- [ ] **Mapping prioritaire avec tags existants** : IA propose uniquement des tags déjà utilisés
- [ ] Validation/modification avant création
#### 🧠 **Daily Assistant**
- [ ] **Bouton "Smart Daily" dans la page Daily**
- [ ] Input libre : "Réunion client 14h, finir le rapport, appeler le fournisseur"
- [ ] IA génère une liste de checkboxes structurées
- [ ] Validation/modification avant ajout au Daily
- [ ] Pas de génération automatique - uniquement sur demande utilisateur
- [ ] **Smart Checkbox Suggestions**
- [ ] Pendant la saisie, IA propose des checkboxes similaires
#### 🎨 **Smart Tagging**
- [ ] **Auto-tagging des nouvelles tâches**
- [ ] IA analyse le titre/description
- [ ] Propose automatiquement 2-3 tags **existants** pertinents
- [ ] Apprentissage des tags utilisés par l'utilisateur
- [ ] **Suggestions de tags pendant la saisie**
- [ ] Dropdown intelligent avec **tags existants** probables uniquement
- [ ] Tri par fréquence d'usage et pertinence
#### 💬 **Chat Assistant**
- [ ] **Widget chat en bas à droite**
- [ ] "Quelles sont mes tâches urgentes cette semaine ?"
- [ ] "Comment optimiser mon planning demain ?"
- [ ] "Résume-moi mes performances de ce mois"
- [ ] **Recherche sémantique**
- [ ] "Tâches liées au projet X" même sans tag exact
- [ ] "Tâches que j'ai faites la semaine dernière"
- [ ] Recherche par contexte, pas juste mots-clés
#### 📈 **Smart Reports**
- [ ] **Génération automatique de rapports**
- [ ] Bouton "Générer rapport IA" dans analytics
- [ ] IA analyse les données et génère un résumé textuel
- [ ] Insights personnalisés ("Tu es plus productif le matin")
- [ ] **Alertes intelligentes**
- [ ] "Attention : tu as 3 tâches urgentes non démarrées"
- [ ] "Suggestion : regrouper les tâches similaires"
- [ ] Notifications contextuelles et actionables
#### ⚡ **Quick Actions**
- [ ] **Bouton "Optimiser" sur une tâche**
- [ ] IA suggère des améliorations (titre, description)
- [ ] Propose des **tags existants** pertinents
- [ ] Propose des sous-tâches manquantes
- [ ] Estimation de durée plus précise
- [ ] **Smart Duplicate Detection**
- [ ] "Cette tâche ressemble à une tâche existante"
- [ ] Suggestions de fusion ou différenciation
- [ ] Évite la duplication accidentelle
- [ ] **Exclusion des tâches avec tag "objectif principal"** : IA ignore ces tâches dans les comparaisons
---
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*

481
TODO_ARCHIVE.md Normal file
View File

@@ -0,0 +1,481 @@
# TowerControl v2.0 - Gestionnaire de tâches moderne
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
### 1.1 Configuration projet Next.js
- [x] Initialiser Next.js avec TypeScript
- [x] Configurer ESLint, Prettier
- [x] Setup structure de dossiers selon les règles du workspace
- [x] Configurer base de données (SQLite local)
- [x] Setup Prisma ORM
### 1.2 Architecture backend standalone
- [x] Créer `services/database.ts` - Pool de connexion DB
- [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
- [x] Nettoyer l'ancien code de synchronisation
### 1.3 API moderne et propre
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
- [x] Supprimer les routes de synchronisation obsolètes
- [x] Configuration moderne dans `lib/config.ts`
**Architecture finale** : App standalone avec backend propre et API REST moderne
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
### 2.1 Système de design et composants UI
- [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge)
- [x] Implémenter le système de design tech dark (couleurs, typographie, spacing)
- [x] Setup Tailwind CSS avec classes utilitaires personnalisées
- [x] Créer une palette de couleurs tech/cyberpunk
### 2.2 Composants Kanban existants (à améliorer)
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
- [x] `components/ui/Header.tsx` - Header avec statistiques
- [x] Refactoriser les composants pour utiliser le nouveau système UI
### 2.3 Gestion des tâches (CRUD)
- [x] Formulaire de création de tâche (Modal + Form)
- [x] Création rapide inline dans les colonnes (QuickAddTask)
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
- [x] Édition inline du titre des tâches (clic sur titre → input)
- [x] Suppression de tâche (icône discrète + API call)
- [x] Changement de statut par drag & drop (@dnd-kit)
- [x] Validation des formulaires et gestion d'erreurs
### 2.4 Gestion des tags
- [x] Créer/éditer des tags avec sélecteur de couleur
- [x] Autocomplete pour les tags existants
- [x] Suppression de tags (avec vérification des dépendances)
- [x] Affichage des tags avec couleurs personnalisées
- [x] Service tags avec CRUD complet (Prisma)
- [x] API routes /api/tags avec validation
- [x] Client HTTP et hook useTags
- [x] Composants UI (TagInput, TagDisplay, TagForm)
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
- [x] Contexte global pour partager les tags
- [x] Page de gestion des tags (/tags) avec interface complète
- [x] Navigation dans le Header (Kanban ↔ Tags)
- [x] Filtrage par tags (intégration dans Kanban)
- [x] Interface de filtrage complète (recherche, priorités, tags)
- [x] Logique de filtrage temps réel dans le contexte
- [x] Intégration des filtres dans KanbanBoard
### 2.5 Clients HTTP et hooks
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
- [x] `clients/tags-client.ts` - Client pour les tags
- [x] `clients/base/http-client.ts` - Client HTTP de base
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
- [x] Gestion des erreurs et loading states
- [x] Architecture SSR + hydratation client optimisée
### 2.6 Fonctionnalités Kanban avancées
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
- [x] Filtrage par statut/priorité/assigné
- [x] Recherche en temps réel dans les tâches
- [x] Interface de filtrage complète (KanbanFilters.tsx)
- [x] Logique de filtrage dans TasksContext
- [x] Tri des tâches (date, priorité, alphabétique)
### 2.7 Système de thèmes (clair/sombre)
- [x] Créer le contexte de thème (ThemeContext + ThemeProvider)
- [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune)
- [x] Définir les variables CSS pour le thème clair
- [x] Adapter tous les composants UI pour supporter les deux thèmes
- [x] Modifier la palette de couleurs pour le mode clair
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
- [x] Adapter les formulaires et modales
- [x] Adapter la page de gestion des tags
- [x] Sauvegarder la préférence de thème (localStorage)
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
### 3.1 Gestion du Daily
- [x] Créer `services/daily.ts` - Service de gestion des daily notes
- [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui)
- [x] Interface Daily avec sections "Hier" et "Aujourd'hui"
- [x] Checkboxes interactives avec état coché/non-coché
- [x] Liaison optionnelle checkbox ↔ tâche existante
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
- [x] Navigation par date (daily précédent/suivant)
- [x] Auto-création du daily du jour si inexistant
- [x] UX améliorée : édition au clic, focus persistant, input large
- [x] Vue calendar/historique des dailies
### 3.2 Intégration Jira Cloud
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
- [x] Authentification Basic Auth (email + API token)
- [x] Récupération des tickets assignés à l'utilisateur
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
- [x] Gestion des diffs - ne pas écraser les modifications locales
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
- [x] Interface de configuration dans les paramètres
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
- [x] Logs de synchronisation pour debug
- [x] Gestion des erreurs et timeouts API
### 3.3 Page d'accueil/dashboard
- [x] Créer une page d'accueil moderne avec vue d'ensemble
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
- [x] Actions rapides vers les différentes sections
- [x] Affichage des tâches récentes
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
- [x] Indicateurs de performance personnels
- [x] Intégration des analytics dans le dashboard
### 3.4 Analytics et métriques
- [x] `services/analytics.ts` - Calculs statistiques
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
- [x] Insights automatiques et métriques visuelles
## Autre Todo
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
- [x] Refactorer les couleurs des priorités dans un seul endroit
- [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
- [x] Système de sauvegarde automatique base de données
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
- [x] Configuration complète dans les paramètres avec interface dédiée
- [x] Rotation automatique des sauvegardes (configurable)
- [x] Format de sauvegarde avec timestamp + compression optionnelle
- [x] Interface complète pour visualiser et gérer les sauvegardes
- [x] CLI d'administration pour les opérations avancées
- [x] API REST complète pour la gestion programmatique
- [x] Vérification d'intégrité et restauration sécurisée
- [x] Option de restauration depuis une sauvegarde sélectionnée
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
### 4.1 Migration vers Server Actions - Actions rapides
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
#### Actions TaskCard (Priorité 1)
- [x] Créer `actions/tasks.ts` avec server actions de base
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
- [x] `deleteTask(taskId)` - Suppression de tâche
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
- [x] Remplacer les props callbacks par calls directs aux actions
- [x] Intégrer `useTransition` pour les loading states natifs
- [x] Tester la revalidation automatique du cache
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
#### Actions Daily (Priorité 2)
- [x] Créer `actions/daily.ts` pour les checkboxes
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
- [x] Modifier les composants Daily pour utiliser server actions
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
#### Actions User Preferences (Priorité 3)
- [x] Créer `actions/preferences.ts` pour les toggles
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
- [x] `updateTheme(theme)` - Changement de thème
- [x] Remplacer les hooks par server actions directes
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
#### Actions Tags (Priorité 4)
- [x] Créer `actions/tags.ts` pour la gestion tags
- [x] `createTag(name, color)` - Création tag
- [x] `updateTag(tagId, data)` - Modification tag
- [x] `deleteTag(tagId)` - Suppression tag
- [x] Modifier les formulaires tags pour server actions
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
#### Migration progressive avec nettoyage immédiat
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
### 4.2 Conservation API Routes - Endpoints complexes
**À GARDER en API routes** (pas de migration)
#### Endpoints de fetching initial
-`GET /api/tasks` - Récupération avec filtres complexes
-`GET /api/daily` - Vue daily avec logique métier
-`GET /api/tags` - Liste tags avec recherche
-`GET /api/user-preferences` - Préférences initiales
#### Endpoints d'intégration externe
-`POST /api/jira/sync` - Synchronisation Jira complexe
-`GET /api/jira/logs` - Logs de synchronisation
- ✅ Configuration Jira (formulaires complexes)
#### Raisons de conservation
- **API publique** : Réutilisable depuis mobile/externe
- **Logique complexe** : Synchronisation, analytics, rapports
- **Monitoring** : Besoin de logs HTTP séparés
- **Real-time futur** : WebSockets/SSE non compatibles server actions
### 4.3 Architecture hybride cible
```
Actions rapides → Server Actions directes
├── TaskCard actions (status, title, delete)
├── Daily checkboxes (toggle, add, edit)
├── Preferences toggles (theme, filters)
└── Tags CRUD (create, update, delete)
Endpoints complexes → API Routes conservées
├── Fetching initial avec filtres
├── Intégrations externes (Jira, webhooks)
├── Analytics et rapports
└── Future real-time features
```
### 4.4 Avantages attendus
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
- **🔄 Cache intelligent** : `revalidatePath()` automatique
- **📦 Bundle reduction** : Moins de code client HTTP
- **⚡ UX** : `useTransition` loading states natifs
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
### 5.1 Configuration projet Jira
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
- [x] Interface pour sélectionner le projet à surveiller
- [x] Validation de l'existence du projet via API Jira
- [x] Sauvegarde de la configuration projet dans les préférences
- [x] Test de connexion spécifique au projet configuré
### 5.2 Service d'analytics Jira
- [x] Créer `services/jira-analytics.ts` - Métriques avancées
- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés)
- [x] Calculs de vélocité d'équipe (story points par sprint)
- [x] Métriques de cycle time (temps entre statuts)
- [x] Analyse de la répartition des tâches par assignee
- [x] Détection des goulots d'étranglement (tickets bloqués)
- [x] Historique des sprints et burndown charts
- [x] Cache intelligent des métriques (éviter API rate limits)
### 5.3 Page de surveillance `/jira-dashboard`
- [x] Créer page dédiée avec navigation depuis settings Jira
- [x] Vue d'ensemble du projet (nom, lead, statut global)
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
- [x] Graphiques de vélocité avec Recharts
- [x] Heatmap d'activité de l'équipe
- [x] Timeline des releases et milestones
- [x] Alertes visuelles (tickets en retard, sprints déviants)
### 5.4 Métriques et graphiques avancés
- [x] **Vélocité** : Story points complétés par sprint
- [x] **Burndown chart** : Progression vs planifié
- [x] **Cycle time** : Temps moyen par type de ticket
- [x] **Throughput** : Nombre de tickets complétés par période
- [x] **Work in Progress** : Répartition par statut et assignee
- [x] **Quality metrics** : Ratio bugs/features, retours clients
- [x] **Predictability** : Variance entre estimé et réel
- [x] **Collaboration** : Matrice d'interactions entre assignees
### 5.5 Fonctionnalités de surveillance
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
- [x] Détection automatique d'anomalies (alertes)
- [x] Filtrage par composant, version, type de ticket
- [x] Vue détaillée par sprint avec drill-down
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
#### **Problème actuel**
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
- Alias TypeScript incohérents dans `tsconfig.json`
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
#### **Plan de migration**
- [x] **Phase 1: Migration des dossiers**
- [x] `mv components/ src/components/`
- [x] `mv lib/ src/lib/`
- [x] `mv hooks/ src/hooks/`
- [x] `mv clients/ src/clients/`
- [x] `mv services/ src/services/`
- [x] **Phase 2: Mise à jour tsconfig.json**
```json
"paths": {
"@/*": ["./src/*"]
// Supprimer les alias spécifiques devenus inutiles
}
```
- [x] **Phase 3: Correction des imports**
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
- [x] Vérifier les imports relatifs dans les scripts/
- [x] **Phase 4: Mise à jour des règles Cursor**
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
- [x] Règle "components" : Mettre à jour avec `src/components/`
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
- [x] Vérifier tous les liens MDC dans les règles
- [x] **Phase 5: Tests et validation**
- [x] `npm run build` - Vérifier que le build passe
- [x] `npm run dev` - Vérifier que le dev fonctionne
- [x] `npm run lint` - Vérifier ESLint
- [x] `npx tsc --noEmit` - Vérifier TypeScript
- [x] Tester les fonctionnalités principales
#### **Structure finale attendue**
```
src/
├── app/ # Pages Next.js (déjà OK)
├── actions/ # Server Actions (déjà OK)
├── contexts/ # React Contexts (déjà OK)
├── components/ # Composants React (à déplacer)
├── lib/ # Utilitaires et types (à déplacer)
├── hooks/ # Hooks React (à déplacer)
├── clients/ # Clients HTTP (à déplacer)
└── services/ # Services backend (à déplacer)
## Autre Todos
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
- [x] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
- [x] split de certains gros composants.
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
- [x] Désactiver le hover sur les taskCard
## 🔄 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

@@ -1,36 +0,0 @@
/**
* Client pour l'API Jira
*/
import { HttpClient } from './base/http-client';
import { JiraSyncResult } from '@/services/jira';
export interface JiraConnectionStatus {
connected: boolean;
message: string;
details?: string;
}
export class JiraClient extends HttpClient {
constructor() {
super('/api/jira');
}
/**
* Teste la connexion à Jira
*/
async testConnection(): Promise<JiraConnectionStatus> {
return this.get<JiraConnectionStatus>('/sync');
}
/**
* Lance la synchronisation manuelle des tickets Jira
*/
async syncTasks(): Promise<JiraSyncResult> {
const response = await this.post<{ data: JiraSyncResult }>('/sync');
return response.data;
}
}
// Instance singleton
export const jiraClient = new JiraClient();

View File

@@ -1,28 +0,0 @@
import { httpClient } from './base/http-client';
import { UserPreferences } from '@/lib/types';
export interface UserPreferencesResponse {
success: boolean;
data?: UserPreferences;
message?: string;
error?: string;
}
/**
* Client HTTP pour les préférences utilisateur (lecture seule)
* Les mutations sont gérées par les server actions dans actions/preferences.ts
*/
export const userPreferencesClient = {
/**
* Récupère toutes les préférences utilisateur
*/
async getPreferences(): Promise<UserPreferences> {
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
if (!response.success || !response.data) {
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
}
return response.data;
}
};

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,146 +0,0 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
interface CategoryData {
count: number;
percentage: number;
color: string;
icon: string;
}
interface CategoryBreakdownProps {
categoryData: { [categoryName: string]: CategoryData };
totalActivities: number;
}
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
const categories = Object.entries(categoryData)
.filter(([, data]) => data.count > 0)
.sort((a, b) => b[1].count - a[1].count);
if (categories.length === 0) {
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
</CardHeader>
<CardContent>
<p className="text-center text-[var(--muted-foreground)]">
Aucune activité à catégoriser
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
<p className="text-sm text-[var(--muted-foreground)]">
Analyse automatique de vos {totalActivities} activités
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Légende des catégories */}
<div className="flex flex-wrap gap-3 justify-center">
{categories.map(([categoryName, data]) => (
<div
key={categoryName}
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: data.color }}
/>
<span className="text-sm font-medium text-[var(--foreground)]">
{data.icon} {categoryName}
</span>
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
{data.count}
</Badge>
</div>
))}
</div>
{/* Barres de progression */}
<div className="space-y-3">
{categories.map(([categoryName, data]) => (
<div key={categoryName} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<span>{data.icon}</span>
<span className="font-medium">{categoryName}</span>
</span>
<span className="text-[var(--muted-foreground)]">
{data.count} ({data.percentage.toFixed(1)}%)
</span>
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div
className="h-2 rounded-full transition-all duration-500"
style={{
backgroundColor: data.color,
width: `${data.percentage}%`
}}
/>
</div>
</div>
))}
</div>
{/* Insights */}
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
<h4 className="font-medium mb-2">💡 Insights</h4>
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
{categories.length > 0 && (
<>
<p>
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
({categories[0][1].percentage.toFixed(1)}% de votre temps).
</p>
{categories.length > 1 && (
<p>
📈 Vous avez une bonne diversité avec {categories.length} catégories d&apos;activités.
</p>
)}
{/* Suggestions basées sur la répartition */}
{categories.some(([, data]) => data.percentage > 70) && (
<p>
Forte concentration sur une seule catégorie.
Pensez à diversifier vos activités pour un meilleur équilibre.
</p>
)}
{(() => {
const learningCategory = categories.find(([name]) => name === 'Learning');
return learningCategory && learningCategory[1].percentage > 0 && (
<p>
🎓 Excellent ! Vous consacrez du temps à l&apos;apprentissage
({learningCategory[1].percentage.toFixed(1)}%).
</p>
);
})()}
{(() => {
const devCategory = categories.find(([name]) => name === 'Dev');
return devCategory && devCategory[1].percentage > 50 && (
<p>
💻 Focus développement intense. N&apos;oubliez pas les pauses et la collaboration !
</p>
);
})()}
</>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,193 +0,0 @@
'use client';
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { JiraSummaryService } from '@/services/jira-summary';
interface JiraWeeklyMetricsProps {
jiraMetrics: JiraWeeklyMetrics | null;
}
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
if (!jiraMetrics) {
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
</CardHeader>
<CardContent>
<p className="text-center text-[var(--muted-foreground)]">
Configuration Jira non disponible
</p>
</CardContent>
</Card>
);
}
if (jiraMetrics.totalJiraTasks === 0) {
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
</CardHeader>
<CardContent>
<p className="text-center text-[var(--muted-foreground)]">
Aucune tâche Jira cette semaine
</p>
</CardContent>
</Card>
);
}
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
<p className="text-sm text-[var(--muted-foreground)]">
Impact business et métriques projet
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Métriques principales */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--primary)]">
{jiraMetrics.totalJiraTasks}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--success)]">
{completionRate.toFixed(0)}%
</div>
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--accent)]">
{jiraMetrics.totalStoryPoints}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
<div className="text-2xl font-bold text-[var(--warning)]">
{jiraMetrics.projectsContributed.length}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
</div>
</div>
{/* Projets contributés */}
{jiraMetrics.projectsContributed.length > 0 && (
<div>
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
<div className="flex flex-wrap gap-2">
{jiraMetrics.projectsContributed.map(project => (
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
{project}
</Badge>
))}
</div>
</div>
)}
{/* Types de tickets */}
<div>
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
<div className="space-y-2">
{Object.entries(jiraMetrics.ticketTypes)
.sort(([,a], [,b]) => b - a)
.map(([type, count]) => {
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
return (
<div key={type} className="flex items-center justify-between">
<span className="text-sm text-[var(--foreground)]">{type}</span>
<div className="flex items-center gap-2">
<div className="w-20 bg-[var(--border)] rounded-full h-2">
<div
className="h-2 bg-[var(--primary)] rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm text-[var(--muted-foreground)] w-8">
{count}
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Liens vers les tickets */}
<div>
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
<div className="space-y-2 max-h-40 overflow-y-auto">
{jiraMetrics.jiraLinks.map((link) => (
<div
key={link.key}
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline font-medium text-sm"
>
{link.key}
</a>
<Badge
className={`text-xs ${
link.status === 'done'
? 'bg-[var(--success)]/10 text-[var(--success)]'
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
}`}
>
{link.status}
</Badge>
</div>
<p className="text-xs text-[var(--muted-foreground)] truncate">
{link.title}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<span>{link.type}</span>
<span>{link.estimatedPoints}pts</span>
</div>
</div>
))}
</div>
</div>
{/* Insights business */}
{insights.length > 0 && (
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
<h4 className="font-medium mb-2">💡 Insights business</h4>
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
{insights.map((insight, index) => (
<p key={index}>{insight}</p>
))}
</div>
</div>
)}
{/* Note sur les story points */}
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
<p>
* Story Points estimés automatiquement basés sur le type de ticket
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,497 +0,0 @@
'use client';
import { useState } from 'react';
import { ManagerSummary } from '@/services/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 { useTasksContext } from '@/contexts/TasksContext';
import { MetricsTab } from './MetricsTab';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface ManagerWeeklySummaryProps {
initialSummary: ManagerSummary;
}
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) {
const [summary] = useState<ManagerSummary>(initialSummary);
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
const { tags: availableTags } = useTasksContext();
const handleRefresh = () => {
// SSR - refresh via page reload
window.location.reload();
};
const formatPeriod = () => {
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${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`;
}
};
return (
<div className="space-y-6">
{/* Header avec navigation */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">👔 Résumé Manager</h1>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div>
<Button
onClick={handleRefresh}
variant="secondary"
size="sm"
>
🔄 Actualiser
</Button>
</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>
{/* Vue Executive / Narrative */}
{activeView === 'narrative' && (
<div className="space-y-6">
{/* Résumé narratif */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold flex items-center gap-2">
📊 Résumé de la semaine
</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>
<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>
<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>
</CardContent>
</Card>
{/* Métriques rapides */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">📈 Métriques en bref</h2>
</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>
<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>
<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>
<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>
</div>
</CardContent>
</Card>
{/* Top accomplissements */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🏆 Top accomplissements</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{summary.keyAccomplishments.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<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>
))
)}
</div>
</CardContent>
</Card>
{/* Top challenges */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🎯 Top enjeux à venir</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{summary.upcomingChallenges.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<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>
))
)}
</div>
</CardContent>
</Card>
</div>
)}
{/* Vue détaillée des accomplissements */}
{activeView === 'accomplishments' && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold"> Accomplissements de la semaine</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>
</CardHeader>
<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>
))}
</div>
</CardContent>
</Card>
)}
{/* Vue détaillée des challenges */}
{activeView === 'challenges' && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🎯 Enjeux et défis à venir</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.upcomingChallenges.length} défis identifiés {summary.upcomingChallenges.filter(c => c.priority === 'high').length} priorité haute {summary.upcomingChallenges.filter(c => c.blockers.length > 0).length} avec blockers
</p>
</CardHeader>
<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>
))}
</div>
</CardContent>
</Card>
)}
{/* Vue Métriques */}
{activeView === 'metrics' && (
<MetricsTab />
)}
</div>
);
}

View File

@@ -1,245 +0,0 @@
'use client';
import { useState } from 'react';
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { DailyStatusChart } from './charts/DailyStatusChart';
import { CompletionRateChart } from './charts/CompletionRateChart';
import { StatusDistributionChart } from './charts/StatusDistributionChart';
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
import { VelocityTrendChart } from './charts/VelocityTrendChart';
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
import { ProductivityInsights } from './charts/ProductivityInsights';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface MetricsTabProps {
className?: string;
}
export function MetricsTab({ className }: MetricsTabProps) {
const [selectedDate] = useState<Date>(new Date());
const [weeksBack, setWeeksBack] = useState(4);
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
const handleRefresh = () => {
refetchMetrics();
refetchTrends();
};
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 })}`;
};
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'improving': return '📈';
case 'declining': return '📉';
case 'stable': return '➡️';
default: return '📊';
}
};
const getPatternIcon = (pattern: string) => {
switch (pattern) {
case 'consistent': return '🎯';
case 'variable': return '📊';
case 'weekend-heavy': return '📅';
default: return '📋';
}
};
if (metricsError || trendsError) {
return (
<div className={className}>
<Card>
<CardContent className="p-6 text-center">
<p className="text-red-500 mb-4">
Erreur lors du chargement des métriques
</p>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{metricsError || trendsError}
</p>
<Button onClick={handleRefresh} variant="secondary" size="sm">
🔄 Réessayer
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className={className}>
{/* Header avec période et contrôles */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-[var(--foreground)]">📊 Métriques & Analytics</h2>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleRefresh}
variant="secondary"
size="sm"
disabled={metricsLoading || trendsLoading}
>
🔄 Actualiser
</Button>
</div>
</div>
{metricsLoading || trendsLoading ? (
<Card>
<CardContent className="p-6 text-center">
<div className="animate-pulse">
<div className="h-4 bg-[var(--border)] rounded w-1/4 mx-auto mb-4"></div>
<div className="h-32 bg-[var(--border)] rounded"></div>
</div>
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p>
</CardContent>
</Card>
) : metrics ? (
<div className="space-y-6">
{/* Vue d'ensemble rapide */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🎯 Vue d&apos;ensemble</h3>
</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">
{metrics.summary.totalTasksCompleted}
</div>
<div className="text-sm text-green-600">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">
{metrics.summary.totalTasksCreated}
</div>
<div className="text-sm text-blue-600">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">
{metrics.summary.averageCompletionRate.toFixed(1)}%
</div>
<div className="text-sm text-purple-600">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">
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
</div>
<div className="text-sm text-orange-600 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">
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
</div>
<div className="text-sm text-gray-600">
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Graphiques principaux */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
</CardHeader>
<CardContent>
<DailyStatusChart data={metrics.dailyBreakdown} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
</CardHeader>
<CardContent>
<CompletionRateChart data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
{/* Distribution et priorités */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
</CardHeader>
<CardContent>
<StatusDistributionChart data={metrics.statusDistribution} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold"> Performance par priorité</h3>
</CardHeader>
<CardContent>
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔥 Heatmap d&apos;activité</h3>
</CardHeader>
<CardContent>
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
{/* Tendances de vélocité */}
{trends.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
<select
value={weeksBack}
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
>
<option value={4}>4 semaines</option>
<option value={8}>8 semaines</option>
<option value={12}>12 semaines</option>
</select>
</div>
</CardHeader>
<CardContent>
<VelocityTrendChart data={trends} />
</CardContent>
</Card>
)}
{/* Analyses de productivité */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
</CardHeader>
<CardContent>
<ProductivityInsights data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
) : null}
</div>
);
}

View File

@@ -1,162 +0,0 @@
'use client';
import { useState, useEffect, useTransition } from 'react';
import { ProductivityMetrics } from '@/services/analytics';
import { getProductivityMetrics } from '@/actions/analytics';
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
import { VelocityChart } from '@/components/charts/VelocityChart';
import { PriorityDistributionChart } from '@/components/charts/PriorityDistributionChart';
import { WeeklyStatsCard } from '@/components/charts/WeeklyStatsCard';
import { Card } from '@/components/ui/Card';
export function ProductivityAnalytics() {
const [metrics, setMetrics] = useState<ProductivityMetrics | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
const loadMetrics = () => {
startTransition(async () => {
try {
setError(null);
const response = await getProductivityMetrics();
if (response.success && response.data) {
setMetrics(response.data);
} else {
setError(response.error || 'Erreur lors du chargement des métriques');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors du chargement des métriques');
console.error('Erreur analytics:', err);
}
});
};
loadMetrics();
}, []);
if (isPending) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="p-6 animate-pulse">
<div className="h-4 bg-[var(--border)] rounded mb-4 w-1/3"></div>
<div className="h-64 bg-[var(--border)] rounded"></div>
</Card>
))}
</div>
);
}
if (error) {
return (
<Card className="p-6 mb-8 mt-8">
<div className="text-center">
<div className="text-red-500 text-4xl mb-2"></div>
<h3 className="text-lg font-semibold mb-2">Erreur de chargement</h3>
<p className="text-[var(--muted-foreground)] text-sm">{error}</p>
</div>
</Card>
);
}
if (!metrics) {
return null;
}
return (
<div className="space-y-8">
{/* Titre de section */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">📊 Analytics & Métriques</h2>
<div className="text-sm text-[var(--muted-foreground)]">
Derniers 30 jours
</div>
</div>
{/* Performance hebdomadaire */}
<WeeklyStatsCard stats={metrics.weeklyStats} />
{/* Graphiques principaux */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CompletionTrendChart data={metrics.completionTrend} />
<VelocityChart data={metrics.velocityData} />
</div>
{/* Distributions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<PriorityDistributionChart data={metrics.priorityDistribution} />
{/* Status Flow - Graphique simple en barres horizontales */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Répartition par Statut</h3>
<div className="space-y-3">
{metrics.statusFlow.map((item, index) => (
<div key={index} className="flex items-center gap-3">
<div className="w-20 text-sm text-[var(--muted-foreground)] text-right">
{item.status}
</div>
<div className="flex-1 bg-[var(--border)] rounded-full h-2 relative">
<div
className="bg-gradient-to-r from-blue-500 to-cyan-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
<div className="w-12 text-sm font-medium text-right">
{item.count}
</div>
<div className="w-10 text-xs text-[var(--muted-foreground)] text-right">
{item.percentage}%
</div>
</div>
))}
</div>
</Card>
</div>
{/* Insights automatiques */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">💡 Insights</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors">
<div className="text-[var(--primary)] font-medium text-sm mb-1">
Vélocité Moyenne
</div>
<div className="text-2xl font-bold text-[var(--foreground)]">
{metrics.velocityData.length > 0
? Math.round(metrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / metrics.velocityData.length)
: 0
} <span className="text-sm font-normal text-[var(--muted-foreground)]">tâches/sem</span>
</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors">
<div className="text-[var(--success)] font-medium text-sm mb-1">
Priorité Principale
</div>
<div className="text-lg font-bold text-[var(--foreground)]">
{metrics.priorityDistribution.reduce((max, item) =>
item.count > max.count ? item : max,
metrics.priorityDistribution[0]
)?.priority || 'N/A'}
</div>
</div>
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors">
<div className="text-[var(--accent)] font-medium text-sm mb-1">
Taux de Completion
</div>
<div className="text-2xl font-bold text-[var(--foreground)]">
{(() => {
const completed = metrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0;
const total = metrics.statusFlow.reduce((acc, s) => acc + s.count, 0);
return total > 0 ? Math.round((completed / total) * 100) : 0;
})()}%
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,131 +0,0 @@
'use client';
import { Task } from '@/lib/types';
import { Card } from '@/components/ui/Card';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { Badge } from '@/components/ui/Badge';
import { useTasksContext } from '@/contexts/TasksContext';
import { getPriorityConfig, getPriorityColorHex, getStatusBadgeClasses, getStatusLabel } from '@/lib/status-config';
import { TaskPriority } from '@/lib/types';
import Link from 'next/link';
interface RecentTasksProps {
tasks: Task[];
}
export function RecentTasks({ tasks }: RecentTasksProps) {
const { tags: availableTags } = useTasksContext();
// Prendre les 5 tâches les plus récentes (créées ou modifiées)
const recentTasks = tasks
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 5);
// Fonctions simplifiées utilisant la configuration centralisée
const getPriorityStyle = (priority: string) => {
try {
const config = getPriorityConfig(priority as TaskPriority);
const hexColor = getPriorityColorHex(config.color);
return { color: hexColor };
} catch {
return { color: '#6b7280' }; // gray-500 par défaut
}
};
return (
<Card className="p-6 mt-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Tâches Récentes</h3>
<Link href="/kanban">
<button className="text-sm text-[var(--primary)] hover:underline">
Voir toutes
</button>
</Link>
</div>
{recentTasks.length === 0 ? (
<div className="text-center py-8 text-[var(--muted-foreground)]">
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p>Aucune tâche disponible</p>
<p className="text-sm">Créez votre première tâche pour commencer</p>
</div>
) : (
<div className="space-y-3">
{recentTasks.map((task) => (
<div
key={task.id}
className="p-3 border border-[var(--border)] rounded-lg hover:bg-[var(--card)]/50 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-sm truncate">{task.title}</h4>
{task.source === 'jira' && (
<Badge variant="outline" className="text-xs">
Jira
</Badge>
)}
</div>
{task.description && (
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-1">
{task.description}
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={`text-xs ${getStatusBadgeClasses(task.status)}`}>
{getStatusLabel(task.status)}
</Badge>
{task.priority && (
<span
className="text-xs font-medium"
style={getPriorityStyle(task.priority)}
>
{(() => {
try {
return getPriorityConfig(task.priority as TaskPriority).label;
} catch {
return task.priority;
}
})()}
</span>
)}
{task.tags && task.tags.length > 0 && (
<div className="flex gap-1">
<TagDisplay
tags={task.tags.slice(0, 2)}
availableTags={availableTags}
size="sm"
maxTags={2}
showColors={true}
/>
{task.tags.length > 2 && (
<span className="text-xs text-[var(--muted-foreground)]">
+{task.tags.length - 2}
</span>
)}
</div>
)}
</div>
</div>
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
{new Date(task.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short'
})}
</div>
</div>
</div>
))}
</div>
)}
</Card>
);
}

View File

@@ -1,274 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagInput } from '@/components/ui/TagInput';
import { RelatedTodos } from '@/components/forms/RelatedTodos';
import { Badge } from '@/components/ui/Badge';
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
// UpdateTaskData removed - using Server Actions directly
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
interface EditTaskFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
task: Task | null;
loading?: boolean;
}
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
const { preferences } = useUserPreferences();
const [formData, setFormData] = useState<{
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
tags: string[];
dueDate?: Date;
}>({
title: '',
description: '',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: [],
dueDate: undefined
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Helper pour construire l'URL Jira
const getJiraTicketUrl = (jiraKey: string): string => {
const baseUrl = preferences.jiraConfig.baseUrl;
if (!baseUrl || !jiraKey) return '';
return `${baseUrl}/browse/${jiraKey}`;
};
// Pré-remplir le formulaire quand la tâche change
useEffect(() => {
if (task) {
setFormData({
title: task.title,
description: task.description || '',
status: task.status,
priority: task.priority,
tags: task.tags || [],
dueDate: task.dueDate ? new Date(task.dueDate) : undefined
});
}
}, [task]);
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.title?.trim()) {
newErrors.title = 'Le titre est requis';
}
if (formData.title && formData.title.length > 200) {
newErrors.title = 'Le titre ne peut pas dépasser 200 caractères';
}
if (formData.description && formData.description.length > 1000) {
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm() || !task) return;
try {
await onSubmit({
taskId: task.id,
...formData
});
handleClose();
} catch (error) {
console.error('Erreur lors de la mise à jour:', error);
}
};
const handleClose = () => {
setErrors({});
onClose();
};
if (!task) return null;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
{/* Titre */}
<Input
label="Titre *"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Titre de la tâche..."
error={errors.title}
disabled={loading}
/>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Description détaillée..."
rows={4}
disabled={loading}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
/>
{errors.description && (
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
<span className="text-red-500"></span>
{errors.description}
</p>
)}
</div>
{/* Priorité et Statut */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Priorité
</label>
<select
value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
disabled={loading}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
>
{getAllPriorities().map(priorityConfig => (
<option key={priorityConfig.key} value={priorityConfig.key}>
{priorityConfig.icon} {priorityConfig.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Statut
</label>
<select
value={formData.status}
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
disabled={loading}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
>
{getAllStatuses().map(statusConfig => (
<option key={statusConfig.key} value={statusConfig.key}>
{statusConfig.label}
</option>
))}
</select>
</div>
</div>
{/* Date d'échéance */}
<Input
label="Date d'échéance"
type="datetime-local"
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
onChange={(e) => setFormData(prev => ({
...prev,
dueDate: e.target.value ? new Date(e.target.value) : undefined
}))}
disabled={loading}
/>
{/* Informations Jira */}
{task.source === 'jira' && task.jiraKey && (
<div className="space-y-3">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Jira
</label>
<div className="flex items-center gap-3">
{preferences.jiraConfig.baseUrl ? (
<a
href={getJiraTicketUrl(task.jiraKey)}
target="_blank"
rel="noopener noreferrer"
className="hover:scale-105 transition-transform inline-flex"
>
<Badge
variant="outline"
size="sm"
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
>
{task.jiraKey}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
{task.jiraKey}
</Badge>
)}
{task.jiraProject && (
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
{task.jiraProject}
</Badge>
)}
{task.jiraType && (
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
{task.jiraType}
</Badge>
)}
</div>
</div>
)}
{/* Tags */}
<div className="space-y-3">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Tags
</label>
<TagInput
tags={formData.tags || []}
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
placeholder="Ajouter des tags..."
maxTags={10}
/>
</div>
{/* Todos reliés */}
<RelatedTodos taskId={task.id} />
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={loading}
>
Annuler
</Button>
<Button
type="submit"
variant="primary"
disabled={loading}
>
{loading ? 'Mise à jour...' : 'Mettre à jour'}
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -1,327 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
interface AdvancedFiltersPanelProps {
availableFilters: AvailableFilters;
activeFilters: Partial<JiraAnalyticsFilters>;
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
className?: string;
}
interface FilterSectionProps {
title: string;
icon: string;
options: FilterOption[];
selectedValues: string[];
onSelectionChange: (values: string[]) => void;
maxDisplay?: number;
}
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
const [showAll, setShowAll] = useState(false);
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
const hasMore = options.length > maxDisplay;
const handleToggle = (value: string) => {
const newValues = selectedValues.includes(value)
? selectedValues.filter(v => v !== value)
: [...selectedValues, value];
onSelectionChange(newValues);
};
const selectAll = () => {
onSelectionChange(options.map(opt => opt.value));
};
const clearAll = () => {
onSelectionChange([]);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm flex items-center gap-2">
<span>{icon}</span>
{title}
{selectedValues.length > 0 && (
<Badge className="bg-blue-100 text-blue-800 text-xs">
{selectedValues.length}
</Badge>
)}
</h4>
{options.length > 0 && (
<div className="flex gap-1">
<button
onClick={selectAll}
className="text-xs text-blue-600 hover:text-blue-800"
>
Tout
</button>
<span className="text-xs text-gray-400">|</span>
<button
onClick={clearAll}
className="text-xs text-gray-600 hover:text-gray-800"
>
Aucun
</button>
</div>
)}
</div>
{options.length === 0 ? (
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
) : (
<>
<div className="space-y-1 max-h-32 overflow-y-auto">
{displayOptions.map(option => (
<label
key={option.value}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
>
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
onChange={() => handleToggle(option.value)}
className="rounded"
/>
<span className="flex-1 truncate">{option.label}</span>
<span className="text-xs text-gray-500">({option.count})</span>
</label>
))}
</div>
{hasMore && (
<button
onClick={() => setShowAll(!showAll)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
</button>
)}
</>
)}
</div>
);
}
export default function AdvancedFiltersPanel({
availableFilters,
activeFilters,
onFiltersChange,
className = ''
}: AdvancedFiltersPanelProps) {
const [showModal, setShowModal] = useState(false);
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
useEffect(() => {
setTempFilters(activeFilters);
}, [activeFilters]);
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
const applyFilters = () => {
onFiltersChange(tempFilters);
setShowModal(false);
};
const clearAllFilters = () => {
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
setTempFilters(emptyFilters);
onFiltersChange(emptyFilters);
setShowModal(false);
};
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
key: K,
value: JiraAnalyticsFilters[K]
) => {
setTempFilters(prev => ({
...prev,
[key]: value
}));
};
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">🔍 Filtres avancés</h3>
{hasActiveFilters && (
<Badge className="bg-blue-100 text-blue-800 text-xs">
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
</Badge>
)}
</div>
<div className="flex gap-2">
{hasActiveFilters && (
<Button
onClick={clearAllFilters}
variant="secondary"
size="sm"
className="text-xs"
>
🗑 Effacer
</Button>
)}
<Button
onClick={() => setShowModal(true)}
size="sm"
className="text-xs"
>
Configurer
</Button>
</div>
</div>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{filtersSummary}
</p>
</CardHeader>
{/* Aperçu rapide des filtres actifs */}
{hasActiveFilters && (
<CardContent className="pt-0">
<div className="p-3 bg-blue-50 rounded-lg">
<div className="flex flex-wrap gap-1">
{activeFilters.components?.map(comp => (
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
📦 {comp}
</Badge>
))}
{activeFilters.fixVersions?.map(version => (
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
🏷 {version}
</Badge>
))}
{activeFilters.issueTypes?.map(type => (
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
📋 {type}
</Badge>
))}
{activeFilters.statuses?.map(status => (
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
🔄 {status}
</Badge>
))}
{activeFilters.assignees?.map(assignee => (
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
👤 {assignee}
</Badge>
))}
{activeFilters.labels?.map(label => (
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
🏷 {label}
</Badge>
))}
{activeFilters.priorities?.map(priority => (
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
{priority}
</Badge>
))}
</div>
</div>
</CardContent>
)}
{/* Modal de configuration des filtres */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Configuration des filtres avancés"
size="lg"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
<FilterSection
title="Composants"
icon="📦"
options={availableFilters.components}
selectedValues={tempFilters.components || []}
onSelectionChange={(values) => updateTempFilter('components', values)}
/>
<FilterSection
title="Versions"
icon="🏷️"
options={availableFilters.fixVersions}
selectedValues={tempFilters.fixVersions || []}
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
/>
<FilterSection
title="Types de tickets"
icon="📋"
options={availableFilters.issueTypes}
selectedValues={tempFilters.issueTypes || []}
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
/>
<FilterSection
title="Statuts"
icon="🔄"
options={availableFilters.statuses}
selectedValues={tempFilters.statuses || []}
onSelectionChange={(values) => updateTempFilter('statuses', values)}
/>
<FilterSection
title="Assignés"
icon="👤"
options={availableFilters.assignees}
selectedValues={tempFilters.assignees || []}
onSelectionChange={(values) => updateTempFilter('assignees', values)}
/>
<FilterSection
title="Labels"
icon="🏷️"
options={availableFilters.labels}
selectedValues={tempFilters.labels || []}
onSelectionChange={(values) => updateTempFilter('labels', values)}
/>
<FilterSection
title="Priorités"
icon="⚡"
options={availableFilters.priorities}
selectedValues={tempFilters.priorities || []}
onSelectionChange={(values) => updateTempFilter('priorities', values)}
/>
</div>
<div className="flex gap-2 pt-6 border-t">
<Button
onClick={applyFilters}
className="flex-1"
>
Appliquer les filtres
</Button>
<Button
onClick={clearAllFilters}
variant="secondary"
className="flex-1"
>
🗑 Effacer tout
</Button>
<Button
onClick={() => setShowModal(false)}
variant="secondary"
>
Annuler
</Button>
</div>
</Modal>
</Card>
);
}

View File

@@ -1,334 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
interface AnomalyDetectionPanelProps {
className?: string;
}
export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetectionPanelProps) {
const [anomalies, setAnomalies] = useState<JiraAnomaly[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false);
const [config, setConfig] = useState<AnomalyDetectionConfig | null>(null);
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// Charger la config au montage, les anomalies seulement si expanded
useEffect(() => {
loadConfig();
}, []);
// Charger les anomalies quand on ouvre le panneau
useEffect(() => {
if (isExpanded && anomalies.length === 0) {
loadAnomalies();
}
}, [isExpanded, anomalies.length]);
const loadAnomalies = async (forceRefresh = false) => {
setLoading(true);
setError(null);
try {
const result = await detectJiraAnomalies(forceRefresh);
if (result.success && result.data) {
setAnomalies(result.data);
setLastUpdate(new Date().toLocaleString('fr-FR'));
} else {
setError(result.error || 'Erreur lors de la détection');
}
} catch {
setError('Erreur de connexion');
} finally {
setLoading(false);
}
};
const loadConfig = async () => {
try {
const result = await getAnomalyDetectionConfig();
if (result.success && result.data) {
setConfig(result.data);
}
} catch (err) {
console.error('Erreur lors du chargement de la config:', err);
}
};
const handleConfigUpdate = async (newConfig: AnomalyDetectionConfig) => {
try {
const result = await updateAnomalyDetectionConfig(newConfig);
if (result.success && result.data) {
setConfig(result.data);
setShowConfig(false);
// Recharger les anomalies avec la nouvelle config
loadAnomalies(true);
}
} catch (err) {
console.error('Erreur lors de la mise à jour de la config:', err);
}
};
const getSeverityColor = (severity: string): string => {
switch (severity) {
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getSeverityIcon = (severity: string): string => {
switch (severity) {
case 'critical': return '🚨';
case 'high': return '⚠️';
case 'medium': return '⚡';
case 'low': return '';
default: return '📊';
}
};
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
const highCount = anomalies.filter(a => a.severity === 'high').length;
const totalCount = anomalies.length;
return (
<Card className={className}>
<CardHeader
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
</span>
<h3 className="font-semibold">🔍 Détection d&apos;anomalies</h3>
{totalCount > 0 && (
<div className="flex gap-1">
{criticalCount > 0 && (
<Badge className="bg-red-100 text-red-800 text-xs">
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
</Badge>
)}
{highCount > 0 && (
<Badge className="bg-orange-100 text-orange-800 text-xs">
{highCount} élevée{highCount > 1 ? 's' : ''}
</Badge>
)}
</div>
)}
</div>
{isExpanded && (
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button
onClick={() => setShowConfig(true)}
variant="secondary"
size="sm"
className="text-xs"
>
Config
</Button>
<Button
onClick={() => loadAnomalies(true)}
disabled={loading}
size="sm"
className="text-xs"
>
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
</Button>
</div>
)}
</div>
{isExpanded && lastUpdate && (
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Dernière analyse: {lastUpdate}
</p>
)}
</CardHeader>
{isExpanded && (
<CardContent>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
<p className="text-red-700 text-sm"> {error}</p>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-sm text-gray-600">Analyse en cours...</p>
</div>
</div>
)}
{!loading && !error && anomalies.length === 0 && (
<div className="text-center py-8">
<div className="text-4xl mb-2"></div>
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
</div>
)}
{!loading && anomalies.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{anomalies.map((anomaly) => (
<div
key={anomaly.id}
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
>
<div className="flex items-start gap-2">
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
{anomaly.severity}
</Badge>
</div>
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
<div className="text-xs text-[var(--muted-foreground)]">
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
{anomaly.threshold > 0 && (
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
)}
</div>
{anomaly.affectedItems.length > 0 && (
<div className="mt-2">
<div className="text-xs text-[var(--muted-foreground)]">
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
{item}
</span>
))}
{anomaly.affectedItems.length > 2 && (
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
)}
{/* Modal de configuration */}
{showConfig && config && (
<Modal
isOpen={showConfig}
onClose={() => setShowConfig(false)}
title="Configuration de la détection d'anomalies"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Seuil de variance de vélocité (%)
</label>
<input
type="number"
value={config.velocityVarianceThreshold}
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
min="0"
max="100"
/>
<p className="text-xs text-gray-500 mt-1">
Pourcentage de variance acceptable dans la vélocité
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Multiplicateur de cycle time
</label>
<input
type="number"
step="0.1"
value={config.cycleTimeThreshold}
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
min="1"
max="5"
/>
<p className="text-xs text-gray-500 mt-1">
Multiplicateur au-delà duquel le cycle time est considéré anormal
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ratio de déséquilibre de charge
</label>
<input
type="number"
step="0.1"
value={config.workloadImbalanceThreshold}
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
min="1"
max="10"
/>
<p className="text-xs text-gray-500 mt-1">
Ratio maximum acceptable entre les charges de travail
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Taux de completion minimum (%)
</label>
<input
type="number"
value={config.completionRateThreshold}
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
min="0"
max="100"
/>
<p className="text-xs text-gray-500 mt-1">
Pourcentage minimum de completion des sprints
</p>
</div>
<div className="flex gap-2 pt-4">
<Button
onClick={() => handleConfigUpdate(config)}
className="flex-1"
>
💾 Sauvegarder
</Button>
<Button
onClick={() => setShowConfig(false)}
variant="secondary"
className="flex-1"
>
Annuler
</Button>
</div>
</div>
</Modal>
)}
</Card>
);
}

View File

@@ -1,425 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
import { Modal } from '@/components/ui/Modal';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
interface SprintDetailModalProps {
isOpen: boolean;
onClose: () => void;
sprint: SprintVelocity | null;
onLoadSprintDetails: (sprintName: string) => Promise<SprintDetails>;
}
export interface SprintDetails {
sprint: SprintVelocity;
issues: JiraTask[];
assigneeDistribution: AssigneeDistribution[];
statusDistribution: StatusDistribution[];
metrics: {
totalIssues: number;
completedIssues: number;
inProgressIssues: number;
blockedIssues: number;
averageCycleTime: number;
velocityTrend: 'up' | 'down' | 'stable';
};
}
export default function SprintDetailModal({
isOpen,
onClose,
sprint,
onLoadSprintDetails
}: SprintDetailModalProps) {
const [sprintDetails, setSprintDetails] = useState<SprintDetails | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const loadSprintDetails = useCallback(async () => {
if (!sprint) return;
setLoading(true);
setError(null);
try {
const details = await onLoadSprintDetails(sprint.sprintName);
setSprintDetails(details);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
} finally {
setLoading(false);
}
}, [sprint, onLoadSprintDetails]);
// Charger les détails du sprint quand le modal s'ouvre
useEffect(() => {
if (isOpen && sprint && !sprintDetails) {
loadSprintDetails();
}
}, [isOpen, sprint, sprintDetails, loadSprintDetails]);
// Reset quand on change de sprint
useEffect(() => {
if (sprint) {
setSprintDetails(null);
setSelectedAssignee(null);
setSelectedStatus(null);
setSelectedTab('overview');
}
}, [sprint]);
// Filtrer les issues selon les sélections
const filteredIssues = sprintDetails?.issues.filter(issue => {
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
return false;
}
if (selectedStatus && issue.status.name !== selectedStatus) {
return false;
}
return true;
}) || [];
const getStatusColor = (status: string): string => {
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
return 'bg-green-100 text-green-800';
}
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
return 'bg-blue-100 text-blue-800';
}
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
return 'bg-red-100 text-red-800';
}
return 'bg-gray-100 text-gray-800';
};
const getPriorityColor = (priority?: string): string => {
switch (priority?.toLowerCase()) {
case 'highest': return 'bg-red-500 text-white';
case 'high': return 'bg-orange-500 text-white';
case 'medium': return 'bg-yellow-500 text-white';
case 'low': return 'bg-green-500 text-white';
case 'lowest': return 'bg-gray-500 text-white';
default: return 'bg-gray-300 text-gray-800';
}
};
if (!sprint) return null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Sprint: ${sprint.sprintName}`}
size="lg"
>
<div className="space-y-6">
{/* En-tête du sprint */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{sprint.completedPoints}
</div>
<div className="text-sm text-gray-600">Points complétés</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-800">
{sprint.plannedPoints}
</div>
<div className="text-sm text-gray-600">Points planifiés</div>
</div>
<div className="text-center">
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
{sprint.completionRate.toFixed(1)}%
</div>
<div className="text-sm text-gray-600">Taux de completion</div>
</div>
<div className="text-center">
<div className="text-sm text-gray-600">Période</div>
<div className="text-xs text-gray-500">
{new Date(sprint.startDate).toLocaleDateString('fr-FR')} - {new Date(sprint.endDate).toLocaleDateString('fr-FR')}
</div>
</div>
</div>
</div>
{/* Onglets */}
<div className="border-b border-gray-200">
<nav className="flex space-x-8">
{[
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
].map(tab => (
<button
key={tab.id}
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
selectedTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Contenu selon l'onglet */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement des détails du sprint...</p>
</div>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700"> {error}</p>
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
Réessayer
</Button>
</div>
)}
{!loading && !error && sprintDetails && (
<>
{/* Vue d'ensemble */}
{selectedTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="font-semibold">👥 Répartition par assigné</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
{sprintDetails.assigneeDistribution.map(assignee => (
<div
key={assignee.assignee}
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
selectedAssignee === assignee.displayName
? 'bg-blue-100'
: 'hover:bg-gray-50'
}`}
onClick={() => setSelectedAssignee(
selectedAssignee === assignee.displayName ? null : assignee.displayName
)}
>
<span className="font-medium">{assignee.displayName}</span>
<div className="flex gap-2">
<Badge className="bg-green-100 text-green-800 text-xs">
{assignee.completedIssues}
</Badge>
<Badge className="bg-blue-100 text-blue-800 text-xs">
🔄 {assignee.inProgressIssues}
</Badge>
<Badge className="bg-gray-100 text-gray-800 text-xs">
📋 {assignee.totalIssues}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="font-semibold">🔄 Répartition par statut</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
{sprintDetails.statusDistribution.map(status => (
<div
key={status.status}
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
selectedStatus === status.status
? 'bg-blue-100'
: 'hover:bg-gray-50'
}`}
onClick={() => setSelectedStatus(
selectedStatus === status.status ? null : status.status
)}
>
<span className="font-medium">{status.status}</span>
<div className="flex gap-2">
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
{status.count} ({status.percentage.toFixed(1)}%)
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
{/* Liste des tickets */}
{selectedTab === 'issues' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-lg">
📋 Tickets du sprint ({filteredIssues.length})
</h3>
<div className="flex gap-2">
{selectedAssignee && (
<Badge className="bg-blue-100 text-blue-800">
👤 {selectedAssignee}
<button
onClick={() => setSelectedAssignee(null)}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</Badge>
)}
{selectedStatus && (
<Badge className="bg-purple-100 text-purple-800">
🔄 {selectedStatus}
<button
onClick={() => setSelectedStatus(null)}
className="ml-1 text-purple-600 hover:text-purple-800"
>
×
</button>
</Badge>
)}
</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{filteredIssues.map(issue => (
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
{issue.status.name}
</Badge>
{issue.priority && (
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
{issue.priority.name}
</Badge>
)}
</div>
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>📋 {issue.issuetype.name}</span>
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
<span>📅 {new Date(issue.created).toLocaleDateString('fr-FR')}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Métriques détaillées */}
{selectedTab === 'metrics' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<h3 className="font-semibold">📊 Métriques générales</h3>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between">
<span>Total tickets:</span>
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
</div>
<div className="flex justify-between">
<span>Tickets complétés:</span>
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
</div>
<div className="flex justify-between">
<span>En cours:</span>
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
</div>
<div className="flex justify-between">
<span>Cycle time moyen:</span>
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="font-semibold">📈 Tendance vélocité</h3>
</CardHeader>
<CardContent>
<div className="text-center">
<div className={`text-4xl mb-2 ${
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
'text-gray-600'
}`}>
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
</div>
<p className="text-sm text-gray-600">
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="font-semibold"> Points d&apos;attention</h3>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
{sprint.completionRate < 70 && (
<div className="text-red-600">
Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
</div>
)}
{sprintDetails.metrics.blockedIssues > 0 && (
<div className="text-orange-600">
{sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
</div>
)}
{sprintDetails.metrics.averageCycleTime > 14 && (
<div className="text-yellow-600">
Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
</div>
)}
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
<div className="text-green-600">
Sprint réussi sans blockers majeurs
</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</>
)}
{/* Actions */}
<div className="flex justify-end">
<Button onClick={onClose} variant="secondary">
Fermer
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -1,100 +0,0 @@
import { Task, TaskStatus } from '@/lib/types';
import { TaskCard } from './TaskCard';
import { QuickAddTask } from './QuickAddTask';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { CreateTaskData } from '@/clients/tasks-client';
import { useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { getStatusConfig, getTechStyle, getBadgeVariant } from '@/lib/status-config';
interface KanbanColumnProps {
id: TaskStatus;
tasks: Task[];
onCreateTask?: (data: CreateTaskData) => Promise<void>;
onEditTask?: (task: Task) => void;
compactView?: boolean;
}
export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView = false }: KanbanColumnProps) {
const [showQuickAdd, setShowQuickAdd] = useState(false);
// Configuration de la zone droppable
const { setNodeRef, isOver } = useDroppable({
id: id,
});
// Récupération de la config du statut
const statusConfig = getStatusConfig(id);
const style = getTechStyle(statusConfig.color);
const badgeVariant = getBadgeVariant(statusConfig.color);
return (
<div className="flex-shrink-0 w-80 md:w-1/4 md:flex-1 h-full">
<Card
ref={setNodeRef}
variant="column"
className={`h-full flex flex-col transition-all duration-200 ${
isOver ? 'ring-2 ring-[var(--primary)]/50 bg-[var(--card-hover)]' : ''
}`}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${style.accent.replace('text-', 'bg-')} animate-pulse`}></div>
<h3 className={`font-mono text-sm font-bold ${style.accent} uppercase tracking-wider`}>
{statusConfig.label} {statusConfig.icon}
</h3>
</div>
<div className="flex items-center gap-2">
<Badge variant={badgeVariant} size="sm">
{String(tasks.length).padStart(2, '0')}
</Badge>
{onCreateTask && (
<button
onClick={() => setShowQuickAdd(true)}
className={`w-5 h-5 rounded-full border border-dashed ${style.border} ${style.accent} hover:bg-[var(--card-hover)] transition-colors flex items-center justify-center text-xs font-mono`}
title="Ajouter une tâche rapide"
>
+
</button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 p-4 h-[calc(100vh-220px)] overflow-y-auto">
<div className="space-y-3">
{/* Quick Add Task */}
{showQuickAdd && onCreateTask && (
<QuickAddTask
status={id}
onSubmit={async (data) => {
await onCreateTask(data);
// Ne pas fermer automatiquement pour permettre la création en série
}}
onCancel={() => setShowQuickAdd(false)}
/>
)}
{tasks.length === 0 && !showQuickAdd ? (
<div className="text-center py-20">
<div className={`w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--card)] border-2 border-dashed ${style.border} flex items-center justify-center`}>
<span className={`text-2xl ${style.accent} opacity-50`}>{statusConfig.icon}</span>
</div>
<p className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide">NO DATA</p>
<div className="mt-2 flex justify-center">
<div className={`w-8 h-0.5 ${style.accent.replace('text-', 'bg-')} opacity-30`}></div>
</div>
</div>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} onEdit={onEditTask} compactView={compactView} />
))
)}
</div>
</CardContent>
</Card>
</div>
);
}

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,634 +0,0 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { TaskPriority, TaskStatus } from '@/lib/types';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useTasksContext } from '@/contexts/TasksContext';
import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
import { SORT_OPTIONS } from '@/lib/sort-config';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
export interface KanbanFilters {
search?: string;
tags?: string[];
priorities?: TaskPriority[];
showCompleted?: boolean;
compactView?: boolean;
swimlanesByTags?: boolean;
swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes
pinnedTag?: string; // Tag pour les objectifs principaux
sortBy?: string; // Clé de l'option de tri sélectionnée
// Filtres spécifiques Jira
showJiraOnly?: boolean; // Afficher seulement les tâches Jira
hideJiraTasks?: boolean; // Masquer toutes les tâches Jira
jiraProjects?: string[]; // Filtrer par projet Jira
jiraTypes?: string[]; // Filtrer par type Jira (Story, Task, Bug, etc.)
}
interface KanbanFiltersProps {
filters: KanbanFilters;
onFiltersChange: (filters: KanbanFilters) => void;
hiddenStatuses?: Set<TaskStatus>;
onToggleStatusVisibility?: (status: TaskStatus) => void;
}
export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsHiddenStatuses, onToggleStatusVisibility }: KanbanFiltersProps) {
const { tags: availableTags, regularTasks, activeFiltersCount } = useTasksContext();
const { preferences, toggleColumnVisibility } = useUserPreferences();
// Utiliser les props si disponibles, sinon utiliser le context
const hiddenStatuses = propsHiddenStatuses || new Set(preferences.columnVisibility.hiddenStatuses);
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
const [isSortExpanded, setIsSortExpanded] = useState(false);
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
const sortButtonRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
// Fermer les dropdowns en cliquant à l'extérieur
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) {
setIsSortExpanded(false);
}
if (swimlaneModeDropdownRef.current && !swimlaneModeDropdownRef.current.contains(event.target as Node)) {
setIsSwimlaneModeExpanded(false);
}
}
if (isSortExpanded || isSwimlaneModeExpanded) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isSortExpanded, isSwimlaneModeExpanded]);
const handleSearchChange = (search: string) => {
onFiltersChange({ ...filters, search: search || undefined });
};
const handleTagToggle = (tagName: string) => {
const currentTags = filters.tags || [];
const newTags = currentTags.includes(tagName)
? currentTags.filter(t => t !== tagName)
: [...currentTags, tagName];
onFiltersChange({
...filters,
tags: newTags
});
};
const handlePriorityToggle = (priority: TaskPriority) => {
const currentPriorities = filters.priorities || [];
const newPriorities = currentPriorities.includes(priority)
? currentPriorities.filter(p => p !== priority)
: [...currentPriorities, priority];
onFiltersChange({
...filters,
priorities: newPriorities
});
};
const handleSwimlanesToggle = () => {
onFiltersChange({
...filters,
swimlanesByTags: !filters.swimlanesByTags
});
};
const handleSwimlaneModeChange = (mode: 'tags' | 'priority') => {
onFiltersChange({
...filters,
swimlanesByTags: true,
swimlanesMode: mode
});
setIsSwimlaneModeExpanded(false);
};
const handleSwimlaneModeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX
});
setIsSwimlaneModeExpanded(!isSwimlaneModeExpanded);
};
const handleSortChange = (sortKey: string) => {
onFiltersChange({
...filters,
sortBy: sortKey
});
};
const handleSortToggle = () => {
if (!isSortExpanded && sortButtonRef.current) {
const rect = sortButtonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX
});
}
setIsSortExpanded(!isSortExpanded);
};
const handleJiraToggle = (mode: 'show' | 'hide' | 'all') => {
const updates: Partial<KanbanFilters> = {};
switch (mode) {
case 'show':
updates.showJiraOnly = true;
updates.hideJiraTasks = false;
break;
case 'hide':
updates.showJiraOnly = false;
updates.hideJiraTasks = true;
break;
case 'all':
updates.showJiraOnly = false;
updates.hideJiraTasks = false;
break;
}
onFiltersChange({ ...filters, ...updates });
};
const handleJiraProjectToggle = (project: string) => {
const currentProjects = filters.jiraProjects || [];
const newProjects = currentProjects.includes(project)
? currentProjects.filter(p => p !== project)
: [...currentProjects, project];
onFiltersChange({
...filters,
jiraProjects: newProjects
});
};
const handleJiraTypeToggle = (type: string) => {
const currentTypes = filters.jiraTypes || [];
const newTypes = currentTypes.includes(type)
? currentTypes.filter(t => t !== type)
: [...currentTypes, type];
onFiltersChange({
...filters,
jiraTypes: newTypes
});
};
const handleClearFilters = () => {
onFiltersChange({});
};
// Récupérer les projets et types Jira disponibles dans TOUTES les tâches (pas seulement les filtrées)
// regularTasks est déjà disponible depuis la ligne 39
const availableJiraProjects = useMemo(() => {
const projects = new Set<string>();
regularTasks.forEach(task => {
if (task.source === 'jira' && task.jiraProject) {
projects.add(task.jiraProject);
}
});
return Array.from(projects).sort();
}, [regularTasks]);
const availableJiraTypes = useMemo(() => {
const types = new Set<string>();
regularTasks.forEach(task => {
if (task.source === 'jira' && task.jiraType) {
types.add(task.jiraType);
}
});
return Array.from(types).sort();
}, [regularTasks]);
// Vérifier s'il y a des tâches Jira dans le système (même masquées)
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
// Calculer les compteurs pour les priorités
const priorityCounts = useMemo(() => {
const counts: Record<string, number> = {};
getAllPriorities().forEach(priority => {
counts[priority.key] = regularTasks.filter(task => task.priority === priority.key).length;
});
return counts;
}, [regularTasks]);
// Calculer les compteurs pour les tags
const tagCounts = useMemo(() => {
const counts: Record<string, number> = {};
availableTags.forEach(tag => {
counts[tag.name] = regularTasks.filter(task => task.tags?.includes(tag.name)).length;
});
return counts;
}, [regularTasks, availableTags]);
const priorityOptions = getAllPriorities().map(priorityConfig => ({
value: priorityConfig.key,
label: priorityConfig.label,
color: priorityConfig.color,
count: priorityCounts[priorityConfig.key] || 0
}));
// Trier les tags par nombre d'utilisation (décroissant)
const sortedTags = useMemo(() => {
return [...availableTags].sort((a, b) => {
const countA = tagCounts[a.name] || 0;
const countB = tagCounts[b.name] || 0;
return countB - countA; // Décroissant
});
}, [availableTags, tagCounts]);
return (
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/50 backdrop-blur-sm">
<div className="container mx-auto px-6 py-4">
{/* Header avec recherche et bouton expand */}
<div className="flex items-center gap-4">
<div className="flex-1 max-w-md">
<Input
type="text"
value={filters.search || ''}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Rechercher des tâches..."
className="bg-[var(--card)] border-[var(--border)]"
/>
</div>
{/* Menu swimlanes */}
<div className="flex gap-1">
<Button
variant={filters.swimlanesByTags ? "primary" : "ghost"}
onClick={handleSwimlanesToggle}
className="flex items-center gap-2"
title="Mode d'affichage"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{filters.swimlanesByTags ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
)}
</svg>
{!filters.swimlanesByTags
? 'Normal'
: filters.swimlanesMode === 'priority'
? 'Par priorité'
: 'Par tags'
}
</Button>
{/* Bouton pour changer le mode des swimlanes */}
{filters.swimlanesByTags && (
<Button
variant="ghost"
onClick={handleSwimlaneModeToggle}
className="flex items-center gap-1 px-2"
>
<svg
className={`w-3 h-3 transition-transform ${isSwimlaneModeExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Button>
)}
</div>
{/* Bouton de tri */}
<div className="relative" ref={sortDropdownRef}>
<Button
ref={sortButtonRef}
variant="ghost"
onClick={handleSortToggle}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
</svg>
Tris
<svg
className={`w-4 h-4 transition-transform ${isSortExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Button>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
onClick={handleClearFilters}
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
>
Effacer
</Button>
)}
</div>
{/* Filtres étendus */}
<div className="mt-4 border-t border-[var(--border)]/50 pt-4">
{/* Grille responsive pour les filtres principaux */}
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6 lg:gap-8">
{/* Filtres par priorité */}
<div className="space-y-3">
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Priorités
</label>
<div className="grid grid-cols-2 gap-2">
{priorityOptions.filter(priority => priority.count > 0).map((priority) => (
<button
key={priority.value}
onClick={() => handlePriorityToggle(priority.value)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium whitespace-nowrap ${
filters.priorities?.includes(priority.value)
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
}`}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: getPriorityColorHex(priority.color) }}
/>
{priority.label} ({priority.count})
</button>
))}
</div>
</div>
{/* Filtres par tags */}
{availableTags.length > 0 && (
<div className="space-y-3">
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Tags
</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{sortedTags.filter(tag => (tagCounts[tag.name] || 0) > 0).map((tag) => (
<button
key={tag.id}
onClick={() => handleTagToggle(tag.name)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium ${
filters.tags?.includes(tag.name)
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
}`}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name} ({tagCounts[tag.name]})
</button>
))}
</div>
</div>
)}
</div>
{/* Filtres Jira - Ligne séparée mais intégrée */}
{hasJiraTasks && (
<div className="border-t border-[var(--border)]/30 pt-4 mt-4">
<div className="flex items-center gap-4 mb-3">
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
🔌 Jira
</label>
{/* Toggle Jira Show/Hide - inline avec le titre */}
<div className="flex gap-1">
<Button
variant={filters.showJiraOnly ? "primary" : "ghost"}
onClick={() => handleJiraToggle('show')}
size="sm"
className="text-xs px-2 py-1 h-auto"
>
🔹 Seul
</Button>
<Button
variant={filters.hideJiraTasks ? "danger" : "ghost"}
onClick={() => handleJiraToggle('hide')}
size="sm"
className="text-xs px-2 py-1 h-auto"
>
🚫 Mask
</Button>
<Button
variant={(!filters.showJiraOnly && !filters.hideJiraTasks) ? "primary" : "ghost"}
onClick={() => handleJiraToggle('all')}
size="sm"
className="text-xs px-2 py-1 h-auto"
>
📋 All
</Button>
</div>
</div>
{/* Projets et Types en 2 colonnes */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Projets Jira */}
{availableJiraProjects.length > 0 && (
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
Projets
</label>
<div className="flex flex-wrap gap-1">
{availableJiraProjects.map((project) => (
<button
key={project}
onClick={() => handleJiraProjectToggle(project)}
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
filters.jiraProjects?.includes(project)
? 'border-blue-400 bg-blue-400/10 text-blue-400'
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
}`}
>
📋 {project}
</button>
))}
</div>
</div>
)}
{/* Types Jira */}
{availableJiraTypes.length > 0 && (
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
Types
</label>
<div className="flex flex-wrap gap-1">
{availableJiraTypes.map((type) => (
<button
key={type}
onClick={() => handleJiraTypeToggle(type)}
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
filters.jiraTypes?.includes(type)
? 'border-purple-400 bg-purple-400/10 text-purple-400'
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
}`}
>
{type === 'Feature' && '✨ '}
{type === 'Story' && '📖 '}
{type === 'Task' && '📝 '}
{type === 'Bug' && '🐛 '}
{type === 'Support' && '🛠️ '}
{type === 'Enabler' && '🔧 '}
{type}
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Visibilité des colonnes */}
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
<ColumnVisibilityToggle
hiddenStatuses={hiddenStatuses}
onToggleStatus={toggleStatusVisibility}
tasks={regularTasks}
className="text-xs"
/>
</div>
{/* Résumé des filtres actifs */}
{activeFiltersCount > 0 && (
<div className="bg-[var(--card)]/30 rounded-lg p-3 border border-[var(--border)]/50 mt-4">
<div className="text-xs text-[var(--muted-foreground)] font-mono uppercase tracking-wider mb-2">
Filtres actifs
</div>
<div className="space-y-1 text-xs">
{filters.search && (
<div className="text-[var(--muted-foreground)]">
Recherche: <span className="text-cyan-400">&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>
)}
</div>
</div>
{/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */}
{isSortExpanded && typeof window !== 'undefined' && createPortal(
<div
ref={sortDropdownRef}
className="fixed w-80 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] max-h-64 overflow-y-auto"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left
}}
>
{SORT_OPTIONS.map((option) => (
<button
key={option.key}
onClick={() => {
handleSortChange(option.key);
setIsSortExpanded(false);
}}
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 ${
(filters.sortBy || 'priority-desc') === option.key
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
: 'text-[var(--muted-foreground)]'
}`}
>
<span className="text-base">{option.icon}</span>
<span className="flex-1">{option.label}</span>
{(filters.sortBy || 'priority-desc') === option.key && (
<svg className="w-4 h-4 text-cyan-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>,
document.body
)}
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
<div
ref={swimlaneModeDropdownRef}
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
}}
>
<button
onClick={() => handleSwimlaneModeChange('tags')}
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 first:rounded-t-lg ${
(!filters.swimlanesMode || filters.swimlanesMode === 'tags') ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
}`}
>
🏷 Par tags
</button>
<button
onClick={() => handleSwimlaneModeChange('priority')}
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 last:rounded-b-lg ${
filters.swimlanesMode === 'priority' ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
}`}
>
🎯 Par priorité
</button>
</div>,
document.body
)}
</div>
);
}

View File

@@ -1,487 +0,0 @@
import { useState, useEffect, useRef, useTransition } from 'react';
import { Task } from '@/lib/types';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { useTasksContext } from '@/contexts/TasksContext';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { useDraggable } from '@dnd-kit/core';
import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config';
import { updateTaskTitle, deleteTask } from '@/actions/tasks';
interface TaskCardProps {
task: Task;
onEdit?: (task: Task) => void;
compactView?: boolean;
}
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState(task.title);
const [showTooltip, setShowTooltip] = useState(false);
const [isPending, startTransition] = useTransition();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { tags: availableTags, refreshTasks } = useTasksContext();
const { preferences } = useUserPreferences();
// Classes CSS pour les différentes tailles de police
const getFontSizeClasses = () => {
switch (preferences.viewPreferences.fontSize) {
case 'small':
return {
title: 'text-xs',
description: 'text-xs',
meta: 'text-xs'
};
case 'large':
return {
title: 'text-base',
description: 'text-sm',
meta: 'text-sm'
};
default: // medium
return {
title: 'text-sm',
description: 'text-xs',
meta: 'text-xs'
};
}
};
const fontClasses = getFontSizeClasses();
// Helper pour construire l'URL Jira
const getJiraTicketUrl = (jiraKey: string): string => {
const baseUrl = preferences.jiraConfig.baseUrl;
if (!baseUrl || !jiraKey) return '';
return `${baseUrl}/browse/${jiraKey}`;
};
// Configuration du draggable
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: task.id,
});
// Mettre à jour le titre local quand la tâche change
useEffect(() => {
setEditTitle(task.title);
}, [task.title]);
// Nettoyer le timeout au démontage
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) {
startTransition(async () => {
const result = await deleteTask(task.id);
if (!result.success) {
console.error('Error deleting task:', result.error);
// TODO: Afficher une notification d'erreur
} else {
// Rafraîchir les données après suppression réussie
await refreshTasks();
}
});
}
};
const handleEdit = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onEdit) {
onEdit(task);
}
};
const handleTitleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging && !isPending) {
setIsEditingTitle(true);
setShowTooltip(false);
}
};
const handleTitleSave = async () => {
const trimmedTitle = editTitle.trim();
if (trimmedTitle && trimmedTitle !== task.title) {
startTransition(async () => {
const result = await updateTaskTitle(task.id, trimmedTitle);
if (!result.success) {
console.error('Error updating task title:', result.error);
// Remettre l'ancien titre en cas d'erreur
setEditTitle(task.title);
} else {
// Mettre à jour optimistiquement le titre local
// La Server Action a déjà mis à jour la DB, on synchronise juste l'affichage
task.title = trimmedTitle;
}
});
}
setIsEditingTitle(false);
setShowTooltip(false);
};
const handleTitleCancel = () => {
setEditTitle(task.title);
setIsEditingTitle(false);
setShowTooltip(false);
};
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTitleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleTitleCancel();
}
};
const handleMouseEnter = () => {
if (!isEditingTitle) {
timeoutRef.current = setTimeout(() => {
setShowTooltip(true);
}, 100);
}
};
const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setShowTooltip(false);
};
// Style de transformation pour le drag
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : undefined;
// Extraire les emojis du titre pour les afficher comme tags visuels
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
const titleEmojis = task.title.match(emojiRegex) || [];
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
// Composant titre avec tooltip
const TitleWithTooltip = () => (
<div className="relative flex-1">
<h4
className={`font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
onClick={handleTitleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title="Cliquer pour éditer"
>
{titleWithoutEmojis}
</h4>
{/* Tooltip */}
{showTooltip && (
<div className="absolute z-50 bottom-full left-0 mb-2 px-2 py-1 bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg max-w-xs whitespace-normal break-words text-xs font-mono text-[var(--foreground)]">
{titleWithoutEmojis}
<div className="absolute top-full left-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--border)]"></div>
</div>
)}
</div>
);
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
let displayEmojis: string[] = titleEmojis;
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
const firstTag = availableTags.find(tag => tag.name === task.tags[0]);
if (firstTag) {
const tagEmojis = firstTag.name.match(emojiRegex);
if (tagEmojis && tagEmojis.length > 0) {
displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag
}
}
}
// Styles spéciaux pour les tâches Jira
const isJiraTask = task.source === 'jira';
const jiraStyles = isJiraTask ? {
border: '1px solid rgba(0, 130, 201, 0.3)',
borderLeft: '3px solid #0082C9',
background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)'
} : {};
// Vue compacte : seulement le titre
if (compactView) {
return (
<Card
ref={setNodeRef}
style={{ ...style, ...jiraStyles }}
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
} ${
task.status === 'done' ? 'opacity-60' : ''
} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
>
<div className="flex items-center gap-2">
{displayEmojis.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{displayEmojis.slice(0, 1).map((emoji, index) => (
<span
key={index}
className="text-base opacity-90 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal'
}}
>
{emoji}
</span>
))}
</div>
)}
{isEditingTitle ? (
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={handleTitleKeyPress}
onBlur={handleTitleSave}
autoFocus
className={`flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono ${fontClasses.title} font-medium leading-tight`}
/>
) : (
<TitleWithTooltip />
)}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Boutons d'action compacts - masqués en mode édition */}
{!isEditingTitle && onEdit && (
<button
onClick={handleEdit}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
title="Modifier la tâche"
>
</button>
)}
{!isEditingTitle && (
<button
onClick={handleDelete}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
title="Supprimer la tâche"
>
×
</button>
)}
{/* Indicateur de priorité compact */}
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color) }}
/>
</div>
</div>
</Card>
);
}
// Vue détaillée : version complète
return (
<Card
ref={setNodeRef}
style={{ ...style, ...jiraStyles }}
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
} ${
task.status === 'done' ? 'opacity-60' : ''
} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
>
{/* Header tech avec titre et status */}
<div className="flex items-start gap-2 mb-2">
{displayEmojis.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{displayEmojis.slice(0, 2).map((emoji, index) => (
<span
key={index}
className="text-sm opacity-80 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal'
}}
>
{emoji}
</span>
))}
</div>
)}
{isEditingTitle ? (
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={handleTitleKeyPress}
onBlur={handleTitleSave}
autoFocus
className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight"
/>
) : (
<TitleWithTooltip />
)}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Bouton d'édition discret - masqué en mode édition */}
{!isEditingTitle && onEdit && (
<button
onClick={handleEdit}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
title="Modifier la tâche"
>
</button>
)}
{/* Bouton de suppression discret - masqué en mode édition */}
{!isEditingTitle && (
<button
onClick={handleDelete}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
title="Supprimer la tâche"
>
×
</button>
)}
{/* Indicateur de priorité tech */}
<div
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
style={{
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
}}
/>
</div>
</div>
{/* Description tech */}
{task.description && (
<p className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}>
{task.description}
</p>
)}
{/* Tags avec couleurs */}
{task.tags && task.tags.length > 0 && (
<div className={
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
? "mb-3"
: "mb-0"
}>
<TagDisplay
tags={task.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
showColors={true}
/>
</div>
)}
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
<div className="pt-2 border-t border-[var(--border)]/50">
<div className={`flex items-center justify-between ${fontClasses.meta}`}>
{task.dueDate ? (
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
<span className="text-[var(--primary)]"></span>
{formatDistanceToNow(new Date(task.dueDate), {
addSuffix: true,
locale: fr
})}
</span>
) : (
<div></div>
)}
<div className="flex items-center gap-2">
{task.source !== 'manual' && task.source && (
task.source === 'jira' && task.jiraKey ? (
preferences.jiraConfig.baseUrl ? (
<a
href={getJiraTicketUrl(task.jiraKey)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="hover:scale-105 transition-transform"
>
<Badge variant="outline" size="sm" className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer">
{task.jiraKey}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
{task.jiraKey}
</Badge>
)
) : (
<Badge variant="outline" size="sm">
{task.source}
</Badge>
)
)}
{task.jiraProject && (
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
{task.jiraProject}
</Badge>
)}
{task.jiraType && (
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
{task.jiraType}
</Badge>
)}
{task.completedAt && (
<span className="text-emerald-400 font-mono font-bold"> DONE</span>
)}
</div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -1,361 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import { UserPreferences, Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { Header } from '@/components/ui/Header';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagForm } from '@/components/forms/TagForm';
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
import Link from 'next/link';
interface GeneralSettingsPageClientProps {
initialPreferences: UserPreferences;
initialTags: Tag[];
}
export function GeneralSettingsPageClient({ initialPreferences, initialTags }: GeneralSettingsPageClientProps) {
const {
tags,
refreshTags,
deleteTag
} = useTags(initialTags as (Tag & { usage: number })[]);
const [searchQuery, setSearchQuery] = useState('');
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
// Filtrer et trier les tags
const filteredTags = useMemo(() => {
let filtered = tags;
// Filtrer par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(tag =>
tag.name.toLowerCase().includes(query)
);
}
// Filtrer pour afficher seulement les non utilisés
if (showOnlyUnused) {
filtered = filtered.filter(tag => {
const usage = (tag as Tag & { usage?: number }).usage || 0;
return usage === 0;
});
}
const sorted = filtered.sort((a, b) => {
const usageA = (a as Tag & { usage?: number }).usage || 0;
const usageB = (b as Tag & { usage?: number }).usage || 0;
if (usageB !== usageA) return usageB - usageA;
return a.name.localeCompare(b.name);
});
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
const hasFilters = searchQuery.trim() || showOnlyUnused;
return hasFilters ? sorted : sorted.slice(0, 12);
}, [tags, searchQuery, showOnlyUnused]);
const handleEditTag = (tag: Tag) => {
setEditingTag(tag);
};
const handleDeleteTag = async (tag: Tag) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
return;
}
setDeletingTagId(tag.id);
try {
await deleteTag(tag.id);
await refreshTags();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
return (
<UserPreferencesProvider initialPreferences={initialPreferences}>
<div className="min-h-screen bg-[var(--background)]">
<Header
title="TowerControl"
subtitle="Paramètres généraux"
/>
<div className="container mx-auto px-4 py-4">
<div className="max-w-4xl mx-auto">
{/* Breadcrumb */}
<div className="mb-4 text-sm">
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
Paramètres
</Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
<span className="text-[var(--foreground)]">Général</span>
</div>
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
Paramètres généraux
</h1>
<p className="text-[var(--muted-foreground)]">
Configuration des préférences de l&apos;interface et du comportement général
</p>
</div>
<div className="space-y-6">
{/* Gestion des tags */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
🏷 Gestion des tags
</h2>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
Créer et organiser les étiquettes pour vos tâches
</p>
</div>
<Button
variant="primary"
size="sm"
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>
Nouveau tag
</Button>
</div>
</CardHeader>
<CardContent>
{/* Stats des tags */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
</div>
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
<div className="text-xl font-bold text-[var(--primary)]">
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
</div>
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
<div className="text-xl font-bold text-[var(--success)]">
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
</div>
</div>
{/* Recherche et filtres */}
<div className="space-y-3 mb-4">
<Input
placeholder="Rechercher un tag..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
{/* Filtres rapides */}
<div className="flex items-center gap-3">
<Button
variant={showOnlyUnused ? "primary" : "ghost"}
size="sm"
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
className="flex items-center gap-2"
>
<span className="text-xs"></span>
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
</Button>
{(searchQuery || showOnlyUnused) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchQuery('');
setShowOnlyUnused(false);
}}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
Réinitialiser
</Button>
)}
</div>
</div>
{/* Liste des tags en grid */}
{filteredTags.length === 0 ? (
<div className="text-center py-8 text-[var(--muted-foreground)]">
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
'Aucun tag créé'}
{!searchQuery && !showOnlyUnused && (
<div className="mt-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
Créer votre premier tag
</Button>
</div>
)}
</div>
) : (
<div className="space-y-4">
{/* Grid des tags */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredTags.map((tag) => {
const usage = (tag as Tag & { usage?: number }).usage || 0;
const isUnused = usage === 0;
return (
<div
key={tag.id}
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
isUnused
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
}`}
>
{/* Header du tag */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="font-medium text-sm truncate">{tag.name}</span>
{tag.isPinned && (
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
📌
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTag(tag)}
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTag(tag)}
disabled={deletingTagId === tag.id}
className={`h-7 w-7 p-0 ${
isUnused
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
}`}
>
{deletingTagId === tag.id ? (
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)}
</Button>
</div>
</div>
{/* Stats et warning */}
<div className="space-y-1">
<div className={`text-xs flex items-center justify-between ${
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
}`}>
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
{isUnused && (
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
Non utilisé
</span>
)}
</div>
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
<div className="text-xs text-[var(--muted-foreground)]">
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Message si plus de tags */}
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Note développement futur */}
<Card>
<CardContent className="p-4">
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
<p className="text-sm text-[var(--warning)] font-medium mb-2">
🚧 Interface de configuration en développement
</p>
<p className="text-xs text-[var(--muted-foreground)]">
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
Pour l&apos;instant, les préférences sont modifiables via les boutons de l&apos;interface principale.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
{/* Modals pour les tags */}
{isCreateModalOpen && (
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={async () => {
setIsCreateModalOpen(false);
await refreshTags();
}}
/>
)}
{editingTag && (
<TagForm
isOpen={!!editingTag}
tag={editingTag}
onClose={() => setEditingTag(null)}
onSuccess={async () => {
setEditingTag(null);
await refreshTags();
}}
/>
)}
</UserPreferencesProvider>
);
}

View File

@@ -1,172 +0,0 @@
'use client';
import { UserPreferences, JiraConfig } from '@/lib/types';
import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
import { JiraSync } from '@/components/jira/JiraSync';
import { JiraLogs } from '@/components/jira/JiraLogs';
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
import Link from 'next/link';
interface IntegrationsSettingsPageClientProps {
initialPreferences: UserPreferences;
initialJiraConfig: JiraConfig;
}
export function IntegrationsSettingsPageClient({
initialPreferences,
initialJiraConfig
}: IntegrationsSettingsPageClientProps) {
return (
<UserPreferencesProvider initialPreferences={initialPreferences}>
<div className="min-h-screen bg-[var(--background)]">
<Header
title="TowerControl"
subtitle="Intégrations externes"
/>
<div className="container mx-auto px-4 py-4">
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<div className="mb-4 text-sm">
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
Paramètres
</Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
<span className="text-[var(--foreground)]">Intégrations</span>
</div>
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
🔌 Intégrations externes
</h1>
<p className="text-[var(--muted-foreground)]">
Configuration des intégrations avec les outils externes
</p>
</div>
{/* Layout en 2 colonnes pour optimiser l'espace */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Colonne principale: Configuration Jira */}
<div className="xl:col-span-2 space-y-6">
<Card>
<CardHeader>
<h2 className="text-xl font-semibold flex items-center gap-2">
<span className="text-blue-600">🏢</span>
Jira Cloud
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Synchronisation automatique des tickets Jira vers TowerControl
</p>
</CardHeader>
<CardContent>
<JiraConfigForm />
</CardContent>
</Card>
{/* Futures intégrations */}
<Card>
<CardHeader>
<h2 className="text-xl font-semibold">Autres intégrations</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Intégrations prévues pour les prochaines versions
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📧</span>
<h3 className="font-medium">Slack/Teams</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Notifications et commandes via chat
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🐙</span>
<h3 className="font-medium">GitHub/GitLab</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Synchronisation des issues et PR
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📊</span>
<h3 className="font-medium">Calendriers</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Google Calendar, Outlook, etc.
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<h3 className="font-medium">Time tracking</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Toggl, RescueTime, etc.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Colonne latérale: Actions et Logs Jira */}
<div className="space-y-4">
{initialJiraConfig?.enabled && (
<>
{/* Dashboard Analytics */}
{initialJiraConfig.projectKey && (
<Card>
<CardHeader>
<h3 className="text-sm font-semibold">📊 Analytics d&apos;équipe</h3>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-[var(--muted-foreground)]">
Surveillance du projet {initialJiraConfig.projectKey}
</p>
<Link
href="/jira-dashboard"
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
>
Voir le Dashboard
</Link>
</CardContent>
</Card>
)}
<JiraSync />
<JiraLogs />
</>
)}
{!initialJiraConfig?.enabled && (
<Card>
<CardContent className="p-4">
<div className="text-center py-6">
<span className="text-4xl mb-4 block">🔧</span>
<p className="text-sm text-[var(--muted-foreground)]">
Configurez Jira pour accéder aux outils de synchronisation
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
</UserPreferencesProvider>
);
}

View File

@@ -1,220 +0,0 @@
'use client';
import { UserPreferences } from '@/lib/types';
import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
import Link from 'next/link';
interface SettingsIndexPageClientProps {
initialPreferences: UserPreferences;
}
export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPageClientProps) {
const settingsPages = [
{
href: '/settings/general',
icon: '⚙️',
title: 'Paramètres généraux',
description: 'Interface, thème, préférences d\'affichage',
status: 'En développement'
},
{
href: '/settings/integrations',
icon: '🔌',
title: 'Intégrations',
description: 'Jira, GitHub, Slack et autres services externes',
status: 'Fonctionnel'
},
{
href: '/settings/advanced',
icon: '🛠️',
title: 'Paramètres avancés',
description: 'Sauvegarde, logs, debug et maintenance',
status: 'Prochainement'
}
];
return (
<UserPreferencesProvider initialPreferences={initialPreferences}>
<div className="min-h-screen bg-[var(--background)]">
<Header
title="TowerControl"
subtitle="Configuration & Paramètres"
/>
<div className="container mx-auto px-4 py-4">
<div className="max-w-4xl mx-auto">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-3">
Paramètres
</h1>
<p className="text-[var(--muted-foreground)] text-lg">
Configuration de TowerControl et de ses intégrations
</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<span className="text-2xl">🎨</span>
<div>
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
<p className="font-medium capitalize">{initialPreferences.viewPreferences.theme}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<span className="text-2xl">🔌</span>
<div>
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
<p className="font-medium">
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<span className="text-2xl">📏</span>
<div>
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
<p className="font-medium capitalize">{initialPreferences.viewPreferences.fontSize}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Settings Sections */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
Sections de configuration
</h2>
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
{settingsPages.map((page) => (
<Link key={page.href} href={page.href}>
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<span className="text-3xl">{page.icon}</span>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
{page.title}
</h3>
<p className="text-[var(--muted-foreground)] mb-2">
{page.description}
</p>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
page.status === 'Fonctionnel'
? 'bg-[var(--success)]/20 text-[var(--success)]'
: page.status === 'En développement'
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
}`}>
{page.status}
</span>
</div>
</div>
</div>
<svg
className="w-5 h-5 text-[var(--muted-foreground)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
{/* Quick Actions */}
<div className="mt-8">
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
Actions rapides
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
<p className="text-sm text-[var(--muted-foreground)]">
Créer une sauvegarde des données
</p>
</div>
<button className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm">
Sauvegarder
</button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium mb-1">Test Jira</h3>
<p className="text-sm text-[var(--muted-foreground)]">
Tester la connexion Jira
</p>
</div>
<button
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm"
disabled={!initialPreferences.jiraConfig.enabled}
>
Tester
</button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* System Info */}
<Card className="mt-8">
<CardHeader>
<h2 className="text-lg font-semibold"> Informations système</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-[var(--muted-foreground)]">Version</p>
<p className="font-medium">TowerControl v1.0.0</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
<p className="font-medium">Il y a 2 jours</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">Env</p>
<p className="font-medium">Development</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</UserPreferencesProvider>
);
}

View File

@@ -1,143 +0,0 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
import { JiraSync } from '@/components/jira/JiraSync';
import { JiraLogs } from '@/components/jira/JiraLogs';
import { useJiraConfig } from '@/hooks/useJiraConfig';
export function SettingsPageClient() {
const { config: jiraConfig } = useJiraConfig();
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
const tabs = [
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
];
return (
<div className="min-h-screen bg-[var(--background)]">
<Header
title="TowerControl"
subtitle="Configuration & Paramètres"
/>
<div className="container mx-auto px-4 py-4">
<div className="max-w-7xl mx-auto">
{/* En-tête compact */}
<div className="mb-4">
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
Paramètres
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Configuration de TowerControl et de ses intégrations
</p>
</div>
<div className="flex gap-6">
{/* Navigation latérale compacte */}
<div className="w-56 flex-shrink-0">
<Card>
<CardContent className="p-0">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
activeTab === tab.id
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
}`}
>
<span className="text-base">{tab.icon}</span>
<span className="font-medium text-sm">{tab.label}</span>
</button>
))}
</CardContent>
</Card>
</div>
{/* Contenu principal */}
<div className="flex-1 min-h-0">
{activeTab === 'general' && (
<div className="space-y-6">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">Préférences générales</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<p className="text-sm text-[var(--muted-foreground)]">
Les paramètres généraux seront disponibles dans une prochaine version.
</p>
</div>
</CardContent>
</Card>
</div>
)}
{activeTab === 'integrations' && (
<div className="h-full">
{/* Layout en 2 colonnes pour optimiser l'espace */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
{/* Colonne 1: Configuration Jira */}
<div className="xl:col-span-2">
<Card className="h-fit">
<CardHeader className="pb-3">
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
<p className="text-xs text-[var(--muted-foreground)]">
Synchronisation automatique des tickets
</p>
</CardHeader>
<CardContent>
<JiraConfigForm />
</CardContent>
</Card>
</div>
{/* Colonne 2: Actions et Logs */}
<div className="space-y-4">
{jiraConfig?.enabled && (
<>
<JiraSync />
<JiraLogs />
</>
)}
</div>
</div>
</div>
)}
{activeTab === 'advanced' && (
<div className="space-y-6">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<p className="text-sm text-[var(--muted-foreground)]">
Les paramètres avancés seront disponibles dans une prochaine version.
</p>
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
<li> Configuration de la base de données</li>
<li> Logs de debug</li>
<li> Export/Import des données</li>
<li> Réinitialisation</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline';
size?: 'sm' | 'md';
}
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ className, variant = 'default', size = 'md', ...props }, ref) => {
const baseStyles = 'inline-flex items-center font-mono font-medium transition-all duration-200';
const variants = {
default: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)]',
primary: 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30',
success: 'bg-[var(--success)]/20 text-[var(--success)] border border-[var(--success)]/30',
warning: 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30',
danger: 'bg-[var(--destructive)]/20 text-[var(--destructive)] border border-[var(--destructive)]/30',
outline: 'bg-transparent text-[var(--muted-foreground)] border border-[var(--border)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
};
const sizes = {
sm: 'px-1.5 py-0.5 text-xs rounded',
md: 'px-2 py-1 text-xs rounded-md'
};
return (
<span
className={cn(
baseStyles,
variants[variant],
sizes[size],
className
)}
ref={ref}
{...props}
/>
);
}
);
Badge.displayName = 'Badge';
export { Badge };

View File

@@ -1,43 +0,0 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center font-mono font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--background)] disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-[var(--primary)] hover:bg-[var(--primary)]/80 text-[var(--primary-foreground)] border border-[var(--primary)]/30 shadow-[var(--primary)]/20 shadow-lg hover:shadow-[var(--primary)]/30 focus:ring-[var(--primary)]',
secondary: 'bg-[var(--card)] hover:bg-[var(--card-hover)] text-[var(--foreground)] border border-[var(--border)] shadow-[var(--muted)]/20 shadow-lg hover:shadow-[var(--muted)]/30 focus:ring-[var(--muted)]',
danger: 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/80 text-white border border-[var(--destructive)]/30 shadow-[var(--destructive)]/20 shadow-lg hover:shadow-[var(--destructive)]/30 focus:ring-[var(--destructive)]',
ghost: 'bg-transparent hover:bg-[var(--card)]/50 text-[var(--muted-foreground)] hover:text-[var(--foreground)] border border-[var(--border)]/50 hover:border-[var(--border)] focus:ring-[var(--muted)]'
};
const sizes = {
sm: 'px-3 py-1.5 text-xs rounded-md',
md: 'px-4 py-2 text-sm rounded-lg',
lg: 'px-6 py-3 text-base rounded-lg'
};
return (
<button
className={cn(
baseStyles,
variants[variant],
sizes[size],
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button };

View File

@@ -1,81 +0,0 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'bordered' | 'column';
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', ...props }, ref) => {
const variants = {
default: 'bg-[var(--card)]/50 border border-[var(--border)]/50',
elevated: 'bg-[var(--card)]/80 border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20',
bordered: 'bg-[var(--card)]/50 border border-[var(--primary)]/30 shadow-[var(--primary)]/10 shadow-lg',
column: 'bg-[var(--card-column)] border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20'
};
return (
<div
ref={ref}
className={cn(
'rounded-lg backdrop-blur-sm transition-all duration-200',
variants[variant],
className
)}
{...props}
/>
);
}
);
Card.displayName = 'Card';
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('p-4 border-b border-[var(--border)]/50', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-mono font-semibold text-[var(--foreground)] tracking-wide', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('p-4', className)}
{...props}
/>
)
);
CardContent.displayName = 'CardContent';
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('p-4 border-t border-[var(--border)]/50', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardTitle, CardContent, CardFooter };

View File

@@ -1,21 +0,0 @@
'use client';
import { Header } from './Header';
import { useTasks } from '@/hooks/useTasks';
interface HeaderContainerProps {
title: string;
subtitle: string;
}
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
const { syncing } = useTasks();
return (
<Header
title={title}
subtitle={subtitle}
syncing={syncing}
/>
);
}

View File

@@ -1,44 +0,0 @@
import { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, ...props }, ref) => {
return (
<div className="space-y-2">
{label && (
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
{label}
</label>
)}
<input
className={cn(
'w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg',
'text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50',
'hover:border-[var(--border)] transition-all duration-200',
'backdrop-blur-sm',
error && 'border-[var(--destructive)]/50 focus:ring-[var(--destructive)]/50 focus:border-[var(--destructive)]/50',
className
)}
ref={ref}
{...props}
/>
{error && (
<p className="text-xs font-mono text-[var(--destructive)] flex items-center gap-1">
<span className="text-[var(--destructive)]"></span>
{error}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -1,95 +0,0 @@
import { Tag } from '@/lib/types';
interface TagListProps {
tags: (Tag & { usage?: number })[];
onTagEdit?: (tag: Tag) => void;
onTagDelete?: (tag: Tag) => void;
showActions?: boolean;
showUsage?: boolean;
deletingTagId?: string | null;
}
export function TagList({
tags,
onTagEdit,
onTagDelete,
showActions = true,
deletingTagId
}: TagListProps) {
if (tags.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🏷</div>
<p className="text-lg mb-2">Aucun tag trouvé</p>
<p className="text-sm">Créez votre premier tag pour commencer</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tags.map((tag) => {
const isDeleting = deletingTagId === tag.id;
return (
<div
key={tag.id}
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
>
{/* Contenu principal */}
<div className="flex items-center gap-3">
<div
className="w-5 h-5 rounded-full shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="text-slate-200 font-medium truncate">
{tag.name}
</h3>
{tag.usage !== undefined && (
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
{tag.usage}
</span>
)}
</div>
</div>
</div>
{/* Actions (apparaissent au hover) */}
{showActions && (onTagEdit || onTagDelete) && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onTagEdit && (
<button
onClick={() => onTagEdit(tag)}
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
>
</button>
)}
{onTagDelete && (
<button
onClick={() => onTagDelete(tag)}
disabled={isDeleting}
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? '⏳' : '🗑️'}
</button>
)}
</div>
)}
{/* Indicateur de couleur en bas */}
<div
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
style={{ backgroundColor: tag.color }}
/>
</div>
);
})}
</div>
);
}

102
data/README.md Normal file
View File

@@ -0,0 +1,102 @@
# 📁 Dossier Data - TowerControl
Ce dossier contient toutes les données persistantes de l'application TowerControl.
## 📋 Structure
```
data/
├── README.md # Ce fichier
├── prod.db # Base de données production (Docker)
├── dev.db # Base de données développement (Docker)
└── backups/ # Sauvegardes automatiques et manuelles
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
├── towercontrol_2025-01-15T11-30-00-000Z.db.gz
└── ...
```
## 🎯 Utilisation
### En développement local
- La base de données principale est dans `prisma/dev.db`
- Ce dossier `data/` est utilisé uniquement par Docker
- Les sauvegardes locales sont dans `backups/` (racine du projet)
### En production Docker
- Base de données : `data/prod.db` ou `data/dev.db`
- Sauvegardes : `data/backups/`
- Tout ce dossier est mappé vers `/app/data` dans le conteneur
## 🔧 Configuration
Les chemins sont configurés via les variables d'environnement :
```bash
# Base de données
DATABASE_URL="file:../data/prod.db"
# Chemin de la base pour les backups
BACKUP_DATABASE_PATH="./data/prod.db"
# Dossier de stockage des sauvegardes
BACKUP_STORAGE_PATH="./data/backups"
```
## 🗂️ Fichiers
### Bases de données SQLite
- **prod.db** : Base de données de production
- **dev.db** : Base de données de développement Docker
- Format : SQLite 3
- Contient : Tasks, Tags, User Preferences, Sync Logs, etc.
### Sauvegardes
- **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz`
- **Compression** : gzip
- **Rétention** : Configurable (défaut: 5 sauvegardes)
- **Fréquence** : Configurable (défaut: horaire)
## 🚀 Commandes utiles
```bash
# Créer une sauvegarde manuelle
npm run backup:create
# Lister les sauvegardes
npm run backup:list
# Voir la configuration
npm run backup:config
# Restaurer une sauvegarde (dev uniquement)
npm run backup:restore filename.db.gz
```
## ⚠️ Important
- **Ne pas modifier** les fichiers `.db` directement
- **Ne pas supprimer** ce dossier en production
- **Sauvegarder régulièrement** le contenu de ce dossier
- **Vérifier l'espace disque** disponible pour les sauvegardes
## 🔒 Sécurité
- Ce dossier est ignoré par Git (`.gitignore`)
- Contient des données sensibles en production
- Accès restreint recommandé sur le serveur
- Chiffrement recommandé pour les sauvegardes externes
## 📊 Monitoring
Pour surveiller l'espace disque :
```bash
# Taille du dossier data
du -sh data/
# Taille des sauvegardes
du -sh data/backups/
# Nombre de sauvegardes
ls -1 data/backups/ | wc -l
```

0
dev.db
View File

View File

@@ -1,31 +1,27 @@
version: '3.8'
services: services:
towercontrol: towercontrol:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
target: runner
ports: ports:
- "3006:3000" - "3006:3000"
environment: environment:
- NODE_ENV=production NODE_ENV: production
- DATABASE_URL=file:/app/data/prod.db DATABASE_URL: "file:../data/dev.db" # Prisma
- TZ=Europe/Paris BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
TZ: Europe/Paris
volumes: volumes:
# Volume persistant pour la base SQLite - ./data:/app/data # Dossier local data/ vers /app/data
- sqlite_data:/app/data
# Monter ta DB locale (décommente pour utiliser tes données locales)
- ./prisma/dev.db:/app/data/prod.db
- ./backups:/app/backups
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"] test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
# Service de développement (optionnel)
towercontrol-dev: towercontrol-dev:
build: build:
context: . context: .
@@ -34,20 +30,29 @@ services:
ports: ports:
- "3005:3000" - "3005:3000"
environment: environment:
- NODE_ENV=development NODE_ENV: development
- DATABASE_URL=file:/app/data/dev.db DATABASE_URL: "file:../data/dev.db" # Prisma
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
TZ: Europe/Paris
volumes: volumes:
- .:/app - .:/app # code en live
- /app/node_modules - /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
- /app/.next - /app/.next
- sqlite_data_dev:/app/data - ./data:/app/data # Dossier local data/ vers /app/data
command: sh -c "npm install && npx prisma generate && npx prisma migrate deploy && npm run dev" command: >
sh -c "npm install &&
npx prisma generate &&
npx prisma migrate deploy &&
npm run dev"
profiles: profiles:
- dev - dev
volumes: # 📁 Structure des données :
sqlite_data: # ./data/ -> /app/data (bind mount)
driver: local # ├── prod.db -> Base de données production
sqlite_data_dev: # ├── dev.db -> Base de données développement
driver: local # └── backups/ -> Sauvegardes automatiques
#
# 🔧 Configuration via .env.docker
# 📚 Documentation : ./data/README.md

View File

@@ -1,5 +1,13 @@
# Base de données (requis) # Base de données (requis)
DATABASE_URL="file:./dev.db" DATABASE_URL="file:../data/dev.db"
# Chemin de la base de données pour les backups (optionnel)
# Si non défini, utilise DATABASE_URL ou le chemin par défaut
BACKUP_DATABASE_PATH="./data/dev.db"
# Dossier de stockage des sauvegardes (optionnel)
# Par défaut: ./backups en local, ./data/backups en production
BACKUP_STORAGE_PATH="./backups"
# Intégration Jira (optionnel) # Intégration Jira (optionnel)
JIRA_BASE_URL="" # https://votre-domaine.atlassian.net JIRA_BASE_URL="" # https://votre-domaine.atlassian.net

View File

@@ -1,98 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { getAvailableJiraFilters, getFilteredJiraAnalytics } from '@/actions/jira-filters';
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
export function useJiraFilters() {
const [availableFilters, setAvailableFilters] = useState<AvailableFilters>({
components: [],
fixVersions: [],
issueTypes: [],
statuses: [],
assignees: [],
labels: [],
priorities: []
});
const [activeFilters, setActiveFilters] = useState<Partial<JiraAnalyticsFilters>>(
JiraAdvancedFiltersService.createEmptyFilters()
);
const [filteredAnalytics, setFilteredAnalytics] = useState<JiraAnalytics | null>(null);
const [isLoadingFilters, setIsLoadingFilters] = useState(false);
const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false);
const [error, setError] = useState<string | null>(null);
// Charger les filtres disponibles
const loadAvailableFilters = useCallback(async () => {
setIsLoadingFilters(true);
setError(null);
try {
const result = await getAvailableJiraFilters();
if (result.success && result.data) {
setAvailableFilters(result.data);
} else {
setError(result.error || 'Erreur lors du chargement des filtres');
}
} catch {
setError('Erreur de connexion');
} finally {
setIsLoadingFilters(false);
}
}, []);
// Appliquer les filtres et récupérer les analytics filtrées
const applyFilters = useCallback(async (filters: Partial<JiraAnalyticsFilters>) => {
setIsLoadingAnalytics(true);
setError(null);
try {
const result = await getFilteredJiraAnalytics(filters);
if (result.success && result.data) {
setFilteredAnalytics(result.data);
setActiveFilters(filters);
} else {
setError(result.error || 'Erreur lors du filtrage');
}
} catch {
setError('Erreur de connexion');
} finally {
setIsLoadingAnalytics(false);
}
}, []);
// Effacer tous les filtres
const clearFilters = useCallback(() => {
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
setActiveFilters(emptyFilters);
setFilteredAnalytics(null);
}, []);
// Chargement initial des filtres disponibles
useEffect(() => {
loadAvailableFilters();
}, [loadAvailableFilters]);
return {
// État
availableFilters,
activeFilters,
filteredAnalytics,
isLoadingFilters,
isLoadingAnalytics,
error,
// Actions
loadAvailableFilters,
applyFilters,
clearFilters,
// Helpers
hasActiveFilters: JiraAdvancedFiltersService.hasActiveFilters(activeFilters),
activeFiltersCount: JiraAdvancedFiltersService.countActiveFilters(activeFilters),
filtersSummary: JiraAdvancedFiltersService.getFiltersSummary(activeFilters)
};
}

2303
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,23 +13,26 @@
"backup:config": "npx tsx scripts/backup-manager.ts config", "backup:config": "npx tsx scripts/backup-manager.ts config",
"backup:start": "npx tsx scripts/backup-manager.ts scheduler-start", "backup:start": "npx tsx scripts/backup-manager.ts scheduler-start",
"backup:stop": "npx tsx scripts/backup-manager.ts scheduler-stop", "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": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.16.1", "@prisma/client": "^6.16.1",
"@types/jspdf": "^1.3.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jspdf": "^3.0.3",
"next": "15.5.3", "next": "15.5.3",
"prisma": "^6.16.1", "prisma": "^6.16.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"recharts": "^3.2.1", "recharts": "^3.2.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
@@ -39,11 +42,8 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "^15.5.3",
"eslint-config-prettier": "^10.1.8", "knip": "^5.64.0",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"tailwindcss": "^4",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5" "typescript": "^5"
} }

View File

@@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@@ -11,27 +8,28 @@ datasource db {
} }
model Task { model Task {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
description String? description String?
status String @default("todo") status String @default("todo")
priority String @default("medium") priority String @default("medium")
source String // "reminders" | "jira" source String
sourceId String? // ID dans le système source sourceId String?
dueDate DateTime? dueDate DateTime?
completedAt DateTime? completedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
jiraProject String?
// Métadonnées Jira jiraKey String?
jiraProject String? assignee String?
jiraKey String? jiraType String?
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc. tfsProject String?
assignee String? tfsPullRequestId Int?
tfsRepository String?
// Relations tfsSourceBranch String?
taskTags TaskTag[] tfsTargetBranch String?
dailyCheckboxes DailyCheckbox[] dailyCheckboxes DailyCheckbox[]
taskTags TaskTag[]
@@unique([source, sourceId]) @@unique([source, sourceId])
@@map("tasks") @@map("tasks")
@@ -41,7 +39,7 @@ model Tag {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
color String @default("#6b7280") color String @default("#6b7280")
isPinned Boolean @default(false) // Tag pour objectifs principaux isPinned Boolean @default(false)
taskTags TaskTag[] taskTags TaskTag[]
@@map("tags") @@map("tags")
@@ -50,8 +48,8 @@ model Tag {
model TaskTag { model TaskTag {
taskId String taskId String
tagId String tagId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@id([taskId, tagId]) @@id([taskId, tagId])
@@map("task_tags") @@map("task_tags")
@@ -59,8 +57,8 @@ model TaskTag {
model SyncLog { model SyncLog {
id String @id @default(cuid()) id String @id @default(cuid())
source String // "reminders" | "jira" source String
status String // "success" | "error" status String
message String? message String?
tasksSync Int @default(0) tasksSync Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -70,39 +68,33 @@ model SyncLog {
model DailyCheckbox { model DailyCheckbox {
id String @id @default(cuid()) id String @id @default(cuid())
date DateTime // Date de la checkbox (YYYY-MM-DD) date DateTime
text String // Texte de la checkbox text String
isChecked Boolean @default(false) isChecked Boolean @default(false)
type String @default("task") // "task" | "meeting" type String @default("task")
order Int @default(0) // Ordre d'affichage pour cette date order Int @default(0)
taskId String? // Liaison optionnelle vers une tâche taskId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
task Task? @relation(fields: [taskId], references: [id])
// Relations
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
@@index([date]) @@index([date])
@@map("daily_checkboxes") @@map("daily_checkboxes")
} }
model UserPreferences { model UserPreferences {
id String @id @default(cuid()) id String @id @default(cuid())
kanbanFilters Json?
// Filtres Kanban (JSON) viewPreferences Json?
kanbanFilters Json?
// Préférences de vue (JSON)
viewPreferences Json?
// Visibilité des colonnes (JSON)
columnVisibility Json? columnVisibility Json?
jiraConfig Json?
// Configuration Jira (JSON) jiraAutoSync Boolean @default(false)
jiraConfig Json? jiraSyncInterval String @default("daily")
tfsConfig Json?
createdAt DateTime @default(now()) tfsAutoSync Boolean @default(false)
updatedAt DateTime @updatedAt tfsSyncInterval String @default("daily")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("user_preferences") @@map("user_preferences")
} }

View File

@@ -4,8 +4,9 @@
* Usage: tsx scripts/backup-manager.ts [command] [options] * Usage: tsx scripts/backup-manager.ts [command] [options]
*/ */
import { backupService, BackupConfig } from '../services/backup'; import { backupService, BackupConfig } from '../src/services/data-management/backup';
import { backupScheduler } from '../services/backup-scheduler'; import { backupScheduler } from '../src/services/data-management/backup-scheduler';
import { formatDateForDisplay } from '../src/lib/date-utils';
interface CliOptions { interface CliOptions {
command: string; command: string;
@@ -21,7 +22,7 @@ class BackupManagerCLI {
🔧 TowerControl Backup Manager 🔧 TowerControl Backup Manager
COMMANDES: COMMANDES:
create Créer une nouvelle sauvegarde create [--force] Créer une nouvelle sauvegarde (--force pour ignorer la détection de changements)
list Lister toutes les sauvegardes list Lister toutes les sauvegardes
delete <filename> Supprimer une sauvegarde delete <filename> Supprimer une sauvegarde
restore <filename> Restaurer une sauvegarde restore <filename> Restaurer une sauvegarde
@@ -35,6 +36,7 @@ COMMANDES:
EXEMPLES: EXEMPLES:
tsx backup-manager.ts create tsx backup-manager.ts create
tsx backup-manager.ts create --force
tsx backup-manager.ts list tsx backup-manager.ts list
tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db
tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
@@ -91,7 +93,7 @@ OPTIONS:
} }
private formatDate(date: Date): string { private formatDate(date: Date): string {
return new Date(date).toLocaleString('fr-FR'); return formatDateForDisplay(date, 'DISPLAY_LONG');
} }
async run(args: string[]): Promise<void> { async run(args: string[]): Promise<void> {
@@ -105,7 +107,7 @@ OPTIONS:
try { try {
switch (options.command) { switch (options.command) {
case 'create': case 'create':
await this.createBackup(); await this.createBackup(options.force || false);
break; break;
case 'list': case 'list':
@@ -167,13 +169,22 @@ OPTIONS:
} }
} }
private async createBackup(): Promise<void> { private async createBackup(force: boolean = false): Promise<void> {
console.log('🔄 Création d\'une sauvegarde...'); console.log('🔄 Création d\'une sauvegarde...');
const result = await backupService.createBackup('manual'); const result = await backupService.createBackup('manual', force);
if (result === null) {
console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde');
console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout');
return;
}
if (result.status === 'success') { if (result.status === 'success') {
console.log(`✅ Sauvegarde créée: ${result.filename}`); console.log(`✅ Sauvegarde créée: ${result.filename}`);
console.log(` Taille: ${this.formatFileSize(result.size)}`); console.log(` Taille: ${this.formatFileSize(result.size)}`);
if (result.databaseHash) {
console.log(` Hash: ${result.databaseHash.substring(0, 12)}...`);
}
} else { } else {
console.error(`❌ Échec de la sauvegarde: ${result.error}`); console.error(`❌ Échec de la sauvegarde: ${result.error}`);
process.exit(1); process.exit(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

@@ -1,4 +1,4 @@
import { prisma } from '../services/database'; import { prisma } from '../src/services/core/database';
/** /**
* Script pour reset la base de données et supprimer les anciennes données * Script pour reset la base de données et supprimer les anciennes données

View File

@@ -1,5 +1,5 @@
import { tasksService } from '../services/tasks'; import { tasksService } from '../src/services/task-management/tasks';
import { TaskStatus, TaskPriority } from '../lib/types'; import { TaskStatus, TaskPriority } from '../src/lib/types';
/** /**
* Script pour ajouter des données de test avec tags et variété * Script pour ajouter des données de test avec tags et variété

View File

@@ -1,4 +1,4 @@
import { tagsService } from '../services/tags'; import { tagsService } from '../src/services/task-management/tags';
async function seedTags() { async function seedTags() {
console.log('🏷️ Création des tags de test...'); console.log('🏷️ Création des tags de test...');

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,415 +0,0 @@
import { promises as fs } from 'fs';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { prisma } from './database';
import { userPreferencesService } from './user-preferences';
const execAsync = promisify(exec);
export interface BackupConfig {
enabled: boolean;
interval: 'hourly' | 'daily' | 'weekly';
maxBackups: number;
backupPath: string;
includeUploads?: boolean;
compression?: boolean;
}
export interface BackupInfo {
id: string;
filename: string;
size: number;
createdAt: Date;
type: 'manual' | 'automatic';
status: 'success' | 'failed' | 'in_progress';
error?: string;
}
export class BackupService {
private defaultConfig: BackupConfig = {
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: path.join(process.cwd(), 'backups'),
includeUploads: true,
compression: true,
};
private config: BackupConfig;
constructor(config?: Partial<BackupConfig>) {
this.config = { ...this.defaultConfig, ...config };
// Charger la config depuis la DB de manière asynchrone
this.loadConfigFromDB().catch(() => {
// Ignorer les erreurs de chargement initial
});
}
/**
* Charge la configuration depuis la base de données
*/
private async loadConfigFromDB(): Promise<void> {
try {
const preferences = await userPreferencesService.getAllPreferences();
if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') {
const backupConfig = (preferences.viewPreferences as Record<string, unknown>).backupConfig;
if (backupConfig) {
this.config = { ...this.defaultConfig, ...backupConfig };
}
}
} catch (error) {
console.warn('Could not load backup config from DB, using defaults:', error);
}
}
/**
* Sauvegarde la configuration dans la base de données
*/
private async saveConfigToDB(): Promise<void> {
try {
// Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences
// TODO: Ajouter un champ dédié dans le schéma pour la config backup
await prisma.userPreferences.upsert({
where: { id: 'default' },
update: {
viewPreferences: JSON.parse(JSON.stringify({
...(await userPreferencesService.getViewPreferences()),
backupConfig: this.config
}))
},
create: {
id: 'default',
kanbanFilters: {},
viewPreferences: JSON.parse(JSON.stringify({ backupConfig: this.config })),
columnVisibility: {},
jiraConfig: {}
}
});
} catch (error) {
console.error('Failed to save backup config to DB:', error);
}
}
/**
* Crée une sauvegarde complète de la base de données
*/
async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise<BackupInfo> {
const backupId = `backup_${Date.now()}`;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `towercontrol_${timestamp}.db`;
const backupPath = path.join(this.config.backupPath, filename);
console.log(`🔄 Starting ${type} backup: ${filename}`);
try {
// Créer le dossier de backup si nécessaire
await this.ensureBackupDirectory();
// Vérifier l'état de la base de données
await this.verifyDatabaseHealth();
// Créer la sauvegarde SQLite
await this.createSQLiteBackup(backupPath);
// Compresser si activé
let finalPath = backupPath;
if (this.config.compression) {
finalPath = await this.compressBackup(backupPath);
await fs.unlink(backupPath); // Supprimer le fichier non compressé
}
// Obtenir les stats du fichier
const stats = await fs.stat(finalPath);
const backupInfo: BackupInfo = {
id: backupId,
filename: path.basename(finalPath),
size: stats.size,
createdAt: new Date(),
type,
status: 'success',
};
// Nettoyer les anciennes sauvegardes
await this.cleanOldBackups();
console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`);
return backupInfo;
} catch (error) {
console.error(`❌ Backup failed:`, error);
return {
id: backupId,
filename,
size: 0,
createdAt: new Date(),
type,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Crée une sauvegarde SQLite en utilisant la commande .backup
*/
private async createSQLiteBackup(backupPath: string): Promise<void> {
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
try {
const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`;
await execAsync(command);
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
return;
} catch (cliError) {
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
}
// Méthode 2: Copie simple du fichier (fallback)
try {
await fs.copyFile(dbPath, backupPath);
console.log(`✅ SQLite backup created using file copy: ${backupPath}`);
} catch (copyError) {
throw new Error(`Failed to create SQLite backup: ${copyError}`);
}
}
/**
* Compresse une sauvegarde
*/
private async compressBackup(filePath: string): Promise<string> {
const compressedPath = `${filePath}.gz`;
try {
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
await execAsync(command);
console.log(`✅ Backup compressed: ${compressedPath}`);
return compressedPath;
} catch (error) {
console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error);
return filePath;
}
}
/**
* Restaure une sauvegarde
*/
async restoreBackup(filename: string): Promise<void> {
const backupPath = path.join(this.config.backupPath, filename);
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`);
console.log(`🔄 Starting restore from: ${filename}`);
try {
// Vérifier que le fichier de sauvegarde existe
await fs.access(backupPath);
// Décompresser si nécessaire
let sourceFile = backupPath;
if (filename.endsWith('.gz')) {
const tempFile = backupPath.replace('.gz', '');
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
try {
await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`);
console.log(`✅ Decompression successful`);
// Vérifier que le fichier décompressé existe
await fs.access(tempFile);
console.log(`✅ Decompressed file exists: ${tempFile}`);
sourceFile = tempFile;
} catch (decompError) {
console.error(`❌ Decompression failed:`, decompError);
throw decompError;
}
}
// Créer une sauvegarde de la base actuelle avant restauration
const currentBackup = await this.createBackup('manual');
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
// Fermer toutes les connexions
await prisma.$disconnect();
// Vérifier que le fichier source existe
await fs.access(sourceFile);
console.log(`✅ Source file verified: ${sourceFile}`);
// Remplacer la base de données
console.log(`🔄 Copying ${sourceFile} to ${dbPath}`);
await fs.copyFile(sourceFile, dbPath);
console.log(`✅ Database file copied successfully`);
// Nettoyer le fichier temporaire si décompressé
if (sourceFile !== backupPath) {
await fs.unlink(sourceFile);
}
// Reconnecter à la base
await prisma.$connect();
// Vérifier l'intégrité après restauration
await this.verifyDatabaseHealth();
console.log(`✅ Database restored from: ${filename}`);
} catch (error) {
console.error(`❌ Restore failed:`, error);
throw new Error(`Failed to restore backup: ${error}`);
}
}
/**
* Liste toutes les sauvegardes disponibles
*/
async listBackups(): Promise<BackupInfo[]> {
try {
await this.ensureBackupDirectory();
const files = await fs.readdir(this.config.backupPath);
const backups: BackupInfo[] = [];
for (const file of files) {
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
const filePath = path.join(this.config.backupPath, file);
const stats = await fs.stat(filePath);
// Extraire la date du nom de fichier
const dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
let createdAt = stats.birthtime;
if (dateMatch) {
// Convertir le format de fichier vers ISO string valide
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
const isoString = dateMatch[1]
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
createdAt = new Date(isoString);
}
backups.push({
id: file,
filename: file,
size: stats.size,
createdAt,
type: 'automatic', // On ne peut pas déterminer le type depuis le nom
status: 'success',
});
}
}
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
} catch (error) {
console.error('Error listing backups:', error);
return [];
}
}
/**
* Supprime une sauvegarde
*/
async deleteBackup(filename: string): Promise<void> {
const backupPath = path.join(this.config.backupPath, filename);
try {
await fs.unlink(backupPath);
console.log(`✅ Backup deleted: ${filename}`);
} catch (error) {
console.error(`❌ Failed to delete backup ${filename}:`, error);
throw error;
}
}
/**
* Vérifie l'intégrité de la base de données
*/
async verifyDatabaseHealth(): Promise<void> {
try {
// Test de connexion simple
await prisma.$queryRaw`SELECT 1`;
// Vérification de l'intégrité SQLite
const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`;
if (result.length > 0 && result[0].integrity_check !== 'ok') {
throw new Error(`Database integrity check failed: ${result[0].integrity_check}`);
}
console.log('✅ Database health check passed');
} catch (error) {
console.error('❌ Database health check failed:', error);
throw error;
}
}
/**
* Nettoie les anciennes sauvegardes selon la configuration
*/
private async cleanOldBackups(): Promise<void> {
try {
const backups = await this.listBackups();
if (backups.length > this.config.maxBackups) {
const toDelete = backups.slice(this.config.maxBackups);
for (const backup of toDelete) {
await this.deleteBackup(backup.filename);
}
console.log(`🧹 Cleaned ${toDelete.length} old backups`);
}
} catch (error) {
console.error('Error cleaning old backups:', error);
}
}
/**
* S'assure que le dossier de backup existe
*/
private async ensureBackupDirectory(): Promise<void> {
try {
await fs.access(this.config.backupPath);
} catch {
await fs.mkdir(this.config.backupPath, { recursive: true });
console.log(`📁 Created backup directory: ${this.config.backupPath}`);
}
}
/**
* Formate la taille de fichier
*/
private formatFileSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Met à jour la configuration
*/
async updateConfig(newConfig: Partial<BackupConfig>): Promise<void> {
this.config = { ...this.config, ...newConfig };
await this.saveConfigToDB();
}
/**
* Obtient la configuration actuelle
*/
getConfig(): BackupConfig {
return { ...this.config };
}
}
// Instance singleton
export const backupService = new BackupService();

View File

@@ -1,155 +0,0 @@
/**
* Service de cache pour les analytics Jira
* Cache en mémoire avec invalidation manuelle
*/
import { JiraAnalytics } from '@/lib/types';
interface CacheEntry {
data: JiraAnalytics;
timestamp: number;
projectKey: string;
configHash: string; // Hash de la config Jira pour détecter les changements
}
class JiraAnalyticsCacheService {
private cache = new Map<string, CacheEntry>();
private readonly CACHE_KEY_PREFIX = 'jira-analytics:';
/**
* Génère une clé de cache basée sur la config Jira
*/
private getCacheKey(projectKey: string, configHash: string): string {
return `${this.CACHE_KEY_PREFIX}${projectKey}:${configHash}`;
}
/**
* Génère un hash de la configuration Jira pour détecter les changements
*/
private generateConfigHash(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): string {
const configString = `${config.baseUrl}|${config.email}|${config.apiToken}|${config.projectKey}`;
// Simple hash (pour production, utiliser crypto.createHash)
let hash = 0;
for (let i = 0; i < configString.length; i++) {
const char = configString.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString();
}
/**
* Récupère les analytics depuis le cache si disponible
*/
get(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): JiraAnalytics | null {
const configHash = this.generateConfigHash(config);
const cacheKey = this.getCacheKey(config.projectKey, configHash);
const entry = this.cache.get(cacheKey);
if (!entry) {
console.log(`📋 Cache MISS pour projet ${config.projectKey}`);
return null;
}
// Vérifier que la config n'a pas changé
if (entry.configHash !== configHash) {
console.log(`🔄 Config changée pour projet ${config.projectKey}, invalidation du cache`);
this.cache.delete(cacheKey);
return null;
}
console.log(`✅ Cache HIT pour projet ${config.projectKey} (${this.getAgeDescription(entry.timestamp)})`);
return entry.data;
}
/**
* Stocke les analytics dans le cache
*/
set(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }, data: JiraAnalytics): void {
const configHash = this.generateConfigHash(config);
const cacheKey = this.getCacheKey(config.projectKey, configHash);
const entry: CacheEntry = {
data,
timestamp: Date.now(),
projectKey: config.projectKey,
configHash
};
this.cache.set(cacheKey, entry);
console.log(`💾 Analytics mises en cache pour projet ${config.projectKey}`);
}
/**
* Invalide le cache pour un projet spécifique
*/
invalidate(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): void {
const configHash = this.generateConfigHash(config);
const cacheKey = this.getCacheKey(config.projectKey, configHash);
const deleted = this.cache.delete(cacheKey);
if (deleted) {
console.log(`🗑️ Cache invalidé pour projet ${config.projectKey}`);
} else {
console.log(` Aucun cache à invalider pour projet ${config.projectKey}`);
}
}
/**
* Invalide tout le cache
*/
invalidateAll(): void {
const size = this.cache.size;
this.cache.clear();
console.log(`🗑️ Tout le cache analytics invalidé (${size} entrées supprimées)`);
}
/**
* Retourne les statistiques du cache
*/
getStats(): {
totalEntries: number;
projects: Array<{ projectKey: string; age: string; size: number }>;
} {
const projects = Array.from(this.cache.entries()).map(([, entry]) => ({
projectKey: entry.projectKey,
age: this.getAgeDescription(entry.timestamp),
size: JSON.stringify(entry.data).length
}));
return {
totalEntries: this.cache.size,
projects
};
}
/**
* Formate l'âge d'une entrée de cache
*/
private getAgeDescription(timestamp: number): string {
const ageMs = Date.now() - timestamp;
const ageMinutes = Math.floor(ageMs / (1000 * 60));
const ageHours = Math.floor(ageMinutes / 60);
if (ageHours > 0) {
return `il y a ${ageHours}h${ageMinutes % 60}m`;
} else if (ageMinutes > 0) {
return `il y a ${ageMinutes}m`;
} else {
return 'maintenant';
}
}
/**
* Vérifie si une entrée existe pour un projet
*/
has(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): boolean {
const configHash = this.generateConfigHash(config);
const cacheKey = this.getCacheKey(config.projectKey, configHash);
return this.cache.has(cacheKey);
}
}
// Instance singleton
export const jiraAnalyticsCache = new JiraAnalyticsCacheService();

View File

@@ -1,180 +0,0 @@
import type { JiraConfig } from './jira';
import { Task } from '@/lib/types';
export interface JiraWeeklyMetrics {
totalJiraTasks: number;
completedJiraTasks: number;
totalStoryPoints: number; // Estimation basée sur le type de ticket
projectsContributed: string[];
ticketTypes: { [type: string]: number };
jiraLinks: Array<{
key: string;
title: string;
status: string;
type: string;
url: string;
estimatedPoints: number;
}>;
}
export class JiraSummaryService {
/**
* Enrichit les tâches hebdomadaires avec des métriques Jira
*/
static async getJiraWeeklyMetrics(
weeklyTasks: Task[],
jiraConfig?: JiraConfig
): Promise<JiraWeeklyMetrics | null> {
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
return null;
}
const jiraTasks = weeklyTasks.filter(task =>
task.source === 'jira' && task.jiraKey && task.jiraProject
);
if (jiraTasks.length === 0) {
return {
totalJiraTasks: 0,
completedJiraTasks: 0,
totalStoryPoints: 0,
projectsContributed: [],
ticketTypes: {},
jiraLinks: []
};
}
// Calculer les métriques basiques
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
// Analyser les types de tickets
const ticketTypes: { [type: string]: number } = {};
jiraTasks.forEach(task => {
const type = task.jiraType || 'Unknown';
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
});
// Estimer les story points basés sur le type de ticket
const estimateStoryPoints = (type: string): number => {
const typeMapping: { [key: string]: number } = {
'Story': 3,
'Task': 2,
'Bug': 1,
'Epic': 8,
'Sub-task': 1,
'Improvement': 2,
'New Feature': 5,
'défaut': 1, // French
'amélioration': 2, // French
'nouvelle fonctionnalité': 5, // French
};
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
};
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
return sum + estimateStoryPoints(task.jiraType || '');
}, 0);
// Créer les liens Jira
const jiraLinks = jiraTasks.map(task => ({
key: task.jiraKey || '',
title: task.title,
status: task.status,
type: task.jiraType || 'Unknown',
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
estimatedPoints: estimateStoryPoints(task.jiraType || '')
}));
return {
totalJiraTasks: jiraTasks.length,
completedJiraTasks: completedJiraTasks.length,
totalStoryPoints,
projectsContributed,
ticketTypes,
jiraLinks
};
}
/**
* Récupère la configuration Jira depuis les préférences utilisateur
*/
static async getJiraConfig(): Promise<JiraConfig | null> {
try {
// Import dynamique pour éviter les cycles de dépendance
const { userPreferencesService } = await import('./user-preferences');
const preferences = await userPreferencesService.getAllPreferences();
if (!preferences.jiraConfig?.baseUrl ||
!preferences.jiraConfig?.email ||
!preferences.jiraConfig?.apiToken) {
return null;
}
return {
baseUrl: preferences.jiraConfig.baseUrl,
email: preferences.jiraConfig.email,
apiToken: preferences.jiraConfig.apiToken,
projectKey: preferences.jiraConfig.projectKey,
ignoredProjects: preferences.jiraConfig.ignoredProjects
};
} catch (error) {
console.error('Erreur lors de la récupération de la config Jira:', error);
return null;
}
}
/**
* Génère des insights business basés sur les métriques Jira
*/
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
const insights: string[] = [];
if (jiraMetrics.totalJiraTasks === 0) {
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
return insights;
}
// Insights sur la completion
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
if (completionRate >= 80) {
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
} else if (completionRate < 50) {
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
}
// Insights sur les story points
if (jiraMetrics.totalStoryPoints > 0) {
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
if (avgPointsPerTask > 4) {
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
}
}
// Insights sur les projets
if (jiraMetrics.projectsContributed.length > 1) {
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
} else if (jiraMetrics.projectsContributed.length === 1) {
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
}
// Insights sur les types de tickets
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
if (bugCount > 0) {
const bugRatio = (bugCount / totalTickets) * 100;
if (bugRatio > 50) {
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
} else if (bugRatio < 20) {
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
}
}
return insights;
}
}

View File

@@ -1,185 +0,0 @@
export interface PredefinedCategory {
name: string;
color: string;
keywords: string[];
icon: string;
}
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
{
name: 'Dev',
color: '#3b82f6', // Blue
icon: '💻',
keywords: [
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
'component', 'service', 'function', 'method', 'class',
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
'test', 'testing', 'unit test', 'integration'
]
},
{
name: 'Meeting',
color: '#8b5cf6', // Purple
icon: '🤝',
keywords: [
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
'one on one', '1on1', 'review meeting', 'sprint planning'
]
},
{
name: 'Admin',
color: '#6b7280', // Gray
icon: '📋',
keywords: [
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
'report', 'reporting', 'timesheet', 'expense', 'invoice',
'email', 'mail', 'communication', 'update', 'status',
'config', 'configuration', 'setup', 'installation', 'maintenance',
'backup', 'security', 'permission', 'user management'
]
},
{
name: 'Learning',
color: '#10b981', // Green
icon: '📚',
keywords: [
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
'research', 'reading', 'documentation', 'knowledge', 'skill',
'certification', 'workshop', 'seminar', 'conference',
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
]
}
];
export class TaskCategorizationService {
/**
* Suggère une catégorie basée sur le titre et la description d'une tâche
*/
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
const text = `${title} ${description || ''}`.toLowerCase();
// Compte les matches pour chaque catégorie
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
).length;
return {
category,
score: matches
};
});
// Trouve la meilleure catégorie
const bestMatch = categoryScores.reduce((best, current) =>
current.score > best.score ? current : best
);
// Retourne la catégorie seulement s'il y a au moins un match
return bestMatch.score > 0 ? bestMatch.category : null;
}
/**
* Suggère plusieurs catégories avec leur score de confiance
*/
static suggestCategoriesWithScore(title: string, description?: string): Array<{
category: PredefinedCategory;
score: number;
confidence: number;
}> {
const text = `${title} ${description || ''}`.toLowerCase();
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
const matches = category.keywords.filter(keyword =>
text.includes(keyword.toLowerCase())
);
const score = matches.length;
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
return {
category,
score,
confidence
};
});
return categoryScores
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
}
/**
* Analyse les activités et retourne la répartition par catégorie
*/
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
[categoryName: string]: {
count: number;
percentage: number;
color: string;
icon: string;
}
} {
const categoryCounts: { [key: string]: number } = {};
const uncategorized = { count: 0 };
// Initialiser les compteurs
PREDEFINED_CATEGORIES.forEach(cat => {
categoryCounts[cat.name] = 0;
});
// Analyser chaque activité
activities.forEach(activity => {
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
if (suggestedCategory) {
categoryCounts[suggestedCategory.name]++;
} else {
uncategorized.count++;
}
});
const total = activities.length;
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
// Ajouter les catégories prédéfinies
PREDEFINED_CATEGORIES.forEach(category => {
const count = categoryCounts[category.name];
result[category.name] = {
count,
percentage: total > 0 ? (count / total) * 100 : 0,
color: category.color,
icon: category.icon
};
});
// Ajouter "Autre" si nécessaire
if (uncategorized.count > 0) {
result['Autre'] = {
count: uncategorized.count,
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
color: '#d1d5db',
icon: '❓'
};
}
return result;
}
/**
* Retourne les tags suggérés pour une tâche
*/
static getSuggestedTags(title: string, description?: string): string[] {
const suggestions = this.suggestCategoriesWithScore(title, description);
return suggestions
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
.slice(0, 2) // Maximum 2 suggestions
.map(s => s.category.name);
}
}

View File

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

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

@@ -1,8 +1,9 @@
'use server'; 'use server';
import { dailyService } from '@/services/daily'; import { dailyService } from '@/services/task-management/daily';
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types'; import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
/** /**
* Toggle l'état d'une checkbox * Toggle l'état d'une checkbox
@@ -19,7 +20,7 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
// (le front-end gère déjà l'état optimiste) // (le front-end gère déjà l'état optimiste)
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle // Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
const today = new Date(); const today = getToday();
const dailyView = await dailyService.getDailyView(today); const dailyView = await dailyService.getDailyView(today);
let checkbox = dailyView.today.find(cb => cb.id === checkboxId); let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
@@ -47,34 +48,6 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
} }
} }
/**
* Ajoute une checkbox à une date donnée
*/
export async function addCheckboxToDaily(dailyId: string, content: string, taskId?: string): Promise<{
success: boolean;
data?: DailyCheckbox;
error?: string;
}> {
try {
// Le dailyId correspond à la date au format YYYY-MM-DD
const date = new Date(dailyId);
const newCheckbox = await dailyService.addCheckbox({
date,
text: content,
taskId
});
revalidatePath('/daily');
return { success: true, data: newCheckbox };
} catch (error) {
console.error('Erreur addCheckboxToDaily:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/** /**
* Ajoute une checkbox pour aujourd'hui * Ajoute une checkbox pour aujourd'hui
@@ -86,7 +59,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
}> { }> {
try { try {
const newCheckbox = await dailyService.addCheckbox({ const newCheckbox = await dailyService.addCheckbox({
date: new Date(), date: getToday(),
text: content, text: content,
type: type || 'task', type: type || 'task',
taskId taskId
@@ -112,8 +85,7 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
error?: string; error?: string;
}> { }> {
try { try {
const yesterday = new Date(); const yesterday = getPreviousWorkday(getToday());
yesterday.setDate(yesterday.getDate() - 1);
const newCheckbox = await dailyService.addCheckbox({ const newCheckbox = await dailyService.addCheckbox({
date: yesterday, date: yesterday,
@@ -133,29 +105,6 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
} }
} }
/**
* Met à jour le contenu d'une checkbox
*/
export async function updateCheckboxContent(checkboxId: string, content: string): Promise<{
success: boolean;
data?: DailyCheckbox;
error?: string;
}> {
try {
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, {
text: content
});
revalidatePath('/daily');
return { success: true, data: updatedCheckbox };
} catch (error) {
console.error('Erreur updateCheckboxContent:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/** /**
* Met à jour une checkbox complète * Met à jour une checkbox complète
@@ -209,8 +158,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
error?: string; error?: string;
}> { }> {
try { try {
const targetDate = date || new Date(); const targetDate = normalizeDate(date || getToday());
targetDate.setHours(0, 0, 0, 0);
const checkboxData: CreateDailyCheckboxData = { const checkboxData: CreateDailyCheckboxData = {
date: targetDate, date: targetDate,
@@ -243,7 +191,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
}> { }> {
try { try {
// Le dailyId correspond à la date au format YYYY-MM-DD // Le dailyId correspond à la date au format YYYY-MM-DD
const date = new Date(dailyId); const date = parseDate(dailyId);
await dailyService.reorderCheckboxes(date, checkboxIds); await dailyService.reorderCheckboxes(date, checkboxIds);
@@ -257,3 +205,25 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
}; };
} }
} }
/**
* Déplace une checkbox non cochée à aujourd'hui
*/
export async function moveCheckboxToToday(checkboxId: string): Promise<{
success: boolean;
data?: DailyCheckbox;
error?: string;
}> {
try {
const updatedCheckbox = await dailyService.moveCheckboxToToday(checkboxId);
revalidatePath('/daily');
return { success: true, data: updatedCheckbox };
} catch (error) {
console.error('Erreur moveCheckboxToToday:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}

View File

@@ -1,7 +1,7 @@
'use server'; 'use server';
import { JiraAnalyticsService } from '@/services/jira-analytics'; import { JiraAnalyticsService } from '@/services/integrations/jira/analytics';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { JiraAnalytics } from '@/lib/types'; import { JiraAnalytics } from '@/lib/types';
export type JiraAnalyticsResult = { export type JiraAnalyticsResult = {
@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
// Créer le service d'analytics // Créer le service d'analytics
const analyticsService = new JiraAnalyticsService({ const analyticsService = new JiraAnalyticsService({
enabled: jiraConfig.enabled,
baseUrl: jiraConfig.baseUrl, baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email, email: jiraConfig.email,
apiToken: jiraConfig.apiToken, apiToken: jiraConfig.apiToken,

View File

@@ -1,8 +1,8 @@
'use server'; 'use server';
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection'; import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics'; import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
export interface AnomalyDetectionResult { export interface AnomalyDetectionResult {
success: boolean; success: boolean;

View File

@@ -1,98 +0,0 @@
'use server';
import { jiraAnalyticsCache } from '@/services/jira-analytics-cache';
import { userPreferencesService } from '@/services/user-preferences';
export type CacheStatsResult = {
success: boolean;
data?: {
totalEntries: number;
projects: Array<{ projectKey: string; age: string; size: number }>;
};
error?: string;
};
export type CacheActionResult = {
success: boolean;
message?: string;
error?: string;
};
/**
* Server Action pour récupérer les statistiques du cache
*/
export async function getJiraCacheStats(): Promise<CacheStatsResult> {
try {
const stats = jiraAnalyticsCache.getStats();
return {
success: true,
data: stats
};
} catch (error) {
console.error('❌ Erreur lors de la récupération des stats du cache:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Server Action pour invalider le cache du projet configuré
*/
export async function invalidateJiraCache(): Promise<CacheActionResult> {
try {
// Récupérer la config Jira actuelle
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken || !jiraConfig.projectKey) {
return {
success: false,
error: 'Configuration Jira incomplète'
};
}
// Invalider le cache pour ce projet
jiraAnalyticsCache.invalidate({
baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email,
apiToken: jiraConfig.apiToken,
projectKey: jiraConfig.projectKey
});
return {
success: true,
message: `Cache invalidé pour le projet ${jiraConfig.projectKey}`
};
} catch (error) {
console.error('❌ Erreur lors de l\'invalidation du cache:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Server Action pour invalider tout le cache analytics
*/
export async function invalidateAllJiraCache(): Promise<CacheActionResult> {
try {
jiraAnalyticsCache.invalidateAll();
return {
success: true,
message: 'Tout le cache analytics a été invalidé'
};
} catch (error) {
console.error('❌ Erreur lors de l\'invalidation totale du cache:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}

View File

@@ -1,6 +1,7 @@
'use server'; 'use server';
import { getJiraAnalytics } from './jira-analytics'; import { getJiraAnalytics } from './jira-analytics';
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
export type ExportFormat = 'csv' | 'json'; export type ExportFormat = 'csv' | 'json';
@@ -103,7 +104,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
} }
const analytics = analyticsResult.data; const analytics = analyticsResult.data;
const timestamp = new Date().toISOString().slice(0, 16).replace(/:/g, '-'); const timestamp = getToday().toISOString().slice(0, 16).replace(/:/g, '-');
const projectKey = analytics.project.key; const projectKey = analytics.project.key;
if (format === 'json') { if (format === 'json') {
@@ -142,7 +143,7 @@ function generateCSV(analytics: JiraAnalytics): string {
// Header du rapport // Header du rapport
lines.push('# Rapport Analytics Jira'); lines.push('# Rapport Analytics Jira');
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`); lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
lines.push(`# Généré le: ${new Date().toLocaleString('fr-FR')}`); lines.push(`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`);
lines.push(`# Total tickets: ${analytics.project.totalIssues}`); lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
lines.push(''); lines.push('');

View File

@@ -1,8 +1,8 @@
'use server'; 'use server';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics'; import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters'; import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types'; import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
export interface FiltersResult { export interface FiltersResult {

View File

@@ -1,9 +1,10 @@
'use server'; 'use server';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics'; import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { SprintDetails } from '@/components/jira/SprintDetailModal'; import { SprintDetails } from '@/components/jira/SprintDetailModal';
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types'; import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
import { parseDate } from '@/lib/date-utils';
export interface SprintDetailsResult { export interface SprintDetailsResult {
success: boolean; success: boolean;
@@ -48,11 +49,11 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
// Filtrer les issues pour ce sprint spécifique // Filtrer les issues pour ce sprint spécifique
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint // Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
// Pour simplifier, on prend les issues dans la période du sprint // Pour simplifier, on prend les issues dans la période du sprint
const sprintStart = new Date(sprint.startDate); const sprintStart = parseDate(sprint.startDate);
const sprintEnd = new Date(sprint.endDate); const sprintEnd = parseDate(sprint.endDate);
const sprintIssues = allIssues.filter(issue => { const sprintIssues = allIssues.filter(issue => {
const issueDate = new Date(issue.created); const issueDate = parseDate(issue.created);
return issueDate >= sprintStart && issueDate <= sprintEnd; return issueDate >= sprintStart && issueDate <= sprintEnd;
}); });
@@ -116,8 +117,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
let averageCycleTime = 0; let averageCycleTime = 0;
if (completedIssuesWithDates.length > 0) { if (completedIssuesWithDates.length > 0) {
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => { const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
const created = new Date(issue.created); const created = parseDate(issue.created);
const updated = new Date(issue.updated); const updated = parseDate(issue.updated);
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
return total + cycleTime; return total + cycleTime;
}, 0); }, 0);
@@ -169,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
totalIssues: stats.total, totalIssues: stats.total,
completedIssues: stats.completed, completedIssues: stats.completed,
inProgressIssues: stats.inProgress, inProgressIssues: stats.inProgress,
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0 percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
count: stats.total // Ajout pour compatibilité
})).sort((a, b) => b.totalIssues - a.totalIssues); })).sort((a, b) => b.totalIssues - a.totalIssues);
} }

View File

@@ -1,7 +1,7 @@
'use server'; 'use server';
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics'; import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics';
import { revalidatePath } from 'next/cache'; import { getToday } from '@/lib/date-utils';
/** /**
* Récupère les métriques hebdomadaires pour une date donnée * Récupère les métriques hebdomadaires pour une date donnée
@@ -12,7 +12,7 @@ export async function getWeeklyMetrics(date?: Date): Promise<{
error?: string; error?: string;
}> { }> {
try { try {
const targetDate = date || new Date(); const targetDate = date || getToday();
const metrics = await MetricsService.getWeeklyMetrics(targetDate); const metrics = await MetricsService.getWeeklyMetrics(targetDate);
return { return {
@@ -59,20 +59,3 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
} }
} }
/**
* Rafraîchir les données de métriques (invalide le cache)
*/
export async function refreshMetrics(): Promise<{
success: boolean;
error?: string;
}> {
try {
revalidatePath('/manager');
return { success: true };
} catch {
return {
success: false,
error: 'Failed to refresh metrics'
};
}
}

View File

@@ -1,7 +1,8 @@
'use server'; 'use server';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types'; import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
import { Theme } from '@/lib/theme-config';
import { revalidatePath } from 'next/cache'; 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; success: boolean;
error?: string; error?: string;
}> { }> {

View File

@@ -0,0 +1,16 @@
'use server';
import { SystemInfoService } from '@/services/core/system-info';
export async function getSystemInfo() {
try {
const systemInfo = await SystemInfoService.getSystemInfo();
return { success: true, data: systemInfo };
} catch (error) {
console.error('Error getting system info:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get system info'
};
}
}

View File

@@ -1,6 +1,6 @@
'use server'; 'use server';
import { tagsService } from '@/services/tags'; import { tagsService } from '@/services/task-management/tags';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { Tag } from '@/lib/types'; import { Tag } from '@/lib/types';
@@ -86,16 +86,3 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
} }
} }
/**
* Action rapide pour créer un tag depuis un input
*/
export async function quickCreateTag(formData: FormData): Promise<ActionResult<Tag>> {
const name = formData.get('name') as string;
const color = formData.get('color') as string;
if (!name?.trim()) {
return { success: false, error: 'Tag name is required' };
}
return createTag(name.trim(), color || '#3B82F6');
}

View File

@@ -1,6 +1,6 @@
'use server' 'use server'
import { tasksService } from '@/services/tasks'; import { tasksService } from '@/services/task-management/tasks';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { TaskStatus, TaskPriority } from '@/lib/types'; import { TaskStatus, TaskPriority } from '@/lib/types';

154
src/actions/tfs.ts Normal file
View File

@@ -0,0 +1,154 @@
'use server';
import { userPreferencesService } from '@/services/core/user-preferences';
import { revalidatePath } from 'next/cache';
import { tfsService, TfsConfig } from '@/services/integrations/tfs';
/**
* Sauvegarde la configuration TFS
*/
export async function saveTfsConfig(config: TfsConfig) {
try {
await userPreferencesService.saveTfsConfig(config);
// Réinitialiser le service pour prendre en compte la nouvelle config
tfsService.reset();
revalidatePath('/settings/integrations');
return {
success: true,
message: 'Configuration TFS sauvegardée avec succès',
};
} catch (error) {
console.error('Erreur sauvegarde config TFS:', error);
return {
success: false,
error:
error instanceof Error ? error.message : 'Erreur lors de la sauvegarde',
};
}
}
/**
* Récupère la configuration TFS
*/
export async function getTfsConfig() {
try {
const config = await userPreferencesService.getTfsConfig();
return { success: true, data: config };
} catch (error) {
console.error('Erreur récupération config TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur lors de la récupération',
data: {
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
},
};
}
}
/**
* Sauvegarde les préférences du scheduler TFS
*/
export async function saveTfsSchedulerConfig(
tfsAutoSync: boolean,
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
) {
try {
await userPreferencesService.saveTfsSchedulerConfig(
tfsAutoSync,
tfsSyncInterval
);
revalidatePath('/settings/integrations');
return {
success: true,
message: 'Configuration scheduler TFS mise à jour',
};
} catch (error) {
console.error('Erreur sauvegarde scheduler TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur lors de la sauvegarde du scheduler',
};
}
}
/**
* Lance la synchronisation manuelle des Pull Requests TFS
*/
export async function syncTfsPullRequests() {
try {
// Lancer la synchronisation via le service singleton
const result = await tfsService.syncTasks();
if (result.success) {
revalidatePath('/');
revalidatePath('/settings/integrations');
return {
success: true,
message: `Synchronisation terminée: ${result.pullRequestsCreated} créées, ${result.pullRequestsUpdated} mises à jour, ${result.pullRequestsDeleted} supprimées`,
data: result,
};
} else {
return {
success: false,
error: result.errors.join(', ') || 'Erreur lors de la synchronisation',
};
}
} catch (error) {
console.error('Erreur sync TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur de connexion lors de la synchronisation',
};
}
}
/**
* Supprime toutes les tâches TFS de la base de données locale
*/
export async function deleteAllTfsTasks() {
try {
// Supprimer toutes les tâches TFS via le service singleton
const result = await tfsService.deleteAllTasks();
if (result.success) {
revalidatePath('/');
revalidatePath('/settings/integrations');
return {
success: true,
message: `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`,
data: { deletedCount: result.deletedCount },
};
} else {
return {
success: false,
error: result.error || 'Erreur lors de la suppression',
};
}
} catch (error) {
console.error('Erreur suppression TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur de connexion lors de la suppression',
};
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { backupService } from '@/services/data-management/backup';
interface RouteParams {
params: Promise<{
filename: string;
}>;
}
export async function DELETE(
request: NextRequest,
{ params }: RouteParams
) {
try {
const { filename } = await params;
// Vérification de sécurité - s'assurer que c'est bien un fichier de backup
if (!filename.startsWith('towercontrol_') ||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
return NextResponse.json(
{ success: false, error: 'Invalid backup filename' },
{ status: 400 }
);
}
await backupService.deleteBackup(filename);
return NextResponse.json({
success: true,
message: `Backup ${filename} deleted successfully`
});
} catch (error) {
console.error('Error deleting backup:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to delete backup'
},
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest,
{ params }: RouteParams
) {
try {
const { filename } = await params;
const body = await request.json();
const { action } = body;
if (action === 'restore') {
// Vérification de sécurité
if (!filename.startsWith('towercontrol_') ||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
return NextResponse.json(
{ success: false, error: 'Invalid backup filename' },
{ status: 400 }
);
}
// Protection environnement de production
if (process.env.NODE_ENV === 'production') {
return NextResponse.json(
{ success: false, error: 'Restore not allowed in production via API' },
{ status: 403 }
);
}
await backupService.restoreBackup(filename);
return NextResponse.json({
success: true,
message: `Database restored from ${filename}`
});
}
return NextResponse.json(
{ success: false, error: 'Invalid action' },
{ status: 400 }
);
} catch (error) {
console.error('Error in backup operation:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Operation failed'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server';
import { backupService } from '@/services/data-management/backup';
import { backupScheduler } from '@/services/data-management/backup-scheduler';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
if (action === 'logs') {
const maxLines = parseInt(searchParams.get('maxLines') || '100');
const logs = await backupService.getBackupLogs(maxLines);
return NextResponse.json({
success: true,
data: { logs }
});
}
if (action === 'stats') {
const days = parseInt(searchParams.get('days') || '30');
const stats = await backupService.getBackupStats(days);
return NextResponse.json({
success: true,
data: stats
});
}
console.log('🔄 API GET /api/backups called');
// Test de la configuration d'abord
const config = backupService.getConfig();
console.log('✅ Config loaded:', config);
// Test du scheduler
const schedulerStatus = backupScheduler.getStatus();
console.log('✅ Scheduler status:', schedulerStatus);
// Test de la liste des backups
const backups = await backupService.listBackups();
console.log('✅ Backups loaded:', backups.length);
const response = {
success: true,
data: {
backups,
scheduler: schedulerStatus,
config,
}
};
console.log('✅ API response ready');
return NextResponse.json(response);
} catch (error) {
console.error('❌ Error fetching backups:', error);
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown');
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch backups',
details: error instanceof Error ? error.stack : undefined
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action, ...params } = body;
switch (action) {
case 'create':
const forceCreate = params.force === true;
const backup = await backupService.createBackup('manual', forceCreate);
if (backup === null) {
return NextResponse.json({
success: true,
skipped: true,
message: 'No changes detected since last backup. Use force=true to create anyway.'
});
}
return NextResponse.json({ success: true, data: backup });
case 'verify':
await backupService.verifyDatabaseHealth();
return NextResponse.json({
success: true,
message: 'Database health check passed'
});
case 'config':
await backupService.updateConfig(params.config);
// Redémarrer le scheduler si la config a changé
if (params.config.enabled !== undefined || params.config.interval !== undefined) {
backupScheduler.restart();
}
return NextResponse.json({
success: true,
message: 'Configuration updated',
data: backupService.getConfig()
});
case 'scheduler':
if (params.enabled) {
backupScheduler.start();
} else {
backupScheduler.stop();
}
return NextResponse.json({
success: true,
data: backupScheduler.getStatus()
});
default:
return NextResponse.json(
{ success: false, error: 'Invalid action' },
{ status: 400 }
);
}
} catch (error) {
console.error('Error in backup operation:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { dailyService } from '@/services/task-management/daily';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: checkboxId } = await params;
if (!checkboxId) {
return NextResponse.json(
{ error: 'Checkbox ID is required' },
{ status: 400 }
);
}
const archivedCheckbox = await dailyService.archiveCheckbox(checkboxId);
return NextResponse.json(archivedCheckbox);
} catch (error) {
console.error('Error archiving checkbox:', error);
return NextResponse.json(
{ error: 'Failed to archive checkbox' },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { dailyService } from '@/services/daily'; import { dailyService } from '@/services/task-management/daily';
/** /**
* API route pour récupérer toutes les dates avec des dailies * API route pour récupérer toutes les dates avec des dailies

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { dailyService } from '@/services/task-management/daily';
import { DailyCheckboxType } from '@/lib/types';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined;
const excludeToday = searchParams.get('excludeToday') === 'true';
const type = searchParams.get('type') as DailyCheckboxType | undefined;
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
maxDays,
excludeToday,
type,
limit
});
return NextResponse.json(pendingCheckboxes);
} catch (error) {
console.error('Error fetching pending checkboxes:', error);
return NextResponse.json(
{ error: 'Failed to fetch pending checkboxes' },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { dailyService } from '@/services/daily'; import { dailyService } from '@/services/task-management/daily';
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
/** /**
* API route pour récupérer la vue daily (hier + aujourd'hui) * API route pour récupérer la vue daily (hier + aujourd'hui)
@@ -32,13 +33,18 @@ export async function GET(request: Request) {
} }
// Vue daily pour une date donnée (ou aujourd'hui par défaut) // Vue daily pour une date donnée (ou aujourd'hui par défaut)
const targetDate = date ? new Date(date) : new Date(); let targetDate: Date;
if (date && isNaN(targetDate.getTime())) { if (date) {
return NextResponse.json( if (!isValidAPIDate(date)) {
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' }, return NextResponse.json(
{ status: 400 } { error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
); { status: 400 }
);
}
targetDate = parseDate(date);
} else {
targetDate = getToday();
} }
const dailyView = await dailyService.getDailyView(targetDate); const dailyView = await dailyService.getDailyView(targetDate);
@@ -73,9 +79,9 @@ export async function POST(request: Request) {
if (typeof body.date === 'string') { if (typeof body.date === 'string') {
// Si c'est une string YYYY-MM-DD, créer une date locale // Si c'est une string YYYY-MM-DD, créer une date locale
const [year, month, day] = body.date.split('-').map(Number); const [year, month, day] = body.date.split('-').map(Number);
date = new Date(year, month - 1, day); // month est 0-indexé date = createDateFromParts(year, month, day);
} else { } else {
date = new Date(body.date); date = parseDate(body.date);
} }
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/services/database'; import { prisma } from '@/services/core/database';
/** /**
* Route GET /api/jira/logs * Route GET /api/jira/logs

View File

@@ -1,14 +1,55 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createJiraService, JiraService } from '@/services/jira'; import { createJiraService, JiraService } from '@/services/integrations/jira/jira';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { jiraScheduler } from '@/services/integrations/jira/scheduler';
/** /**
* Route POST /api/jira/sync * Route POST /api/jira/sync
* Synchronise les tickets Jira avec la base locale * Synchronise les tickets Jira avec la base locale
* Supporte aussi les actions du scheduler
*/ */
export async function POST() { export async function POST(request: Request) {
try { try {
// Essayer d'abord la config depuis la base de données // Vérifier s'il y a des actions spécifiques (scheduler)
const body = await request.json().catch(() => ({}));
const { action, ...params } = body;
// Actions du scheduler
if (action) {
switch (action) {
case 'scheduler':
if (params.enabled) {
await jiraScheduler.start();
} else {
jiraScheduler.stop();
}
return NextResponse.json({
success: true,
data: await jiraScheduler.getStatus()
});
case 'config':
await userPreferencesService.saveJiraSchedulerConfig(
params.jiraAutoSync,
params.jiraSyncInterval
);
// Redémarrer le scheduler si la config a changé
await jiraScheduler.restart();
return NextResponse.json({
success: true,
message: 'Configuration scheduler mise à jour',
data: await jiraScheduler.getStatus()
});
default:
return NextResponse.json(
{ success: false, error: 'Action inconnue' },
{ status: 400 }
);
}
}
// Synchronisation normale (manuelle)
const jiraConfig = await userPreferencesService.getJiraConfig(); const jiraConfig = await userPreferencesService.getJiraConfig();
let jiraService: JiraService | null = null; let jiraService: JiraService | null = null;
@@ -16,6 +57,7 @@ export async function POST() {
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) { if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
// Utiliser la config depuis la base de données // Utiliser la config depuis la base de données
jiraService = new JiraService({ jiraService = new JiraService({
enabled: jiraConfig.enabled,
baseUrl: jiraConfig.baseUrl, baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email, email: jiraConfig.email,
apiToken: jiraConfig.apiToken, apiToken: jiraConfig.apiToken,
@@ -34,7 +76,7 @@ export async function POST() {
); );
} }
console.log('🔄 Début de la synchronisation Jira...'); console.log('🔄 Début de la synchronisation Jira manuelle...');
// Tester la connexion d'abord // Tester la connexion d'abord
const connectionOk = await jiraService.testConnection(); const connectionOk = await jiraService.testConnection();
@@ -90,6 +132,7 @@ export async function GET() {
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) { if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
// Utiliser la config depuis la base de données // Utiliser la config depuis la base de données
jiraService = new JiraService({ jiraService = new JiraService({
enabled: jiraConfig.enabled,
baseUrl: jiraConfig.baseUrl, baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email, email: jiraConfig.email,
apiToken: jiraConfig.apiToken, apiToken: jiraConfig.apiToken,
@@ -118,6 +161,9 @@ export async function GET() {
projectValidation = await jiraService.validateProject(jiraConfig.projectKey); projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
} }
// Récupérer aussi le statut du scheduler
const schedulerStatus = await jiraScheduler.getStatus();
return NextResponse.json({ return NextResponse.json({
connected, connected,
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira', message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
@@ -126,7 +172,8 @@ export async function GET() {
exists: projectValidation.exists, exists: projectValidation.exists,
name: projectValidation.name, name: projectValidation.name,
error: projectValidation.error error: projectValidation.error
} : null } : null,
scheduler: schedulerStatus
}); });
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createJiraService } from '@/services/jira'; import { createJiraService } from '@/services/integrations/jira/jira';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
/** /**
* POST /api/jira/validate-project * POST /api/jira/validate-project

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { tagsService } from '@/services/tags'; import { tagsService } from '@/services/task-management/tags';
/** /**
* GET /api/tags/[id] - Récupère un tag par son ID * GET /api/tags/[id] - Récupère un tag par son ID

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { tagsService } from '@/services/tags'; import { tagsService } from '@/services/task-management/tags';
/** /**
* GET /api/tags - Récupère tous les tags ou recherche par query * GET /api/tags - Récupère tous les tags ou recherche par query

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { tasksService } from '@/services/tasks'; import { tasksService } from '@/services/task-management/tasks';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { tasksService } from '@/services/tasks'; import { tasksService } from '@/services/task-management/tasks';
import { TaskStatus } from '@/lib/types'; import { TaskStatus } from '@/lib/types';
/** /**

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { tfsService } from '@/services/integrations/tfs';
/**
* Supprime toutes les tâches TFS de la base de données locale
*/
export async function DELETE() {
try {
console.log('🔄 Début de la suppression des tâches TFS...');
// Supprimer via le service singleton
const result = await tfsService.deleteAllTasks();
if (result.success) {
return NextResponse.json({
success: true,
message: result.deletedCount > 0
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
: 'Aucune tâche TFS trouvée à supprimer',
data: {
deletedCount: result.deletedCount
}
});
} else {
return NextResponse.json({
success: false,
error: result.error || 'Erreur lors de la suppression',
}, { status: 500 });
}
} catch (error) {
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
return NextResponse.json({
success: false,
error: 'Erreur lors de la suppression des tâches TFS',
details: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}

View File

@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';
import { tfsService } from '@/services/integrations/tfs';
/**
* Route POST /api/tfs/sync
* Synchronise les Pull Requests TFS/Azure DevOps avec la base locale
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function POST(_request: Request) {
try {
console.log('🔄 Début de la synchronisation TFS manuelle...');
// Effectuer la synchronisation via le service singleton
const result = await tfsService.syncTasks();
if (result.success) {
return NextResponse.json({
message: 'Synchronisation TFS terminée avec succès',
data: result,
});
} else {
return NextResponse.json(
{
error: 'Synchronisation TFS terminée avec des erreurs',
data: result,
},
{ status: 207 } // Multi-Status
);
}
} catch (error) {
console.error('❌ Erreur API sync TFS:', error);
return NextResponse.json(
{
error: 'Erreur interne lors de la synchronisation',
details: error instanceof Error ? error.message : 'Erreur inconnue',
},
{ status: 500 }
);
}
}
/**
* Route GET /api/tfs/sync
* Teste la connexion TFS
*/
export async function GET() {
try {
// Tester la connexion via le service singleton
const isConnected = await tfsService.testConnection();
if (isConnected) {
return NextResponse.json({
message: 'Connexion TFS OK',
connected: true,
});
} else {
return NextResponse.json(
{
error: 'Connexion TFS échouée',
connected: false,
},
{ status: 401 }
);
}
} catch (error) {
console.error('❌ Erreur test connexion TFS:', error);
return NextResponse.json(
{
error: 'Erreur interne',
details: error instanceof Error ? error.message : 'Erreur inconnue',
connected: false,
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server';
import { tfsService } from '@/services/integrations/tfs';
/**
* Route GET /api/tfs/test
* Teste uniquement la connexion TFS/Azure DevOps sans effectuer de synchronisation
*/
export async function GET() {
try {
console.log('🔄 Test de connexion TFS...');
// Valider la configuration via le service singleton
const configValidation = await tfsService.validateConfig();
if (!configValidation.valid) {
return NextResponse.json(
{
error: 'Configuration TFS invalide',
connected: false,
details: configValidation.error,
},
{ status: 400 }
);
}
// Tester la connexion
const isConnected = await tfsService.testConnection();
if (isConnected) {
// Test approfondi : récupérer des métadonnées
try {
const repositories = await tfsService.getMetadata();
return NextResponse.json({
message: 'Connexion Azure DevOps réussie',
connected: true,
details: {
repositoriesCount: repositories.repositories.length,
},
});
} catch (repoError) {
return NextResponse.json(
{
error: 'Connexion OK mais accès aux repositories limité',
connected: false,
details: `Vérifiez les permissions du token PAT: ${repoError instanceof Error ? repoError.message : 'Erreur inconnue'}`,
},
{ status: 403 }
);
}
} else {
return NextResponse.json(
{
error: 'Connexion Azure DevOps échouée',
connected: false,
details: "Vérifiez l'URL d'organisation et le token PAT",
},
{ status: 401 }
);
}
} catch (error) {
console.error('❌ Erreur test connexion TFS:', error);
return NextResponse.json(
{
error: 'Erreur interne',
connected: false,
details: error instanceof Error ? error.message : 'Erreur inconnue',
},
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { JiraConfig } from '@/lib/types'; import { JiraConfig } from '@/lib/types';
/** /**

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
/** /**
* GET /api/user-preferences - Récupère toutes les préférences utilisateur * GET /api/user-preferences - Récupère toutes les préférences utilisateur

View File

@@ -3,24 +3,32 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import React from 'react'; import React from 'react';
import { useDaily } from '@/hooks/useDaily'; import { useDaily } from '@/hooks/useDaily';
import { DailyView, DailyCheckboxType } from '@/lib/types'; import { DailyView, DailyCheckboxType, DailyCheckbox } from '@/lib/types';
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } 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 { DailySection } from '@/components/daily/DailySection';
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
import { dailyClient } from '@/clients/daily-client'; import { dailyClient } from '@/clients/daily-client';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
interface DailyPageClientProps { interface DailyPageClientProps {
initialDailyView?: DailyView; initialDailyView?: DailyView;
initialDailyDates?: string[]; initialDailyDates?: string[];
initialDate?: Date; initialDate?: Date;
initialDeadlineMetrics?: DeadlineMetrics | null;
initialPendingTasks?: DailyCheckbox[];
} }
export function DailyPageClient({ export function DailyPageClient({
initialDailyView, initialDailyView,
initialDailyDates = [], initialDailyDates = [],
initialDate initialDate,
initialDeadlineMetrics,
initialPendingTasks = []
}: DailyPageClientProps = {}) { }: DailyPageClientProps = {}) {
const { const {
dailyView, dailyView,
@@ -40,7 +48,8 @@ export function DailyPageClient({
goToPreviousDay, goToPreviousDay,
goToNextDay, goToNextDay,
goToToday, goToToday,
setDate setDate,
refreshDailySilent
} = useDaily(initialDate, initialDailyView); } = useDaily(initialDate, initialDailyView);
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates); const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
@@ -98,10 +107,9 @@ export function DailyPageClient({
await reorderCheckboxes({ date, checkboxIds }); await reorderCheckboxes({ date, checkboxIds });
}; };
const getYesterdayDate = () => { const getYesterdayDate = () => {
const yesterday = new Date(currentDate); return getPreviousWorkday(currentDate);
yesterday.setDate(yesterday.getDate() - 1);
return yesterday;
}; };
const getTodayDate = () => { const getTodayDate = () => {
@@ -113,19 +121,59 @@ export function DailyPageClient({
}; };
const formatCurrentDate = () => { const formatCurrentDate = () => {
return currentDate.toLocaleDateString('fr-FR', { return formatDateLong(currentDate);
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}; };
const isToday = () => { const isTodayDate = () => {
const today = new Date(); return isToday(currentDate);
return currentDate.toDateString() === today.toDateString();
}; };
const getTodayTitle = () => {
return generateDateTitle(currentDate, '🎯');
};
const getYesterdayTitle = () => {
const yesterdayDate = getYesterdayDate();
if (isYesterday(yesterdayDate)) {
return "📋 Hier";
}
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) { if (loading) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -179,7 +227,7 @@ export function DailyPageClient({
<div className="text-sm font-bold text-[var(--foreground)] font-mono"> <div className="text-sm font-bold text-[var(--foreground)] font-mono">
{formatCurrentDate()} {formatCurrentDate()}
</div> </div>
{!isToday() && ( {!isTodayDate() && (
<button <button
onClick={goToToday} onClick={goToToday}
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono" className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
@@ -201,54 +249,113 @@ export function DailyPageClient({
</div> </div>
</div> </div>
{/* Rappel des échéances urgentes - Desktop uniquement */}
<div className="hidden sm:block container mx-auto px-4 pt-4 pb-2">
<AlertBanner
title="Rappel - Tâches urgentes"
items={convertDeadlineMetricsToAlertItems(initialDeadlineMetrics || null)}
icon="⚠️"
variant="warning"
onItemClick={(item) => {
// Rediriger vers la page Kanban avec la tâche sélectionnée
window.location.href = `/kanban?taskId=${item.id}`;
}}
/>
</div>
{/* Contenu principal */} {/* Contenu principal */}
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-6 sm:py-4">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> {/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
{/* Calendrier - toujours visible */} <div className="block sm:hidden">
<div className="xl:col-span-1">
<DailyCalendar
currentDate={currentDate}
onDateSelect={handleDateSelect}
dailyDates={dailyDates}
/>
</div>
{/* Sections daily */}
{dailyView && ( {dailyView && (
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="space-y-6">
{/* Section Hier */} {/* Section Aujourd'hui - Mobile First */}
<DailySection <DailySection
title="📋 Hier" title={getTodayTitle()}
date={getYesterdayDate()} date={getTodayDate()}
checkboxes={dailyView.yesterday} checkboxes={dailyView.today}
onAddCheckbox={handleAddYesterdayCheckbox} onAddCheckbox={handleAddTodayCheckbox}
onToggleCheckbox={handleToggleCheckbox} onToggleCheckbox={handleToggleCheckbox}
onUpdateCheckbox={handleUpdateCheckbox} onUpdateCheckbox={handleUpdateCheckbox}
onDeleteCheckbox={handleDeleteCheckbox} onDeleteCheckbox={handleDeleteCheckbox}
onReorderCheckboxes={handleReorderCheckboxes} onReorderCheckboxes={handleReorderCheckboxes}
onToggleAll={toggleAllYesterday} onToggleAll={toggleAllToday}
saving={saving} saving={saving}
refreshing={refreshing} refreshing={refreshing}
/> />
{/* Section Aujourd'hui */} {/* Calendrier en bas sur mobile */}
<DailySection <Calendar
title="🎯 Aujourd'hui" currentDate={currentDate}
date={getTodayDate()} onDateSelect={handleDateSelect}
checkboxes={dailyView.today} markedDates={dailyDates}
onAddCheckbox={handleAddTodayCheckbox} showTodayButton={true}
onToggleCheckbox={handleToggleCheckbox} showLegend={true}
onUpdateCheckbox={handleUpdateCheckbox} />
onDeleteCheckbox={handleDeleteCheckbox}
onReorderCheckboxes={handleReorderCheckboxes}
onToggleAll={toggleAllToday}
saving={saving}
refreshing={refreshing}
/>
</div> </div>
)} )}
</div> </div>
{/* Layout Tablette/Desktop - Layout original */}
<div className="hidden sm:block">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Calendrier - Desktop */}
<div className="xl:col-span-1">
<Calendar
currentDate={currentDate}
onDateSelect={handleDateSelect}
markedDates={dailyDates}
showTodayButton={true}
showLegend={true}
/>
</div>
{/* Sections daily - Desktop */}
{dailyView && (
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Section Hier - Desktop seulement */}
<DailySection
title={getYesterdayTitle()}
date={getYesterdayDate()}
checkboxes={dailyView.yesterday}
onAddCheckbox={handleAddYesterdayCheckbox}
onToggleCheckbox={handleToggleCheckbox}
onUpdateCheckbox={handleUpdateCheckbox}
onDeleteCheckbox={handleDeleteCheckbox}
onReorderCheckboxes={handleReorderCheckboxes}
onToggleAll={toggleAllYesterday}
saving={saving}
refreshing={refreshing}
/>
{/* Section Aujourd'hui - Desktop */}
<DailySection
title={getTodayTitle()}
date={getTodayDate()}
checkboxes={dailyView.today}
onAddCheckbox={handleAddTodayCheckbox}
onToggleCheckbox={handleToggleCheckbox}
onUpdateCheckbox={handleUpdateCheckbox}
onDeleteCheckbox={handleDeleteCheckbox}
onReorderCheckboxes={handleReorderCheckboxes}
onToggleAll={toggleAllToday}
saving={saving}
refreshing={refreshing}
/>
</div>
)}
</div>
</div>
{/* Section des tâches en attente */}
<PendingTasksSection
onToggleCheckbox={handleToggleCheckbox}
onDeleteCheckbox={handleDeleteCheckbox}
onRefreshDaily={refreshDailySilent}
refreshTrigger={0}
initialPendingTasks={initialPendingTasks}
/>
{/* Footer avec stats - dans le flux normal */} {/* Footer avec stats - dans le flux normal */}
{dailyView && ( {dailyView && (
<Card className="mt-8 p-4"> <Card className="mt-8 p-4">

View File

@@ -1,6 +1,8 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { DailyPageClient } from './DailyPageClient'; import { DailyPageClient } from './DailyPageClient';
import { dailyService } from '@/services/daily'; 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) // Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -12,12 +14,18 @@ export const metadata: Metadata = {
export default async function DailyPage() { export default async function DailyPage() {
// Récupérer les données côté serveur // Récupérer les données côté serveur
const today = new Date(); const today = getToday();
try { try {
const [dailyView, dailyDates] = await Promise.all([ const [dailyView, dailyDates, deadlineMetrics, pendingTasks] = await Promise.all([
dailyService.getDailyView(today), 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 ( return (
@@ -25,6 +33,8 @@ export default async function DailyPage() {
initialDailyView={dailyView} initialDailyView={dailyView}
initialDailyDates={dailyDates} initialDailyDates={dailyDates}
initialDate={today} initialDate={today}
initialDeadlineMetrics={deadlineMetrics}
initialPendingTasks={pendingTasks}
/> />
); );
} catch (error) { } catch (error) {

View File

@@ -1,25 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
/* Dark theme (default) */ /* Valeurs par défaut (Light theme) */
--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 */
--background: #f1f5f9; /* slate-100 */ --background: #f1f5f9; /* slate-100 */
--foreground: #0f172a; /* slate-900 */ --foreground: #0f172a; /* slate-900 */
--card: #ffffff; /* white */ --card: #ffffff; /* white */
@@ -34,6 +16,404 @@
--accent: #d97706; /* amber-600 */ --accent: #d97706; /* amber-600 */
--destructive: #dc2626; /* red-600 */ --destructive: #dc2626; /* red-600 */
--success: #059669; /* emerald-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 { @theme inline {
@@ -69,10 +449,96 @@ body {
background: var(--primary); 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 */ /* Animations tech */
@keyframes glow { @keyframes glow {
0%, 100% { box-shadow: 0 0 5px rgba(6, 182, 212, 0.3); } 0%, 100% { box-shadow: 0 0 5px var(--primary); }
50% { box-shadow: 0 0 20px rgba(6, 182, 212, 0.6); } 50% { box-shadow: 0 0 20px var(--primary); }
} }
.animate-glow { .animate-glow {

View File

@@ -1,13 +1,17 @@
'use client'; 'use client';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { JiraConfig } from '@/lib/types'; import { JiraConfig, JiraAnalytics } from '@/lib/types';
import { useJiraAnalytics } from '@/hooks/useJiraAnalytics'; import { useJiraAnalytics } from '@/hooks/useJiraAnalytics';
import { useJiraExport } from '@/hooks/useJiraExport'; import { useJiraExport } from '@/hooks/useJiraExport';
import { filterAnalyticsByPeriod, getPeriodInfo, type PeriodFilter } from '@/lib/jira-period-filter'; import { filterAnalyticsByPeriod, getPeriodInfo, type PeriodFilter } from '@/lib/jira-period-filter';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; 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 { VelocityChart } from '@/components/jira/VelocityChart';
import { TeamDistributionChart } from '@/components/jira/TeamDistributionChart'; import { TeamDistributionChart } from '@/components/jira/TeamDistributionChart';
import { CycleTimeChart } from '@/components/jira/CycleTimeChart'; import { CycleTimeChart } from '@/components/jira/CycleTimeChart';
@@ -28,10 +32,11 @@ import Link from 'next/link';
interface JiraDashboardPageClientProps { interface JiraDashboardPageClientProps {
initialJiraConfig: JiraConfig; initialJiraConfig: JiraConfig;
initialAnalytics?: JiraAnalytics | null;
} }
export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) { export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }: JiraDashboardPageClientProps) {
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(); const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(initialAnalytics);
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport(); const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport();
const { const {
availableFilters, availableFilters,
@@ -39,7 +44,7 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
filteredAnalytics, filteredAnalytics,
applyFilters, applyFilters,
hasActiveFilters hasActiveFilters
} = useJiraFilters(); } = useJiraFilters(rawAnalytics);
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current'); const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null); const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null);
const [showSprintModal, setShowSprintModal] = useState(false); 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 // Filtrer les analytics selon la période sélectionnée et les filtres avancés
const analytics = useMemo(() => { 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; const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
if (!baseAnalytics) return null; if (!baseAnalytics) return null;
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod); return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
@@ -56,11 +64,11 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
const periodInfo = getPeriodInfo(selectedPeriod); const periodInfo = getPeriodInfo(selectedPeriod);
useEffect(() => { useEffect(() => {
// Charger les analytics au montage si Jira est configuré avec un projet // 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) { if (initialJiraConfig.enabled && initialJiraConfig.projectKey && !initialAnalytics) {
loadAnalytics(); loadAnalytics();
} }
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics]); }, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics, initialAnalytics]);
// Gestion du clic sur un sprint // Gestion du clic sur un sprint
const handleSprintClick = (sprint: SprintVelocity) => { const handleSprintClick = (sprint: SprintVelocity) => {
@@ -192,26 +200,16 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Sélecteur de période */} {/* 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: '7d', label: '7j' },
{ value: '30d', label: '30j' }, { value: '30d', label: '30j' },
{ value: '3m', label: '3m' }, { value: '3m', label: '3m' },
{ value: 'current', label: 'Sprint' } { value: 'current', label: 'Sprint' }
].map((period: { value: string; label: string }) => ( ]}
<button selectedValue={selectedPeriod}
key={period.value} onValueChange={(value) => setSelectedPeriod(value as PeriodFilter)}
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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{analytics && ( {analytics && (
@@ -255,40 +253,27 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
{/* Contenu principal */} {/* Contenu principal */}
{error && ( {error && (
<Card className="mb-6 border-red-500/20 bg-red-500/10"> <AlertBanner
<CardContent className="p-4"> title="Erreur"
<div className="flex items-center gap-2 text-red-600 dark:text-red-400"> items={[{ id: 'error', title: error }]}
<span></span> icon="❌"
<span>{error}</span> variant="error"
</div> className="mb-6"
</CardContent> />
</Card>
)} )}
{exportError && ( {exportError && (
<Card className="mb-6 border-orange-500/20 bg-orange-500/10"> <AlertBanner
<CardContent className="p-4"> title="Erreur d'export"
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400"> items={[{ id: 'export-error', title: exportError }]}
<span></span> icon="⚠️"
<span>Erreur d&apos;export: {exportError}</span> variant="warning"
</div> className="mb-6"
</CardContent> />
</Card>
)} )}
{isLoading && !analytics && ( {isLoading && !analytics && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <SkeletonGrid count={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>
)} )}
{analytics && ( {analytics && (
@@ -302,41 +287,36 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<span className="text-sm font-normal text-[var(--muted-foreground)]"> <span className="text-sm font-normal text-[var(--muted-foreground)]">
({periodInfo.label}) ({periodInfo.label})
</span> </span>
{hasActiveFilters && (
<Badge className="bg-purple-100 text-purple-800 text-xs">
🔍 Filtré
</Badge>
)}
</h2> </h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <MetricsGrid
<div className="text-center"> metrics={[
<div className="text-xl font-bold text-[var(--primary)]"> {
{analytics.project.totalIssues} title: 'Tickets',
</div> value: analytics.project.totalIssues,
<div className="text-xs text-[var(--muted-foreground)]"> color: 'primary'
Tickets },
</div> {
</div> title: 'Équipe',
<div className="text-center"> value: analytics.teamMetrics.totalAssignees,
<div className="text-xl font-bold text-blue-500"> color: 'default'
{analytics.teamMetrics.totalAssignees} },
</div> {
<div className="text-xs text-[var(--muted-foreground)]"> title: 'Actifs',
Équipe value: analytics.teamMetrics.activeAssignees,
</div> color: 'success'
</div> },
<div className="text-center"> {
<div className="text-xl font-bold text-green-500"> title: 'Points',
{analytics.teamMetrics.activeAssignees} value: analytics.velocityMetrics.currentSprintPoints,
</div> color: 'warning'
<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>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -346,34 +326,23 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
availableFilters={availableFilters} availableFilters={availableFilters}
activeFilters={activeFilters} activeFilters={activeFilters}
onFiltersChange={applyFilters} onFiltersChange={applyFilters}
isLoading={false}
/> />
{/* Détection d'anomalies */} {/* Détection d'anomalies */}
<AnomalyDetectionPanel /> <AnomalyDetectionPanel />
{/* Onglets de navigation */} {/* Onglets de navigation */}
<div className="border-b border-[var(--border)]"> <Tabs
<nav className="flex space-x-8"> items={[
{[ { id: 'overview', label: '📊 Vue d\'ensemble' },
{ id: 'overview', label: '📊 Vue d\'ensemble' }, { id: 'velocity', label: '🚀 Vélocité & Sprints' },
{ id: 'velocity', label: '🚀 Vélocité & Sprints' }, { id: 'analytics', label: '📈 Analytics avancées' },
{ id: 'analytics', label: '📈 Analytics avancées' }, { id: 'quality', label: '🎯 Qualité & Collaboration' }
{ id: 'quality', label: '🎯 Qualité & Collaboration' } ]}
].map(tab => ( activeTab={activeTab}
<button onTabChange={(tabId) => setActiveTab(tabId as 'overview' | 'velocity' | 'analytics' | 'quality')}
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>
{/* Contenu des onglets */} {/* Contenu des onglets */}
{activeTab === 'overview' && ( {activeTab === 'overview' && (
@@ -470,11 +439,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📉 Burndown Chart</h3> <h3 className="font-semibold">📉 Burndown Chart</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<BurndownChart <div className="w-full h-96 overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <BurndownChart
className="h-96" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -482,11 +453,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📈 Throughput</h3> <h3 className="font-semibold">📈 Throughput</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<ThroughputChart <div className="w-full h-96 overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <ThroughputChart
className="h-96" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -496,11 +469,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">🎯 Métriques de qualité</h3> <h3 className="font-semibold">🎯 Métriques de qualité</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<QualityMetrics <div className="w-full overflow-hidden">
analytics={analytics} <QualityMetrics
className="min-h-96" analytics={analytics}
/> className="min-h-96 w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -509,11 +484,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📊 Predictabilité</h3> <h3 className="font-semibold">📊 Predictabilité</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<PredictabilityMetrics <div className="w-full overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <PredictabilityMetrics
className="h-auto" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-auto w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -522,11 +499,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">🤝 Matrice de collaboration</h3> <h3 className="font-semibold">🤝 Matrice de collaboration</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<CollaborationMatrix <div className="w-full overflow-hidden">
analytics={analytics} <CollaborationMatrix
className="h-auto" analytics={analytics}
/> className="h-auto w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -535,11 +514,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3> <h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<SprintComparison <div className="w-full overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <SprintComparison
className="h-auto" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-auto w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -548,12 +529,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">🔥 Heatmap d&apos;activité de l&apos;équipe</h3> <h3 className="font-semibold">🔥 Heatmap d&apos;activité de l&apos;équipe</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<TeamActivityHeatmap <div className="w-full overflow-hidden">
workloadByAssignee={analytics.workInProgress.byAssignee} <TeamActivityHeatmap
statusDistribution={analytics.workInProgress.byStatus} workloadByAssignee={analytics.workInProgress.byAssignee}
className="min-h-96" statusDistribution={analytics.workInProgress.byStatus}
/> className="min-h-96 w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -566,12 +549,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">🚀 Vélocité des sprints</h3> <h3 className="font-semibold">🚀 Vélocité des sprints</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<VelocityChart <div className="w-full h-64 overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <VelocityChart
className="h-64" sprintHistory={analytics.velocityMetrics.sprintHistory}
onSprintClick={handleSprintClick} className="h-full w-full"
/> onSprintClick={handleSprintClick}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -581,11 +566,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📉 Burndown Chart</h3> <h3 className="font-semibold">📉 Burndown Chart</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<BurndownChart <div className="w-full h-96 overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <BurndownChart
className="h-96" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -593,11 +580,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📊 Throughput</h3> <h3 className="font-semibold">📊 Throughput</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<ThroughputChart <div className="w-full h-96 overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <ThroughputChart
className="h-96" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -607,11 +596,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📊 Comparaison des sprints</h3> <h3 className="font-semibold">📊 Comparaison des sprints</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<SprintComparison <div className="w-full overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <SprintComparison
className="h-auto" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-auto w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -625,11 +616,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold"> Cycle Time par type</h3> <h3 className="font-semibold"> Cycle Time par type</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<CycleTimeChart <div className="w-full h-64 overflow-hidden">
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType} <CycleTimeChart
className="h-64" cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
/> className="h-full w-full"
/>
</div>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<div className="text-2xl font-bold text-[var(--primary)]"> <div className="text-2xl font-bold text-[var(--primary)]">
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)} {analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
@@ -645,12 +638,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">🔥 Heatmap d&apos;activité</h3> <h3 className="font-semibold">🔥 Heatmap d&apos;activité</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<TeamActivityHeatmap <div className="w-full h-64 overflow-hidden">
workloadByAssignee={analytics.workInProgress.byAssignee} <TeamActivityHeatmap
statusDistribution={analytics.workInProgress.byStatus} workloadByAssignee={analytics.workInProgress.byAssignee}
className="h-64" statusDistribution={analytics.workInProgress.byStatus}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -661,11 +656,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">🎯 Métriques de qualité</h3> <h3 className="font-semibold">🎯 Métriques de qualité</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<QualityMetrics <div className="w-full h-64 overflow-hidden">
analytics={analytics} <QualityMetrics
className="h-64" analytics={analytics}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -673,11 +670,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">📈 Predictabilité</h3> <h3 className="font-semibold">📈 Predictabilité</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<PredictabilityMetrics <div className="w-full h-64 overflow-hidden">
sprintHistory={analytics.velocityMetrics.sprintHistory} <PredictabilityMetrics
className="h-64" sprintHistory={analytics.velocityMetrics.sprintHistory}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -692,11 +691,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">👥 Répartition de l&apos;équipe</h3> <h3 className="font-semibold">👥 Répartition de l&apos;équipe</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<TeamDistributionChart <div className="w-full h-64 overflow-hidden">
distribution={analytics.teamMetrics.issuesDistribution} <TeamDistributionChart
className="h-64" distribution={analytics.teamMetrics.issuesDistribution}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -704,11 +705,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader> <CardHeader>
<h3 className="font-semibold">🤝 Matrice de collaboration</h3> <h3 className="font-semibold">🤝 Matrice de collaboration</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4">
<CollaborationMatrix <div className="w-full h-64 overflow-hidden">
analytics={analytics} <CollaborationMatrix
className="h-64" analytics={analytics}
/> className="h-full w-full"
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { userPreferencesService } from '@/services/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { getJiraAnalytics } from '@/actions/jira-analytics';
import { JiraDashboardPageClient } from './JiraDashboardPageClient'; import { JiraDashboardPageClient } from './JiraDashboardPageClient';
// Force dynamic rendering // Force dynamic rendering
@@ -7,8 +8,20 @@ export const dynamic = 'force-dynamic';
export default async function JiraDashboardPage() { export default async function JiraDashboardPage() {
// Récupérer la config Jira côté serveur // Récupérer la config Jira côté serveur
const jiraConfig = await userPreferencesService.getJiraConfig(); 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 ( return (
<JiraDashboardPageClient initialJiraConfig={jiraConfig} /> <JiraDashboardPageClient
initialJiraConfig={jiraConfig}
initialAnalytics={initialAnalytics}
/>
); );
} }

View File

@@ -1,27 +1,30 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer'; import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext'; import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
import { UserPreferencesProvider, useUserPreferences } from '@/contexts/UserPreferencesContext'; import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { Task, Tag, UserPreferences } from '@/lib/types'; import { Task, Tag } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client'; import { CreateTaskData } from '@/clients/tasks-client';
import { CreateTaskForm } from '@/components/forms/CreateTaskForm'; import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
import { Button } from '@/components/ui/Button'; import { MobileControls } from '@/components/kanban/MobileControls';
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter'; import { DesktopControls } from '@/components/kanban/DesktopControls';
import { FontSizeToggle } from '@/components/ui/FontSizeToggle'; import { useIsMobile } from '@/hooks/useIsMobile';
interface KanbanPageClientProps { interface KanbanPageClientProps {
initialTasks: Task[]; initialTasks: Task[];
initialTags: (Tag & { usage: number })[]; initialTags: (Tag & { usage: number })[];
initialPreferences: UserPreferences;
} }
function KanbanPageContent() { function KanbanPageContent() {
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext(); const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
const { preferences, updateViewPreferences } = useUserPreferences(); const { preferences, updateViewPreferences } = useUserPreferences();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); 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 // Extraire les préférences du context
const showFilters = preferences.viewPreferences.showFilters; const showFilters = preferences.viewPreferences.showFilters;
@@ -60,111 +63,42 @@ function KanbanPageContent() {
syncing={syncing} syncing={syncing}
/> />
{/* Barre de contrôles de visibilité */} {/* Barre de contrôles responsive */}
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30"> {isMobile ? (
<div className="container mx-auto px-6 py-2"> <MobileControls
<div className="flex items-center justify-between w-full"> showFilters={showFilters}
<div className="flex items-center gap-4"> showObjectives={showObjectives}
<div className="flex items-center gap-2"> compactView={compactView}
<button activeFiltersCount={activeFiltersCount}
onClick={handleToggleFilters} kanbanFilters={kanbanFilters}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${ onToggleFilters={handleToggleFilters}
showFilters onToggleObjectives={handleToggleObjectives}
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30' onToggleCompactView={handleToggleCompactView}
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50' onFiltersChange={setKanbanFilters}
}`} onCreateTask={() => setIsCreateModalOpen(true)}
> />
<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" /> <DesktopControls
</svg> showFilters={showFilters}
Filtres{activeFiltersCount > 0 && ` (${activeFiltersCount})`} showObjectives={showObjectives}
</button> compactView={compactView}
swimlanesByTags={swimlanesByTags}
<button activeFiltersCount={activeFiltersCount}
onClick={handleToggleObjectives} kanbanFilters={kanbanFilters}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${ onToggleFilters={handleToggleFilters}
showObjectives onToggleObjectives={handleToggleObjectives}
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30' onToggleCompactView={handleToggleCompactView}
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--accent)]/50' onToggleSwimlanes={handleToggleSwimlanes}
}`} onFiltersChange={setKanbanFilters}
> onCreateTask={() => setIsCreateModalOpen(true)}
<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>
<main className="h-[calc(100vh-160px)]"> <main className="h-[calc(100vh-160px)]">
<KanbanBoardContainer <KanbanBoardContainer
showFilters={showFilters} showFilters={showFilters}
showObjectives={showObjectives} showObjectives={showObjectives}
initialTaskIdToEdit={taskIdFromUrl}
/> />
</main> </main>
@@ -179,15 +113,13 @@ function KanbanPageContent() {
); );
} }
export function KanbanPageClient({ initialTasks, initialTags, initialPreferences }: KanbanPageClientProps) { export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) {
return ( return (
<UserPreferencesProvider initialPreferences={initialPreferences}> <TasksProvider
<TasksProvider initialTasks={initialTasks}
initialTasks={initialTasks} initialTags={initialTags}
initialTags={initialTags} >
> <KanbanPageContent />
<KanbanPageContent /> </TasksProvider>
</TasksProvider>
</UserPreferencesProvider>
); );
} }

View File

@@ -1,6 +1,5 @@
import { tasksService } from '@/services/tasks'; import { tasksService } from '@/services/task-management/tasks';
import { tagsService } from '@/services/tags'; import { tagsService } from '@/services/task-management/tags';
import { userPreferencesService } from '@/services/user-preferences';
import { KanbanPageClient } from './KanbanPageClient'; import { KanbanPageClient } from './KanbanPageClient';
// Force dynamic rendering (no static generation) // Force dynamic rendering (no static generation)
@@ -8,17 +7,15 @@ export const dynamic = 'force-dynamic';
export default async function KanbanPage() { export default async function KanbanPage() {
// SSR - Récupération des données côté serveur // SSR - Récupération des données côté serveur
const [initialTasks, initialTags, initialPreferences] = await Promise.all([ const [initialTasks, initialTags] = await Promise.all([
tasksService.getTasks(), tasksService.getTasks(),
tagsService.getTags(), tagsService.getTags()
userPreferencesService.getAllPreferences()
]); ]);
return ( return (
<KanbanPageClient <KanbanPageClient
initialTasks={initialTasks} initialTasks={initialTasks}
initialTags={initialTags} initialTags={initialTags}
initialPreferences={initialPreferences}
/> />
); );
} }

View File

@@ -3,7 +3,9 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/contexts/ThemeContext"; import { ThemeProvider } from "@/contexts/ThemeContext";
import { JiraConfigProvider } from "@/contexts/JiraConfigContext"; import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
import { userPreferencesService } from "@/services/user-preferences"; import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
import { userPreferencesService } from "@/services/core/user-preferences";
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -25,20 +27,23 @@ export default async function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
// Récupérer les données côté serveur pour le SSR // Récupérer toutes les préférences côté serveur pour le SSR
const [initialTheme, jiraConfig] = await Promise.all([ const initialPreferences = await userPreferencesService.getAllPreferences();
userPreferencesService.getTheme(),
userPreferencesService.getJiraConfig()
]);
return ( return (
<html lang="en" className={initialTheme}> <html lang="fr">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ThemeProvider initialTheme={initialTheme}> <ThemeProvider
<JiraConfigProvider config={jiraConfig}> initialTheme={initialPreferences.viewPreferences.theme}
{children} userPreferredTheme={initialPreferences.viewPreferences.theme === 'light' ? 'dark' : initialPreferences.viewPreferences.theme}
>
<KeyboardShortcuts />
<JiraConfigProvider config={initialPreferences.jiraConfig}>
<UserPreferencesProvider initialPreferences={initialPreferences}>
{children}
</UserPreferencesProvider>
</JiraConfigProvider> </JiraConfigProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>

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