feat: introduce responsive task management with a dedicated mobile tasks page and form

This commit is contained in:
Michael committed 2025-12-10 12:51:58 +01:00
1 parent 1df477927a
commit b51687f301
3 files changed
+499 -7

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
/**
* ResponsiveTasks - Wrapper component that automatically detects screen size
* and renders either the mobile or desktop version of the Tasks page.
*/
import { useScreenSize } from "@/hooks/use-screen-size";
import Tasks from "@/pages/Tasks";
import MobileTasksPage from "@/pages/mobile/TasksPage";
export default function ResponsiveTasks() {
const { isMobile } = useScreenSize();
// Render mobile version for screens < 768px
if (isMobile) {
return <MobileTasksPage />;
}
// Render desktop version for larger screens
return <Tasks />;
}
+8 -7
View File
@@ -20,6 +20,7 @@ import DlcPage from "@/pages/DlcPage";
import BackupManager from "@/pages/BackupManager";
import Utilities from "@/pages/Utilities";
import Tasks from "@/pages/Tasks";
import ResponsiveTasks from "@/components/ResponsiveTasks";
import SavTickets from "@/pages/SavTickets";
import Avoirs from "@/pages/Avoirs";
import WeatherSettings from "@/pages/WeatherSettings";
@@ -30,7 +31,7 @@ import Layout from "@/components/Layout";
function RouterProduction() {
const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified();
// Debug uniquement en développement
if (import.meta.env.DEV) {
console.log('🔍 RouterProduction Debug:', {
@@ -43,7 +44,7 @@ function RouterProduction() {
error: error?.message
});
}
// Debug minimal basé sur l'environnement
if (environment === 'production' && error && import.meta.env.DEV) {
console.error('🚨 Production Auth Error:', error);
@@ -76,7 +77,7 @@ function RouterProduction() {
</div>
);
}
return (
<Switch>
<Route path="/auth" component={AuthPage} />
@@ -102,19 +103,19 @@ function RouterProduction() {
<Route path="/customer-orders" component={CustomerOrders} />
<Route path="/dlc" component={DlcPage} />
<Route path="/utilities" component={Utilities} />
<Route path="/tasks" component={Tasks} />
<Route path="/tasks" component={ResponsiveTasks} />
<Route path="/sav" component={SavTickets} />
<Route path="/avoirs" component={Avoirs} />
<Route path="/analytics" component={Analytics} />
<Route path="/sales-analysis" component={SalesAnalysisPage} />
<Route path="/payment-schedule" component={PaymentSchedulePage} />
{/* Routes de compatibilité - redirection vers utilities */}
<Route path="/backup" component={Utilities} />
<Route path="/nocodb-config" component={Utilities} />
<Route path="/database-debug" component={Utilities} />
<Route path="/weather-settings" component={Utilities} />
{/* Redirection depuis /auth vers dashboard après authentification */}
<Route path="/auth">
{() => {
@@ -127,7 +128,7 @@ function RouterProduction() {
return <Dashboard />;
}}
</Route>
<Route path="/" component={Dashboard} />
<Route component={NotFound} />
</Switch>
+472
View File
@@ -0,0 +1,472 @@
import { useState, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ListTodo,
Plus,
Search,
CheckCircle,
Edit,
Trash2,
Calendar,
AlertTriangle,
Clock,
Circle,
X
} from "lucide-react";
import { format, isPast, differenceInDays, isToday } from "date-fns";
import { fr } from "date-fns/locale";
import type { Task } from "@shared/schema";
type TaskWithRelations = Task & {
assignedUser?: { id: string; username: string; firstName?: string; lastName?: string; };
creator?: { id: string; username: string; firstName?: string; lastName?: string; };
group?: { id: number; name: string; color: string; };
isFutureTask?: boolean;
};
// Mobile Task Form Component
function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const assignedTo = formData.get('assignedTo') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
toast({ title: "Erreur", description: "Le titre est requis", variant: "destructive" });
setIsSubmitting(false);
return;
}
try {
const taskData = {
title: title.trim(),
description: description?.trim() || "",
priority: priority || "medium",
status: task?.status || "pending",
assignedTo: assignedTo?.trim() || "Non assigné",
dueDate: dueDate || null,
};
const url = task ? `/api/tasks/${task.id}` : '/api/tasks';
const method = task ? 'PUT' : 'POST';
if (!task) {
(taskData as any).groupId = selectedStoreId ? parseInt(selectedStoreId.toString()) : 1;
(taskData as any).createdBy = user?.username || 'admin';
}
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
if (!response.ok) throw new Error(`Erreur ${response.status}`);
toast({
title: "Succès",
description: task ? "Tâche modifiée" : "Tâche créée",
});
window.location.reload();
} catch (error) {
toast({ title: "Erreur", description: "Erreur lors de l'opération", variant: "destructive" });
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-white z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-white sticky top-0">
<h2 className="text-lg font-semibold">{task ? "Modifier" : "Nouvelle tâche"}</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-auto p-4 space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">Titre *</label>
<Input
name="title"
defaultValue={task?.title || ""}
placeholder="Titre de la tâche"
required
className="h-12 text-base"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Description</label>
<textarea
name="description"
defaultValue={task?.description || ""}
placeholder="Description (optionnel)"
rows={3}
className="w-full p-3 border rounded-md text-base resize-none"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Priorité</label>
<select
name="priority"
defaultValue={task?.priority || "medium"}
className="w-full h-12 p-3 border rounded-md text-base bg-white"
>
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🔴 Élevée</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Date d'échéance</label>
<Input
name="dueDate"
type="date"
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
className="h-12 text-base"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Assigné à</label>
<Input
name="assignedTo"
defaultValue={task?.assignedTo || ""}
placeholder="Nom de la personne"
className="h-12 text-base"
/>
</div>
{/* Submit Button - Fixed at bottom */}
<div className="pt-4 pb-safe">
<Button
type="submit"
disabled={isSubmitting}
className="w-full h-14 text-base font-semibold bg-blue-600 hover:bg-blue-700"
>
{isSubmitting ? "..." : (task ? "Modifier" : "Créer la tâche")}
</Button>
</div>
</form>
</div>
);
}
// Task Card Component
function TaskCard({
task,
onComplete,
onEdit,
onDelete,
canEdit
}: {
task: TaskWithRelations;
onComplete: () => void;
onEdit: () => void;
onDelete: () => void;
canEdit: boolean;
}) {
const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'high': return { color: 'bg-red-100 text-red-700 border-red-200', icon: AlertTriangle, label: 'Élevée' };
case 'medium': return { color: 'bg-yellow-100 text-yellow-700 border-yellow-200', icon: Clock, label: 'Moyenne' };
case 'low': return { color: 'bg-green-100 text-green-700 border-green-200', icon: Circle, label: 'Faible' };
default: return { color: 'bg-gray-100 text-gray-700 border-gray-200', icon: Circle, label: 'Moyenne' };
}
};
const getDueDateInfo = () => {
if (!task.dueDate || task.status === 'completed') return null;
const due = new Date(task.dueDate);
const daysDiff = differenceInDays(due, new Date());
if (isPast(due) && !isToday(due)) {
return { text: `En retard (${Math.abs(daysDiff)}j)`, color: 'text-red-600' };
} else if (isToday(due)) {
return { text: "Aujourd'hui", color: 'text-orange-600' };
} else if (daysDiff <= 3) {
return { text: `Dans ${daysDiff}j`, color: 'text-yellow-600' };
}
return { text: format(due, 'dd/MM', { locale: fr }), color: 'text-gray-500' };
};
const priority = getPriorityConfig(task.priority);
const PriorityIcon = priority.icon;
const dueDateInfo = getDueDateInfo();
const isCompleted = task.status === 'completed';
return (
<Card className={`mb-3 ${isCompleted ? 'opacity-60' : ''}`}>
<CardContent className="p-4">
{/* Header: Priority + Due Date */}
<div className="flex items-center justify-between mb-2">
<Badge className={`${priority.color} border text-xs`}>
<PriorityIcon className="w-3 h-3 mr-1" />
{priority.label}
</Badge>
{dueDateInfo && (
<span className={`text-xs font-medium ${dueDateInfo.color}`}>
<Calendar className="w-3 h-3 inline mr-1" />
{dueDateInfo.text}
</span>
)}
</div>
{/* Title */}
<h3 className={`font-semibold text-base mb-1 ${isCompleted ? 'line-through text-gray-500' : ''}`}>
{task.title}
</h3>
{/* Description */}
{task.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{task.description}</p>
)}
{/* Assigned To */}
{task.assignedTo && (
<p className="text-xs text-gray-500 mb-3">👤 {task.assignedTo}</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t">
{!isCompleted && (
<Button
variant="outline"
size="sm"
onClick={onComplete}
className="flex-1 h-10 text-green-600 border-green-200 hover:bg-green-50"
>
<CheckCircle className="w-4 h-4 mr-1" />
Terminer
</Button>
)}
{canEdit && (
<>
<Button variant="outline" size="sm" onClick={onEdit} className="h-10 px-3">
<Edit className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={onDelete} className="h-10 px-3 text-red-600 border-red-200 hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
</div>
</CardContent>
</Card>
);
}
// Main Mobile Tasks Page
export default function MobileTasksPage() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'completed'>('pending');
const [showForm, setShowForm] = useState(false);
const [editingTask, setEditingTask] = useState<TaskWithRelations | null>(null);
const [deletingTask, setDeletingTask] = useState<TaskWithRelations | null>(null);
// Fetch tasks
const { data: tasks = [], isLoading } = useQuery({
queryKey: ["/api/tasks", selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedStoreId) params.append('storeId', selectedStoreId.toString());
const response = await fetch(`/api/tasks?${params.toString()}`, { credentials: 'include' });
if (!response.ok) throw new Error('Erreur chargement');
const data = await response.json();
return Array.isArray(data) ? data.filter((t: any) => t && t.id) : [];
},
enabled: !!user,
});
// Filter tasks
const filteredTasks = tasks
.filter((task: TaskWithRelations) => {
// Tab filter
if (activeTab === 'pending' && task.status === 'completed') return false;
if (activeTab === 'completed' && task.status !== 'completed') return false;
// Search filter
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
return true;
})
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
// Pending first, then by priority
if (a.status !== b.status) return a.status === 'pending' ? -1 : 1;
const priorityOrder = { high: 3, medium: 2, low: 1 };
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) -
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2);
});
// Handlers
const handleComplete = async (taskId: number) => {
try {
await fetch(`/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({}),
});
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({ title: "✅ Tâche terminée" });
} catch (error) {
toast({ title: "Erreur", variant: "destructive" });
}
};
const handleDelete = async () => {
if (!deletingTask) return;
try {
await fetch(`/api/tasks/${deletingTask.id}`, { method: 'DELETE', credentials: 'include' });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({ title: "🗑️ Tâche supprimée" });
setDeletingTask(null);
} catch (error) {
toast({ title: "Erreur", variant: "destructive" });
}
};
const canEdit = user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur';
// Loading
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pb-24">
{/* Header */}
<div className="bg-white border-b sticky top-0 z-40">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<ListTodo className="w-6 h-6 text-blue-600" />
<h1 className="text-xl font-bold">Tâches</h1>
</div>
<Badge variant="secondary">{filteredTasks.length}</Badge>
</div>
{/* Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-11"
/>
</div>
{/* Tabs */}
<div className="flex gap-2">
{(['pending', 'completed', 'all'] as const).map((tab) => (
<Button
key={tab}
variant={activeTab === tab ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveTab(tab)}
className="flex-1 h-10"
>
{tab === 'pending' && 'En cours'}
{tab === 'completed' && 'Terminées'}
{tab === 'all' && 'Toutes'}
</Button>
))}
</div>
</div>
</div>
{/* Task List */}
<div className="p-4">
{filteredTasks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<ListTodo className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Aucune tâche trouvée</p>
</div>
) : (
filteredTasks.map((task: TaskWithRelations) => (
<TaskCard
key={task.id}
task={task}
onComplete={() => handleComplete(task.id)}
onEdit={() => { setEditingTask(task); setShowForm(true); }}
onDelete={() => setDeletingTask(task)}
canEdit={canEdit}
/>
))
)}
</div>
{/* FAB Button */}
{canEdit && (
<Button
onClick={() => { setEditingTask(null); setShowForm(true); }}
className="fixed bottom-20 right-4 w-14 h-14 rounded-full shadow-lg bg-blue-600 hover:bg-blue-700 z-40"
>
<Plus className="w-6 h-6" />
</Button>
)}
{/* Form Modal */}
{showForm && (
<MobileTaskForm
task={editingTask}
onClose={() => { setShowForm(false); setEditingTask(null); }}
selectedStoreId={selectedStoreId}
user={user}
/>
)}
{/* Delete Confirmation */}
{deletingTask && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end">
<div className="bg-white w-full rounded-t-xl p-4 pb-8">
<h3 className="font-semibold text-lg mb-2">Supprimer cette tâche ?</h3>
<p className="text-gray-600 mb-4">{deletingTask.title}</p>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setDeletingTask(null)} className="flex-1 h-12">
Annuler
</Button>
<Button variant="destructive" onClick={handleDelete} className="flex-1 h-12">
Supprimer
</Button>
</div>
</div>
</div>
)}
</div>
);
}