From 878934e705f1dc559dd4abece498c5d08a1499f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sun, 8 Mar 2026 00:55:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 27 + package.json | 1 + src/app/dashboard/cabinet/page.tsx | 648 +++++++++++++++++++++ src/app/dashboard/offers/page.tsx | 15 +- src/app/dashboard/orders/page.tsx | 633 ++++++++++++++++++++ src/app/layout.tsx | 17 +- src/app/offers/[id]/page.tsx | 35 +- src/app/search/page.tsx | 5 +- src/components/map/LiveMap.tsx | 96 ++- src/components/orders/CreateOrderModal.tsx | 223 +++++++ src/components/providers/QueryProvider.tsx | 25 + src/shared/api/orders.ts | 90 +++ src/shared/lib/formatPrice.ts | 26 + src/widgets/Header.tsx | 20 +- 14 files changed, 1820 insertions(+), 41 deletions(-) create mode 100644 src/app/dashboard/cabinet/page.tsx create mode 100644 src/app/dashboard/orders/page.tsx create mode 100644 src/components/orders/CreateOrderModal.tsx create mode 100644 src/components/providers/QueryProvider.tsx create mode 100644 src/shared/api/orders.ts create mode 100644 src/shared/lib/formatPrice.ts diff --git a/package-lock.json b/package-lock.json index 947c747..85bdf66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", + "@tanstack/react-query": "^5.90.21", "@tiptap/extension-link": "^3.19.0", "@tiptap/react": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", @@ -1891,6 +1892,32 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tiptap/core": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz", diff --git a/package.json b/package.json index a5a1da5..90ac086 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", + "@tanstack/react-query": "^5.90.21", "@tiptap/extension-link": "^3.19.0", "@tiptap/react": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", diff --git a/src/app/dashboard/cabinet/page.tsx b/src/app/dashboard/cabinet/page.tsx new file mode 100644 index 0000000..982ce93 --- /dev/null +++ b/src/app/dashboard/cabinet/page.tsx @@ -0,0 +1,648 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { + Loader2, + Clock, + CheckCircle2, + XCircle, + AlertCircle, + Package, + Timer, + RefreshCw, + ShoppingBag, + User, + MapPin, + ArrowRight, + CreditCard, +} from "lucide-react" +import { useSessionStore } from "@/entities/session/store" +import { formatPrice } from "@/shared/lib/formatPrice" +import { Badge } from "@/components/ui/badge" +import { + Order, + OrderStatus, + getMyOrdersAsCustomer, + getMyOrdersAsPerformer, + acceptOrder, + completeOrder, + cancelOrder, +} from "@/shared/api/orders" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +// ─── SLA Countdown ────────────────────────────────────────────────────────── + +function SlaCountdown({ slaSecondsLeft }: { slaSecondsLeft: number }) { + const [seconds, setSeconds] = React.useState(slaSecondsLeft) + + React.useEffect(() => { + setSeconds(slaSecondsLeft) + if (slaSecondsLeft <= 0) return + const interval = setInterval(() => setSeconds((p) => Math.max(0, p - 1)), 1000) + return () => clearInterval(interval) + }, [slaSecondsLeft]) + + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + const isUrgent = seconds < 300 // < 5 минут — мигает красным + + return ( +
+ + {String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")} +
+ ) +} + +// ─── Статус-бейдж ──────────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record< + OrderStatus, + { label: string; className: string; icon: React.ReactNode } +> = { + Created: { + label: "Создан", + className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", + icon: , + }, + Published: { + label: "Опубликован", + className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300", + icon: , + }, + PendingAcceptance: { + label: "Ожидает мастера", + className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", + icon: , + }, + InProgress: { + label: "В работе", + className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", + icon: , + }, + Completed: { + label: "Завершён", + className: + "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400", + icon: , + }, + Cancelled: { + label: "Отменён", + className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400", + icon: , + }, + Expired: { + label: "Время истекло", + className: + "bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400", + icon: , + }, +} + +function StatusBadge({ status }: { status: OrderStatus }) { + const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created + return ( + + {cfg.icon} + {cfg.label} + + ) +} + +// ─── Карточка заказа ───────────────────────────────────────────────────────── + +interface OrderCardProps { + order: Order + userId: string + isPerformerRole: boolean +} + +function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) { + const qc = useQueryClient() + const router = useRouter() + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["orders"] }) + } + + const acceptMutation = useMutation({ + mutationFn: () => acceptOrder(order.id, userId), + onSuccess: () => { + toast.success("Заказ принят!") + invalidate() + }, + onError: (err: any) => + toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"), + }) + + const completeMutation = useMutation({ + mutationFn: () => completeOrder(order.id, userId), + onSuccess: () => { + toast.success("Заказ завершён!") + invalidate() + }, + onError: (err: any) => + toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"), + }) + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => cancelOrder(order.id, userId, reason), + onSuccess: () => { + toast.success("Заказ отменён") + invalidate() + }, + onError: (err: any) => + toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"), + }) + + const isLoading = + acceptMutation.isPending || + completeMutation.isPending || + cancelMutation.isPending + + const isCustomer = order.customerId === userId + + const formatDate = (date: string) => + new Date(date).toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + + return ( +
+ {/* Верхняя полоска с ролью */} +
+
+
+ {isCustomer ? "Покупка (Я Клиент)" : "Продажа (Я Мастер)"} +
+ ID {order.id.slice(0, 8)} +
+ +
+ {/* Шапка: Статус и Таймер */} +
+
+ + + {formatDate(order.createdAt)} + +
+ {order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && ( +
+ +
+ )} +
+ + {/* Блок услуги (карточка) */} +
router.push(`/offers/${order.serviceId}`)} + className="relative rounded-2xl border border-border/40 bg-muted/20 p-5 space-y-4 cursor-pointer hover:bg-muted/40 hover:border-primary/20 transition-all group/card" + > +
+
+

+ {order.serviceTitle} +

+
+
+ + + {isCustomer + ? <>Исполнитель: {order.performerName || "Ожидание выбора..."} + : <>Заказчик: {order.customerName} + } + +
+ {order.address && ( +
+ + {order.address} +
+ )} +
+
+
+
+

+ {formatPrice(order.priceAmount, order.priceType)} +

+
+
+
+ +
+ +
+
+ + {/* Доп. инфо (отмена) */} + {order.cancellationReason && ( +
+ +

+ Причина отмены + {order.cancellationReason} +

+
+ )} + + {/* Кнопки действий */} +
+ {/* Для мастера: Принять/Отклонить */} + {!isCustomer && order.status === "PendingAcceptance" && ( +
+ + +
+ )} + + {/* Завершить работу (мастер или клиент) */} + {order.status === "InProgress" && ( + + )} + + {/* Клиент: Отменить (пока мастер не принял) */} + {isCustomer && order.status === "PendingAcceptance" && ( + + )} +
+
+
+ ) +} + +// ─── Главная страница ───────────────────────────────────────────────────────── + +const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"] +const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"] +const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"] + +type OrderTab = "new" | "process" | "done" + +export default function OrdersDashboardPage() { + const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore() + const router = useRouter() + + const [customerTab, setCustomerTab] = React.useState("new") + const [performerTab, setPerformerTab] = React.useState("new") + + // Редирект если не авторизован + React.useEffect(() => { + if (isInitialized && !isAuthLoading && !isAuth) { + router.push("/auth/login") + } + }, [isAuth, isAuthLoading, isInitialized, router]) + + const isMaster = !!user?.roles?.some((r) => ["Master", "Company", "Candidate", "Admin"].includes(r)) + + // ─── React Query с polling каждые 10 секунд ───────────────────────────── + + const customerQuery = useQuery({ + queryKey: ["orders", "customer", user?.id], + queryFn: () => getMyOrdersAsCustomer(user!.id), + enabled: !!user?.id, + refetchInterval: 10_000, // polling каждые 10 секунд + }) + + const performerQuery = useQuery({ + queryKey: ["orders", "performer", user?.id], + queryFn: () => getMyOrdersAsPerformer(user!.id), + enabled: !!user?.id && isMaster, + refetchInterval: 10_000, // polling каждые 10 секунд + }) + + // Показываем уведомление при поступлении новых заказов + const notifiedOrdersRef = React.useRef>(new Set()) + React.useEffect(() => { + if (performerQuery.data && performerQuery.data.length > 0) { + const pendingOrders = performerQuery.data.filter( + (o) => o.status === "PendingAcceptance" + ) + + let hasNew = false + pendingOrders.forEach((o) => { + if (!notifiedOrdersRef.current.has(o.id)) { + notifiedOrdersRef.current.add(o.id) + hasNew = true + } + }) + + if (hasNew) { + toast("У вас новый заказ!", { + description: "Загляните во вкладку «Активные», чтобы принять его.", + icon: , + duration: 10000, + }) + } + } + }, [performerQuery.data]) + + const qc = useQueryClient() + const refetch = () => { + qc.invalidateQueries({ queryKey: ["orders"] }) + } + + // Объединяем заказы без дублей + const allOrdersMap = new Map< + string, + { order: Order; isPerformerRole: boolean } + >() + ; (customerQuery.data ?? []).forEach((o) => + allOrdersMap.set(o.id, { order: o, isPerformerRole: false }) + ) + ; (performerQuery.data ?? []).forEach((o) => { + if (!allOrdersMap.has(o.id)) { + allOrdersMap.set(o.id, { order: o, isPerformerRole: true }) + } + }) + const allOrders = Array.from(allOrdersMap.values()) + + const isLoading = + (customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data + const isFetching = customerQuery.isFetching || performerQuery.isFetching + const error = customerQuery.error || performerQuery.error + + const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => { + if (!data) return [] + if (tab === "new") return data.filter(o => NEW_STATUSES.includes(o.status)) + if (tab === "process") return data.filter(o => PROGRESS_STATUSES.includes(o.status)) + return data.filter(o => DONE_STATUSES.includes(o.status)) + } + + const getCounts = (data: Order[] | undefined) => { + if (!data) return { new: 0, process: 0, done: 0 } + return { + new: data.filter(o => NEW_STATUSES.includes(o.status)).length, + process: data.filter(o => PROGRESS_STATUSES.includes(o.status)).length, + done: data.filter(o => DONE_STATUSES.includes(o.status)).length, + } + } + + const customerCounts = getCounts(customerQuery.data) + const performerCounts = getCounts(performerQuery.data) + + // Ждём инициализации сессии + if (!isInitialized || isAuthLoading) { + return ( +
+ +
+ ) + } + + if (!user) return null + + return ( +
+ {/* Заголовок */} +
+
+

+ + Личный кабинет +

+

+ Управление вашими заказами и услугами +

+
+
+ {isFetching && ( + + + Sync + + )} + +
+
+ +
+ {/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */} +
+
+
+

+ + Заказы +

+

Мои заказы

+
+
+ +
+
+ {[ + { id: "new" as OrderTab, label: "Новые", count: customerCounts.new }, + { id: "process" as OrderTab, label: "В работе", count: customerCounts.process }, + { id: "done" as OrderTab, label: "Завершенные", count: customerCounts.done }, + ].map(t => ( + + ))} +
+
+ +
+ {customerQuery.isLoading ? ( +
+ + Загрузка ваших заказов... +
+ ) : ( + getFilteredOrders(customerQuery.data, customerTab).length === 0 ? ( +
+
+ +
+

Здесь пока ничего нет

+ {customerTab === "new" && ( + + )} +
+ ) : ( + getFilteredOrders(customerQuery.data, customerTab).map(order => ( + + )) + ) + )} +
+
+ + {/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */} +
+
+
+

+ + Услуги +

+

Заказы клиентов

+
+
+ + {!isMaster ? ( +
+
+ +
+

+ Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую. +

+ +
+ ) : ( + <> +
+
+ {[ + { id: "new" as OrderTab, label: "Новые", count: performerCounts.new }, + { id: "process" as OrderTab, label: "В работе", count: performerCounts.process }, + { id: "done" as OrderTab, label: "Завершенные", count: performerCounts.done }, + ].map(t => ( + + ))} +
+
+ +
+ {performerQuery.isLoading ? ( +
+ + Синхронизация заказов... +
+ ) : ( + getFilteredOrders(performerQuery.data, performerTab).length === 0 ? ( +
+
+ +
+

Новых заявок пока нет

+
+ ) : ( + getFilteredOrders(performerQuery.data, performerTab).map(order => ( + + )) + ) + )} +
+ + )} +
+
+
+ ) +} diff --git a/src/app/dashboard/offers/page.tsx b/src/app/dashboard/offers/page.tsx index 17a5b39..fe35de3 100644 --- a/src/app/dashboard/offers/page.tsx +++ b/src/app/dashboard/offers/page.tsx @@ -9,6 +9,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle, Image as ImageIcon } from "lucide-react"; import { useSessionStore } from "@/entities/session/store"; +import { formatPrice } from "@/shared/lib/formatPrice"; export default function MyOffersPage() { const router = useRouter(); @@ -133,11 +134,15 @@ export default function MyOffersPage() {

{offer.description}

-
- {offer.price.amount} ₽ - - {offer.price.type === 0 ? "фиксированно" : offer.price.type === 1 ? "в час" : "договорная"} - +
+ {offer.price.type === 2 + ? Договорная + : <> + {formatPrice(offer.price.amount)} + + {offer.price.type === 1 ? "в час" : "фиксированно"} + + }
diff --git a/src/app/dashboard/orders/page.tsx b/src/app/dashboard/orders/page.tsx new file mode 100644 index 0000000..1ebe84e --- /dev/null +++ b/src/app/dashboard/orders/page.tsx @@ -0,0 +1,633 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { + Loader2, + Clock, + CheckCircle2, + XCircle, + AlertCircle, + Package, + Timer, + RefreshCw, + ShoppingBag, + User, + MapPin, + ArrowRight, + CreditCard, +} from "lucide-react" +import { useSessionStore } from "@/entities/session/store" +import { formatPrice } from "@/shared/lib/formatPrice" +import { Badge } from "@/components/ui/badge" +import { + Order, + OrderStatus, + getMyOrdersAsCustomer, + getMyOrdersAsPerformer, + acceptOrder, + completeOrder, + cancelOrder, +} from "@/shared/api/orders" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +// ─── SLA Countdown ────────────────────────────────────────────────────────── + +function SlaCountdown({ slaSecondsLeft }: { slaSecondsLeft: number }) { + const [seconds, setSeconds] = React.useState(slaSecondsLeft) + + React.useEffect(() => { + setSeconds(slaSecondsLeft) + if (slaSecondsLeft <= 0) return + const interval = setInterval(() => setSeconds((p) => Math.max(0, p - 1)), 1000) + return () => clearInterval(interval) + }, [slaSecondsLeft]) + + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + const isUrgent = seconds < 300 // < 5 минут — мигает красным + + return ( +
+ + {String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")} +
+ ) +} + +// ─── Статус-бейдж ──────────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record< + OrderStatus, + { label: string; className: string; icon: React.ReactNode } +> = { + Created: { + label: "Создан", + className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", + icon: , + }, + Published: { + label: "Опубликован", + className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300", + icon: , + }, + PendingAcceptance: { + label: "Ожидает мастера", + className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", + icon: , + }, + InProgress: { + label: "В работе", + className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", + icon: , + }, + Completed: { + label: "Завершён", + className: + "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400", + icon: , + }, + Cancelled: { + label: "Отменён", + className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400", + icon: , + }, + Expired: { + label: "Время истекло", + className: + "bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400", + icon: , + }, +} + +function StatusBadge({ status }: { status: OrderStatus }) { + const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created + return ( + + {cfg.icon} + {cfg.label} + + ) +} + +// ─── Карточка заказа ───────────────────────────────────────────────────────── + +interface OrderCardProps { + order: Order + userId: string + isPerformerRole: boolean +} + +function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) { + const qc = useQueryClient() + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["orders"] }) + } + + const acceptMutation = useMutation({ + mutationFn: () => acceptOrder(order.id, userId), + onSuccess: () => { + toast.success("Заказ принят!") + invalidate() + }, + onError: (err: any) => + toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"), + }) + + const completeMutation = useMutation({ + mutationFn: () => completeOrder(order.id, userId), + onSuccess: () => { + toast.success("Заказ завершён!") + invalidate() + }, + onError: (err: any) => + toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"), + }) + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => cancelOrder(order.id, userId, reason), + onSuccess: () => { + toast.success("Заказ отменён") + invalidate() + }, + onError: (err: any) => + toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"), + }) + + const isLoading = + acceptMutation.isPending || + completeMutation.isPending || + cancelMutation.isPending + + const isCustomer = order.customerId === userId + // Мастер считается исполнителем, если он SelectedPerformerId (для Direct) + // или если он уже принял заказ (статус перешел в InProgress) + const isPerformer = order.performerId === userId + + const formatDate = (date: string) => + new Date(date).toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + + return ( +
+ {/* Верхняя полоска с ролью */} +
+
+ {isCustomer ? "🛒 Мой заказ (я клиент)" : "🛠 Заказ услуги (я мастер)"} +
+ #{order.id.slice(0, 8)} +
+ +
+ {/* Шапка: Статус и Таймер */} +
+
+ +

+ {formatDate(order.createdAt)} +

+
+ {order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && ( +
+ +
+ )} +
+ + {/* Блок услуги (карточка) */} +
+
+
+

+ {order.serviceTitle} +

+
+ + + {isCustomer + ? `Исполнитель: ${order.performerName || "Ожидание..."}` + : `Заказчик: ${order.customerName}`} + +
+
+
+

+ {formatPrice(order.priceAmount, order.priceType)} +

+
+
+ + {order.address && ( +
+ + {order.address} +
+ )} +
+ + {/* Доп. инфо (отмена) */} + {order.cancellationReason && ( +
+ +

Причина отмены: {order.cancellationReason}

+
+ )} + + {/* Кнопки действий */} + {/* Для мастера: Принять/Отклонить */} + {!isCustomer && order.status === "PendingAcceptance" && ( +
+ + +
+ )} + + {/* Завершить работу (мастер или клиент) */} + {order.status === "InProgress" && ( + + )} + + {/* Клиент: Отменить (пока мастер не принял) */} + {isCustomer && order.status === "PendingAcceptance" && ( + + )} +
+
+ ) +} + +// ─── Главная страница ───────────────────────────────────────────────────────── + +const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"] +const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"] +const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"] + +type OrderTab = "new" | "process" | "done" + +export default function OrdersDashboardPage() { + const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore() + const router = useRouter() + + const [customerTab, setCustomerTab] = React.useState("new") + const [performerTab, setPerformerTab] = React.useState("new") + + // Редирект если не авторизован + React.useEffect(() => { + if (isInitialized && !isAuthLoading && !isAuth) { + router.push("/auth/login") + } + }, [isAuth, isAuthLoading, isInitialized, router]) + + const isMaster = !!user?.roles?.some((r) => ["Master", "Company", "Candidate", "Admin"].includes(r)) + + // ─── React Query с polling каждые 10 секунд ───────────────────────────── + + const customerQuery = useQuery({ + queryKey: ["orders", "customer", user?.id], + queryFn: () => getMyOrdersAsCustomer(user!.id), + enabled: !!user?.id, + refetchInterval: 10_000, // polling каждые 10 секунд + }) + + const performerQuery = useQuery({ + queryKey: ["orders", "performer", user?.id], + queryFn: () => getMyOrdersAsPerformer(user!.id), + enabled: !!user?.id && isMaster, + refetchInterval: 10_000, // polling каждые 10 секунд + }) + + // Показываем уведомление при поступлении новых заказов + const notifiedOrdersRef = React.useRef>(new Set()) + React.useEffect(() => { + if (performerQuery.data && performerQuery.data.length > 0) { + const pendingOrders = performerQuery.data.filter( + (o) => o.status === "PendingAcceptance" + ) + + let hasNew = false + pendingOrders.forEach((o) => { + if (!notifiedOrdersRef.current.has(o.id)) { + notifiedOrdersRef.current.add(o.id) + hasNew = true + } + }) + + if (hasNew) { + toast("У вас новый заказ!", { + description: "Загляните во вкладку «Активные», чтобы принять его.", + icon: , + duration: 10000, + }) + } + } + }, [performerQuery.data]) + + const qc = useQueryClient() + const refetch = () => { + qc.invalidateQueries({ queryKey: ["orders"] }) + } + + // Объединяем заказы без дублей + const allOrdersMap = new Map< + string, + { order: Order; isPerformerRole: boolean } + >() + ; (customerQuery.data ?? []).forEach((o) => + allOrdersMap.set(o.id, { order: o, isPerformerRole: false }) + ) + ; (performerQuery.data ?? []).forEach((o) => { + if (!allOrdersMap.has(o.id)) { + allOrdersMap.set(o.id, { order: o, isPerformerRole: true }) + } + }) + const allOrders = Array.from(allOrdersMap.values()) + + const isLoading = + (customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data + const isFetching = customerQuery.isFetching || performerQuery.isFetching + const error = customerQuery.error || performerQuery.error + + const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => { + if (!data) return [] + if (tab === "new") return data.filter(o => NEW_STATUSES.includes(o.status)) + if (tab === "process") return data.filter(o => PROGRESS_STATUSES.includes(o.status)) + return data.filter(o => DONE_STATUSES.includes(o.status)) + } + + const getCounts = (data: Order[] | undefined) => { + if (!data) return { new: 0, process: 0, done: 0 } + return { + new: data.filter(o => NEW_STATUSES.includes(o.status)).length, + process: data.filter(o => PROGRESS_STATUSES.includes(o.status)).length, + done: data.filter(o => DONE_STATUSES.includes(o.status)).length, + } + } + + const customerCounts = getCounts(customerQuery.data) + const performerCounts = getCounts(performerQuery.data) + + // Ждём инициализации сессии + if (!isInitialized || isAuthLoading) { + return ( +
+ +
+ ) + } + + if (!user) return null + + return ( +
+ {/* Заголовок */} +
+
+

+ + Личный кабинет +

+

+ Управление вашими заказами и услугами +

+
+
+ {isFetching && ( + + + Sync + + )} + +
+
+ +
+ {/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */} +
+
+
+

+ + Заказы +

+

Мои заказы

+
+
+ +
+
+ {[ + { id: "new" as OrderTab, label: "Новые", count: customerCounts.new }, + { id: "process" as OrderTab, label: "В работе", count: customerCounts.process }, + { id: "done" as OrderTab, label: "Завершенные", count: customerCounts.done }, + ].map(t => ( + + ))} +
+
+ +
+ {customerQuery.isLoading ? ( +
+ + Загрузка ваших заказов... +
+ ) : ( + getFilteredOrders(customerQuery.data, customerTab).length === 0 ? ( +
+
+ +
+

Здесь пока ничего нет

+ {customerTab === "new" && ( + + )} +
+ ) : ( + getFilteredOrders(customerQuery.data, customerTab).map(order => ( + + )) + ) + )} +
+
+ + {/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */} +
+
+
+

+ + Услуги +

+

Заказы клиентов

+
+
+ + {!isMaster ? ( +
+
+ +
+

+ Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую. +

+ +
+ ) : ( + <> +
+
+ {[ + { id: "new" as OrderTab, label: "Новые", count: performerCounts.new }, + { id: "process" as OrderTab, label: "В работе", count: performerCounts.process }, + { id: "done" as OrderTab, label: "Завершенные", count: performerCounts.done }, + ].map(t => ( + + ))} +
+
+ +
+ {performerQuery.isLoading ? ( +
+ + Синхронизация заказов... +
+ ) : ( + getFilteredOrders(performerQuery.data, performerTab).length === 0 ? ( +
+
+ +
+

Новых заявок пока нет

+
+ ) : ( + getFilteredOrders(performerQuery.data, performerTab).map(order => ( + + )) + ) + )} +
+ + )} +
+
+
+ ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7312f35..ab29d50 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { ThemeProvider } from "@/components/theme/theme-provider"; import { Header } from "@/widgets/Header"; import { Toaster } from "@/components/ui/sonner"; import { AuthProvider } from "@/components/auth/AuthProvider"; +import { QueryProvider } from "@/components/providers/QueryProvider"; const inter = Inter({ subsets: ["latin", "cyrillic"] }); @@ -28,13 +29,15 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - -
-
-
{children}
-
- -
+ + +
+
+
{children}
+
+ +
+
diff --git a/src/app/offers/[id]/page.tsx b/src/app/offers/[id]/page.tsx index afec955..a3e1131 100644 --- a/src/app/offers/[id]/page.tsx +++ b/src/app/offers/[id]/page.tsx @@ -4,8 +4,10 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import { getOfferById, Offer } from "@/shared/api/catalog" import { Button } from "@/components/ui/button" -import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, Clock, ChevronLeft, ChevronRight, ListOrdered } from "lucide-react" +import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, Clock, ChevronLeft, ChevronRight, ListOrdered, ShoppingBag } from "lucide-react" import { toast } from "sonner" +import { CreateOrderModal } from "@/components/orders/CreateOrderModal" +import { formatPrice } from "@/shared/lib/formatPrice" export default function PublicOfferPage() { const params = useParams() @@ -15,6 +17,7 @@ export default function PublicOfferPage() { const [offer, setOffer] = useState(null) const [isLoading, setIsLoading] = useState(true) const [activeImageIndex, setActiveImageIndex] = useState(0) + const [isOrderModalOpen, setIsOrderModalOpen] = useState(false) useEffect(() => { if (!offerId) return; @@ -96,7 +99,7 @@ export default function PublicOfferPage() {
- {offer.price.amount > 0 ? `${offer.price.amount} ₽` : "Цена договорная"} + {offer.price.amount > 0 ? formatPrice(offer.price.amount, offer.price.type) : "Цена договорная"}
{priceTypeStr} @@ -278,11 +281,19 @@ export default function PublicOfferPage() {
- + {!offer?.isActive && ( +

Услуга временно приостановлена

+ )}

- Нажимая кнопку, вы принимаете пользовательское соглашение + Мастер должен принять заказ в течение 60 минут

@@ -315,7 +326,7 @@ export default function PublicOfferPage() { {offer.title} - Похожее предложение #{i}
- от {offer.price.amount} ₽ + от {formatPrice(offer.price.amount)} 4.8 @@ -326,6 +337,18 @@ export default function PublicOfferPage() { ))}
+ + {/* Модальное окно оформления заказа */} + {offer && ( + + )} ) } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index d3f8bbd..a69c7a5 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -3,7 +3,8 @@ import dynamic from "next/dynamic" import { useState, useCallback, useRef, useEffect, useMemo } from "react" import { Search, User, Filter, MapPin, Briefcase } from "lucide-react" -import { globalSearch, SearchResultItem } from "@/shared/api/search" +import { SearchResultItem, SearchParams, globalSearch } from "@/shared/api/search" +import { formatPrice } from "@/shared/lib/formatPrice" import { Input } from "@/components/ui/input" import { Checkbox } from "@/components/ui/checkbox" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -323,7 +324,7 @@ export default function SearchPage() {

{item.offer.title}

- {item.price} ₽ + {item.price > 0 ? formatPrice(item.price) : "Цена не указана"} diff --git a/src/components/map/LiveMap.tsx b/src/components/map/LiveMap.tsx index 1edda36..3aaa0fe 100644 --- a/src/components/map/LiveMap.tsx +++ b/src/components/map/LiveMap.tsx @@ -4,6 +4,8 @@ import { useEffect, useState, useCallback, useRef } from "react" import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet" import L from "leaflet" import { SearchResultItem } from "@/shared/api/search" +import { Offer } from "@/shared/api/catalog" +import { CreateOrderModal } from "@/components/orders/CreateOrderModal" // Фикс иконок Leaflet для webpack/Next.js const defaultIcon = L.icon({ @@ -63,8 +65,6 @@ function getRadiusFromZoom(map: L.Map): number { /** Компонент, который центрирует карту на пользователе */ function LocateUser({ onLocated }: { onLocated: (lat: number, lon: number) => void }) { const map = useMap() - - // Используем useRef/useState, чтобы вызвать только 1 раз при старте const [located, setLocated] = useState(false) useEffect(() => { @@ -125,14 +125,13 @@ function ActivePerformerHandler({ useEffect(() => { if (!activePerformerId) return; - // Перемещаем карту только если изменился centerTrigger (явный клик в списке) if (centerTrigger !== undefined && prevTriggerRef.current !== centerTrigger) { const target = performers.find(p => p.performerId === activePerformerId) if (target) { map.flyTo([target.latitude, target.longitude], 15, { animate: true, duration: 1 }) const marker = markerRefs.current[activePerformerId] if (marker) { - setTimeout(() => marker.openPopup(), 500) // Задержка для анимации карты + setTimeout(() => marker.openPopup(), 500) } } prevTriggerRef.current = centerTrigger; @@ -142,6 +141,17 @@ function ActivePerformerHandler({ return null } +// ─── Состояние модального окна для заказа ──────────────────────────────────── + +interface OrderModalState { + open: boolean + performerId: string + performerName?: string + offers: Offer[] + userLat?: number + userLon?: number +} + interface LiveMapProps { performers: SearchResultItem[] isLoading: boolean @@ -158,11 +168,46 @@ export default function LiveMap({ performers, isLoading, error, initialCenter = const [userPos, setUserPos] = useState<[number, number] | null>(null) const markerRefs = useRef>({}) + // Состояние модального окна заказа + const [orderModal, setOrderModal] = useState({ + open: false, + performerId: "", + offers: [], + }) + const handleUserLocated = useCallback((lat: number, lon: number) => { setUserPos([lat, lon]) onUserLocated?.(lat, lon) }, [onUserLocated]) + const openOrderModal = (performer: SearchResultItem) => { + // Преобразуем matchedOffers (SearchOffer) в формат Offer для модального окна + const offers: Offer[] = performer.matchedOffers.map((o) => ({ + id: o.id, + performerId: performer.performerId, + categoryId: "", + title: o.title, + description: o.description ?? "", + price: { + amount: o.priceAmount ?? o.amount ?? 0, + currency: o.priceCurrency ?? o.currency ?? "RUB", + type: 0, + }, + attributes: null, + isActive: true, + images: [], + })) + + setOrderModal({ + open: true, + performerId: performer.performerId, + performerName: performer.name, + offers, + userLat: userPos?.[0], + userLon: userPos?.[1], + }) + } + return (
-
-

{p.name}

-

- ● {p.status} -

+
+
+

{p.name}

+

+ ● {p.status} +

+
+ {p.matchedOffers.length > 0 && ( -
+
Услуги ({p.matchedOffers.length}): -
    +
      {p.matchedOffers.slice(0, 2).map((o) => (
    • {o.title}
    • ))} - {p.matchedOffers.length > 2 &&
    • и еще {p.matchedOffers.length - 2}...
    • } + {p.matchedOffers.length > 2 &&
    • и ещё {p.matchedOffers.length - 2}...
    • }
)} + + {/* ─── Кнопка «Заказать» ─── */} + {p.matchedOffers.length > 0 && ( + + )}
@@ -249,6 +307,20 @@ export default function LiveMap({ performers, isLoading, error, initialCenter = {error}
)} + + {/* Модальное окно создания заказа */} + setOrderModal((s) => ({ ...s, open }))} + performerId={orderModal.performerId} + performerName={orderModal.performerName} + offers={orderModal.offers} + userLocation={ + orderModal.userLat + ? { lat: orderModal.userLat, lon: orderModal.userLon! } + : undefined + } + />
) } diff --git a/src/components/orders/CreateOrderModal.tsx b/src/components/orders/CreateOrderModal.tsx new file mode 100644 index 0000000..ee9599c --- /dev/null +++ b/src/components/orders/CreateOrderModal.tsx @@ -0,0 +1,223 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { Loader2, ShoppingBag, MapPin, Clock, ChevronDown } from "lucide-react" +import { createOrder, CreateOrderPayload } from "@/shared/api/orders" +import { Offer } from "@/shared/api/catalog" +import { formatPrice as fmtPrice } from "@/shared/lib/formatPrice" +import { useSessionStore } from "@/entities/session/store" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" + +interface CreateOrderModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Мастер, которому делается заказ */ + performerId: string + /** Имя мастера (для отображения в заказе) */ + performerName?: string + /** Список услуг этого мастера (для выбора) */ + offers: (Offer | any)[] + /** Предустановка конкретной услуги */ + preselectedOfferId?: string + userLocation?: { lat: number; lon: number; address?: string } +} + +const PRICE_TYPE_LABEL: Record = { + 0: "Фиксированная", + 1: "В час", + 2: "Договорная", + "Fixed": "Фиксированная", + "Hourly": "В час", + "Negotiable": "Договорная", +} + +export function CreateOrderModal({ + open, + onOpenChange, + performerId, + performerName = "Мастер", + offers, + preselectedOfferId, + userLocation, +}: CreateOrderModalProps) { + const { user } = useSessionStore() + const [selectedOfferId, setSelectedOfferId] = React.useState( + preselectedOfferId ?? offers[0]?.id ?? "" + ) + const [isLoading, setIsLoading] = React.useState(false) + + // Синхронизируем выбранную услугу при изменении пропсов + React.useEffect(() => { + if (preselectedOfferId) { + setSelectedOfferId(preselectedOfferId) + } else if (offers.length > 0 && !selectedOfferId) { + setSelectedOfferId(offers[0].id) + } + }, [preselectedOfferId, offers]) + + const selectedOffer = offers.find((o) => o.id === selectedOfferId) + + const handleOrder = async () => { + if (!user) { + toast.error("Необходимо войти в систему для оформления заказа") + return + } + + if (!selectedOffer) { + toast.error("Выберите услугу") + return + } + + setIsLoading(true) + try { + const payload: CreateOrderPayload = { + customerId: user.id, + customerName: `${user.firstName} ${user.lastName}`.trim() || user.phone || "Клиент", + serviceId: selectedOffer.id, + serviceTitle: selectedOffer.title, + priceAmount: selectedOffer.price.amount, + priceType: selectedOffer.price.type, + type: "Direct", + address: userLocation?.address || "Адрес не указан", + latitude: userLocation?.lat || 55.7558, + longitude: userLocation?.lon || 37.6173, + performerId: performerId, + performerName: performerName, + } + + const orderId = await createOrder(payload) + toast.success("Заказ успешно оформлен! Мастер получит уведомление.") + onOpenChange(false) + } catch (err: any) { + const message = + err?.response?.data?.detail ?? err?.response?.data ?? "Ошибка при оформлении заказа" + toast.error(String(message)) + } finally { + setIsLoading(false) + } + } + + const formatOfferPrice = (offer: Offer) => fmtPrice(offer.price.amount, offer.price.type) + + return ( + + + + + + Оформить заказ + + + Мастер получит уведомление и должен принять заказ в течение{" "} + 60 минут. + + + +
+ {/* Выбор услуги */} + {offers.length > 1 && ( +
+

Выберите услугу

+
+ {offers.map((offer) => ( + + ))} +
+
+ )} + + {/* Детали выбранной услуги */} + {selectedOffer && ( +
+
+
+

{selectedOffer.title}

+ {selectedOffer.description && ( +

+ {selectedOffer.description} +

+ )} +
+
+ +
+
+ Стоимость + {selectedOffer ? formatOfferPrice(selectedOffer) : ""} +
+
+ Тип оплаты + {PRICE_TYPE_LABEL[selectedOffer.price.type]} +
+
+
+ )} + + {/* Адрес */} + {userLocation?.address && ( +
+ + {userLocation.address} +
+ )} + + {/* SLA-предупреждение */} +
+ + + После оформления заказа мастеру даётся 60 минут на подтверждение. + Если мастер не ответит — заказ автоматически отменится. + +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/providers/QueryProvider.tsx b/src/components/providers/QueryProvider.tsx new file mode 100644 index 0000000..a01cc68 --- /dev/null +++ b/src/components/providers/QueryProvider.tsx @@ -0,0 +1,25 @@ +"use client" + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { useState } from "react" + +export function QueryProvider({ children }: { children: React.ReactNode }) { + // Создаём QueryClient один раз на клиенте (не в модуле, а через useState) + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + // Не рефетчить при фокусе окна (чтобы не мешать работе) + refetchOnWindowFocus: false, + // Повторять запрос 1 раз при ошибке + retry: 1, + // Данные считаются свежими 30 секунд + staleTime: 30_000, + }, + }, + }) + ) + + return {children} +} diff --git a/src/shared/api/orders.ts b/src/shared/api/orders.ts new file mode 100644 index 0000000..32ce065 --- /dev/null +++ b/src/shared/api/orders.ts @@ -0,0 +1,90 @@ +import { api } from "./axios"; + +// ─── Типы ───────────────────────────────────────────────────────────────────── + +export type OrderStatus = + | "Created" + | "Published" + | "PendingAcceptance" + | "InProgress" + | "Completed" + | "Cancelled" + | "Expired"; + +export type OrderType = "Direct" | "PublicJob"; + +export interface Order { + id: string; + customerId: string; + customerName: string; + performerId: string | null; + performerName: string | null; + serviceId: string; + serviceTitle: string; + priceAmount: number; + priceType: string | number; + type: OrderType; + status: OrderStatus; + address: string; + createdAt: string; + acceptedAt: string | null; + completedAt: string | null; + cancellationReason: string | null; + /** Секунды до истечения SLA (только для PendingAcceptance) */ + slaSecondsLeft: number | null; +} + +export interface CreateOrderPayload { + customerId: string; + customerName: string; + serviceId: string; + serviceTitle: string; + priceAmount: number; + priceType: string | number; + type: OrderType; + address: string; + latitude: number; + longitude: number; + deadline?: string | null; + performerId?: string | null; + performerName?: string | null; +} + +// ─── API-функции ────────────────────────────────────────────────────────────── + +/** Получить заказы, где я — заказчик */ +export const getMyOrdersAsCustomer = async (userId: string): Promise => { + const response = await api.get(`/orders/as-customer/${userId}`); + return response.data; +}; + +/** Получить заказы, где я — исполнитель */ +export const getMyOrdersAsPerformer = async (userId: string): Promise => { + const response = await api.get(`/orders/as-performer/${userId}`); + return response.data; +}; + +/** Создать заказ */ +export const createOrder = async (payload: CreateOrderPayload): Promise => { + const response = await api.post("/orders", payload); + return response.data; +}; + +/** Принять заказ (исполнитель) */ +export const acceptOrder = async (orderId: string, performerId: string): Promise => { + await api.post(`/orders/${orderId}/accept`, { performerId }); +}; + +/** Завершить заказ */ +export const completeOrder = async (orderId: string, requesterId: string): Promise => { + await api.post(`/orders/${orderId}/complete`, { requesterId }); +}; + +/** Отменить заказ */ +export const cancelOrder = async ( + orderId: string, + requesterId: string, + reason?: string +): Promise => { + await api.post(`/orders/${orderId}/cancel`, { requesterId, reason }); +}; diff --git a/src/shared/lib/formatPrice.ts b/src/shared/lib/formatPrice.ts new file mode 100644 index 0000000..365a521 --- /dev/null +++ b/src/shared/lib/formatPrice.ts @@ -0,0 +1,26 @@ +/** + * Форматирует цену с разделением разрядов пробелом (русский стандарт). + * Пример: 400000 → "400 000 ₽" + */ +export function formatPrice(amount: number, type?: number | string): string { + if (type === 2 || type === "Negotiable") return "Договорная" + + const formatted = amount.toLocaleString("ru-RU", { + maximumFractionDigits: 0, + useGrouping: true, + }) + + const suffix = (type === 1 || type === "Hourly") ? "/час" : "" + return `${formatted} ₽${suffix}` +} + +/** + * Форматирует число с разделением разрядов пробелом. + * Пример: 400000 → "400 000" + */ +export function formatNumber(amount: number): string { + return amount.toLocaleString("ru-RU", { + maximumFractionDigits: 0, + useGrouping: true, + }) +} diff --git a/src/widgets/Header.tsx b/src/widgets/Header.tsx index 460ba30..a03e883 100644 --- a/src/widgets/Header.tsx +++ b/src/widgets/Header.tsx @@ -4,7 +4,7 @@ import Link from "next/link" import { ModeToggle } from "@/components/theme/mode-toggle" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" -import { Search, User, LogOut, Briefcase, MapPin } from "lucide-react" +import { Search, User, LogOut, Briefcase, MapPin, ShoppingBag } from "lucide-react" import { DropdownMenu, DropdownMenuContent, @@ -52,10 +52,11 @@ export function Header() { Поиск - Мои заказы + + Личный кабинет
@@ -106,9 +107,9 @@ export function Header() { - - - Мои заказы + + + Личный кабинет {isMasterOrCompany && ( @@ -119,6 +120,7 @@ export function Header() { )} + logout()}> @@ -136,8 +138,8 @@ export function Header() { )} {isMasterOrCompany ? ( ) : (