mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-21 14:08:15 +02:00
- Routes en React.lazy (sauf connexion et accueil) avec un Suspense dans la zone de contenu ; recharts n'est plus préchargé. JS initial : 1,72 Mo -> 625 Ko (183 Ko gzip). Rechargement automatique unique si un ancien fichier JS a disparu après un déploiement, et ErrorBoundary par page pour garder le menu affiché en cas d'erreur. - Authentification unifiée sur le cache React Query ['/api/user'] : un seul GET /api/user pour toute l'application (au lieu de 4 à 5 par page), plus de rechargement complet après la connexion. - Pas de nouvel essai sur les erreurs 4xx ; fournisseurs et magasins en cache 5 minutes ; valeur du StoreContext mémoïsée et redimensionnement qui ne re-rend que lorsque le palier d'écran change. - Pages : requêtes inutilisées ou en double supprimées, appels lancés seulement une fois l'utilisateur connu, invalidations ciblées au lieu de rechargements complets, filtres et tris mémoïsés, anciennes données gardées (et estompées) pendant un changement de filtre ou de mois, recherche DLC mobile temporisée, vérifications de factures à concurrence bornée, navigation interne sans rechargement de la page. - index.html : script de bannière Replit retiré, lang="fr". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
699 lines
35 KiB
TypeScript
699 lines
35 KiB
TypeScript
/**
|
|
* MobileCustomerOrdersPage.tsx
|
|
* Version mobile de la page Commandes Clients avec création
|
|
*/
|
|
import { useState, useEffect, useRef, 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 {
|
|
Package,
|
|
Search,
|
|
Phone,
|
|
PhoneCall,
|
|
User,
|
|
ClipboardList,
|
|
Plus,
|
|
MoreVertical,
|
|
X,
|
|
Filter,
|
|
Loader2,
|
|
MessageSquare
|
|
} 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,
|
|
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";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
// 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().min(1, "Prise de commande requise"),
|
|
notes: z.string().optional(),
|
|
deposit: z.coerce.number().optional().default(0),
|
|
});
|
|
|
|
// Référence stable tant que la liste n'est pas chargée
|
|
const NO_ORDERS: any[] = [];
|
|
|
|
export default function MobileCustomerOrdersPage() {
|
|
const { user } = useAuthUnified();
|
|
const { selectedStoreId } = useStore();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
const [contactOrder, setContactOrder] = useState<any | null>(null);
|
|
const [contactComment, setContactComment] = useState("");
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
// Queries
|
|
const { data: orders = NO_ORDERS, isLoading } = useQuery({
|
|
queryKey: ["/api/customer-orders", selectedStoreId],
|
|
queryFn: async () => {
|
|
if (!selectedStoreId) return [];
|
|
const response = await fetch(
|
|
`/api/customer-orders?storeId=${selectedStoreId}`,
|
|
{ credentials: 'include' }
|
|
);
|
|
if (!response.ok) throw new Error("Failed to fetch customer orders");
|
|
return response.json();
|
|
},
|
|
enabled: !!selectedStoreId && !!user,
|
|
});
|
|
|
|
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({
|
|
mutationFn: ({ id, status }: { id: number; status: string }) =>
|
|
apiRequest(`/api/customer-orders/${id}`, 'PUT', { status }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
|
|
toast({ title: "Statut mis à jour" });
|
|
},
|
|
});
|
|
|
|
const markCalledMutation = useMutation({
|
|
mutationFn: ({ id, comment }: { id: number; comment?: string }) =>
|
|
apiRequest(`/api/customer-orders/${id}/mark-called`, 'PATCH', { comment }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
|
|
toast({ title: "Client marqué comme contacté" });
|
|
setContactOrder(null);
|
|
setContactComment("");
|
|
},
|
|
});
|
|
|
|
const unmarkCalledMutation = useMutation({
|
|
mutationFn: (id: number) =>
|
|
apiRequest(`/api/customer-orders/${id}`, 'PUT', { customerNotified: false, notifiedAt: null, notifiedComment: null }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
|
|
toast({ title: "Contact annulé" });
|
|
setContactOrder(null);
|
|
},
|
|
});
|
|
|
|
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
|
|
}
|
|
});
|
|
|
|
// Lookup API ffnancy par gencode ou référence (déclenché manuellement)
|
|
const [articleLookupLoading, setArticleLookupLoading] = useState(false);
|
|
const [articleNotFound, setArticleNotFound] = useState(false);
|
|
|
|
const fetchAndFillArticle = async (params: URLSearchParams) => {
|
|
setArticleLookupLoading(true);
|
|
setArticleNotFound(false);
|
|
try {
|
|
const res = await fetch(`/api/ffnancy/articles?${params}&limit=1`, { credentials: 'include' });
|
|
if (!res.ok) { setArticleNotFound(true); return; }
|
|
const data = await res.json();
|
|
const article = data.articles?.[0];
|
|
if (!article) { setArticleNotFound(true); return; }
|
|
|
|
setArticleNotFound(false);
|
|
form.setValue("productName", article.libelle1, { shouldValidate: true });
|
|
if (article.gtin) form.setValue("gencode", article.gtin, { shouldValidate: true });
|
|
if (article.ref_fou_principale) form.setValue("productReference", article.ref_fou_principale, { shouldValidate: true });
|
|
|
|
let codefouToMatch = article.codefou_principal;
|
|
try {
|
|
const mvtRes = await fetch(
|
|
`/api/ffnancy/mouvements/entrees?artNoId=${article.no_id}&limit=1&dateDebut=2000-01-01`,
|
|
{ credentials: 'include' }
|
|
);
|
|
if (mvtRes.ok) {
|
|
const mvtData = await mvtRes.json();
|
|
const lastEntree = mvtData.entrees?.[0];
|
|
if (lastEntree?.codefou) codefouToMatch = lastEntree.codefou;
|
|
}
|
|
} catch { /* fallback codefou_principal */ }
|
|
|
|
if (codefouToMatch) {
|
|
const matched = (suppliers as any[]).find((s: any) =>
|
|
s.codefou && s.codefou.trim().toLowerCase() === codefouToMatch.trim().toLowerCase()
|
|
) || (suppliers as any[]).find((s: any) =>
|
|
s.name.toLowerCase().trim().includes(article.nom_fou_principal?.toLowerCase().trim() || '') ||
|
|
(article.nom_fou_principal?.toLowerCase().trim() || '').includes(s.name.toLowerCase().trim())
|
|
);
|
|
if (matched) form.setValue("supplierId", matched.id, { shouldValidate: true });
|
|
}
|
|
} catch { setArticleNotFound(true); } finally {
|
|
setArticleLookupLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSearchByGencode = () => {
|
|
const val = form.getValues("gencode");
|
|
if (val) fetchAndFillArticle(new URLSearchParams({ ean: val }));
|
|
};
|
|
|
|
const handleSearchByReference = () => {
|
|
const val = form.getValues("productReference");
|
|
if (val) fetchAndFillArticle(new URLSearchParams({ codein: val }));
|
|
};
|
|
|
|
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: data.isPromotionalPrice,
|
|
customerNotified: data.customerNotified
|
|
};
|
|
|
|
createMutation.mutate(submitData);
|
|
};
|
|
|
|
// Recalculé seulement quand la liste ou la recherche change (et non à
|
|
// chaque frappe dans le commentaire de contact)
|
|
const filteredOrders = useMemo(() => {
|
|
const search = searchTerm.toLowerCase();
|
|
return orders.filter((order: any) =>
|
|
order.customerName.toLowerCase().includes(search) ||
|
|
order.productDesignation.toLowerCase().includes(search) ||
|
|
(order.productReference && order.productReference.toLowerCase().includes(search))
|
|
);
|
|
}, [orders, searchTerm]);
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case "Disponible": return "bg-green-100 text-green-800 border-green-200";
|
|
case "Commande en Cours": return "bg-blue-100 text-blue-800 border-blue-200";
|
|
case "Retiré": return "bg-gray-100 text-gray-800 border-gray-200";
|
|
case "Annulé": return "bg-red-100 text-red-800 border-red-200";
|
|
default: return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<MobileLayout>
|
|
<div className="p-4 space-y-4 pb-20">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-6 w-6 text-blue-600" />
|
|
<h1 className="text-xl font-bold text-gray-900">Commandes Clients</h1>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
|
<Input
|
|
placeholder="Client, produit, rév..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-9 h-10"
|
|
/>
|
|
</div>
|
|
|
|
{/* Orders 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-blue-600 border-t-transparent rounded-full" />
|
|
</div>
|
|
) : filteredOrders.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 bg-white rounded-lg border border-dashed border-gray-300">
|
|
<Package className="h-10 w-10 mx-auto text-gray-300 mb-2" />
|
|
<p>Aucune commande trouvée</p>
|
|
</div>
|
|
) : (
|
|
filteredOrders.map((order: any) => (
|
|
<Card key={order.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">
|
|
<Badge className={`${getStatusColor(order.status)} border px-2 py-0.5 text-xs`}>
|
|
{order.status}
|
|
</Badge>
|
|
<span className="text-xs text-gray-500">
|
|
{format(new Date(order.createdAt), "dd/MM", { locale: fr })}
|
|
</span>
|
|
</div>
|
|
<h3 className="font-bold text-gray-900 leading-tight">
|
|
{order.customerName}
|
|
</h3>
|
|
<p className="text-sm text-gray-600 line-clamp-1 mt-1">
|
|
{order.productDesignation}
|
|
</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: order.id, status: 'Disponible' })}>
|
|
Marquer Disponible
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => statusMutation.mutate({ id: order.id, status: 'Retiré' })}>
|
|
Marquer Retiré
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="text-red-600" onClick={() => statusMutation.mutate({ id: order.id, status: 'Annulé' })}>
|
|
Annuler
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-gray-100 text-sm text-gray-600 flex-wrap">
|
|
{order.customerPhone && (
|
|
<a
|
|
href={`tel:${order.customerPhone}`}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-50 text-green-700 rounded-full hover:bg-green-100 transition-colors"
|
|
>
|
|
<Phone className="h-3.5 w-3.5" />
|
|
<span className="font-medium">Appeler</span>
|
|
</a>
|
|
)}
|
|
<button
|
|
onClick={() => { setContactOrder(order); setContactComment(""); }}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-colors text-xs font-medium ${order.customerNotified ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
|
>
|
|
<PhoneCall className="h-3.5 w-3.5" />
|
|
{order.customerNotified ? 'Contacté' : 'Marquer contacté'}
|
|
</button>
|
|
<div className="flex items-center gap-1.5 ml-auto">
|
|
<div className="bg-gray-100 px-2 py-1 rounded text-xs">
|
|
Qty: {order.quantity || 1}
|
|
</div>
|
|
{order.deposit && parseFloat(order.deposit) > 0 && (
|
|
<div className="bg-orange-100 text-orange-800 px-2 py-1 rounded text-xs font-medium">
|
|
Acompte: {parseFloat(order.deposit)}€
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</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">Vendeur</h3>
|
|
<FormField
|
|
control={form.control}
|
|
name="orderTaker"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Prise par</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Nom du vendeur" {...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>
|
|
<Textarea placeholder="Nom du produit" {...field} />
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="productReference"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Référence (Optionnel)</FormLabel>
|
|
<FormControl>
|
|
<div className="flex gap-2">
|
|
<Input placeholder="REF-123..." {...field} />
|
|
<Button type="button" variant="outline" size="sm" onClick={handleSearchByReference} disabled={articleLookupLoading} className="shrink-0">
|
|
{articleLookupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
<div className="flex gap-2">
|
|
<Input placeholder="EAN13" {...field} />
|
|
<Button type="button" variant="outline" size="sm" onClick={handleSearchByGencode} disabled={articleLookupLoading} className="shrink-0">
|
|
{articleLookupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
|
</Button>
|
|
</div>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name={"isPromotionalPrice" as any}
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
|
<FormControl>
|
|
<Checkbox
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
<div className="space-y-1 leading-none">
|
|
<FormLabel>
|
|
Prix promotionnel / Pub
|
|
</FormLabel>
|
|
</div>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name={"customerNotified" as any}
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
|
<FormControl>
|
|
<Checkbox
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
<div className="space-y-1 leading-none">
|
|
<FormLabel>
|
|
Client déjà notifié
|
|
</FormLabel>
|
|
</div>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="notes"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Notes internes</FormLabel>
|
|
<FormControl>
|
|
<Textarea placeholder="..." className="h-20" {...field} />
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{articleNotFound && (
|
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
<span className="font-medium">Produit non référencé</span> — ce produit n'existe pas dans la base. La commande ne peut pas être créée.
|
|
</div>
|
|
)}
|
|
<Button type="submit" className="w-full bg-blue-600 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending || articleNotFound || articleLookupLoading}>
|
|
{createMutation.isPending ? "Création..." : "Valider la commande"}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* FAB */}
|
|
<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 transition-transform active:scale-95"
|
|
onClick={() => setIsCreateOpen(true)}
|
|
>
|
|
<Plus className="h-6 w-6 text-white" />
|
|
</Button>
|
|
|
|
{/* Modal contact client */}
|
|
{contactOrder && (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50" onClick={() => { setContactOrder(null); setContactComment(""); }}>
|
|
<div className="w-full max-w-lg bg-white rounded-t-2xl p-5 space-y-4" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center gap-2">
|
|
{contactOrder.customerNotified
|
|
? <PhoneCall className="h-5 w-5 text-green-600" />
|
|
: <Phone className="h-5 w-5 text-blue-600" />}
|
|
<h3 className="font-semibold text-gray-900">
|
|
{contactOrder.customerNotified ? "Client déjà contacté" : "Marquer comme contacté"}
|
|
</h3>
|
|
</div>
|
|
<p className="text-sm text-gray-500">{contactOrder.customerName} — {contactOrder.productDesignation}</p>
|
|
|
|
{contactOrder.customerNotified ? (
|
|
<div className="rounded-md bg-green-50 border border-green-200 p-3 space-y-1 text-sm">
|
|
<p className="font-medium text-green-800">
|
|
Contacté le {contactOrder.notifiedAt ? format(new Date(contactOrder.notifiedAt), "dd/MM/yyyy 'à' HH:mm") : "—"}
|
|
</p>
|
|
{contactOrder.notifiedComment && (
|
|
<p className="text-green-700 flex items-start gap-1.5">
|
|
<MessageSquare className="h-4 w-4 mt-0.5 shrink-0" />
|
|
{contactOrder.notifiedComment}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
rows={3}
|
|
placeholder="Commentaire optionnel (ex: laissé un message...)"
|
|
value={contactComment}
|
|
onChange={(e) => setContactComment(e.target.value)}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
<button onClick={() => { setContactOrder(null); setContactComment(""); }} className="flex-1 py-2.5 rounded-lg border border-gray-300 text-sm font-medium text-gray-700">
|
|
{contactOrder.customerNotified ? "Fermer" : "Annuler"}
|
|
</button>
|
|
{contactOrder.customerNotified ? (
|
|
<button
|
|
onClick={() => unmarkCalledMutation.mutate(contactOrder.id)}
|
|
disabled={unmarkCalledMutation.isPending}
|
|
className="flex-1 py-2.5 rounded-lg bg-red-600 text-white text-sm font-medium"
|
|
>
|
|
Annuler le contact
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => markCalledMutation.mutate({ id: contactOrder.id, comment: contactComment || undefined })}
|
|
disabled={markCalledMutation.isPending}
|
|
className="flex-1 py-2.5 rounded-lg bg-green-600 text-white text-sm font-medium flex items-center justify-center gap-2"
|
|
>
|
|
<PhoneCall className="h-4 w-4" /> Confirmer
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</MobileLayout>
|
|
);
|
|
}
|