mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
feat: add mobile pages for SAV, customer orders, credit notes, and DLC, along with a production router.
This commit is contained in:
1 parent
75be8e75b3
commit
676d8cee96
5 files changed
+1408
-20
No files matched your search
@@ -38,6 +38,9 @@ import MobileCalendarPage from "@/pages/mobile/CalendarPage";
|
|||||||
import MobileTasksPage from "@/pages/mobile/TasksPage";
|
import MobileTasksPage from "@/pages/mobile/TasksPage";
|
||||||
import MobilePublicitiesPage from "@/pages/mobile/PublicitiesPage";
|
import MobilePublicitiesPage from "@/pages/mobile/PublicitiesPage";
|
||||||
import MobileCustomerOrdersPage from "@/pages/mobile/CustomerOrdersPage";
|
import MobileCustomerOrdersPage from "@/pages/mobile/CustomerOrdersPage";
|
||||||
|
import MobileDlcPage from "@/pages/mobile/DlcPage";
|
||||||
|
import MobileSavPage from "@/pages/mobile/SavPage";
|
||||||
|
import MobileAvoirsPage from "@/pages/mobile/AvoirsPage";
|
||||||
|
|
||||||
function RouterProduction() {
|
function RouterProduction() {
|
||||||
const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified();
|
const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified();
|
||||||
@@ -106,10 +109,10 @@ function RouterProduction() {
|
|||||||
<Route path="/bl-reconciliation" component={BLReconciliation} />
|
<Route path="/bl-reconciliation" component={BLReconciliation} />
|
||||||
<Route path="/publicities" component={MobilePublicitiesPage} />
|
<Route path="/publicities" component={MobilePublicitiesPage} />
|
||||||
<Route path="/customer-orders" component={MobileCustomerOrdersPage} />
|
<Route path="/customer-orders" component={MobileCustomerOrdersPage} />
|
||||||
<Route path="/dlc" component={DlcPage} />
|
<Route path="/dlc" component={MobileDlcPage} />
|
||||||
<Route path="/utilities" component={Utilities} />
|
<Route path="/utilities" component={Utilities} />
|
||||||
<Route path="/sav" component={SavTickets} />
|
<Route path="/sav" component={MobileSavPage} />
|
||||||
<Route path="/avoirs" component={Avoirs} />
|
<Route path="/avoirs" component={MobileAvoirsPage} />
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={Analytics} />
|
||||||
<Route path="/sales-analysis" component={SalesAnalysisPage} />
|
<Route path="/sales-analysis" component={SalesAnalysisPage} />
|
||||||
<Route path="/payment-schedule" component={PaymentSchedulePage} />
|
<Route path="/payment-schedule" component={PaymentSchedulePage} />
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/**
|
||||||
|
* MobileAvoirsPage.tsx
|
||||||
|
* Version mobile de la page Gestion des Avoirs
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
|
import MobileLayout from "./MobileLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
MoreVertical,
|
||||||
|
Trash2,
|
||||||
|
Ban
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
const avoirSchema = z.object({
|
||||||
|
supplierId: z.coerce.number().min(1, "Fournisseur requis"),
|
||||||
|
invoiceReference: z.string().optional(),
|
||||||
|
amount: z.coerce.number().optional(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
status: z.enum(["En attente de demande", "Demandé", "Reçu"]).default("En attente de demande"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function MobileAvoirsPage() {
|
||||||
|
const { user } = useAuthUnified();
|
||||||
|
const { selectedStoreId } = useStore();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: avoirs = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["/api/avoirs", selectedStoreId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedStoreId) return [];
|
||||||
|
const res = await fetch(`/api/avoirs?storeId=${selectedStoreId}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error("Erreur fetch Avoirs");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedStoreId && !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: suppliers = [] } = useQuery({
|
||||||
|
queryKey: ['/api/suppliers'],
|
||||||
|
queryFn: () => apiRequest('/api/suppliers')
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: groups = [] } = useQuery({
|
||||||
|
queryKey: ['/api/groups'],
|
||||||
|
queryFn: () => apiRequest('/api/groups')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiRequest('/api/avoirs', 'POST', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/avoirs"] });
|
||||||
|
toast({ title: "Avoir créé avec succès" });
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast({ title: "Erreur", description: err.message, variant: "destructive" })
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number, data: any }) => apiRequest(`/api/avoirs/${id}`, 'PUT', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/avoirs"] });
|
||||||
|
toast({ title: "Statut mis à jour" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiRequest(`/api/avoirs/${id}`, 'DELETE'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/avoirs"] });
|
||||||
|
toast({ title: "Avoir supprimé" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(avoirSchema),
|
||||||
|
defaultValues: {
|
||||||
|
supplierId: 0,
|
||||||
|
invoiceReference: "",
|
||||||
|
amount: 0,
|
||||||
|
comment: "",
|
||||||
|
status: "En attente de demande"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => {
|
||||||
|
let groupId = selectedStoreId;
|
||||||
|
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
|
||||||
|
if (!groupId && groups.length > 0 && user?.role === 'admin') groupId = groups[0].id;
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
toast({ title: "Erreur", description: "Aucun magasin sélectionné", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate({
|
||||||
|
...data,
|
||||||
|
groupId,
|
||||||
|
commercialProcessed: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedAvoirs = [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
const filteredAvoirs = sortedAvoirs.filter((a: any) =>
|
||||||
|
(a.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) || "") ||
|
||||||
|
(a.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) || "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "En attente de demande": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">En attente</Badge>;
|
||||||
|
case "Demandé": return <Badge className="bg-blue-100 text-blue-800 border-blue-200">Demandé</Badge>;
|
||||||
|
case "Reçu": return <Badge className="bg-green-100 text-green-800 border-green-200">Reçu</Badge>;
|
||||||
|
default: return <Badge variant="outline">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileLayout>
|
||||||
|
<div className="p-4 space-y-4 pb-20">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6 text-indigo-600" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Suivi des Avoirs</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
placeholder="Fournisseur, Facture..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-9 h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center p-8">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-indigo-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : filteredAvoirs.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 bg-white rounded-lg border border-dashed border-gray-300">
|
||||||
|
<FileText className="h-10 w-10 mx-auto text-gray-300 mb-2" />
|
||||||
|
<p>Aucun avoir trouvé</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAvoirs.map((avoir: any) => (
|
||||||
|
<Card key={avoir.id} className="overflow-hidden">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
{getStatusBadge(avoir.status)}
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{format(new Date(avoir.createdAt), "dd/MM", { locale: fr })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-gray-900">{avoir.supplier?.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{avoir.invoiceReference && (
|
||||||
|
<span className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-600 font-mono">
|
||||||
|
{avoir.invoiceReference}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{avoir.amount && (
|
||||||
|
<span className="text-sm font-bold text-indigo-700">
|
||||||
|
{parseFloat(avoir.amount).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 -mr-2">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||||
|
id: avoir.id,
|
||||||
|
data: { ...avoir, status: 'En attente de demande' }
|
||||||
|
})}>
|
||||||
|
Marquer En attente
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||||
|
id: avoir.id,
|
||||||
|
data: { ...avoir, status: 'Demandé' }
|
||||||
|
})}>
|
||||||
|
Marquer Demandé
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
|
||||||
|
id: avoir.id,
|
||||||
|
data: { ...avoir, status: 'Reçu' }
|
||||||
|
})}>
|
||||||
|
Marquer Reçu
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => deleteMutation.mutate(avoir.id)}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{avoir.comment && (
|
||||||
|
<div className="bg-gray-50 p-2 rounded text-xs text-gray-600 italic mt-2">
|
||||||
|
"{avoir.comment}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avoir.nocodbVerified && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-green-700 bg-green-50 p-1.5 rounded w-fit">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Vérifié Compta
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Sheet */}
|
||||||
|
<Sheet open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<SheetContent side="bottom" className="h-[85vh] overflow-y-auto rounded-t-xl px-4 py-6">
|
||||||
|
<SheetHeader className="mb-6">
|
||||||
|
<SheetTitle className="text-left flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5 bg-indigo-100 text-indigo-600 rounded p-0.5" />
|
||||||
|
Nouvel Avoir
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 pb-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="supplierId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Fournisseur</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||||
|
defaultValue={field.value ? field.value.toString() : undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choisir..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{suppliers.map((s: any) => (
|
||||||
|
<SelectItem key={s.id} value={s.id.toString()}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="invoiceReference"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>Réf. Facture</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="amount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-1/3">
|
||||||
|
<FormLabel>Montant</FormLabel>
|
||||||
|
<FormControl><Input type="number" step="0.01" {...field} /></FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="comment"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Commentaire / Raison</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Erreur prix, manquant..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full bg-indigo-600 hover:bg-indigo-700 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "Création..." : "Créer la demande"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<Button
|
||||||
|
className="fixed bottom-20 right-4 h-14 w-14 rounded-full shadow-lg bg-indigo-600 hover:bg-indigo-700 p-0 flex items-center justify-center z-50"
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-6 w-6 text-white" />
|
||||||
|
</Button>
|
||||||
|
</MobileLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* MobileCustomerOrdersPage.tsx
|
* MobileCustomerOrdersPage.tsx
|
||||||
* Version mobile de la page Commandes Clients
|
* Version mobile de la page Commandes Clients avec création
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
User,
|
User,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Plus,
|
Plus,
|
||||||
MoreVertical
|
MoreVertical,
|
||||||
|
X,
|
||||||
|
Filter
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
@@ -30,14 +32,56 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
// Schema for form validation
|
||||||
|
const customerOrderFormSchema = z.object({
|
||||||
|
customerName: z.string().min(1, "Nom du client est requis"),
|
||||||
|
contactNumber: z.string().optional(),
|
||||||
|
productName: z.string().min(1, "Désignation est requise"),
|
||||||
|
productReference: z.string().optional(),
|
||||||
|
gencode: z.string().optional(),
|
||||||
|
quantity: z.coerce.number().int().positive().default(1),
|
||||||
|
supplierId: z.coerce.number().int().positive().default(1),
|
||||||
|
orderTaker: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
deposit: z.coerce.number().optional().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
export default function MobileCustomerOrdersPage() {
|
export default function MobileCustomerOrdersPage() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Queries
|
||||||
const { data: orders = [], isLoading } = useQuery({
|
const { data: orders = [], isLoading } = useQuery({
|
||||||
queryKey: ["/api/customer-orders", selectedStoreId],
|
queryKey: ["/api/customer-orders", selectedStoreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -52,7 +96,23 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
enabled: !!selectedStoreId && !!user,
|
enabled: !!selectedStoreId && !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutation pour changer le statut
|
const { data: suppliers = [] } = useQuery({
|
||||||
|
queryKey: ['/api/suppliers'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/suppliers', { credentials: 'include' });
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: groups = [] } = useQuery({
|
||||||
|
queryKey: ['/api/groups'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/groups', { credentials: 'include' });
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: number; status: string }) =>
|
mutationFn: ({ id, status }: { id: number; status: string }) =>
|
||||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', { status }),
|
apiRequest(`/api/customer-orders/${id}`, 'PUT', { status }),
|
||||||
@@ -62,22 +122,74 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
|
||||||
|
toast({ title: "Commande créée avec succès" });
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Erreur lors de la création",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form setup
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(customerOrderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
customerName: "",
|
||||||
|
contactNumber: "",
|
||||||
|
productName: "",
|
||||||
|
productReference: "",
|
||||||
|
gencode: "",
|
||||||
|
quantity: 1,
|
||||||
|
supplierId: 1,
|
||||||
|
orderTaker: user?.username || "",
|
||||||
|
notes: "",
|
||||||
|
deposit: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => {
|
||||||
|
// Validation basique groupe
|
||||||
|
let groupId = selectedStoreId;
|
||||||
|
if (!groupId && user?.userGroups?.[0]?.groupId) {
|
||||||
|
groupId = user.userGroups[0].groupId;
|
||||||
|
}
|
||||||
|
if (!groupId && groups.length > 0 && user?.role === 'admin') {
|
||||||
|
groupId = groups[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
toast({ title: "Erreur", description: "Impossible de déterminer le magasin", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...data,
|
||||||
|
customerPhone: data.contactNumber,
|
||||||
|
productDesignation: data.productName,
|
||||||
|
status: "En attente de Commande",
|
||||||
|
groupId,
|
||||||
|
isPromotionalPrice: false,
|
||||||
|
customerNotified: false
|
||||||
|
};
|
||||||
|
|
||||||
|
createMutation.mutate(submitData);
|
||||||
|
};
|
||||||
|
|
||||||
const filteredOrders = orders.filter((order: any) =>
|
const filteredOrders = orders.filter((order: any) =>
|
||||||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase()))
|
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
const getStatusVariant = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Disponible": return "success"; // vert
|
|
||||||
case "Commande en Cours": return "default"; // bleu
|
|
||||||
case "Retiré": return "secondary"; // gris
|
|
||||||
case "Annulé": return "destructive"; // rouge
|
|
||||||
default: return "outline";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "Disponible": return "bg-green-100 text-green-800 border-green-200";
|
case "Disponible": return "bg-green-100 text-green-800 border-green-200";
|
||||||
@@ -189,12 +301,152 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create Order Sheet */}
|
||||||
|
<Sheet open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto rounded-t-xl px-4 py-6">
|
||||||
|
<SheetHeader className="mb-6">
|
||||||
|
<SheetTitle className="text-left flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5 bg-blue-100 text-blue-600 rounded p-0.5" />
|
||||||
|
Nouvelle Commande
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 pb-8">
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-3 rounded-lg space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-gray-500 uppercase">Client</h3>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customerName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nom</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Nom du client" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="contactNumber"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Téléphone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="06..." type="tel" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-3 rounded-lg space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-gray-500 uppercase">Produit</h3>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="productName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Désignation</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Nom du produit" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="quantity"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>Qté</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="deposit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>Acompte (€)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="supplierId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Fournisseur</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||||
|
defaultValue={field.value.toString()}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choisir..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{suppliers.map((s: any) => (
|
||||||
|
<SelectItem key={s.id} value={s.id.toString()}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gencode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Gencode (Scanner si dispo)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="EAN13" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Notes internes</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="..." className="h-20" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full bg-blue-600 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "Création..." : "Valider la commande"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
<Button
|
<Button
|
||||||
className="fixed bottom-20 right-4 h-14 w-14 rounded-full shadow-lg bg-blue-600 hover:bg-blue-700 p-0 flex items-center justify-center z-50"
|
className="fixed bottom-20 right-4 h-14 w-14 rounded-full shadow-lg bg-blue-600 hover:bg-blue-700 p-0 flex items-center justify-center z-50 transition-transform active:scale-95"
|
||||||
onClick={() => {
|
onClick={() => setIsCreateOpen(true)}
|
||||||
// TODO: Create modal logic
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Plus className="h-6 w-6 text-white" />
|
<Plus className="h-6 w-6 text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* MobileDlcPage.tsx
|
||||||
|
* Version mobile de la page Gestion des DLC
|
||||||
|
*/
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
|
import MobileLayout from "./MobileLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
X,
|
||||||
|
Calendar,
|
||||||
|
MoreVertical
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
const dlcFormSchema = z.object({
|
||||||
|
productName: z.string().min(1, "Nom du produit requis"),
|
||||||
|
gencode: z.string().optional(),
|
||||||
|
dlcDate: z.string().min(1, "Date requise"),
|
||||||
|
dateType: z.enum(["dlc", "ddm", "dluo"]).default("dlc"),
|
||||||
|
supplierId: z.coerce.number().min(1, "Fournisseur requis"),
|
||||||
|
status: z.string().default("en_cours"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function MobileDlcPage() {
|
||||||
|
const { user } = useAuthUnified();
|
||||||
|
const { selectedStoreId } = useStore();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Data fetching
|
||||||
|
const { data: dlcProducts = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, searchTerm],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedStoreId) params.append("storeId", selectedStoreId.toString());
|
||||||
|
// Simplify fetching everything client side for mobile smoothness or filter server side?
|
||||||
|
// Let's filter client side for better UX given the likely smaller dataset per store
|
||||||
|
if (selectedStoreId) {
|
||||||
|
const res = await fetch(`/api/dlc-products?storeId=${selectedStoreId}`, { credentials: 'include' });
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
enabled: !!selectedStoreId && !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: suppliers = [] } = useQuery({
|
||||||
|
queryKey: ["/api/suppliers", "dlc"],
|
||||||
|
queryFn: () => apiRequest("/api/suppliers?dlc=true"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: groups = [] } = useQuery({
|
||||||
|
queryKey: ["/api/groups"],
|
||||||
|
queryFn: () => apiRequest("/api/groups"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiRequest("/api/dlc-products", "POST", data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||||
|
toast({ title: "Produit ajouté" });
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast({ title: "Erreur", description: err.message, variant: "destructive" })
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||||
|
toast({ title: "Produit validé (retiré)" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(dlcFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
productName: "",
|
||||||
|
gencode: "",
|
||||||
|
dlcDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
dateType: "dlc",
|
||||||
|
supplierId: 1,
|
||||||
|
status: "en_cours"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => {
|
||||||
|
let groupId = selectedStoreId;
|
||||||
|
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
|
||||||
|
if (!groupId && groups.length > 0 && user?.role === 'admin') groupId = groups[0].id;
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
toast({ title: "Erreur", description: "Magasin introuvable", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...data,
|
||||||
|
name: data.productName,
|
||||||
|
expiryDate: new Date(data.dlcDate),
|
||||||
|
quantity: 1,
|
||||||
|
unit: "unité",
|
||||||
|
location: "Magasin",
|
||||||
|
alertThreshold: 15,
|
||||||
|
groupId
|
||||||
|
};
|
||||||
|
createMutation.mutate(submitData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtering logic
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
if (!Array.isArray(dlcProducts)) return [];
|
||||||
|
|
||||||
|
let filtered = dlcProducts;
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if (searchTerm) {
|
||||||
|
const lower = searchTerm.toLowerCase();
|
||||||
|
filtered = filtered.filter((p: any) =>
|
||||||
|
p.productName.toLowerCase().includes(lower) ||
|
||||||
|
(p.gencode && p.gencode.includes(lower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status logic
|
||||||
|
const today = new Date();
|
||||||
|
filtered = filtered.filter((p: any) => {
|
||||||
|
const expiry = new Date(p.expiryDate);
|
||||||
|
const daysUntil = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (p.status === 'valides') return statusFilter === 'valides';
|
||||||
|
|
||||||
|
if (statusFilter === 'expires') return daysUntil < 0;
|
||||||
|
if (statusFilter === 'expires_soon') return daysUntil >= 0 && daysUntil <= 15;
|
||||||
|
if (statusFilter === 'en_cours') return daysUntil > 15;
|
||||||
|
|
||||||
|
return true; // 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.sort((a: any, b: any) => new Date(a.expiryDate).getTime() - new Date(b.expiryDate).getTime());
|
||||||
|
}, [dlcProducts, searchTerm, statusFilter]);
|
||||||
|
|
||||||
|
const getDaysBadge = (dateStr: string) => {
|
||||||
|
const today = new Date();
|
||||||
|
const expiry = new Date(dateStr);
|
||||||
|
const days = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days < 0) return <Badge variant="destructive">Expiré J{days}</Badge>;
|
||||||
|
if (days <= 3) return <Badge className="bg-red-500 hover:bg-red-600">J-{days}</Badge>;
|
||||||
|
if (days <= 15) return <Badge className="bg-orange-500 hover:bg-orange-600">J-{days}</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-green-600 border-green-200">J-{days}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileLayout>
|
||||||
|
<div className="p-4 space-y-4 pb-20">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-6 w-6 text-orange-600" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Gestion DLC</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-9 h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs for quick filtering */}
|
||||||
|
<Tabs defaultValue="en_cours" value={statusFilter} onValueChange={setStatusFilter} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 h-9">
|
||||||
|
<TabsTrigger value="en_cours" className="text-xs px-0">OK</TabsTrigger>
|
||||||
|
<TabsTrigger value="expires_soon" className="text-xs px-0">Bientôt</TabsTrigger>
|
||||||
|
<TabsTrigger value="expires" className="text-xs px-0">Expirés</TabsTrigger>
|
||||||
|
<TabsTrigger value="valides" className="text-xs px-0">Validés</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center p-8">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : filteredProducts.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 bg-white rounded-lg border border-dashed border-gray-300">
|
||||||
|
<Clock className="h-10 w-10 mx-auto text-gray-300 mb-2" />
|
||||||
|
<p>Aucun produit trouvé</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredProducts.map((p: any) => (
|
||||||
|
<Card key={p.id} className="overflow-hidden">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{getDaysBadge(p.expiryDate)}
|
||||||
|
<span className="text-xs text-gray-500 font-mono">
|
||||||
|
{format(new Date(p.expiryDate), 'dd/MM/yy')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-gray-900">{p.productName}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{p.supplier?.name} {p.gencode ? `• ${p.gencode}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusFilter !== 'valides' && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 -mr-2">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => validateMutation.mutate(p.id)}>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4 text-green-600" />
|
||||||
|
Valider (Sortir)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Sheet */}
|
||||||
|
<Sheet open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto rounded-t-xl px-4 py-6">
|
||||||
|
<SheetHeader className="mb-6">
|
||||||
|
<SheetTitle className="text-left flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5 bg-orange-100 text-orange-600 rounded p-0.5" />
|
||||||
|
Nouveau Produit DLC
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 pb-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="productName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nom du produit</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Beurre, Yaourt..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dlcDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Date Expiration</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dateType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="dlc">DLC</SelectItem>
|
||||||
|
<SelectItem value="ddm">DDM</SelectItem>
|
||||||
|
<SelectItem value="dluo">DLUO</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="supplierId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Fournisseur</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||||
|
defaultValue={field.value.toString()}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choisir..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{suppliers.map((s: any) => (
|
||||||
|
<SelectItem key={s.id} value={s.id.toString()}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gencode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Gencode (Scanner si dispo)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="EAN13" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full bg-orange-600 hover:bg-orange-700 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "Ajout..." : "Ajouter le produit"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<Button
|
||||||
|
className="fixed bottom-20 right-4 h-14 w-14 rounded-full shadow-lg bg-orange-600 hover:bg-orange-700 p-0 flex items-center justify-center z-50"
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-6 w-6 text-white" />
|
||||||
|
</Button>
|
||||||
|
</MobileLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* MobileSavPage.tsx
|
||||||
|
* Version mobile de la page SAV (Service Après-Vente)
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
|
import MobileLayout from "./MobileLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Wrench,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
Camera,
|
||||||
|
Image as ImageIcon,
|
||||||
|
MoreVertical,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Phone
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
// Schema
|
||||||
|
const savFormSchema = z.object({
|
||||||
|
customerName: z.string().min(1, "Nom client requis"),
|
||||||
|
customerPhone: z.string().optional(),
|
||||||
|
productName: z.string().min(1, "Produit requis"),
|
||||||
|
issueDescription: z.string().min(1, "Description requise"),
|
||||||
|
priority: z.enum(["low", "medium", "high", "critical"]).default("medium"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function MobileSavPage() {
|
||||||
|
const { user } = useAuthUnified();
|
||||||
|
const { selectedStoreId } = useStore();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: tickets = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["/api/sav-tickets", selectedStoreId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedStoreId) return [];
|
||||||
|
const res = await fetch(`/api/sav-tickets?storeId=${selectedStoreId}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error("Erreur fetch SAV");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedStoreId && !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: groups = [] } = useQuery({
|
||||||
|
queryKey: ['/api/groups'],
|
||||||
|
queryFn: () => apiRequest('/api/groups')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: number; status: string }) =>
|
||||||
|
apiRequest(`/api/sav-tickets/${id}/status`, 'PATCH', { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/sav-tickets"] });
|
||||||
|
toast({ title: "Statut mis à jour" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiRequest('/api/sav-tickets', 'POST', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/sav-tickets"] });
|
||||||
|
toast({ title: "Ticket SAV créé" });
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast({ title: "Erreur", description: err.message, variant: "destructive" })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(savFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
customerName: "",
|
||||||
|
customerPhone: "",
|
||||||
|
productName: "",
|
||||||
|
issueDescription: "",
|
||||||
|
priority: "medium"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => {
|
||||||
|
let groupId = selectedStoreId;
|
||||||
|
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
|
||||||
|
if (!groupId && groups.length > 0 && user?.role === 'admin') groupId = groups[0].id;
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
toast({ title: "Erreur", description: "Aucun magasin sélectionné", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate({
|
||||||
|
...data,
|
||||||
|
groupId,
|
||||||
|
status: "open"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTickets = tickets.filter((t: any) =>
|
||||||
|
t.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
t.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
t.ticketNumber?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "open": return <Badge className="bg-blue-100 text-blue-800 border-blue-200">Ouvert</Badge>;
|
||||||
|
case "in_progress": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">En cours</Badge>;
|
||||||
|
case "waiting_parts": return <Badge className="bg-orange-100 text-orange-800 border-orange-200">Pièces</Badge>;
|
||||||
|
case "resolved": return <Badge className="bg-green-100 text-green-800 border-green-200">Résolu</Badge>;
|
||||||
|
case "closed": return <Badge className="bg-gray-100 text-gray-800 border-gray-200">Fermé</Badge>;
|
||||||
|
default: return <Badge variant="outline">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileLayout>
|
||||||
|
<div className="p-4 space-y-4 pb-20">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-6 w-6 text-purple-600" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">SAV / Réparations</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
placeholder="N° Ticket, Client, Produit..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-9 h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center p-8">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : filteredTickets.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 bg-white rounded-lg border border-dashed border-gray-300">
|
||||||
|
<Wrench className="h-10 w-10 mx-auto text-gray-300 mb-2" />
|
||||||
|
<p>Aucun ticket SAV</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTickets.map((ticket: any) => (
|
||||||
|
<Card key={ticket.id} className="overflow-hidden">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
{getStatusBadge(ticket.status)}
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
#{ticket.ticketNumber} • {format(new Date(ticket.createdAt), "dd/MM", { locale: fr })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-gray-900">{ticket.customerName}</h3>
|
||||||
|
<p className="text-sm text-gray-600 font-medium">{ticket.productName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 -mr-2">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => statusMutation.mutate({ id: ticket.id, status: 'in_progress' })}>
|
||||||
|
En cours
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => statusMutation.mutate({ id: ticket.id, status: 'waiting_parts' })}>
|
||||||
|
Attente Pièces
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => statusMutation.mutate({ id: ticket.id, status: 'resolved' })} className="text-green-600">
|
||||||
|
Résolu
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => statusMutation.mutate({ id: ticket.id, status: 'closed' })} className="text-gray-600">
|
||||||
|
Fermer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-2 rounded text-xs text-gray-700 line-clamp-2 mb-3">
|
||||||
|
{ticket.issueDescription}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ticket.customerPhone && (
|
||||||
|
<div className="flex mt-2">
|
||||||
|
<a href={`tel:${ticket.customerPhone}`} className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1.5 rounded-full">
|
||||||
|
<Phone className="h-3.5 w-3.5" />
|
||||||
|
Appeler
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Sheet */}
|
||||||
|
<Sheet open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto rounded-t-xl px-4 py-6">
|
||||||
|
<SheetHeader className="mb-6">
|
||||||
|
<SheetTitle className="text-left flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5 bg-purple-100 text-purple-600 rounded p-0.5" />
|
||||||
|
Nouveau Ticket SAV
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 pb-8">
|
||||||
|
<div className="bg-gray-50 p-3 rounded-lg space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-gray-500 uppercase">Client</h3>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customerName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nom</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customerPhone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Téléphone</FormLabel>
|
||||||
|
<FormControl><Input type="tel" {...field} /></FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-3 rounded-lg space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-gray-500 uppercase">Problème</h3>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="productName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Produit</FormLabel>
|
||||||
|
<FormControl><Input placeholder="Marque, Modèle..." {...field} /></FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Priorité</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Faible</SelectItem>
|
||||||
|
<SelectItem value="medium">Moyenne</SelectItem>
|
||||||
|
<SelectItem value="high">Haute</SelectItem>
|
||||||
|
<SelectItem value="critical">Critique</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="issueDescription"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description Panne</FormLabel>
|
||||||
|
<FormControl><Textarea {...field} className="h-24" /></FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full bg-purple-600 hover:bg-purple-700 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "Création..." : "Créer le ticket"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<Button
|
||||||
|
className="fixed bottom-20 right-4 h-14 w-14 rounded-full shadow-lg bg-purple-600 hover:bg-purple-700 p-0 flex items-center justify-center z-50"
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-6 w-6 text-white" />
|
||||||
|
</Button>
|
||||||
|
</MobileLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user