/
masterOK
/
FEF
Обзор
Документация
Войти
/
masterOK
/
FEF
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
OrderCreatePage.tsx
424 строки
17 KB
masterOK
upload files
10 ноя 2025, 19:52
10 ноя 2025, 19:52
2cb4a49
Код
Авторство
О чём код?
import { useState } from "react"; import { useRoute, useLocation } from "wouter"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Calendar, MapPin, Clock, AlertCircle, Loader2, CreditCard, Video, Phone, MessageCircle } from "lucide-react"; import { apiRequest, queryClient } from "@/lib/queryClient"; import type { MasterProfile } from "@shared/schema"; const orderSchema = z.object({ serviceId: z.string().min(1, "Выберите услугу"), address: z.string().optional(), scheduledDate: z.string().min(1, "Выберите дату и время"), notes: z.string().optional(), isConsultation: z.boolean().default(false), consultationType: z.enum(["video", "phone", "chat"]).optional(), consultationDuration: z.number().optional(), }).superRefine((data, ctx) => { if (!data.isConsultation && !data.address) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Укажите адрес для очного визита", path: ["address"], }); } if (data.isConsultation && !data.consultationType) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Выберите тип консультации", path: ["consultationType"], }); } }); type OrderFormData = z.infer<typeof orderSchema>; export default function OrderCreatePage() { const [, params] = useRoute("/order/create"); const masterId = new URLSearchParams(window.location.search).get("masterId"); const [, setLocation] = useLocation(); const [selectedServiceId, setSelectedServiceId] = useState(""); const { data: master, isLoading: masterLoading, error: masterError } = useQuery<MasterProfile>({ queryKey: [`/api/masters/${masterId}`], enabled: !!masterId, }); const { register, handleSubmit, formState: { errors }, setValue, watch, } = useForm<OrderFormData>({ resolver: zodResolver(orderSchema), defaultValues: { isConsultation: false, address: "", notes: "", consultationType: undefined, consultationDuration: 30, }, }); const watchIsConsultation = watch("isConsultation"); const watchConsultationType = watch("consultationType"); const createOrderMutation = useMutation({ mutationFn: async (data: OrderFormData) => { const services = master?.services || []; const selectedService = services.find((s: any) => s.id === data.serviceId); const consultationLink = data.isConsultation && data.consultationType === "video" ? `https://meet.jit.si/masterok-${masterId}-${Date.now()}` : undefined; return await apiRequest("POST", "/api/orders", { ...data, masterId, totalPrice: selectedService?.price || "0", consultationLink, }); }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["/api/orders"] }); setLocation(`/orders/${data.id}`); }, }); const services = master?.services || []; const selectedService = services.find((s: any) => s.id === selectedServiceId); const onSubmit = (data: OrderFormData) => { createOrderMutation.mutate(data); }; if (!masterId) { return ( <div className="min-h-screen flex items-center justify-center"> <Alert variant="destructive"> <AlertCircle className="h-4 w-4" /> <AlertDescription>Мастер не указан. Вернитесь к поиску мастеров.</AlertDescription> </Alert> </div> ); } if (masterLoading) { return ( <div className="min-h-screen flex items-center justify-center"> <Loader2 className="h-8 w-8 animate-spin text-primary" /> </div> ); } if (masterError || !master) { return ( <div className="min-h-screen flex items-center justify-center"> <Alert variant="destructive"> <AlertCircle className="h-4 w-4" /> <AlertDescription>Не удалось загрузить данные мастера.</AlertDescription> </Alert> </div> ); } if (services.length === 0) { return ( <div className="min-h-screen flex items-center justify-center"> <Alert> <AlertCircle className="h-4 w-4" /> <AlertDescription>У данного мастера пока нет доступных услуг.</AlertDescription> </Alert> </div> ); } return ( <div className="min-h-screen bg-muted/30 py-8"> <div className="container mx-auto px-4 max-w-5xl"> <h1 className="text-3xl font-bold mb-8" data-testid="text-page-title">Создание заказа</h1> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="lg:col-span-2 space-y-6"> <Card> <CardHeader> <CardTitle>1. Выберите услугу</CardTitle> </CardHeader> <CardContent className="space-y-3"> {services.map((service: any) => ( <div key={service.id} onClick={() => { setSelectedServiceId(service.id); setValue("serviceId", service.id); }} className={`p-4 border rounded-lg cursor-pointer transition-all hover-elevate ${ selectedServiceId === service.id ? "border-primary bg-primary/5" : "border-border" }`} data-testid={`card-service-${service.id}`} > <div className="flex justify-between items-start"> <div> <h3 className="font-semibold mb-1">{service.title}</h3> <p className="text-sm text-muted-foreground mb-2">{service.description}</p> {service.durationMinutes && ( <div className="flex items-center gap-1 text-sm text-muted-foreground"> <Clock className="h-3 w-3" /> {service.durationMinutes} минут </div> )} </div> <div className="text-right"> <p className="text-2xl font-bold text-primary"> {parseFloat(service.price).toLocaleString('ru-RU')} ₽ </p> </div> </div> </div> ))} {errors.serviceId && ( <p className="text-sm text-destructive">{errors.serviceId.message}</p> )} </CardContent> </Card> <Card> <CardHeader> <CardTitle>2. Детали заказа</CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <div className="flex items-center justify-between p-4 border rounded-lg bg-muted/50"> <div className="flex items-center gap-3"> <Video className="h-5 w-5 text-primary" /> <div> <Label htmlFor="consultation-mode" className="text-base font-semibold cursor-pointer"> Онлайн-консультация </Label> <p className="text-sm text-muted-foreground"> Дистанционная консультация вместо визита </p> </div> </div> <Switch id="consultation-mode" {...register("isConsultation")} checked={watchIsConsultation} onCheckedChange={(checked) => setValue("isConsultation", checked)} data-testid="switch-consultation" /> </div> {watchIsConsultation && ( <div className="space-y-4"> <div className="space-y-2"> <Label htmlFor="consultationType">Тип консультации</Label> <Select value={watchConsultationType} onValueChange={(value) => setValue("consultationType", value as "video" | "phone" | "chat")} > <SelectTrigger id="consultationType" data-testid="select-consultation-type"> <SelectValue placeholder="Выберите тип" /> </SelectTrigger> <SelectContent> <SelectItem value="video"> <div className="flex items-center gap-2"> <Video className="h-4 w-4" /> Видеозвонок </div> </SelectItem> <SelectItem value="phone"> <div className="flex items-center gap-2"> <Phone className="h-4 w-4" /> Телефонный звонок </div> </SelectItem> <SelectItem value="chat"> <div className="flex items-center gap-2"> <MessageCircle className="h-4 w-4" /> Текстовый чат </div> </SelectItem> </SelectContent> </Select> {errors.consultationType && ( <p className="text-sm text-destructive">{errors.consultationType.message}</p> )} </div> <div className="space-y-2"> <Label htmlFor="consultationDuration">Длительность (минуты)</Label> <Input id="consultationDuration" type="number" min="15" max="180" step="15" {...register("consultationDuration", { valueAsNumber: true })} data-testid="input-consultation-duration" /> </div> <Alert> <Video className="h-4 w-4" /> <AlertDescription> Ссылка для связи будет создана автоматически после подтверждения мастером. </AlertDescription> </Alert> </div> )} {!watchIsConsultation && ( <div className="space-y-2"> <Label htmlFor="address">Адрес</Label> <div className="relative"> <MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Input id="address" placeholder="г. Москва, ул. Примерная, д. 1" className="pl-10" {...register("address")} data-testid="input-address" /> </div> {errors.address && ( <p className="text-sm text-destructive">{errors.address.message}</p> )} </div> )} <div className="space-y-2"> <Label htmlFor="scheduledDate">Дата и время</Label> <div className="relative"> <Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Input id="scheduledDate" type="datetime-local" className="pl-10" {...register("scheduledDate")} data-testid="input-date" /> </div> {errors.scheduledDate && ( <p className="text-sm text-destructive">{errors.scheduledDate.message}</p> )} </div> <div className="space-y-2"> <Label htmlFor="notes">Дополнительные пожелания (необязательно)</Label> <Textarea id="notes" placeholder="Расскажите подробнее о задаче..." rows={4} {...register("notes")} data-testid="input-notes" /> </div> {createOrderMutation.error && ( <Alert variant="destructive"> <AlertCircle className="h-4 w-4" /> <AlertDescription> {(createOrderMutation.error as any).message || "Не удалось создать заказ"} </AlertDescription> </Alert> )} <Button type="submit" className="w-full gap-2" disabled={createOrderMutation.isPending} data-testid="button-submit" > {createOrderMutation.isPending ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : ( <CreditCard className="h-4 w-4" /> )} Создать заказ </Button> </form> </CardContent> </Card> </div> <div className="space-y-6"> {master && ( <Card> <CardHeader> <CardTitle>Мастер</CardTitle> </CardHeader> <CardContent> <div className="flex items-center gap-3 mb-4"> <Avatar> <AvatarImage src={master.user?.avatarUrl || undefined} /> <AvatarFallback>{master.user?.fullName?.charAt(0)}</AvatarFallback> </Avatar> <div> <p className="font-semibold">{master.user?.fullName}</p> <p className="text-sm text-muted-foreground">{master.specialization}</p> </div> </div> {master.isVerified && ( <Badge className="bg-trust-verified text-white"> Проверенный мастер </Badge> )} </CardContent> </Card> )} <Card> <CardHeader> <CardTitle>Итого</CardTitle> </CardHeader> <CardContent className="space-y-4"> {selectedService ? ( <> <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Услуга:</span> <span className="font-medium">{selectedService.title}</span> </div> {selectedService.durationMinutes && ( <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Длительность:</span> <span className="font-medium">{selectedService.durationMinutes} мин</span> </div> )} <div className="pt-4 border-t"> <div className="flex justify-between items-center"> <span className="text-lg font-semibold">Стоимость:</span> <span className="text-2xl font-bold text-primary"> {parseFloat(selectedService.price).toLocaleString('ru-RU')} ₽ </span> </div> </div> </> ) : ( <p className="text-sm text-muted-foreground text-center py-4"> Выберите услугу </p> )} </CardContent> </Card> </div> </div> </div> </div> ); }