mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-17 20:27:10 +02:00
493 lines
20 KiB
TypeScript
493 lines
20 KiB
TypeScript
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 MobileLayout from "./MobileLayout";
|
|
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 overflow-hidden ${isCompleted ? 'opacity-60' : ''}`}>
|
|
<CardContent className="p-3">
|
|
{/* 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 break-words ${isCompleted ? 'line-through text-gray-500' : ''}`}>
|
|
{task.title}
|
|
</h3>
|
|
|
|
{/* Description */}
|
|
{task.description && (
|
|
<p className="text-sm text-gray-600 mb-3 line-clamp-2 break-words">{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"
|
|
style={{
|
|
width: '100%',
|
|
maxWidth: '100vw',
|
|
overflowX: 'hidden',
|
|
boxSizing: 'border-box'
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div className="bg-white border-b sticky top-0 z-40" style={{ width: '100%', boxSizing: 'border-box' }}>
|
|
<div className="px-3 py-3">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<ListTodo className="w-5 h-5 text-blue-600" />
|
|
<h1 className="text-lg 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-10 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tabs - Grid layout pour garantir 3 colonnes égales */}
|
|
<div className="grid grid-cols-3 gap-1">
|
|
<Button
|
|
variant={activeTab === 'pending' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setActiveTab('pending')}
|
|
className="h-9 text-xs px-1 truncate"
|
|
>
|
|
⏳ Actives
|
|
</Button>
|
|
<Button
|
|
variant={activeTab === 'completed' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setActiveTab('completed')}
|
|
className="h-9 text-xs px-1 truncate"
|
|
>
|
|
✅ Faites
|
|
</Button>
|
|
<Button
|
|
variant={activeTab === 'all' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setActiveTab('all')}
|
|
className="h-9 text-xs px-1 truncate"
|
|
>
|
|
📋 Toutes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Task List */}
|
|
<div className="px-3 py-2 overflow-hidden" style={{ maxWidth: '100%' }}>
|
|
{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>
|
|
);
|
|
}
|