feat: implement drag-and-drop reordering for daily checkboxes

- Added DnD functionality to `DailySection` for reordering checkboxes using `@dnd-kit/core` and `@dnd-kit/sortable`.
- Introduced `onReorderCheckboxes` prop to handle server updates after reordering.
- Updated `useDaily` hook to streamline error handling during reordering.
- Cleaned up `Header` component by removing unnecessary syncing text.
- Adjusted `DailyPageClient` to pass reorder function to `DailySection`.
This commit is contained in:
Julien Froidefond
2025-09-18 14:56:05 +02:00
parent 2c262b06e7
commit 618b2e9e5c
5 changed files with 220 additions and 60 deletions

View File

@@ -0,0 +1,78 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
import { DailyCheckboxItem } from './DailyCheckboxItem';
interface DailyCheckboxSortableProps {
checkbox: DailyCheckbox;
onToggle: (checkboxId: string) => Promise<void>;
onUpdate: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>;
onDelete: (checkboxId: string) => Promise<void>;
saving?: boolean;
}
export function DailyCheckboxSortable({
checkbox,
onToggle,
onUpdate,
onDelete,
saving = false
}: DailyCheckboxSortableProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: checkbox.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`
${isDragging ? 'z-50' : 'z-0'}
${isDragging ? 'shadow-lg' : ''}
transition-shadow duration-200
`}
>
<div className="relative group">
{/* Handle de drag */}
<div
{...attributes}
{...listeners}
className="absolute left-0 top-0 bottom-0 w-3 cursor-grab active:cursor-grabbing flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
title="Glisser pour réorganiser"
>
<div className="w-1.5 h-6 flex flex-col justify-center gap-0.5">
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
</div>
</div>
{/* Checkbox item avec padding left pour le handle */}
<div className="pl-4">
<DailyCheckboxItem
checkbox={checkbox}
onToggle={onToggle}
onUpdate={onUpdate}
onDelete={onDelete}
saving={saving}
/>
</div>
</div>
</div>
);
}

View File

@@ -3,8 +3,13 @@
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { DailyCheckboxSortable } from './DailyCheckboxSortable';
import { DailyCheckboxItem } from './DailyCheckboxItem';
import { DailyAddForm } from './DailyAddForm';
import { DndContext, closestCenter, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { useState } from 'react';
import React from 'react';
interface DailySectionProps {
title: string;
@@ -14,6 +19,7 @@ interface DailySectionProps {
onToggleCheckbox: (checkboxId: string) => Promise<void>;
onUpdateCheckbox: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>;
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
onReorderCheckboxes: (date: Date, checkboxIds: string[]) => Promise<void>;
onToggleAll?: () => Promise<void>;
saving: boolean;
refreshing?: boolean;
@@ -27,10 +33,13 @@ export function DailySection({
onToggleCheckbox,
onUpdateCheckbox,
onDeleteCheckbox,
onReorderCheckboxes,
onToggleAll,
saving,
refreshing = false
}: DailySectionProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(checkboxes);
const formatShortDate = (date: Date) => {
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
@@ -39,67 +48,136 @@ export function DailySection({
});
};
// Mettre à jour les items quand les checkboxes changent
React.useEffect(() => {
setItems(checkboxes);
}, [checkboxes]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) {
return;
}
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
// Mise à jour optimiste
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
// Envoyer l'ordre au serveur
const checkboxIds = newItems.map(item => item.id);
try {
await onReorderCheckboxes(date, checkboxIds);
} catch (error) {
// Rollback en cas d'erreur
setItems(checkboxes);
console.error('Erreur lors du réordonnancement:', error);
}
}
};
const activeCheckbox = activeId ? items.find(item => item.id === activeId) : null;
return (
<Card className="p-0 flex flex-col h-[600px]">
{/* Header */}
<div className="p-4 pb-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-[var(--foreground)] font-mono flex items-center gap-2">
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]">({formatShortDate(date)})</span>
{refreshing && (
<div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
)}
</h2>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)] font-mono">
{checkboxes.filter(cb => cb.isChecked).length}/{checkboxes.length}
</span>
{onToggleAll && checkboxes.length > 0 && (
<Button
type="button"
onClick={onToggleAll}
variant="ghost"
size="sm"
disabled={saving}
className="text-xs px-2 py-1 h-6"
title="Tout cocher/décocher"
>
</Button>
)}
<DndContext
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
>
<Card className="p-0 flex flex-col h-[600px]">
{/* Header */}
<div className="p-4 pb-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-[var(--foreground)] font-mono flex items-center gap-2">
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]">({formatShortDate(date)})</span>
{refreshing && (
<div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
)}
</h2>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)] font-mono">
{checkboxes.filter(cb => cb.isChecked).length}/{checkboxes.length}
</span>
{onToggleAll && checkboxes.length > 0 && (
<Button
type="button"
onClick={onToggleAll}
variant="ghost"
size="sm"
disabled={saving}
className="text-xs px-2 py-1 h-6"
title="Tout cocher/décocher"
>
</Button>
)}
</div>
</div>
</div>
</div>
{/* Liste des checkboxes - zone scrollable */}
<div className="flex-1 px-4 overflow-y-auto min-h-0">
<div className="space-y-1.5 pb-4">
{checkboxes.map((checkbox) => (
<DailyCheckboxItem
key={checkbox.id}
checkbox={checkbox}
onToggle={onToggleCheckbox}
onUpdate={onUpdateCheckbox}
onDelete={onDeleteCheckbox}
saving={saving}
/>
))}
{/* Liste des checkboxes - zone scrollable avec drag & drop */}
<div className="flex-1 px-4 overflow-y-auto min-h-0">
<SortableContext items={items.map(item => item.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-1.5 pb-4">
{items.map((checkbox) => (
<DailyCheckboxSortable
key={checkbox.id}
checkbox={checkbox}
onToggle={onToggleCheckbox}
onUpdate={onUpdateCheckbox}
onDelete={onDeleteCheckbox}
saving={saving}
/>
))}
{checkboxes.length === 0 && (
<div className="text-center py-8 text-[var(--muted-foreground)] text-sm font-mono">
Aucune tâche pour cette période
{items.length === 0 && (
<div className="text-center py-8 text-[var(--muted-foreground)] text-sm font-mono">
Aucune tâche pour cette période
</div>
)}
</div>
)}
</SortableContext>
</div>
</div>
{/* Footer - Formulaire d'ajout toujours en bas */}
<div className="p-4 pt-2 border-t border-[var(--border)]/30 bg-[var(--card)]/50">
<DailyAddForm
onAdd={onAddCheckbox}
disabled={saving}
/>
</div>
</Card>
{/* Footer - Formulaire d'ajout toujours en bas */}
<div className="p-4 pt-2 border-t border-[var(--border)]/30 bg-[var(--card)]/50">
<DailyAddForm
onAdd={onAddCheckbox}
disabled={saving}
/>
</div>
</Card>
<DragOverlay
dropAnimation={null}
style={{
transformOrigin: '0 0',
}}
>
{activeCheckbox ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-md shadow-xl opacity-95 transform rotate-3 scale-105">
<div className="pl-4">
<DailyCheckboxItem
checkbox={activeCheckbox}
onToggle={() => Promise.resolve()}
onUpdate={() => Promise.resolve()}
onDelete={() => Promise.resolve()}
saving={false}
/>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -51,7 +51,7 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
{title}
</h1>
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-sm">
{subtitle} {syncing && '• Synchronisation...'}
{subtitle}
</p>
</div>
</div>