refacto: passing by server actions on taskCard

This commit is contained in:
Julien Froidefond
2025-09-18 09:37:46 +02:00
parent 228e1563c6
commit 4a4eb9c8ad
15 changed files with 286 additions and 330 deletions

26
TODO.md
View File

@@ -138,23 +138,27 @@
- [ ] Graphiques avec Chart.js ou Recharts
- [ ] Export des données en CSV/JSON
## Autre Todo
- [ ] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
### 4.1 Migration vers Server Actions - Actions rapides
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
#### Actions TaskCard (Priorité 1)
- [ ] Créer `actions/tasks.ts` avec server actions de base
- [ ] `updateTaskStatus(taskId, status)` - Changement de statut
- [ ] `updateTaskTitle(taskId, title)` - Édition inline du titre
- [ ] `deleteTask(taskId)` - Suppression de tâche
- [ ] Modifier `TaskCard.tsx` pour utiliser server actions directement
- [ ] Remplacer les props callbacks par calls directs aux actions
- [ ] Intégrer `useTransition` pour les loading states natifs
- [ ] Tester la revalidation automatique du cache
- [ ] **Nettoyage** : Supprimer `PATCH /api/tasks` et `DELETE /api/tasks`
- [ ] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
- [ ] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
- [x] Créer `actions/tasks.ts` avec server actions de base
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
- [x] `deleteTask(taskId)` - Suppression de tâche
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
- [x] Remplacer les props callbacks par calls directs aux actions
- [x] Intégrer `useTransition` pour les loading states natifs
- [x] Tester la revalidation automatique du cache
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
#### Actions Daily (Priorité 2)
- [ ] Créer `actions/daily.ts` pour les checkboxes

View File

@@ -65,43 +65,8 @@ export class TasksClient {
return httpClient.get<TasksResponse>('/tasks', params);
}
/**
* Crée une nouvelle tâche
*/
async createTask(data: CreateTaskData): Promise<{ success: boolean; data: Task; message: string }> {
const payload = {
...data,
dueDate: data.dueDate?.toISOString()
};
return httpClient.post('/tasks', payload);
}
/**
* Met à jour une tâche
*/
async updateTask(data: UpdateTaskData): Promise<{ success: boolean; data: Task; message: string }> {
const payload = {
...data,
dueDate: data.dueDate?.toISOString()
};
return httpClient.patch('/tasks', payload);
}
/**
* Supprime une tâche
*/
async deleteTask(taskId: string): Promise<{ success: boolean; message: string }> {
return httpClient.delete('/tasks', { taskId });
}
/**
* Met à jour le statut d'une tâche
*/
async updateTaskStatus(taskId: string, status: TaskStatus): Promise<{ success: boolean; data: Task; message: string }> {
return this.updateTask({ taskId, status });
}
// Note: Les méthodes createTask, updateTask et deleteTask ont été migrées vers Server Actions
// Voir /src/actions/tasks.ts pour createTask, updateTask, updateTaskTitle, updateTaskStatus, deleteTask
}
// Instance singleton

View File

@@ -6,19 +6,26 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagInput } from '@/components/ui/TagInput';
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
import { UpdateTaskData } from '@/clients/tasks-client';
// UpdateTaskData removed - using Server Actions directly
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
interface EditTaskFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: UpdateTaskData) => Promise<void>;
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
task: Task | null;
loading?: boolean;
}
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
const [formData, setFormData] = useState<Omit<UpdateTaskData, 'taskId'>>({
const [formData, setFormData] = useState<{
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
tags: string[];
dueDate?: Date;
}>({
title: '',
description: '',
status: 'todo' as TaskStatus,

View File

@@ -18,15 +18,13 @@ import { TaskCard } from './TaskCard';
interface KanbanBoardProps {
tasks: Task[];
onCreateTask?: (data: CreateTaskData) => Promise<void>;
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
compactView?: boolean;
visibleStatuses?: TaskStatus[];
}
export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onUpdateTitle, onUpdateStatus, compactView = false, visibleStatuses }: KanbanBoardProps) {
export function KanbanBoard({ tasks, onCreateTask, onEditTask, onUpdateStatus, compactView = false, visibleStatuses }: KanbanBoardProps) {
const [activeTask, setActiveTask] = useState<Task | null>(null);
const { isColumnVisible } = useUserPreferences();
const { isMounted, sensors } = useDragAndDrop();
@@ -95,9 +93,7 @@ export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onU
id={column.id}
tasks={column.tasks}
onCreateTask={onCreateTask}
onDeleteTask={onDeleteTask}
onEditTask={onEditTask}
onUpdateTitle={onUpdateTitle}
compactView={compactView}
/>
))}
@@ -124,9 +120,7 @@ export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onU
<div className="rotate-3 opacity-90">
<TaskCard
task={activeTask}
onDelete={undefined}
onEdit={undefined}
onUpdateTitle={undefined}
/>
</div>
) : null}

View File

@@ -9,8 +9,9 @@ import { KanbanFilters } from './KanbanFilters';
import { EditTaskForm } from '@/components/forms/EditTaskForm';
import { useTasksContext } from '@/contexts/TasksContext';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { Task, TaskStatus } from '@/lib/types';
import { UpdateTaskData, CreateTaskData } from '@/clients/tasks-client';
import { Task, TaskStatus, TaskPriority } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
import { updateTask, createTask } from '@/actions/tasks';
import { getAllStatuses } from '@/lib/status-config';
interface KanbanBoardContainerProps {
@@ -26,13 +27,11 @@ export function KanbanBoardContainer({
filteredTasks,
pinnedTasks,
loading,
createTask,
deleteTask,
updateTask,
updateTaskOptimistic,
kanbanFilters,
setKanbanFilters,
tags
tags,
refreshTasks
} = useTasksContext();
const { preferences, toggleColumnVisibility, isColumnVisible } = useUserPreferences();
@@ -45,24 +44,20 @@ export function KanbanBoardContainer({
setEditingTask(task);
};
const handleUpdateTask = async (data: UpdateTaskData) => {
await updateTask(data);
const handleUpdateTask = async (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => {
const result = await updateTask(data);
if (result.success) {
await refreshTasks(); // Rafraîchir les données
setEditingTask(null);
} else {
console.error('Error updating task:', result.error);
}
};
const handleUpdateTitle = async (taskId: string, newTitle: string) => {
await updateTask({
taskId,
title: newTitle
});
};
const handleUpdateStatus = async (taskId: string, newStatus: TaskStatus) => {
// Utiliser la mise à jour optimiste pour le drag & drop
await updateTaskOptimistic({
taskId,
status: newStatus
});
await updateTaskOptimistic(taskId, newStatus);
};
// Obtenir le nom du tag épinglé pour l'affichage
@@ -70,7 +65,12 @@ export function KanbanBoardContainer({
// Wrapper pour adapter le type de createTask
const handleCreateTask = async (data: CreateTaskData): Promise<void> => {
await createTask(data);
const result = await createTask(data);
if (result.success) {
await refreshTasks(); // Rafraîchir les données
} else {
console.error('Error creating task:', result.error);
}
};
return (
@@ -89,9 +89,7 @@ export function KanbanBoardContainer({
{showObjectives && pinnedTasks.length > 0 && (
<ObjectivesBoard
tasks={pinnedTasks}
onDeleteTask={deleteTask}
onEditTask={handleEditTask}
onUpdateTitle={handleUpdateTitle}
onUpdateStatus={handleUpdateStatus}
compactView={kanbanFilters.compactView}
pinnedTagName={pinnedTagName}
@@ -103,9 +101,7 @@ export function KanbanBoardContainer({
<PrioritySwimlanesBoard
tasks={filteredTasks}
onCreateTask={handleCreateTask}
onDeleteTask={deleteTask}
onEditTask={handleEditTask}
onUpdateTitle={handleUpdateTitle}
onUpdateStatus={handleUpdateStatus}
compactView={kanbanFilters.compactView}
visibleStatuses={visibleStatuses}
@@ -115,9 +111,7 @@ export function KanbanBoardContainer({
<SwimlanesBoard
tasks={filteredTasks}
onCreateTask={handleCreateTask}
onDeleteTask={deleteTask}
onEditTask={handleEditTask}
onUpdateTitle={handleUpdateTitle}
onUpdateStatus={handleUpdateStatus}
compactView={kanbanFilters.compactView}
visibleStatuses={visibleStatuses}
@@ -128,9 +122,7 @@ export function KanbanBoardContainer({
<KanbanBoard
tasks={filteredTasks}
onCreateTask={handleCreateTask}
onDeleteTask={deleteTask}
onEditTask={handleEditTask}
onUpdateTitle={handleUpdateTitle}
onUpdateStatus={handleUpdateStatus}
compactView={kanbanFilters.compactView}
visibleStatuses={visibleStatuses}

View File

@@ -12,13 +12,11 @@ interface KanbanColumnProps {
id: TaskStatus;
tasks: Task[];
onCreateTask?: (data: CreateTaskData) => Promise<void>;
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
compactView?: boolean;
}
export function KanbanColumn({ id, tasks, onCreateTask, onDeleteTask, onEditTask, onUpdateTitle, compactView = false }: KanbanColumnProps) {
export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView = false }: KanbanColumnProps) {
const [showQuickAdd, setShowQuickAdd] = useState(false);
// Configuration de la zone droppable
@@ -91,7 +89,7 @@ export function KanbanColumn({ id, tasks, onCreateTask, onDeleteTask, onEditTask
</div>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} onDelete={onDeleteTask} onEdit={onEditTask} onUpdateTitle={onUpdateTitle} compactView={compactView} />
<TaskCard key={task.id} task={task} onEdit={onEditTask} compactView={compactView} />
))
)}
</div>

View File

@@ -21,9 +21,7 @@ import { useDroppable } from '@dnd-kit/core';
interface ObjectivesBoardProps {
tasks: Task[];
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
compactView?: boolean;
pinnedTagName?: string;
@@ -36,9 +34,7 @@ function DroppableColumn({
title,
color,
icon,
onDeleteTask,
onEditTask,
onUpdateTitle,
compactView
}: {
status: TaskStatus;
@@ -46,9 +42,7 @@ function DroppableColumn({
title: string;
color: string;
icon: string;
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
compactView: boolean;
}) {
const { setNodeRef } = useDroppable({
@@ -80,9 +74,7 @@ function DroppableColumn({
<div key={task.id} className="transform hover:scale-[1.02] transition-transform duration-200">
<TaskCard
task={task}
onDelete={onDeleteTask}
onEdit={onEditTask}
onUpdateTitle={onUpdateTitle}
compactView={compactView}
/>
</div>
@@ -96,9 +88,7 @@ function DroppableColumn({
export function ObjectivesBoard({
tasks,
onDeleteTask,
onEditTask,
onUpdateTitle,
onUpdateStatus,
compactView = false,
pinnedTagName = "Objectifs"
@@ -209,9 +199,7 @@ export function ObjectivesBoard({
title="À faire"
color="bg-[var(--primary)]"
icon="📋"
onDeleteTask={onDeleteTask}
onEditTask={onEditTask}
onUpdateTitle={onUpdateTitle}
compactView={compactView}
/>
@@ -221,9 +209,7 @@ export function ObjectivesBoard({
title="En cours"
color="bg-yellow-400"
icon="🔄"
onDeleteTask={onDeleteTask}
onEditTask={onEditTask}
onUpdateTitle={onUpdateTitle}
compactView={compactView}
/>
@@ -233,9 +219,7 @@ export function ObjectivesBoard({
title="Terminé"
color="bg-green-400"
icon="✅"
onDeleteTask={onDeleteTask}
onEditTask={onEditTask}
onUpdateTitle={onUpdateTitle}
compactView={compactView}
/>
</div>
@@ -267,9 +251,7 @@ export function ObjectivesBoard({
<div className="rotate-3 opacity-90">
<TaskCard
task={activeTask}
onDelete={undefined}
onEdit={undefined}
onUpdateTitle={undefined}
compactView={compactView}
/>
</div>

View File

@@ -10,9 +10,7 @@ interface PrioritySwimlanesBoardProps {
loading: boolean;
tasks: Task[];
onCreateTask?: (data: CreateTaskData) => Promise<void>;
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
compactView?: boolean;
visibleStatuses?: TaskStatus[];
@@ -21,9 +19,7 @@ interface PrioritySwimlanesBoardProps {
export function PrioritySwimlanesBoard({
tasks,
onCreateTask,
onDeleteTask,
onEditTask,
onUpdateTitle,
onUpdateStatus,
compactView = false,
visibleStatuses,
@@ -66,9 +62,7 @@ export function PrioritySwimlanesBoard({
tasks={tasks}
swimlanes={swimlanesData}
onCreateTask={onCreateTask}
onDeleteTask={onDeleteTask}
onEditTask={onEditTask}
onUpdateTitle={onUpdateTitle}
onUpdateStatus={onUpdateStatus}
compactView={compactView}
visibleStatuses={visibleStatuses}

View File

@@ -25,9 +25,7 @@ import { useDroppable } from '@dnd-kit/core';
function DroppableColumn({
status,
tasks,
onDeleteTask,
onEditTask,
onUpdateTitle,
compactView,
onCreateTask,
showQuickAdd,
@@ -36,9 +34,7 @@ function DroppableColumn({
}: {
status: TaskStatus;
tasks: Task[];
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
compactView: boolean;
onCreateTask?: (data: CreateTaskData) => Promise<void>;
showQuickAdd?: boolean;
@@ -60,9 +56,7 @@ function DroppableColumn({
<TaskCard
key={task.id}
task={task}
onDelete={onDeleteTask}
onEdit={onEditTask}
onUpdateTitle={onUpdateTitle}
compactView={compactView}
/>
))}
@@ -117,9 +111,7 @@ interface SwimlanesBaseProps {
tasks: Task[];
swimlanes: SwimlaneData[];
onCreateTask?: (data: CreateTaskData) => Promise<void>;
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
compactView?: boolean;
visibleStatuses?: TaskStatus[];
@@ -129,9 +121,7 @@ export function SwimlanesBase({
tasks,
swimlanes,
onCreateTask,
onDeleteTask,
onEditTask,
onUpdateTitle,
onUpdateStatus,
compactView = false,
visibleStatuses
@@ -270,9 +260,7 @@ export function SwimlanesBase({
key={columnId}
status={status}
tasks={statusTasks}
onDeleteTask={onDeleteTask}
onEditTask={onEditTask}
onUpdateTitle={onUpdateTitle}
compactView={compactView}
onCreateTask={onCreateTask ? (data) => handleQuickAdd(data, columnId) : undefined}
showQuickAdd={showQuickAdd[columnId] || false}

View File

@@ -9,9 +9,7 @@ import { SwimlanesBase, SwimlaneData } from './SwimlanesBase';
interface SwimlanesboardProps {
tasks: Task[];
onCreateTask?: (data: CreateTaskData) => Promise<void>;
onDeleteTask?: (taskId: string) => Promise<void>;
onEditTask?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
compactView?: boolean;
visibleStatuses?: TaskStatus[];
@@ -21,9 +19,7 @@ interface SwimlanesboardProps {
export function SwimlanesBoard({
tasks,
onCreateTask,
onDeleteTask,
onEditTask,
onUpdateTitle,
onUpdateStatus,
compactView = false,
visibleStatuses,
@@ -88,9 +84,7 @@ export function SwimlanesBoard({
tasks={tasks}
swimlanes={swimlanesData}
onCreateTask={onCreateTask}
onDeleteTask={onDeleteTask}
onEditTask={onEditTask}
onUpdateTitle={onUpdateTitle}
onUpdateStatus={onUpdateStatus}
compactView={compactView}
visibleStatuses={visibleStatuses}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useTransition } from 'react';
import { Task } from '@/lib/types';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
@@ -9,21 +9,21 @@ import { useTasksContext } from '@/contexts/TasksContext';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { useDraggable } from '@dnd-kit/core';
import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config';
import { updateTaskTitle, deleteTask } from '@/actions/tasks';
interface TaskCardProps {
task: Task;
onDelete?: (taskId: string) => Promise<void>;
onEdit?: (task: Task) => void;
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
compactView?: boolean;
}
export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView = false }: TaskCardProps) {
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState(task.title);
const [showTooltip, setShowTooltip] = useState(false);
const [isPending, startTransition] = useTransition();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { tags: availableTags } = useTasksContext();
const { tags: availableTags, refreshTasks } = useTasksContext();
const { preferences } = useUserPreferences();
// Helper pour construire l'URL Jira
@@ -61,8 +61,18 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onDelete) {
await onDelete(task.id);
if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) {
startTransition(async () => {
const result = await deleteTask(task.id);
if (!result.success) {
console.error('Error deleting task:', result.error);
// TODO: Afficher une notification d'erreur
} else {
// Rafraîchir les données après suppression réussie
await refreshTasks();
}
});
}
};
@@ -77,7 +87,7 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
const handleTitleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onUpdateTitle && !isDragging) {
if (!isDragging && !isPending) {
setIsEditingTitle(true);
setShowTooltip(false);
}
@@ -85,8 +95,19 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
const handleTitleSave = async () => {
const trimmedTitle = editTitle.trim();
if (trimmedTitle && trimmedTitle !== task.title && onUpdateTitle) {
await onUpdateTitle(task.id, trimmedTitle);
if (trimmedTitle && trimmedTitle !== task.title) {
startTransition(async () => {
const result = await updateTaskTitle(task.id, trimmedTitle);
if (!result.success) {
console.error('Error updating task title:', result.error);
// Remettre l'ancien titre en cas d'erreur
setEditTitle(task.title);
} else {
// Mettre à jour optimistiquement le titre local
// La Server Action a déjà mis à jour la DB, on synchronise juste l'affichage
task.title = trimmedTitle;
}
});
}
setIsEditingTitle(false);
setShowTooltip(false);
@@ -142,7 +163,7 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
onClick={handleTitleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title={onUpdateTitle ? "Cliquer pour éditer" : undefined}
title="Cliquer pour éditer"
>
{titleWithoutEmojis}
</h4>
@@ -190,6 +211,8 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
task.status === 'done' ? 'opacity-60' : ''
} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
@@ -231,17 +254,19 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
{!isEditingTitle && onEdit && (
<button
onClick={handleEdit}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs"
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
title="Modifier la tâche"
>
</button>
)}
{!isEditingTitle && onDelete && (
{!isEditingTitle && (
<button
onClick={handleDelete}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs"
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
title="Supprimer la tâche"
>
×
@@ -270,6 +295,8 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
task.status === 'done' ? 'opacity-60' : ''
} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
@@ -312,7 +339,8 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
{!isEditingTitle && onEdit && (
<button
onClick={handleEdit}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs"
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
title="Modifier la tâche"
>
@@ -320,10 +348,11 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
)}
{/* Bouton de suppression discret - masqué en mode édition */}
{!isEditingTitle && onDelete && (
{!isEditingTitle && (
<button
onClick={handleDelete}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs"
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
title="Supprimer la tâche"
>
×

View File

@@ -1,8 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { tasksClient, TaskFilters, CreateTaskData, UpdateTaskData } from '@/clients/tasks-client';
import { Task, TaskStats } from '@/lib/types';
import { tasksClient, TaskFilters, CreateTaskData } from '@/clients/tasks-client';
import { updateTaskStatus } from '@/actions/tasks';
import { Task, TaskStats, TaskStatus } from '@/lib/types';
interface UseTasksState {
tasks: Task[];
@@ -15,9 +16,7 @@ interface UseTasksState {
interface UseTasksActions {
refreshTasks: () => Promise<void>;
createTask: (data: CreateTaskData) => Promise<Task | null>;
updateTask: (data: UpdateTaskData) => Promise<Task | null>;
updateTaskOptimistic: (data: UpdateTaskData) => Promise<Task | null>;
deleteTask: (taskId: string) => Promise<void>;
updateTaskOptimistic: (taskId: string, status: TaskStatus) => Promise<Task | null>;
setFilters: (filters: TaskFilters) => void;
}
@@ -95,35 +94,13 @@ export function useTasks(
}
}, [refreshTasks]);
/**
* Met à jour une tâche
*/
const updateTask = useCallback(async (data: UpdateTaskData): Promise<Task | null> => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await tasksClient.updateTask(data);
// Rafraîchir la liste après mise à jour
await refreshTasks();
return response.data;
} catch (error) {
setState(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Erreur lors de la mise à jour'
}));
return null;
}
}, [refreshTasks]);
// Note: updateTask et deleteTask ont été migrés vers Server Actions
// Voir /src/actions/tasks.ts pour updateTaskTitle, updateTaskStatus, deleteTask
/**
* Met à jour une tâche de manière optimiste (pour drag & drop)
* Met à jour le statut d'une tâche de manière optimiste (pour drag & drop)
*/
const updateTaskOptimistic = useCallback(async (data: UpdateTaskData): Promise<Task | null> => {
const { taskId, ...updates } = data;
const updateTaskOptimistic = useCallback(async (taskId: string, status: TaskStatus): Promise<Task | null> => {
// 1. Sauvegarder l'état actuel pour rollback
const currentTasks = state.tasks;
const taskToUpdate = currentTasks.find(t => t.id === taskId);
@@ -134,7 +111,7 @@ export function useTasks(
}
// 2. Mise à jour optimiste immédiate de l'état local
const updatedTask = { ...taskToUpdate, ...updates };
const updatedTask = { ...taskToUpdate, status };
const updatedTasks = currentTasks.map(task =>
task.id === taskId ? updatedTask : task
);
@@ -162,24 +139,17 @@ export function useTasks(
syncing: true // Indiquer qu'une synchronisation est en cours
}));
// 3. Appel API en arrière-plan
// 3. Appel Server Action en arrière-plan
try {
const response = await tasksClient.updateTask(data);
const result = await updateTaskStatus(taskId, status);
// Si l'API retourne des données différentes, on met à jour
if (response.data) {
setState(prev => ({
...prev,
tasks: prev.tasks.map(task =>
task.id === taskId ? response.data : task
),
syncing: false // Synchronisation terminée
}));
} else {
// Si l'action réussit, la revalidation automatique se charge du reste
if (result.success) {
setState(prev => ({ ...prev, syncing: false }));
return result.data as Task;
} else {
throw new Error(result.error || 'Erreur lors de la mise à jour');
}
return response.data;
} catch (error) {
// 4. Rollback en cas d'erreur
setState(prev => ({
@@ -207,26 +177,8 @@ export function useTasks(
}
}, [state.tasks]);
/**
* Supprime une tâche
*/
const deleteTask = useCallback(async (taskId: string): Promise<void> => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
await tasksClient.deleteTask(taskId);
// Rafraîchir la liste après suppression
await refreshTasks();
} catch (error) {
setState(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Erreur lors de la suppression'
}));
throw error; // Re-throw pour que l'UI puisse gérer l'erreur
}
}, [refreshTasks]);
// Note: deleteTask a été migré vers Server Actions
// Utilisez directement deleteTask depuis /src/actions/tasks.ts dans les composants
// Charger les tâches au montage seulement si pas de données initiales
useEffect(() => {
@@ -239,9 +191,7 @@ export function useTasks(
...state,
refreshTasks,
createTask,
updateTask,
updateTaskOptimistic,
deleteTask,
setFilters
};
}

165
src/actions/tasks.ts Normal file
View File

@@ -0,0 +1,165 @@
'use server'
import { tasksService } from '@/services/tasks';
import { revalidatePath } from 'next/cache';
import { TaskStatus, TaskPriority } from '@/lib/types';
export type ActionResult<T = unknown> = {
success: boolean;
data?: T;
error?: string;
};
/**
* Server Action pour mettre à jour le statut d'une tâche
*/
export async function updateTaskStatus(
taskId: string,
status: TaskStatus
): Promise<ActionResult> {
try {
const task = await tasksService.updateTask(taskId, { status });
// Revalidation automatique du cache
revalidatePath('/');
revalidatePath('/tasks');
return { success: true, data: task };
} catch (error) {
console.error('Error updating task status:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update task status'
};
}
}
/**
* Server Action pour mettre à jour le titre d'une tâche
*/
export async function updateTaskTitle(
taskId: string,
title: string
): Promise<ActionResult> {
try {
if (!title.trim()) {
return { success: false, error: 'Title cannot be empty' };
}
const task = await tasksService.updateTask(taskId, { title: title.trim() });
// Revalidation automatique du cache
revalidatePath('/');
revalidatePath('/tasks');
return { success: true, data: task };
} catch (error) {
console.error('Error updating task title:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update task title'
};
}
}
/**
* Server Action pour supprimer une tâche
*/
export async function deleteTask(taskId: string): Promise<ActionResult> {
try {
await tasksService.deleteTask(taskId);
// Revalidation automatique du cache
revalidatePath('/');
revalidatePath('/tasks');
return { success: true };
} catch (error) {
console.error('Error deleting task:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete task'
};
}
}
/**
* Server Action pour mettre à jour une tâche complète (formulaire d'édition)
*/
export async function updateTask(data: {
taskId: string;
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
dueDate?: Date;
}): Promise<ActionResult> {
try {
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) {
if (!data.title.trim()) {
return { success: false, error: 'Title cannot be empty' };
}
updateData.title = data.title.trim();
}
if (data.description !== undefined) updateData.description = data.description.trim();
if (data.status !== undefined) updateData.status = data.status;
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.tags !== undefined) updateData.tags = data.tags;
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
const task = await tasksService.updateTask(data.taskId, updateData);
// Revalidation automatique du cache
revalidatePath('/');
revalidatePath('/tasks');
return { success: true, data: task };
} catch (error) {
console.error('Error updating task:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update task'
};
}
}
/**
* Server Action pour créer une nouvelle tâche
*/
export async function createTask(data: {
title: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
}): Promise<ActionResult> {
try {
if (!data.title.trim()) {
return { success: false, error: 'Title is required' };
}
const task = await tasksService.createTask({
title: data.title.trim(),
description: data.description?.trim() || '',
status: data.status || 'todo',
priority: data.priority || 'medium',
tags: data.tags || []
});
// Revalidation automatique du cache
revalidatePath('/');
revalidatePath('/tasks');
return { success: true, data: task };
} catch (error) {
console.error('Error creating task:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create task'
};
}
}

View File

@@ -65,112 +65,8 @@ export async function GET(request: Request) {
}
}
/**
* API route pour créer une nouvelle tâche
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { title, description, status, priority, tags, dueDate } = body;
if (!title) {
return NextResponse.json({
success: false,
error: 'Le titre est requis'
}, { status: 400 });
}
const task = await tasksService.createTask({
title,
description,
status: status as TaskStatus,
priority: priority as TaskPriority,
tags,
dueDate: dueDate ? new Date(dueDate) : undefined
});
return NextResponse.json({
success: true,
data: task,
message: 'Tâche créée avec succès'
});
} catch (error) {
console.error('❌ Erreur lors de la création de la tâche:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}
/**
* API route pour mettre à jour une tâche
*/
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { taskId, ...updates } = body;
if (!taskId) {
return NextResponse.json({
success: false,
error: 'taskId est requis'
}, { status: 400 });
}
// Convertir dueDate si présent
if (updates.dueDate) {
updates.dueDate = new Date(updates.dueDate);
}
const updatedTask = await tasksService.updateTask(taskId, updates);
return NextResponse.json({
success: true,
data: updatedTask,
message: 'Tâche mise à jour avec succès'
});
} catch (error) {
console.error('❌ Erreur lors de la mise à jour de la tâche:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}
/**
* API route pour supprimer une tâche
*/
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get('taskId');
if (!taskId) {
return NextResponse.json({
success: false,
error: 'taskId est requis'
}, { status: 400 });
}
await tasksService.deleteTask(taskId);
return NextResponse.json({
success: true,
message: 'Tâche supprimée avec succès'
});
} catch (error) {
console.error('❌ Erreur lors de la suppression de la tâche:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}
// POST, PATCH, DELETE methods have been migrated to Server Actions
// See /src/actions/tasks.ts for:
// - createTask (replaces POST)
// - updateTask, updateTaskStatus, updateTaskTitle (replaces PATCH)
// - deleteTask (replaces DELETE)

View File

@@ -5,7 +5,7 @@ import { useTasks } from '@/hooks/useTasks';
import { useTags } from '@/hooks/useTags';
import { useUserPreferences } from './UserPreferencesContext';
import { Task, Tag, TaskStats } from '@/lib/types';
import { CreateTaskData, UpdateTaskData, TaskFilters } from '@/clients/tasks-client';
import { CreateTaskData, TaskFilters } from '@/clients/tasks-client';
import { KanbanFilters } from '@/components/kanban/KanbanFilters';
import { sortTasks, getSortOption, DEFAULT_SORT, createSortKey } from '@/lib/sort-config';
@@ -17,9 +17,7 @@ interface TasksContextType {
syncing: boolean;
error: string | null;
createTask: (data: CreateTaskData) => Promise<Task | null>;
updateTask: (data: UpdateTaskData) => Promise<Task | null>;
updateTaskOptimistic: (data: UpdateTaskData) => Promise<Task | null>;
deleteTask: (taskId: string) => Promise<void>;
updateTaskOptimistic: (taskId: string, status: TaskStatus) => Promise<Task | null>;
refreshTasks: () => Promise<void>;
setFilters: (filters: TaskFilters) => void;
// Kanban filters