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:
|
### Completed Refactoring
|
||||||
- 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
|
|
||||||
|
|
||||||
3. **HTML Sanitization**: Email HTML content is sanitized and processed using:
|
The following refactoring tasks have been completed:
|
||||||
- `sanitizeHtml` function in `lib/utils/email-utils.ts` (centralized implementation)
|
|
||||||
- DOMPurify with specific configuration to handle email content safely
|
|
||||||
|
|
||||||
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
|
- **Status Utilities** (`lib/utils/status-utils.ts`): Centralized task status color and label handling
|
||||||
- Email is sent through the `/api/courrier/send` endpoint
|
- **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
|
- **Task Hook** (`hooks/use-tasks.ts`): Reusable hook for fetching and managing task data
|
||||||
- **Email Composition**: Rich text editor with reply and forwarding capabilities
|
- **Calendar Events Hook** (`hooks/use-calendar-events.ts`): Reusable hook for calendar event handling
|
||||||
- **Email Display**: Secure rendering of HTML emails
|
|
||||||
- **Attachment Handling**: View and download attachments
|
|
||||||
|
|
||||||
## 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
|
### In Progress
|
||||||
- `/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
|
|
||||||
|
|
||||||
## 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
|
## API Documentation
|
||||||
- React Server Components
|
|
||||||
- TailwindCSS for styling
|
|
||||||
- Mailparser for email parsing
|
|
||||||
- ImapFlow for email fetching
|
|
||||||
- DOMPurify for HTML sanitization
|
|
||||||
- Redis for caching
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
- HTML sanitization
|
npm run dev
|
||||||
- RTL/LTR text direction
|
# or
|
||||||
- MIME encoding and decoding for email composition
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
Key functions include:
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
- `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.
|
|
||||||
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 />
|
<QuoteCard />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<Calendar />
|
<Calendar limit={5} showMore={true} showRefresh={true} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<News />
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { 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 {
|
interface CalendarProps {
|
||||||
id: string;
|
limit?: number;
|
||||||
title: string;
|
showMore?: boolean;
|
||||||
start: string;
|
showRefresh?: boolean;
|
||||||
end: string;
|
cardClassName?: string;
|
||||||
allDay: boolean;
|
|
||||||
calendar: string;
|
|
||||||
calendarColor: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Calendar() {
|
export function Calendar({
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
limit = 5,
|
||||||
const [loading, setLoading] = useState(true);
|
showMore = true,
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 router = useRouter();
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
const formatEventDate = (date: Date | string, isAllDay: boolean) => {
|
||||||
setLoading(true);
|
const eventDate = date instanceof Date ? date : new Date(date);
|
||||||
try {
|
let dateString = "";
|
||||||
const response = await fetch('/api/calendars');
|
|
||||||
if (!response.ok) {
|
if (isToday(eventDate)) {
|
||||||
throw new Error('Failed to fetch events');
|
dateString = "Today";
|
||||||
|
} else if (isTomorrow(eventDate)) {
|
||||||
|
dateString = "Tomorrow";
|
||||||
|
} else {
|
||||||
|
dateString = format(eventDate, "EEEE d MMMM", { locale: fr });
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarsData = await response.json();
|
if (!isAllDay) {
|
||||||
console.log('Calendar Widget - Fetched calendars:', calendarsData);
|
dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`;
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
return dateString;
|
||||||
fetchEvents();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 (
|
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">
|
<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">
|
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
<CalendarIcon className="h-5 w-5 text-gray-600" />
|
<CalendarIcon className="h-5 w-5 text-gray-600" />
|
||||||
Agenda
|
Calendar
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{showRefresh && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => fetchEvents()}
|
onClick={refresh}
|
||||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
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" />
|
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
|
||||||
</Button>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -123,21 +93,21 @@ export function Calendar() {
|
|||||||
<div
|
<div
|
||||||
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
|
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${event.calendarColor}10`,
|
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
|
||||||
borderColor: event.calendarColor
|
borderColor: event.calendarColor || '#4F46E5'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-medium"
|
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>
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-bold mt-0.5"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<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">
|
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
|
||||||
{event.title}
|
{event.title}
|
||||||
</p>
|
</p>
|
||||||
{!event.allDay && (
|
{!event.isAllDay && (
|
||||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
|
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${event.calendarColor}10`,
|
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
|
||||||
color: event.calendarColor
|
color: event.calendarColor || '#4F46E5'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{event.calendar}</span>
|
<span className="truncate">{event.calendarName || 'Calendar'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RefreshCw, MessageSquare, Mail, MailOpen, Loader2 } from "lucide-react";
|
import { RefreshCw, MessageSquare, Mail, MailOpen, Loader2 } from "lucide-react";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
interface Email {
|
interface Email {
|
||||||
id: string;
|
id: string;
|
||||||
@ -17,28 +18,43 @@ interface Email {
|
|||||||
folder: string;
|
folder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmailResponse {
|
interface EmailProps {
|
||||||
emails: Email[];
|
limit?: number;
|
||||||
mailUrl: string;
|
showTitle?: boolean;
|
||||||
error?: string;
|
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 [emails, setEmails] = useState<Email[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [mailUrl, setMailUrl] = useState<string | null>(null);
|
const [mailUrl, setMailUrl] = useState<string | null>(null);
|
||||||
|
const { status } = useSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") {
|
||||||
fetchEmails();
|
fetchEmails();
|
||||||
}, []);
|
}
|
||||||
|
}, [status, folder, limit]);
|
||||||
|
|
||||||
const fetchEmails = async (isRefresh = false) => {
|
const fetchEmails = async (isRefresh = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch emails');
|
throw new Error('Failed to fetch emails');
|
||||||
}
|
}
|
||||||
@ -52,20 +68,19 @@ export function Email() {
|
|||||||
// Transform data format if needed
|
// Transform data format if needed
|
||||||
const transformedEmails = data.emails.map((email: any) => ({
|
const transformedEmails = data.emails.map((email: any) => ({
|
||||||
id: email.id,
|
id: email.id,
|
||||||
subject: email.subject,
|
subject: email.subject || '(No subject)',
|
||||||
from: email.from[0]?.address || '',
|
from: email.from[0]?.address || '',
|
||||||
fromName: email.from[0]?.name || '',
|
fromName: email.from[0]?.name || '',
|
||||||
date: email.date,
|
date: email.date,
|
||||||
read: email.flags.seen,
|
read: email.flags.seen,
|
||||||
starred: email.flags.flagged,
|
starred: email.flags.flagged,
|
||||||
folder: email.folder
|
folder: email.folder
|
||||||
})).slice(0, 5); // Only show the first 5 emails
|
})).slice(0, limit);
|
||||||
|
|
||||||
setEmails(transformedEmails);
|
setEmails(transformedEmails);
|
||||||
setMailUrl('/courrier');
|
setMailUrl('/courrier');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching emails:', error);
|
|
||||||
setError('Failed to load emails');
|
setError('Failed to load emails');
|
||||||
setEmails([]);
|
setEmails([]);
|
||||||
} finally {
|
} finally {
|
||||||
@ -85,40 +100,33 @@ export function Email() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (error) {
|
||||||
return (
|
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">
|
<div className="text-center py-4 text-gray-500">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : loading && emails.length === 0 ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && emails.length === 0) {
|
||||||
|
return (
|
||||||
<div className="text-center py-6 flex flex-col items-center">
|
<div className="text-center py-6 flex flex-col items-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
||||||
<p className="text-gray-500">Chargement des emails...</p>
|
<p className="text-gray-500">Loading emails...</p>
|
||||||
</div>
|
</div>
|
||||||
) : emails.length === 0 ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emails.length === 0) {
|
||||||
|
return (
|
||||||
<div className="text-center py-6">
|
<div className="text-center py-6">
|
||||||
<p className="text-gray-500">Aucun email non lu</p>
|
<p className="text-gray-500">No emails found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{emails.map((email) => (
|
{emails.map((email) => (
|
||||||
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
|
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
|
||||||
@ -138,15 +146,42 @@ export function Email() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{mailUrl && (
|
{showMoreLink && mailUrl && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
|
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
|
||||||
Voir tous les emails →
|
View all emails →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
|
import { getTaskStatusColor, getTaskStatusLabel } from "@/lib/utils/status-utils";
|
||||||
|
import { useTasks, Task } from "@/hooks/use-tasks";
|
||||||
|
|
||||||
interface Task {
|
interface FlowProps {
|
||||||
id: number;
|
limit?: number;
|
||||||
headline: string;
|
showRefresh?: boolean;
|
||||||
description: string;
|
showHeader?: boolean;
|
||||||
dateToFinish: string | null;
|
cardClassName?: string;
|
||||||
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 ProjectSummary {
|
export function Duties({
|
||||||
name: string;
|
limit = 5,
|
||||||
tasks: {
|
showRefresh = true,
|
||||||
status: number;
|
showHeader = true,
|
||||||
count: number;
|
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,
|
||||||
interface TaskWithDate extends Task {
|
includeDone: false,
|
||||||
validDate?: Date;
|
sortField: 'dateToFinish',
|
||||||
}
|
sortDirection: 'asc'
|
||||||
|
|
||||||
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 => ({
|
// TaskDate component
|
||||||
id: t.id,
|
const TaskDate = ({ task }: { task: Task }) => {
|
||||||
date: t.dateToFinish,
|
const dateStr = task.dateToFinish || task.dueDate;
|
||||||
status: t.status,
|
if (!dateStr) {
|
||||||
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') {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-[10px] text-gray-600 font-medium">NO</span>
|
<span className="text-[10px] text-gray-600 font-medium">NO</span>
|
||||||
@ -205,7 +69,6 @@ export function Duties() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error formatting date for task', task.id, error);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-[10px] text-gray-600 font-medium">ERR</span>
|
<span className="text-[10px] text-gray-600 font-medium">ERR</span>
|
||||||
@ -215,32 +78,28 @@ export function Duties() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
|
|
||||||
<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
|
|
||||||
</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>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-6">
|
<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 className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
<div className="text-xs text-red-500 text-center py-3">{error}</div>
|
<div className="text-xs text-red-500 text-center py-3">{error}</div>
|
||||||
) : tasks.length === 0 ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return (
|
||||||
<div className="text-xs text-gray-500 text-center py-6">No tasks with due dates found</div>
|
<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">
|
<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) => (
|
{tasks.map((task) => (
|
||||||
<div
|
<div
|
||||||
@ -248,28 +107,63 @@ export function Duties() {
|
|||||||
className="p-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-all duration-200 border border-gray-100"
|
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 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">
|
<div className="flex-shrink-0 w-14 h-14 rounded-md flex items-center justify-center">
|
||||||
<TaskDate task={task} />
|
<TaskDate task={task} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
<a
|
<div className="flex items-start justify-between gap-2">
|
||||||
href={`https://agilite.slm-lab.net/tickets/showTicket/${task.id}`}
|
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:text-blue-700 font-medium block text-sm line-clamp-2"
|
|
||||||
>
|
|
||||||
{task.headline}
|
{task.headline}
|
||||||
</a>
|
</p>
|
||||||
<div className="flex items-center text-gray-500 text-[10px] bg-gray-50 px-1.5 py-0.5 rounded-md">
|
<div className={`px-2 py-1 rounded-full w-3 h-3 ${getTaskStatusColor(task.status)}`}
|
||||||
<Folder className="h-2.5 w-2.5 mr-1 opacity-70" />
|
title={getTaskStatusLabel(task.status)} />
|
||||||
<span className="truncate">{task.projectName}</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showHeader) {
|
||||||
|
return renderContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
Tasks
|
||||||
|
</CardTitle>
|
||||||
|
{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">
|
||||||
|
{renderContent()}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,106 +1,121 @@
|
|||||||
import { useState, useEffect } from "react";
|
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) {
|
export interface CalendarEvent {
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
id: string;
|
||||||
const [loading, setLoading] = useState(false);
|
title: string;
|
||||||
const [error, setError] = useState<Error | null>(null);
|
start: Date | string;
|
||||||
|
end: Date | string;
|
||||||
|
isAllDay: boolean;
|
||||||
|
calendarId?: string;
|
||||||
|
calendarName?: string;
|
||||||
|
calendarColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Charger les événements
|
export interface UseCalendarEventsOptions {
|
||||||
|
limit?: number;
|
||||||
|
includeAllDay?: boolean;
|
||||||
|
includeMultiDay?: boolean;
|
||||||
|
filterCalendarIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const fetchEvents = async () => {
|
||||||
if (!calendarId) return;
|
if (status !== "authenticated") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch(
|
try {
|
||||||
`/api/calendars/${calendarId}/events?` +
|
const response = await fetch('/api/calendars');
|
||||||
`start=${start.toISOString()}&end=${end.toISOString()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Erreur ${response.status}: ${await response.text()}`);
|
throw new Error('Failed to fetch events');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const calendarsData = await response.json();
|
||||||
setEvents(data);
|
|
||||||
} catch (err) {
|
// Get current date at the start of the day
|
||||||
console.error("Erreur lors du chargement des événements:", err);
|
const now = new Date();
|
||||||
setError(err as Error);
|
now.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 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 {
|
} finally {
|
||||||
setLoading(false);
|
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();
|
fetchEvents();
|
||||||
}, [calendarId, start.toISOString(), end.toISOString()]);
|
}, [status, limit, includeAllDay, includeMultiDay, filterCalendarIds, refreshTrigger]);
|
||||||
|
|
||||||
return {
|
return { events, loading, error, refresh };
|
||||||
events,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refresh: fetchEvents,
|
|
||||||
createEvent,
|
|
||||||
updateEvent,
|
|
||||||
deleteEvent,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
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