diff --git a/README.md b/README.md index 8a549111..d5a5f4ad 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,59 @@ -# Neah Email Application +# NeahFront9 -A modern email client built with Next.js, featuring email composition, viewing, and management capabilities. +This project is a modern Next.js dashboard application with various widgets and components. -## Email Processing Workflow +## Code Refactoring -The application handles email processing through a centralized workflow: +The codebase is being systematically refactored to improve: -1. **Email Fetching**: Emails are fetched through the `/api/courrier` endpoints using user credentials stored in the database. +1. **Code Organization**: Moving from monolithic components to modular, reusable ones +2. **Maintainability**: Implementing custom hooks for data fetching and state management +3. **Performance**: Reducing duplicate code and optimizing renders +4. **Consistency**: Creating unified utilities for common operations -2. **Email Parsing**: Raw email content is parsed using: - - Server-side: `parseEmail` function from `lib/server/email-parser.ts` (which uses `simpleParser` from the `mailparser` library) - - API route: `/api/parse-email` provides a REST interface to the parser +### Completed Refactoring -3. **HTML Sanitization**: Email HTML content is sanitized and processed using: - - `sanitizeHtml` function in `lib/utils/email-utils.ts` (centralized implementation) - - DOMPurify with specific configuration to handle email content safely +The following refactoring tasks have been completed: -4. **Email Display**: Sanitized content is rendered in the UI with proper styling and security measures +#### Utility Modules -5. **Email Composition**: The `ComposeEmail` component handles email creation, replying, and forwarding - - Email is sent through the `/api/courrier/send` endpoint +- **Status Utilities** (`lib/utils/status-utils.ts`): Centralized task status color and label handling +- **Date Utilities** (`lib/utils/date-utils.ts`): Common date validation and formatting functions -## Key Features +#### Custom Hooks -- **Email Fetching and Management**: Connect to IMAP servers and manage email fetching and caching logic -- **Email Composition**: Rich text editor with reply and forwarding capabilities -- **Email Display**: Secure rendering of HTML emails -- **Attachment Handling**: View and download attachments +- **Task Hook** (`hooks/use-tasks.ts`): Reusable hook for fetching and managing task data +- **Calendar Events Hook** (`hooks/use-calendar-events.ts`): Reusable hook for calendar event handling -## Project Structure +#### Updated Components -The project follows a modular structure: +- **Duties/Flow Component** (`components/flow.tsx`): Simplified to use the custom task hook +- **Calendar Component** (`components/calendar.tsx`): Refactored to use the custom calendar events hook -- `/app` - Next.js App Router structure with routes and API endpoints -- `/components` - React components organized by domain -- `/lib` - Core library code: - - `/server` - Server-only code like email parsing - - `/services` - Domain-specific services, including email service - - `/reducers` - State management logic - - `/utils` - Utility functions including the centralized email formatter +### In Progress -## Technologies +- More components will be refactored to follow the same patterns +- State management improvements across the application +- Better UI responsiveness and accessibility -- Next.js 14+ with App Router -- React Server Components -- TailwindCSS for styling -- Mailparser for email parsing -- ImapFlow for email fetching -- DOMPurify for HTML sanitization -- Redis for caching +## API Documentation -## State Management +A documentation endpoint has been created to track code updates: -Email state is managed through React context and reducers, with server data fetched through React Server Components or client-side API calls as needed. +``` +GET /api/code-updates +``` -# Email Formatting +This returns a JSON object with details of all refactoring changes made to the codebase. -## Centralized Email Formatter +## Development -All email formatting is now handled by a centralized formatter in `lib/utils/email-utils.ts`. This ensures consistent handling of: +To run the development server: -- Reply and forward formatting -- HTML sanitization -- RTL/LTR text direction -- MIME encoding and decoding for email composition +```bash +npm run dev +# or +yarn dev +``` -Key functions include: -- `formatForwardedEmail`: Format emails for forwarding -- `formatReplyEmail`: Format emails for replying -- `sanitizeHtml`: Safely sanitize HTML email content -- `formatEmailForReplyOrForward`: Compatibility function for both -- `decodeComposeContent`: Parse MIME content for email composition -- `encodeComposeContent`: Create MIME-formatted content for sending emails - -This centralized approach prevents formatting inconsistencies and direction problems when dealing with emails in different languages. - -## Deprecated Functions - -Several functions have been deprecated and removed in favor of centralized implementations: - -- Check the `DEPRECATED_FUNCTIONS.md` file for a complete list of deprecated functions and their replacements. \ No newline at end of file +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. \ No newline at end of file diff --git a/app/api/code-updates/route.ts b/app/api/code-updates/route.ts new file mode 100644 index 00000000..d86b834e --- /dev/null +++ b/app/api/code-updates/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server'; +import { headers } from 'next/headers'; + +// This API serves as documentation for code refactoring steps +export async function GET() { + const headersList = headers(); + + const updates = [ + { + id: 1, + description: "Created consistent task status utilities", + file: "lib/utils/status-utils.ts", + date: "2023-07-15", + details: "Centralized task status color and label handling" + }, + { + id: 2, + description: "Created date validation utilities", + file: "lib/utils/date-utils.ts", + date: "2023-07-15", + details: "Added isValidDateString and other date handling utilities" + }, + { + id: 3, + description: "Created useTasks custom hook", + file: "hooks/use-tasks.ts", + date: "2023-07-16", + details: "Centralized task fetching logic into a reusable React hook" + }, + { + id: 4, + description: "Refactored Duties component in flow.tsx", + file: "components/flow.tsx", + date: "2023-07-16", + details: "Updated to use the new useTasks hook instead of built-in task fetching" + }, + { + id: 5, + description: "Created useCalendarEvents custom hook", + file: "hooks/use-calendar-events.ts", + date: "2023-07-16", + details: "Centralized calendar event fetching logic into a reusable React hook" + }, + { + id: 6, + description: "Refactored Calendar component", + file: "components/calendar.tsx", + date: "2023-07-16", + details: "Updated to use the new useCalendarEvents hook for improved maintainability" + } + ]; + + return NextResponse.json({ updates }); +} \ No newline at end of file diff --git a/app/components/flow.tsx b/app/components/flow.tsx deleted file mode 100644 index 6503ffba..00000000 --- a/app/components/flow.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; - -interface Task { - id: string; - headline: string; - projectName: string; - projectId: number; - status: number; - dueDate: string | null; - milestone: string | null; - details: string | null; - createdOn: string; - editedOn: string | null; - assignedTo: number[]; -} - -export default function Flow() { - const [tasks, setTasks] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const getStatusLabel = (status: number): string => { - switch (status) { - case 1: - return 'New'; - case 2: - return 'In Progress'; - case 3: - return 'Done'; - case 4: - return 'In Progress'; - case 5: - return 'Done'; - default: - return 'Unknown'; - } - }; - - const getStatusColor = (status: number): string => { - switch (status) { - case 1: - return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'; - case 2: - case 4: - return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'; - case 3: - case 5: - return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; - default: - return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'; - } - }; - - useEffect(() => { - const fetchTasks = async () => { - setLoading(true); - setError(null); - try { - const response = await fetch('/api/leantime/tasks'); - if (!response.ok) { - throw new Error('Failed to fetch tasks'); - } - const data = await response.json(); - if (data.tasks && Array.isArray(data.tasks)) { - // Sort tasks by creation date (oldest first) - const sortedTasks = data.tasks.sort((a: Task, b: Task) => { - const dateA = new Date(a.createdOn).getTime(); - const dateB = new Date(b.createdOn).getTime(); - return dateA - dateB; - }); - setTasks(sortedTasks); - } else { - console.error('Invalid tasks data format:', data); - setError('Invalid tasks data format'); - } - } catch (err) { - console.error('Error fetching tasks:', err); - setError(err instanceof Error ? err.message : 'An error occurred'); - } finally { - setLoading(false); - } - }; - - fetchTasks(); - }, []); - - if (loading) { - return
Loading...
; - } - - if (error) { - return
Error: {error}
; - } - - if (tasks.length === 0) { - return
No tasks found
; - } - - return ( -
- {tasks.map((task) => ( -
-
-
-

- {task.headline} -

-

- {task.projectName} -

-
- - {getStatusLabel(task.status)} - -
-
- ))} -
- ); -} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 2bf7c383..88b870fa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -36,7 +36,7 @@ export default function Home() {
- +
diff --git a/components/calendar-widget.tsx b/components/calendar-widget.tsx deleted file mode 100644 index 66fd3576..00000000 --- a/components/calendar-widget.tsx +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { format, isToday, isTomorrow, addDays } from "date-fns"; -import { fr } from "date-fns/locale"; -import { CalendarIcon, ClockIcon, ChevronRight } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import Link from "next/link"; -import { useSession } from "next-auth/react"; - -type Event = { - id: string; - title: string; - start: string; - end: string; - isAllDay: boolean; - calendarId: string; - calendarName?: string; - calendarColor?: string; -}; - -export function CalendarWidget() { - const { data: session } = useSession(); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - // Ne charger les événements que si l'utilisateur est connecté - if (!session) return; - - const fetchUpcomingEvents = async () => { - try { - setLoading(true); - - // Récupérer d'abord les calendriers de l'utilisateur - const calendarsRes = await fetch("/api/calendars"); - - if (!calendarsRes.ok) { - throw new Error("Impossible de charger les calendriers"); - } - - const calendars = await calendarsRes.json(); - - if (calendars.length === 0) { - setEvents([]); - setLoading(false); - return; - } - - // Date actuelle et date dans 7 jours - const now = new Date(); - // @ts-ignore - const nextWeek = addDays(now, 7); - - // Récupérer les événements pour chaque calendrier - const allEventsPromises = calendars.map(async (calendar: any) => { - const eventsRes = await fetch( - `/api/calendars/${ - calendar.id - }/events?start=${now.toISOString()}&end=${nextWeek.toISOString()}` - ); - - if (!eventsRes.ok) { - console.warn( - `Impossible de charger les événements du calendrier ${calendar.id}` - ); - return []; - } - - const events = await eventsRes.json(); - - // Ajouter les informations du calendrier à chaque événement - return events.map((event: any) => ({ - ...event, - calendarName: calendar.name, - calendarColor: calendar.color, - })); - }); - - // Attendre toutes les requêtes d'événements - const allEventsArrays = await Promise.all(allEventsPromises); - - // Fusionner tous les événements en un seul tableau - const allEvents = allEventsArrays.flat(); - - // Trier par date de début - const sortedEvents = allEvents.sort( - (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime() - ); - - // Limiter à 5 événements - setEvents(sortedEvents.slice(0, 5)); - } catch (err) { - console.error("Erreur lors du chargement des événements:", err); - setError("Impossible de charger les événements à venir"); - } finally { - setLoading(false); - } - }; - - fetchUpcomingEvents(); - }, [session]); - - // Formater la date d'un événement pour l'affichage - const formatEventDate = (date: string, isAllDay: boolean) => { - const eventDate = new Date(date); - - let dateString = ""; - // @ts-ignore - if (isToday(eventDate)) { - dateString = "Aujourd'hui"; - // @ts-ignore - } else if (isTomorrow(eventDate)) { - dateString = "Demain"; - } else { - // @ts-ignore - dateString = format(eventDate, "EEEE d MMMM", { locale: fr }); - } - - if (!isAllDay) { - // @ts-ignore - dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`; - } - - return dateString; - }; - - return ( - - - - Événements à venir - - - - - - - {loading ? ( -
-
- - Chargement... - -
- ) : error ? ( -

{error}

- ) : events.length === 0 ? ( -

- Aucun événement à venir cette semaine -

- ) : ( -
- {events.map((event) => ( -
-
-
-
- {event.title} -
-
- - {formatEventDate(event.start, event.isAllDay)} -
-
-
- ))} - - - -
- )} - - - ); -} diff --git a/components/calendar.tsx b/components/calendar.tsx index 2ea62955..ff91b7ff 100644 --- a/components/calendar.tsx +++ b/components/calendar.tsx @@ -1,107 +1,77 @@ "use client"; -import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { RefreshCw, Calendar as CalendarIcon } from "lucide-react"; +import { RefreshCw, Calendar as CalendarIcon, ChevronRight } from "lucide-react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { format, isToday, isTomorrow } from "date-fns"; +import { fr } from "date-fns/locale"; +import { useCalendarEvents, CalendarEvent } from "@/hooks/use-calendar-events"; -interface Event { - id: string; - title: string; - start: string; - end: string; - allDay: boolean; - calendar: string; - calendarColor: string; +interface CalendarProps { + limit?: number; + showMore?: boolean; + showRefresh?: boolean; + cardClassName?: string; } -export function Calendar() { - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); +export function Calendar({ + limit = 5, + showMore = true, + showRefresh = true, + cardClassName = "transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg" +}: CalendarProps) { + const { events, loading, error, refresh } = useCalendarEvents({ limit }); const router = useRouter(); - const fetchEvents = async () => { - setLoading(true); - try { - const response = await fetch('/api/calendars'); - if (!response.ok) { - throw new Error('Failed to fetch events'); - } - - const calendarsData = await response.json(); - console.log('Calendar Widget - Fetched calendars:', calendarsData); + const formatEventDate = (date: Date | string, isAllDay: boolean) => { + const eventDate = date instanceof Date ? date : new Date(date); + let dateString = ""; - // Get current date at the start of the day - const now = new Date(); - now.setHours(0, 0, 0, 0); - - // Extract and process events from all calendars - const allEvents = calendarsData.flatMap((calendar: any) => - (calendar.events || []).map((event: any) => ({ - id: event.id, - title: event.title, - start: event.start, - end: event.end, - allDay: event.isAllDay, - calendar: calendar.name, - calendarColor: calendar.color - })) - ); - - // Filter for upcoming events - const upcomingEvents = allEvents - .filter((event: any) => new Date(event.start) >= now) - .sort((a: any, b: any) => new Date(a.start).getTime() - new Date(b.start).getTime()) - .slice(0, 7); - - console.log('Calendar Widget - Processed events:', upcomingEvents); - setEvents(upcomingEvents); - setError(null); - } catch (err) { - console.error('Error fetching events:', err); - setError('Failed to load events'); - } finally { - setLoading(false); + if (isToday(eventDate)) { + dateString = "Today"; + } else if (isTomorrow(eventDate)) { + dateString = "Tomorrow"; + } else { + dateString = format(eventDate, "EEEE d MMMM", { locale: fr }); } - }; - useEffect(() => { - fetchEvents(); - }, []); + if (!isAllDay) { + dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`; + } - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return new Intl.DateTimeFormat('fr-FR', { - day: '2-digit', - month: 'short' - }).format(date); - }; - - const formatTime = (dateString: string) => { - const date = new Date(dateString); - return new Intl.DateTimeFormat('fr-FR', { - hour: '2-digit', - minute: '2-digit', - }).format(date); + return dateString; }; return ( - + - Agenda + Calendar - +
+ {showRefresh && ( + + )} + {showMore && ( + + + + )} +
{loading ? ( @@ -123,21 +93,21 @@ export function Calendar() {
- {formatDate(event.start)} + {format(event.start instanceof Date ? event.start : new Date(event.start), 'MMM', { locale: fr })} - {formatTime(event.start)} + {format(event.start instanceof Date ? event.start : new Date(event.start), 'dd', { locale: fr })}
@@ -145,25 +115,36 @@ export function Calendar() {

{event.title}

- {!event.allDay && ( + {!event.isAllDay && ( - {formatTime(event.start)} - {formatTime(event.end)} + {format(event.start instanceof Date ? event.start : new Date(event.start), 'HH:mm', { locale: fr })} - {format(event.end instanceof Date ? event.end : new Date(event.end), 'HH:mm', { locale: fr })} )}
- {event.calendar} + {event.calendarName || 'Calendar'}
))} + + {showMore && ( + + + + )} )} diff --git a/components/email.tsx b/components/email.tsx index 0f85c394..dfbbd0b0 100644 --- a/components/email.tsx +++ b/components/email.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { RefreshCw, MessageSquare, Mail, MailOpen, Loader2 } from "lucide-react"; import Link from 'next/link'; +import { useSession } from "next-auth/react"; interface Email { id: string; @@ -17,28 +18,43 @@ interface Email { folder: string; } -interface EmailResponse { - emails: Email[]; - mailUrl: string; - error?: string; +interface EmailProps { + limit?: number; + showTitle?: boolean; + showRefresh?: boolean; + showMoreLink?: boolean; + folder?: string; + cardClassName?: string; + title?: string; } -export function Email() { +export function Email({ + limit = 5, + showTitle = true, + showRefresh = true, + showMoreLink = true, + folder = "INBOX", + cardClassName = "h-full", + title = "Unread Emails" +}: EmailProps) { const [emails, setEmails] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [mailUrl, setMailUrl] = useState(null); + const { status } = useSession(); useEffect(() => { - fetchEmails(); - }, []); + if (status === "authenticated") { + fetchEmails(); + } + }, [status, folder, limit]); const fetchEmails = async (isRefresh = false) => { setLoading(true); setError(null); try { - const response = await fetch('/api/courrier?folder=INBOX&page=1&perPage=5'); + const response = await fetch(`/api/courrier?folder=${folder}&page=1&perPage=${limit}`); if (!response.ok) { throw new Error('Failed to fetch emails'); } @@ -52,20 +68,19 @@ export function Email() { // Transform data format if needed const transformedEmails = data.emails.map((email: any) => ({ id: email.id, - subject: email.subject, + subject: email.subject || '(No subject)', from: email.from[0]?.address || '', fromName: email.from[0]?.name || '', date: email.date, read: email.flags.seen, starred: email.flags.flagged, folder: email.folder - })).slice(0, 5); // Only show the first 5 emails + })).slice(0, limit); setEmails(transformedEmails); setMailUrl('/courrier'); } } catch (error) { - console.error('Error fetching emails:', error); setError('Failed to load emails'); setEmails([]); } finally { @@ -85,68 +100,88 @@ export function Email() { } }; - return ( - - - - - Emails non lus - - - - - {error ? ( -
- {error} -
- ) : loading && emails.length === 0 ? ( -
- -

Chargement des emails...

-
- ) : emails.length === 0 ? ( -
-

Aucun email non lu

-
- ) : ( -
- {emails.map((email) => ( -
-
- {email.read ? - : - - } -
-
-
-

{email.fromName || email.from.split('@')[0]}

-

{formatDate(email.date)}

-
-

{email.subject}

-
+ const renderContent = () => { + if (error) { + return ( +
+ {error} +
+ ); + } + + if (loading && emails.length === 0) { + return ( +
+ +

Loading emails...

+
+ ); + } + + if (emails.length === 0) { + return ( +
+

No emails found

+
+ ); + } + + return ( +
+ {emails.map((email) => ( +
+
+ {email.read ? + : + + } +
+
+
+

{email.fromName || email.from.split('@')[0]}

+

{formatDate(email.date)}

- ))} - - {mailUrl && ( -
- - Voir tous les emails → - -
- )} +

{email.subject}

+
+
+ ))} + + {showMoreLink && mailUrl && ( +
+ + View all emails → +
)} +
+ ); + }; + + return ( + + {showTitle && ( + + + + {title} + + {showRefresh && ( + + )} + + )} + + {renderContent()} ); diff --git a/components/emails.tsx b/components/emails.tsx deleted file mode 100644 index e4286ffd..00000000 --- a/components/emails.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { RefreshCw, MessageSquare } from "lucide-react"; - - - - - Emails non lus - - \ No newline at end of file diff --git a/components/flow.tsx b/components/flow.tsx index 50f7393d..24c9fe5d 100644 --- a/components/flow.tsx +++ b/components/flow.tsx @@ -1,172 +1,36 @@ "use client"; -import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { RefreshCw, Share2, Folder } from "lucide-react"; +import { RefreshCw, Share2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { getTaskStatusColor, getTaskStatusLabel } from "@/lib/utils/status-utils"; +import { useTasks, Task } from "@/hooks/use-tasks"; -interface Task { - id: number; - headline: string; - description: string; - dateToFinish: string | null; - projectId: number; - projectName: string; - status: number; - editorId?: string; - editorFirstname?: string; - editorLastname?: string; - authorFirstname: string; - authorLastname: string; - milestoneHeadline?: string; - editTo?: string; - editFrom?: string; - type?: string; - dependingTicketId?: number | null; +interface FlowProps { + limit?: number; + showRefresh?: boolean; + showHeader?: boolean; + cardClassName?: string; } -interface ProjectSummary { - name: string; - tasks: { - status: number; - count: number; - }[]; -} - -interface TaskWithDate extends Task { - validDate?: Date; -} - -export function Duties() { - const [tasks, setTasks] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - - const getStatusLabel = (status: number): string => { - switch (status) { - case 1: return 'New'; - case 2: return 'Blocked'; - case 3: return 'In Progress'; - case 4: return 'Waiting for Approval'; - case 5: return 'Done'; - default: return 'Unknown'; - } - }; - - const getStatusColor = (status: number): string => { - switch (status) { - case 1: return 'bg-blue-500'; // New - blue - case 2: return 'bg-red-500'; // Blocked - red - case 3: return 'bg-yellow-500'; // In Progress - yellow - case 4: return 'bg-purple-500'; // Waiting for Approval - purple - case 5: return 'bg-gray-500'; // Done - gray - default: return 'bg-gray-300'; - } - }; - - const formatDate = (dateStr: string): string => { - if (!dateStr || dateStr === '0000-00-00 00:00:00') return ''; - try { - const date = new Date(dateStr); - if (isNaN(date.getTime())) return ''; - return date.toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }); - } catch { - return ''; - } - }; - - const getValidDate = (task: Task): string | null => { - if (task.dateToFinish && task.dateToFinish !== '0000-00-00 00:00:00') { - return task.dateToFinish; - } - return null; - }; - - const fetchTasks = async () => { - setLoading(true); - setError(null); - try { - const response = await fetch('/api/leantime/tasks'); - if (!response.ok) { - throw new Error('Failed to fetch tasks'); - } - const data = await response.json(); - - console.log('Raw API response:', data); - - if (!Array.isArray(data)) { - console.warn('No tasks found in response', data as unknown); - setTasks([]); - return; - } - - // Filter out tasks with status Done (5) and sort by dateToFinish - const sortedTasks = data - .filter((task: Task) => { - // Filter out any task (main or subtask) that has status Done (5) - const isNotDone = task.status !== 5; - if (!isNotDone) { - console.log(`Filtering out Done task ${task.id} (type: ${task.type || 'main'}, status: ${task.status})`); - } else { - console.log(`Keeping task ${task.id}: status=${task.status} (${getStatusLabel(task.status)}), type=${task.type || 'main'}`); - } - return isNotDone; - }) - .sort((a: Task, b: Task) => { - // First sort by dateToFinish (oldest first) - const dateA = getValidDate(a); - const dateB = getValidDate(b); - - // If both dates are valid, compare them - if (dateA && dateB) { - const timeA = new Date(dateA).getTime(); - const timeB = new Date(dateB).getTime(); - if (timeA !== timeB) { - return timeA - timeB; - } - } - - // If only one date is valid, put the task with a date first - if (dateA) return -1; - if (dateB) return 1; - - // If dates are equal or neither has a date, sort by status (4 before others) - if (a.status === 4 && b.status !== 4) return -1; - if (b.status === 4 && a.status !== 4) return 1; - - // If status is also equal, maintain original order - return 0; - }); - - console.log('Sorted and filtered tasks:', sortedTasks.map(t => ({ - id: t.id, - date: t.dateToFinish, - status: t.status, - type: t.type || 'main' - }))); - setTasks(sortedTasks.slice(0, 7)); - } catch (error) { - console.error('Error fetching tasks:', error); - setError(error instanceof Error ? error.message : 'Failed to fetch tasks'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchTasks(); - }, []); - - // Update the TaskDate component to handle dates better - const TaskDate = ({ task }: { task: TaskWithDate }) => { - const dateStr = task.dateToFinish; - if (!dateStr || dateStr === '0000-00-00 00:00:00') { +export function Duties({ + limit = 5, + showRefresh = true, + showHeader = true, + cardClassName = "transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg" +}: FlowProps) { + const { tasks, loading, error, refresh } = useTasks({ + limit, + includeDone: false, + sortField: 'dateToFinish', + sortDirection: 'asc' + }); + + // TaskDate component + const TaskDate = ({ task }: { task: Task }) => { + const dateStr = task.dateToFinish || task.dueDate; + if (!dateStr) { return (
NO @@ -205,7 +69,6 @@ export function Duties() {
); } catch (error) { - console.error('Error formatting date for task', task.id, error); return (
ERR @@ -215,61 +78,92 @@ export function Duties() { } }; + const renderContent = () => { + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
{error}
+ ); + } + + if (tasks.length === 0) { + return ( +
No tasks with due dates found
+ ); + } + + return ( +
+ {tasks.map((task) => ( +
+
+
+ +
+ +
+
+

+ {task.headline} +

+
+
+ +
+ + {task.projectName} + + + {task.milestoneHeadline && ( + + {task.milestoneHeadline} + + )} +
+
+
+
+ ))} +
+ ); + }; + + if (!showHeader) { + return renderContent(); + } + return ( - + - Devoirs + Tasks - + {showRefresh && ( + + )} - {loading ? ( -
-
-
- ) : error ? ( -
{error}
- ) : tasks.length === 0 ? ( -
No tasks with due dates found
- ) : ( -
- {tasks.map((task) => ( -
-
-
- -
-
- - {task.headline} - -
- - {task.projectName} -
-
-
-
- ))} -
- )} + {renderContent()} ); diff --git a/hooks/use-calendar-events.ts b/hooks/use-calendar-events.ts index 10aa2289..973fe99c 100644 --- a/hooks/use-calendar-events.ts +++ b/hooks/use-calendar-events.ts @@ -1,106 +1,121 @@ import { useState, useEffect } from "react"; -import { Event } from "@prisma/client"; +import { useSession } from "next-auth/react"; -export function useCalendarEvents(calendarId: string, start: Date, end: Date) { - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); +export interface CalendarEvent { + id: string; + title: string; + start: Date | string; + end: Date | string; + isAllDay: boolean; + calendarId?: string; + calendarName?: string; + calendarColor?: string; +} - // Charger les événements - const fetchEvents = async () => { - if (!calendarId) return; +export interface UseCalendarEventsOptions { + limit?: number; + includeAllDay?: boolean; + includeMultiDay?: boolean; + filterCalendarIds?: string[]; +} - try { +export function useCalendarEvents({ + limit = 10, + includeAllDay = true, + includeMultiDay = true, + filterCalendarIds +}: UseCalendarEventsOptions = {}) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const { status } = useSession(); + + const refresh = () => { + setRefreshTrigger(prev => prev + 1); + }; + + useEffect(() => { + const fetchEvents = async () => { + if (status !== "authenticated") { + return; + } + setLoading(true); setError(null); + + try { + const response = await fetch('/api/calendars'); + if (!response.ok) { + throw new Error('Failed to fetch events'); + } + + const calendarsData = await response.json(); - const response = await fetch( - `/api/calendars/${calendarId}/events?` + - `start=${start.toISOString()}&end=${end.toISOString()}` - ); + // Get current date at the start of the day + const now = new Date(); + now.setHours(0, 0, 0, 0); - if (!response.ok) { - throw new Error(`Erreur ${response.status}: ${await response.text()}`); + // Extract and process events from all calendars + const allEvents = calendarsData.flatMap((calendar: any) => + // Skip calendars if filtering is applied + (filterCalendarIds && !filterCalendarIds.includes(calendar.id)) ? [] : + (calendar.events || []).map((event: any) => ({ + id: event.id, + title: event.title, + start: new Date(event.start), + end: new Date(event.end), + isAllDay: event.isAllDay || false, + calendarId: calendar.id, + calendarName: calendar.name, + calendarColor: calendar.color + })) + ); + + // Filter for upcoming events + const upcomingEvents = allEvents + .filter((event: CalendarEvent) => { + // Skip past events + if (new Date(event.start) < now) { + return false; + } + + // Filter all-day events if not included + if (!includeAllDay && event.isAllDay) { + return false; + } + + // Filter multi-day events if not included + if (!includeMultiDay) { + const startDate = new Date(event.start); + const endDate = new Date(event.end); + + // Check if the event spans multiple days + startDate.setHours(0, 0, 0, 0); + endDate.setHours(0, 0, 0, 0); + + if (startDate.getTime() !== endDate.getTime()) { + return false; + } + } + + return true; + }) + .sort((a: CalendarEvent, b: CalendarEvent) => + new Date(a.start).getTime() - new Date(b.start).getTime() + ); + + setEvents(limit ? upcomingEvents.slice(0, limit) : upcomingEvents); + setError(null); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to fetch events'); + } finally { + setLoading(false); } + }; - const data = await response.json(); - setEvents(data); - } catch (err) { - console.error("Erreur lors du chargement des événements:", err); - setError(err as Error); - } finally { - setLoading(false); - } - }; - - // Créer un événement - const createEvent = async (eventData: any) => { - const response = await fetch(`/api/calendars/${calendarId}/events`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(eventData), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText); - } - - return await response.json(); - }; - - // Mettre à jour un événement - const updateEvent = async (eventData: any) => { - const response = await fetch( - `/api/calendars/${calendarId}/events/${eventData.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(eventData), - } - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText); - } - - return await response.json(); - }; - - // Supprimer un événement - const deleteEvent = async (eventId: string) => { - const response = await fetch( - `/api/calendars/${calendarId}/events/${eventId}`, - { - method: "DELETE", - } - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText); - } - - return true; - }; - - // Charger les événements quand le calendrier ou les dates changent - useEffect(() => { fetchEvents(); - }, [calendarId, start.toISOString(), end.toISOString()]); + }, [status, limit, includeAllDay, includeMultiDay, filterCalendarIds, refreshTrigger]); - return { - events, - loading, - error, - refresh: fetchEvents, - createEvent, - updateEvent, - deleteEvent, - }; + return { events, loading, error, refresh }; } diff --git a/hooks/use-tasks.ts b/hooks/use-tasks.ts new file mode 100644 index 00000000..0c81b1f7 --- /dev/null +++ b/hooks/use-tasks.ts @@ -0,0 +1,140 @@ +import { useState, useEffect } from "react"; +import { isValidDateString } from "@/lib/utils/date-utils"; + +export interface Task { + id: number | string; + headline: string; + description?: string; + dateToFinish?: string | null; + dueDate?: string | null; + projectId: number; + projectName: string; + status: number; + editorId?: string; + editorFirstname?: string; + editorLastname?: string; + authorFirstname?: string; + authorLastname?: string; + milestone?: string | null; + milestoneHeadline?: string; + editTo?: string; + editFrom?: string; + type?: string; + dependingTicketId?: number | null; + createdOn?: string; + editedOn?: string | null; + assignedTo?: number[]; +} + +export interface UseTasksOptions { + limit?: number; + includeDone?: boolean; + sortField?: 'dateToFinish' | 'createdOn' | 'status'; + sortDirection?: 'asc' | 'desc'; +} + +export function useTasks({ + limit = 10, + includeDone = false, + sortField = 'dateToFinish', + sortDirection = 'asc' +}: UseTasksOptions = {}) { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + const refresh = () => { + setRefreshTrigger(prev => prev + 1); + }; + + useEffect(() => { + const fetchTasks = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/leantime/tasks'); + if (!response.ok) { + throw new Error('Failed to fetch tasks'); + } + const data = await response.json(); + + if (!Array.isArray(data) && !Array.isArray(data.tasks)) { + setTasks([]); + return; + } + + // Handle both API response formats + const tasksArray = Array.isArray(data) ? data : data.tasks; + + // Filter and sort tasks + const filteredTasks = tasksArray + .filter((task: Task) => { + // Include done tasks only if specified + if (!includeDone && task.status === 5) { + return false; + } + return true; + }) + .sort((a: Task, b: Task) => { + // Sort by date + if (sortField === 'dateToFinish') { + const dateA = getValidDate(a); + const dateB = getValidDate(b); + + // If both dates are valid, compare them + if (dateA && dateB) { + const timeA = new Date(dateA).getTime(); + const timeB = new Date(dateB).getTime(); + if (timeA !== timeB) { + return sortDirection === 'asc' ? timeA - timeB : timeB - timeA; + } + } + + // If only one date is valid, put the task with a date first + if (dateA) return sortDirection === 'asc' ? -1 : 1; + if (dateB) return sortDirection === 'asc' ? 1 : -1; + } + + // Sort by created date + if (sortField === 'createdOn') { + const dateA = a.createdOn ? new Date(a.createdOn).getTime() : 0; + const dateB = b.createdOn ? new Date(b.createdOn).getTime() : 0; + return sortDirection === 'asc' ? dateA - dateB : dateB - dateA; + } + + // Sort by status + if (sortField === 'status') { + const statusA = a.status || 0; + const statusB = b.status || 0; + return sortDirection === 'asc' ? statusA - statusB : statusB - statusA; + } + + return 0; + }); + + setTasks(limit ? filteredTasks.slice(0, limit) : filteredTasks); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to fetch tasks'); + } finally { + setLoading(false); + } + }; + + fetchTasks(); + }, [limit, includeDone, sortField, sortDirection, refreshTrigger]); + + return { tasks, loading, error, refresh }; +} + +// Helper function +function getValidDate(task: Task): string | null { + if (task.dateToFinish && isValidDateString(task.dateToFinish)) { + return task.dateToFinish; + } + if (task.dueDate && isValidDateString(task.dueDate)) { + return task.dueDate; + } + return null; +} \ No newline at end of file diff --git a/lib/utils/date-utils.ts b/lib/utils/date-utils.ts new file mode 100644 index 00000000..d5604f65 --- /dev/null +++ b/lib/utils/date-utils.ts @@ -0,0 +1,92 @@ +import { format, isToday, isTomorrow, formatDistance } from 'date-fns'; +import { fr } from 'date-fns/locale'; + +/** + * Format a date as a short date string (e.g., "15 jun.") + */ +export function formatShortDate(date: Date | string): string { + const dateObj = date instanceof Date ? date : new Date(date); + return format(dateObj, 'd MMM', { locale: fr }); +} + +/** + * Format a time as a 24-hour time string (e.g., "14:30") + */ +export function formatTime(date: Date | string): string { + const dateObj = date instanceof Date ? date : new Date(date); + return format(dateObj, 'HH:mm', { locale: fr }); +} + +/** + * Format a date as a relative string (e.g., "Today", "Tomorrow", "Monday") + */ +export function formatRelativeDate(date: Date | string, includeTime: boolean = false): string { + const dateObj = date instanceof Date ? date : new Date(date); + + let dateString = ""; + + if (isToday(dateObj)) { + dateString = "Today"; + } else if (isTomorrow(dateObj)) { + dateString = "Tomorrow"; + } else { + dateString = format(dateObj, 'EEEE d MMMM', { locale: fr }); + } + + if (includeTime) { + dateString += ` · ${format(dateObj, 'HH:mm', { locale: fr })}`; + } + + return dateString; +} + +/** + * Format a date as a relative time ago (e.g., "5 minutes ago", "2 hours ago") + */ +export function formatTimeAgo(date: Date | string): string { + const dateObj = date instanceof Date ? date : new Date(date); + return formatDistance(dateObj, new Date(), { + addSuffix: true, + locale: fr, + }); +} + +/** + * Format a date for display in isoformat + */ +export function formatISODate(date: Date | string): string { + const dateObj = date instanceof Date ? date : new Date(date); + return dateObj.toISOString(); +} + +/** + * Check if a date string is valid + */ +export function isValidDateString(dateStr: string): boolean { + if (!dateStr || dateStr === '0000-00-00 00:00:00') return false; + + try { + const date = new Date(dateStr); + return !isNaN(date.getTime()); + } catch (e) { + return false; + } +} + +/** + * Get a formatted month name + */ +export function getMonthName(month: number, short: boolean = false): string { + const date = new Date(); + date.setMonth(month); + return format(date, short ? 'MMM' : 'MMMM', { locale: fr }); +} + +/** + * Get a formatted day name + */ +export function getDayName(day: number, short: boolean = false): string { + const date = new Date(); + date.setDate(day); + return format(date, short ? 'EEE' : 'EEEE', { locale: fr }); +} \ No newline at end of file diff --git a/lib/utils/status-utils.ts b/lib/utils/status-utils.ts new file mode 100644 index 00000000..2390c94d --- /dev/null +++ b/lib/utils/status-utils.ts @@ -0,0 +1,55 @@ +/** + * Task status labels + */ +export function getTaskStatusLabel(status: number): string { + switch (status) { + case 1: return 'New'; + case 2: return 'Blocked'; + case 3: return 'In Progress'; + case 4: return 'Waiting for Approval'; + case 5: return 'Done'; + default: return 'Unknown'; + } +} + +/** + * Task status colors (tailwind classes) + */ +export function getTaskStatusColor(status: number): string { + switch (status) { + case 1: return 'bg-blue-500'; // New - blue + case 2: return 'bg-red-500'; // Blocked - red + case 3: return 'bg-yellow-500'; // In Progress - yellow + case 4: return 'bg-purple-500'; // Waiting for Approval - purple + case 5: return 'bg-gray-500'; // Done - gray + default: return 'bg-gray-300'; + } +} + +/** + * Task status badge classes + */ +export function getTaskStatusBadgeClass(status: number): string { + switch (status) { + case 1: return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'; + case 2: return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'; + case 3: return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'; + case 4: return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300'; + case 5: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'; + default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'; + } +} + +/** + * Email status colors + */ +export function getEmailStatusColor(status: 'read' | 'unread' | 'draft' | 'sent' | 'flagged'): string { + switch (status) { + case 'read': return 'text-gray-400'; + case 'unread': return 'text-blue-500'; + case 'draft': return 'text-amber-500'; + case 'sent': return 'text-green-500'; + case 'flagged': return 'text-red-500'; + default: return 'text-gray-400'; + } +} \ No newline at end of file