cleaning hard
This commit is contained in:
parent
a4412c081a
commit
35d4e37d79
97
README.md
97
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.
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
54
app/api/code-updates/route.ts
Normal file
54
app/api/code-updates/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
@ -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<Task[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return <div>No tasks found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{task.headline}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{task.projectName}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
|
||||
task.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusLabel(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -36,7 +36,7 @@ export default function Home() {
|
||||
<QuoteCard />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Calendar />
|
||||
<Calendar limit={5} showMore={true} showRefresh={true} />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<News />
|
||||
|
||||
@ -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<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Card className='transition-transform duration-500 ease-in-out transform hover:scale-105'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<CardTitle className='text-lg font-medium'>
|
||||
Événements à venir
|
||||
</CardTitle>
|
||||
<Link href='/calendar' passHref>
|
||||
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
<span className='sr-only'>Voir le calendrier</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className='pb-3'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center py-4'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent' />
|
||||
<span className='ml-2 text-sm text-muted-foreground'>
|
||||
Chargement...
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className='text-sm text-red-500'>{error}</p>
|
||||
) : events.length === 0 ? (
|
||||
<p className='text-sm text-muted-foreground py-2'>
|
||||
Aucun événement à venir cette semaine
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className='flex items-start space-x-3 rounded-md border border-muted p-2'
|
||||
>
|
||||
<div
|
||||
className='h-3 w-3 flex-shrink-0 rounded-full mt-1'
|
||||
style={{ backgroundColor: event.calendarColor || "#0082c9" }}
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h5
|
||||
className='text-sm font-medium truncate'
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
</h5>
|
||||
<div className='flex items-center text-xs text-muted-foreground mt-1'>
|
||||
<CalendarIcon className='h-3 w-3 mr-1' />
|
||||
<span>{formatEventDate(event.start, event.isAllDay)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link href='/calendar' passHref>
|
||||
<Button
|
||||
size='sm'
|
||||
className='w-full transition-all ease-in-out duration-500 bg-muted text-black hover:text-white hover:bg-primary'
|
||||
>
|
||||
Voir tous les événements
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -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<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
|
||||
<Card className={cardClassName}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<CalendarIcon className="h-5 w-5 text-gray-600" />
|
||||
Agenda
|
||||
Calendar
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fetchEvents()}
|
||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{showRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={refresh}
|
||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
|
||||
</Button>
|
||||
)}
|
||||
{showMore && (
|
||||
<Link href='/agenda' passHref>
|
||||
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
<span className='sr-only'>View calendar</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading ? (
|
||||
@ -123,21 +93,21 @@ export function Calendar() {
|
||||
<div
|
||||
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
|
||||
style={{
|
||||
backgroundColor: `${event.calendarColor}10`,
|
||||
borderColor: event.calendarColor
|
||||
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
|
||||
borderColor: event.calendarColor || '#4F46E5'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: event.calendarColor }}
|
||||
style={{ color: event.calendarColor || '#4F46E5' }}
|
||||
>
|
||||
{formatDate(event.start)}
|
||||
{format(event.start instanceof Date ? event.start : new Date(event.start), 'MMM', { locale: fr })}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-bold mt-0.5"
|
||||
style={{ color: event.calendarColor }}
|
||||
style={{ color: event.calendarColor || '#4F46E5' }}
|
||||
>
|
||||
{formatTime(event.start)}
|
||||
{format(event.start instanceof Date ? event.start : new Date(event.start), 'dd', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
@ -145,25 +115,36 @@ export function Calendar() {
|
||||
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
|
||||
{event.title}
|
||||
</p>
|
||||
{!event.allDay && (
|
||||
{!event.isAllDay && (
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">
|
||||
{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 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
|
||||
style={{
|
||||
backgroundColor: `${event.calendarColor}10`,
|
||||
color: event.calendarColor
|
||||
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
|
||||
color: event.calendarColor || '#4F46E5'
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{event.calendar}</span>
|
||||
<span className="truncate">{event.calendarName || 'Calendar'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showMore && (
|
||||
<Link href='/agenda' passHref>
|
||||
<Button
|
||||
size='sm'
|
||||
className='w-full transition-all ease-in-out duration-500 bg-gray-100 text-gray-700 hover:bg-gray-200 mt-2'
|
||||
>
|
||||
View all events
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -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<Email[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mailUrl, setMailUrl] = useState<string | null>(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 (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-gray-600" />
|
||||
Emails non lus
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fetchEmails(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{error ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
) : loading && emails.length === 0 ? (
|
||||
<div className="text-center py-6 flex flex-col items-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
||||
<p className="text-gray-500">Chargement des emails...</p>
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-500">Aucun email non lu</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{emails.map((email) => (
|
||||
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
|
||||
<div className="pt-1">
|
||||
{email.read ?
|
||||
<MailOpen className="h-4 w-4 text-gray-400" /> :
|
||||
<Mail className="h-4 w-4 text-blue-500" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between">
|
||||
<p className="font-medium truncate" style={{maxWidth: '180px'}}>{email.fromName || email.from.split('@')[0]}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(email.date)}</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 truncate">{email.subject}</p>
|
||||
</div>
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && emails.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6 flex flex-col items-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
||||
<p className="text-gray-500">Loading emails...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (emails.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-500">No emails found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{emails.map((email) => (
|
||||
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
|
||||
<div className="pt-1">
|
||||
{email.read ?
|
||||
<MailOpen className="h-4 w-4 text-gray-400" /> :
|
||||
<Mail className="h-4 w-4 text-blue-500" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between">
|
||||
<p className="font-medium truncate" style={{maxWidth: '180px'}}>{email.fromName || email.from.split('@')[0]}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(email.date)}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mailUrl && (
|
||||
<div className="pt-2">
|
||||
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
|
||||
Voir tous les emails →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-700 truncate">{email.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showMoreLink && mailUrl && (
|
||||
<div className="pt-2">
|
||||
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
|
||||
View all emails →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cardClassName}>
|
||||
{showTitle && (
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-gray-600" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
{showRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fetchEmails(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
{renderContent()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -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";
|
||||
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-gray-600" />
|
||||
Emails non lus
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -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<TaskWithDate[]>([]);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[10px] text-gray-600 font-medium">NO</span>
|
||||
@ -205,7 +69,6 @@ export function Duties() {
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error formatting date for task', task.id, error);
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[10px] text-gray-600 font-medium">ERR</span>
|
||||
@ -215,61 +78,92 @@ export function Duties() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-xs text-red-500 text-center py-3">{error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-gray-500 text-center py-6">No tasks with due dates found</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-all duration-200 border border-gray-100"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-shrink-0 w-14 h-14 rounded-md flex items-center justify-center">
|
||||
<TaskDate task={task} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
|
||||
{task.headline}
|
||||
</p>
|
||||
<div className={`px-2 py-1 rounded-full w-3 h-3 ${getTaskStatusColor(task.status)}`}
|
||||
title={getTaskStatusLabel(task.status)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] bg-gray-50">
|
||||
{task.projectName}
|
||||
</Badge>
|
||||
|
||||
{task.milestoneHeadline && (
|
||||
<Badge variant="outline" className="text-[10px] bg-blue-50">
|
||||
{task.milestoneHeadline}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!showHeader) {
|
||||
return renderContent();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
|
||||
<Card className={cardClassName}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Share2 className="h-5 w-5 text-gray-600" />
|
||||
Devoirs
|
||||
Tasks
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fetchTasks()}
|
||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
|
||||
</Button>
|
||||
{showRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={refresh}
|
||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 text-gray-600 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500 text-center py-3">{error}</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 text-center py-6">No tasks with due dates found</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-all duration-200 border border-gray-100"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-blue-50 flex flex-col items-center justify-center border border-blue-100">
|
||||
<TaskDate task={task} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<a
|
||||
href={`https://agilite.slm-lab.net/tickets/showTicket/${task.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 font-medium block text-sm line-clamp-2"
|
||||
>
|
||||
{task.headline}
|
||||
</a>
|
||||
<div className="flex items-center text-gray-500 text-[10px] bg-gray-50 px-1.5 py-0.5 rounded-md">
|
||||
<Folder className="h-2.5 w-2.5 mr-1 opacity-70" />
|
||||
<span className="truncate">{task.projectName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -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<Event[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(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<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
||||
140
hooks/use-tasks.ts
Normal file
140
hooks/use-tasks.ts
Normal file
@ -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<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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;
|
||||
}
|
||||
92
lib/utils/date-utils.ts
Normal file
92
lib/utils/date-utils.ts
Normal file
@ -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 });
|
||||
}
|
||||
55
lib/utils/status-utils.ts
Normal file
55
lib/utils/status-utils.ts
Normal file
@ -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';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user