Создание заказов
This commit is contained in:
Generated
+27
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
@@ -1891,6 +1892,32 @@
|
|||||||
"tslib": "^2.8.0"
|
"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": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "3.19.0",
|
"version": "3.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 text-sm font-mono font-semibold",
|
||||||
|
isUrgent
|
||||||
|
? "text-red-500 animate-pulse"
|
||||||
|
: "text-amber-600 dark:text-amber-400"
|
||||||
|
)}
|
||||||
|
title="Время до автоматической отмены заказа"
|
||||||
|
>
|
||||||
|
<Timer className="h-3.5 w-3.5" />
|
||||||
|
{String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Статус-бейдж ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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: <Package className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Published: {
|
||||||
|
label: "Опубликован",
|
||||||
|
className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
||||||
|
icon: <Package className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
PendingAcceptance: {
|
||||||
|
label: "Ожидает мастера",
|
||||||
|
className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
InProgress: {
|
||||||
|
label: "В работе",
|
||||||
|
className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||||
|
icon: <RefreshCw className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Completed: {
|
||||||
|
label: "Завершён",
|
||||||
|
className:
|
||||||
|
"bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
|
||||||
|
icon: <CheckCircle2 className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Cancelled: {
|
||||||
|
label: "Отменён",
|
||||||
|
className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400",
|
||||||
|
icon: <XCircle className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Expired: {
|
||||||
|
label: "Время истекло",
|
||||||
|
className:
|
||||||
|
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
|
||||||
|
icon: <AlertCircle className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: OrderStatus }) {
|
||||||
|
const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||||
|
cfg.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cfg.icon}
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Карточка заказа ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="group rounded-3xl border bg-card/60 backdrop-blur-md p-0 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-500 overflow-hidden border-border/50">
|
||||||
|
{/* Верхняя полоска с ролью */}
|
||||||
|
<div className={cn(
|
||||||
|
"px-6 py-2.5 text-[10px] font-black uppercase tracking-[0.1em] flex items-center justify-between",
|
||||||
|
isCustomer
|
||||||
|
? "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-b border-blue-500/10"
|
||||||
|
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-b border-amber-500/10"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn("w-1.5 h-1.5 rounded-full animate-pulse", isCustomer ? "bg-blue-500" : "bg-amber-500")} />
|
||||||
|
{isCustomer ? "Покупка (Я Клиент)" : "Продажа (Я Мастер)"}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono opacity-40">ID {order.id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Шапка: Статус и Таймер */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
<span className="text-[11px] text-muted-foreground/60 font-medium">
|
||||||
|
{formatDate(order.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && (
|
||||||
|
<div className="bg-amber-500/5 dark:bg-amber-500/10 px-3 py-1.5 rounded-2xl border border-amber-500/10 ring-4 ring-amber-500/5">
|
||||||
|
<SlaCountdown slaSecondsLeft={order.slaSecondsLeft!} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Блок услуги (карточка) */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-bold text-base leading-tight text-foreground group-hover/card:text-primary transition-colors">
|
||||||
|
{order.serviceTitle}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground/80">
|
||||||
|
<User className="h-3.5 w-3.5 text-primary/60" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{isCustomer
|
||||||
|
? <>Исполнитель: <span className="text-foreground">{order.performerName || "Ожидание выбора..."}</span></>
|
||||||
|
: <>Заказчик: <span className="text-foreground">{order.customerName}</span></>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{order.address && (
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/80">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-blue-500/60" />
|
||||||
|
<span className="line-clamp-1">{order.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="bg-primary/5 px-3 py-1.5 rounded-xl border border-primary/10">
|
||||||
|
<p className="font-black text-sm text-primary">
|
||||||
|
{formatPrice(order.priceAmount, order.priceType)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-3 right-3 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||||
|
<ArrowRight className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Доп. инфо (отмена) */}
|
||||||
|
{order.cancellationReason && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-2xl bg-red-500/5 text-[12px] text-red-600 dark:text-red-400 border border-red-500/10 shadow-inner">
|
||||||
|
<XCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<p className="font-medium leading-relaxed">
|
||||||
|
<span className="opacity-60 block text-[10px] uppercase tracking-wider mb-0.5">Причина отмены</span>
|
||||||
|
{order.cancellationReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопки действий */}
|
||||||
|
<div className="pt-2">
|
||||||
|
{/* Для мастера: Принять/Отклонить */}
|
||||||
|
{!isCustomer && order.status === "PendingAcceptance" && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button
|
||||||
|
className="rounded-2xl h-12 shadow-lg shadow-primary/20 bg-primary hover:bg-primary/90 font-bold text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => acceptMutation.mutate()}
|
||||||
|
>
|
||||||
|
{acceptMutation.isPending ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Принять заказ"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-2xl h-12 border-red-500/20 text-red-600 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all font-bold text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => cancelMutation.mutate("Отклонён мастером")}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Отклонить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Завершить работу (мастер или клиент) */}
|
||||||
|
{order.status === "InProgress" && (
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-2xl h-12 shadow-lg shadow-blue-500/20 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => completeMutation.mutate()}
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Завершить работу"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Клиент: Отменить (пока мастер не принял) */}
|
||||||
|
{isCustomer && order.status === "PendingAcceptance" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full rounded-2xl h-12 text-muted-foreground hover:text-red-500 hover:bg-red-500/5 transition-all font-bold text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => cancelMutation.mutate("Отменён заказчиком")}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Отменить заказ"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Главная страница ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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<OrderTab>("new")
|
||||||
|
const [performerTab, setPerformerTab] = React.useState<OrderTab>("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<Set<string>>(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: <ShoppingBag className="h-4 w-4 text-primary" />,
|
||||||
|
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 (
|
||||||
|
<div className="flex h-[50vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-7xl py-8 space-y-8">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black tracking-tight flex items-center gap-3">
|
||||||
|
<User className="h-8 w-8 text-primary" />
|
||||||
|
Личный кабинет
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Управление вашими заказами и услугами
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isFetching && (
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground flex items-center gap-1.5 bg-muted px-2.5 py-1 rounded-full animate-pulse">
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
|
Sync
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={refetch}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-start">
|
||||||
|
{/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<ShoppingBag className="h-5 w-5 text-blue-500" />
|
||||||
|
Заказы
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Мои заказы</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/40 rounded-[2rem] p-1.5 border border-border/40 backdrop-blur-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setCustomerTab(t.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2.5 px-1 text-[11px] font-bold rounded-[1.5rem] transition-all duration-300 flex items-center justify-center gap-1.5",
|
||||||
|
customerTab === t.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.count > 0 && (
|
||||||
|
<span className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded-full text-[9px] min-w-[18px]",
|
||||||
|
customerTab === t.id ? "bg-primary text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{t.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 min-h-[400px]">
|
||||||
|
{customerQuery.isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
<span className="text-[11px] font-medium italic">Загрузка ваших заказов...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(customerQuery.data, customerTab).length === 0 ? (
|
||||||
|
<div className="border border-dashed border-muted-foreground/20 rounded-[2rem] py-24 text-center space-y-4 bg-muted/5">
|
||||||
|
<div className="h-12 w-12 mx-auto rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Package className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">Здесь пока ничего нет</p>
|
||||||
|
{customerTab === "new" && (
|
||||||
|
<Button variant="outline" size="sm" className="rounded-full text-[10px] h-8" asChild>
|
||||||
|
<a href="/search">Найти услуги</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(customerQuery.data, customerTab).map(order => (
|
||||||
|
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={false} />
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */}
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<RefreshCw className="h-5 w-5 text-amber-500" />
|
||||||
|
Услуги
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Заказы клиентов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMaster ? (
|
||||||
|
<div className="bg-card border border-amber-500/10 rounded-[2rem] p-10 text-center space-y-5 shadow-sm relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-5">
|
||||||
|
<User className="h-20 w-20" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed max-w-[240px] mx-auto">
|
||||||
|
Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую.
|
||||||
|
</p>
|
||||||
|
<Button variant="default" className="rounded-full px-8 shadow-lg shadow-primary/20" onClick={() => router.push("/dashboard/become-performer")}>
|
||||||
|
Стать мастером
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-muted/40 rounded-[2rem] p-1.5 border border-border/40 backdrop-blur-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setPerformerTab(t.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2.5 px-1 text-[11px] font-bold rounded-[1.5rem] transition-all duration-300 flex items-center justify-center gap-1.5",
|
||||||
|
performerTab === t.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.count > 0 && (
|
||||||
|
<span className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded-full text-[9px] min-w-[18px]",
|
||||||
|
performerTab === t.id ? "bg-primary text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{t.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 min-h-[400px]">
|
||||||
|
{performerQuery.isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-amber-500" />
|
||||||
|
<span className="text-[11px] font-medium italic">Синхронизация заказов...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(performerQuery.data, performerTab).length === 0 ? (
|
||||||
|
<div className="border border-dashed border-muted-foreground/20 rounded-[2rem] py-24 text-center space-y-4 bg-muted/5">
|
||||||
|
<div className="h-12 w-12 mx-auto rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Timer className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">Новых заявок пока нет</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(performerQuery.data, performerTab).map(order => (
|
||||||
|
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={true} />
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle, Image as ImageIcon } from "lucide-react";
|
import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle, Image as ImageIcon } from "lucide-react";
|
||||||
import { useSessionStore } from "@/entities/session/store";
|
import { useSessionStore } from "@/entities/session/store";
|
||||||
|
import { formatPrice } from "@/shared/lib/formatPrice";
|
||||||
|
|
||||||
export default function MyOffersPage() {
|
export default function MyOffersPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -133,11 +134,15 @@ export default function MyOffersPage() {
|
|||||||
<p className="text-sm text-foreground/80 line-clamp-3 mb-4 leading-relaxed">
|
<p className="text-sm text-foreground/80 line-clamp-3 mb-4 leading-relaxed">
|
||||||
{offer.description}
|
{offer.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="font-semibold text-lg flex items-center gap-1">
|
<div className="font-semibold text-lg flex items-center gap-1.5">
|
||||||
{offer.price.amount} ₽
|
{offer.price.type === 2
|
||||||
|
? <span className="text-muted-foreground text-base">Договорная</span>
|
||||||
|
: <>
|
||||||
|
<span>{formatPrice(offer.price.amount)}</span>
|
||||||
<span className="text-sm font-normal text-muted-foreground relative top-0.5">
|
<span className="text-sm font-normal text-muted-foreground relative top-0.5">
|
||||||
{offer.price.type === 0 ? "фиксированно" : offer.price.type === 1 ? "в час" : "договорная"}
|
{offer.price.type === 1 ? "в час" : "фиксированно"}
|
||||||
</span>
|
</span>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex gap-2 justify-end bg-muted/20 pt-4 mt-auto border-t">
|
<CardFooter className="flex gap-2 justify-end bg-muted/20 pt-4 mt-auto border-t">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 text-sm font-mono font-semibold",
|
||||||
|
isUrgent
|
||||||
|
? "text-red-500 animate-pulse"
|
||||||
|
: "text-amber-600 dark:text-amber-400"
|
||||||
|
)}
|
||||||
|
title="Время до автоматической отмены заказа"
|
||||||
|
>
|
||||||
|
<Timer className="h-3.5 w-3.5" />
|
||||||
|
{String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Статус-бейдж ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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: <Package className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Published: {
|
||||||
|
label: "Опубликован",
|
||||||
|
className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
||||||
|
icon: <Package className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
PendingAcceptance: {
|
||||||
|
label: "Ожидает мастера",
|
||||||
|
className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
InProgress: {
|
||||||
|
label: "В работе",
|
||||||
|
className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||||
|
icon: <RefreshCw className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Completed: {
|
||||||
|
label: "Завершён",
|
||||||
|
className:
|
||||||
|
"bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
|
||||||
|
icon: <CheckCircle2 className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Cancelled: {
|
||||||
|
label: "Отменён",
|
||||||
|
className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400",
|
||||||
|
icon: <XCircle className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Expired: {
|
||||||
|
label: "Время истекло",
|
||||||
|
className:
|
||||||
|
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
|
||||||
|
icon: <AlertCircle className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: OrderStatus }) {
|
||||||
|
const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||||
|
cfg.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cfg.icon}
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Карточка заказа ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="group rounded-2xl border bg-card p-0 shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden">
|
||||||
|
{/* Верхняя полоска с ролью */}
|
||||||
|
<div className={cn(
|
||||||
|
"px-5 py-2 text-[10px] font-bold uppercase tracking-wider flex items-center justify-between",
|
||||||
|
isCustomer
|
||||||
|
? "bg-primary/5 text-primary border-b border-primary/10"
|
||||||
|
: "bg-amber-500/5 text-amber-600 border-b border-amber-500/10"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isCustomer ? "🛒 Мой заказ (я клиент)" : "🛠 Заказ услуги (я мастер)"}
|
||||||
|
</div>
|
||||||
|
<span className="opacity-60">#{order.id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
{/* Шапка: Статус и Таймер */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{formatDate(order.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-950/30 px-3 py-1.5 rounded-xl border border-amber-100 dark:border-amber-900">
|
||||||
|
<SlaCountdown slaSecondsLeft={order.slaSecondsLeft!} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Блок услуги (карточка) */}
|
||||||
|
<div className="rounded-xl border bg-muted/30 p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="font-bold text-sm leading-tight text-foreground/90">
|
||||||
|
{order.serviceTitle}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{isCustomer
|
||||||
|
? `Исполнитель: ${order.performerName || "Ожидание..."}`
|
||||||
|
: `Заказчик: ${order.customerName}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="font-bold text-sm text-primary">
|
||||||
|
{formatPrice(order.priceAmount, order.priceType)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.address && (
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground pt-2 border-t border-border/50">
|
||||||
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="line-clamp-1">{order.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Доп. инфо (отмена) */}
|
||||||
|
{order.cancellationReason && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-950/20 text-[11px] text-red-600 dark:text-red-400 border border-red-100 dark:border-red-900/30">
|
||||||
|
<XCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||||
|
<p>Причина отмены: {order.cancellationReason}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопки действий */}
|
||||||
|
{/* Для мастера: Принять/Отклонить */}
|
||||||
|
{!isCustomer && order.status === "PendingAcceptance" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
className="rounded-xl h-10 shadow-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => acceptMutation.mutate()}
|
||||||
|
>
|
||||||
|
{acceptMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Принять заказ"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl h-10 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => cancelMutation.mutate("Отклонён мастером")}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Отклонить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Завершить работу (мастер или клиент) */}
|
||||||
|
{order.status === "InProgress" && (
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-xl h-10 shadow-sm bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => completeMutation.mutate()}
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Завершить работу"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Клиент: Отменить (пока мастер не принял) */}
|
||||||
|
{isCustomer && order.status === "PendingAcceptance" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full rounded-xl h-10 text-muted-foreground hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => cancelMutation.mutate("Отменён заказчиком")}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Отменить заказ"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Главная страница ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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<OrderTab>("new")
|
||||||
|
const [performerTab, setPerformerTab] = React.useState<OrderTab>("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<Set<string>>(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: <ShoppingBag className="h-4 w-4 text-primary" />,
|
||||||
|
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 (
|
||||||
|
<div className="flex h-[50vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-7xl py-8 space-y-8">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black tracking-tight flex items-center gap-3">
|
||||||
|
<User className="h-8 w-8 text-primary" />
|
||||||
|
Личный кабинет
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Управление вашими заказами и услугами
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isFetching && (
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground flex items-center gap-1.5 bg-muted px-2.5 py-1 rounded-full animate-pulse">
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
|
Sync
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={refetch}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-start">
|
||||||
|
{/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<ShoppingBag className="h-5 w-5 text-blue-500" />
|
||||||
|
Заказы
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Мои заказы</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/40 rounded-[2rem] p-1.5 border border-border/40 backdrop-blur-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setCustomerTab(t.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2.5 px-1 text-[11px] font-bold rounded-[1.5rem] transition-all duration-300 flex items-center justify-center gap-1.5",
|
||||||
|
customerTab === t.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.count > 0 && (
|
||||||
|
<span className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded-full text-[9px] min-w-[18px]",
|
||||||
|
customerTab === t.id ? "bg-primary text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{t.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 min-h-[400px]">
|
||||||
|
{customerQuery.isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
<span className="text-[11px] font-medium italic">Загрузка ваших заказов...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(customerQuery.data, customerTab).length === 0 ? (
|
||||||
|
<div className="border border-dashed border-muted-foreground/20 rounded-[2rem] py-24 text-center space-y-4 bg-muted/5">
|
||||||
|
<div className="h-12 w-12 mx-auto rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Package className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">Здесь пока ничего нет</p>
|
||||||
|
{customerTab === "new" && (
|
||||||
|
<Button variant="outline" size="sm" className="rounded-full text-[10px] h-8" asChild>
|
||||||
|
<a href="/search">Найти услуги</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(customerQuery.data, customerTab).map(order => (
|
||||||
|
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={false} />
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */}
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<RefreshCw className="h-5 w-5 text-amber-500" />
|
||||||
|
Услуги
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Заказы клиентов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMaster ? (
|
||||||
|
<div className="bg-card border border-amber-500/10 rounded-[2rem] p-10 text-center space-y-5 shadow-sm relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-5">
|
||||||
|
<User className="h-20 w-20" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed max-w-[240px] mx-auto">
|
||||||
|
Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую.
|
||||||
|
</p>
|
||||||
|
<Button variant="default" className="rounded-full px-8 shadow-lg shadow-primary/20" onClick={() => router.push("/dashboard/become-performer")}>
|
||||||
|
Стать мастером
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-muted/40 rounded-[2rem] p-1.5 border border-border/40 backdrop-blur-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setPerformerTab(t.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2.5 px-1 text-[11px] font-bold rounded-[1.5rem] transition-all duration-300 flex items-center justify-center gap-1.5",
|
||||||
|
performerTab === t.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.count > 0 && (
|
||||||
|
<span className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded-full text-[9px] min-w-[18px]",
|
||||||
|
performerTab === t.id ? "bg-primary text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{t.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 min-h-[400px]">
|
||||||
|
{performerQuery.isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-amber-500" />
|
||||||
|
<span className="text-[11px] font-medium italic">Синхронизация заказов...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(performerQuery.data, performerTab).length === 0 ? (
|
||||||
|
<div className="border border-dashed border-muted-foreground/20 rounded-[2rem] py-24 text-center space-y-4 bg-muted/5">
|
||||||
|
<div className="h-12 w-12 mx-auto rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Timer className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">Новых заявок пока нет</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getFilteredOrders(performerQuery.data, performerTab).map(order => (
|
||||||
|
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={true} />
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from "@/components/theme/theme-provider";
|
|||||||
import { Header } from "@/widgets/Header";
|
import { Header } from "@/widgets/Header";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { AuthProvider } from "@/components/auth/AuthProvider";
|
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||||
|
import { QueryProvider } from "@/components/providers/QueryProvider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin", "cyrillic"] });
|
const inter = Inter({ subsets: ["latin", "cyrillic"] });
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<QueryProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<div className="relative flex min-h-screen flex-col">
|
<div className="relative flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -35,6 +37,7 @@ export default function RootLayout({
|
|||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react"
|
|||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { getOfferById, Offer } from "@/shared/api/catalog"
|
import { getOfferById, Offer } from "@/shared/api/catalog"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { toast } from "sonner"
|
||||||
|
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
||||||
|
import { formatPrice } from "@/shared/lib/formatPrice"
|
||||||
|
|
||||||
export default function PublicOfferPage() {
|
export default function PublicOfferPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -15,6 +17,7 @@ export default function PublicOfferPage() {
|
|||||||
const [offer, setOffer] = useState<Offer | null>(null)
|
const [offer, setOffer] = useState<Offer | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [activeImageIndex, setActiveImageIndex] = useState<number>(0)
|
const [activeImageIndex, setActiveImageIndex] = useState<number>(0)
|
||||||
|
const [isOrderModalOpen, setIsOrderModalOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!offerId) return;
|
if (!offerId) return;
|
||||||
@@ -96,7 +99,7 @@ export default function PublicOfferPage() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<div className="flex items-center gap-1.5 text-primary bg-primary/10 px-3 py-1.5 rounded-md font-bold text-lg">
|
<div className="flex items-center gap-1.5 text-primary bg-primary/10 px-3 py-1.5 rounded-md font-bold text-lg">
|
||||||
{offer.price.amount > 0 ? `${offer.price.amount} ₽` : "Цена договорная"}
|
{offer.price.amount > 0 ? formatPrice(offer.price.amount, offer.price.type) : "Цена договорная"}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground font-medium bg-muted px-2 py-1 rounded">
|
<span className="text-muted-foreground font-medium bg-muted px-2 py-1 rounded">
|
||||||
{priceTypeStr}
|
{priceTypeStr}
|
||||||
@@ -278,11 +281,19 @@ export default function PublicOfferPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full h-12 text-base font-semibold shadow-md hover:shadow-lg transition-all bg-primary">
|
<Button
|
||||||
Связаться с исполнителем
|
className="w-full h-12 text-base font-semibold shadow-md hover:shadow-lg transition-all bg-blue-600 hover:bg-blue-700 text-white gap-2"
|
||||||
|
onClick={() => setIsOrderModalOpen(true)}
|
||||||
|
disabled={!offer?.isActive}
|
||||||
|
>
|
||||||
|
<ShoppingBag className="h-5 w-5" />
|
||||||
|
Заказать
|
||||||
</Button>
|
</Button>
|
||||||
|
{!offer?.isActive && (
|
||||||
|
<p className="text-xs text-center text-red-500 mt-2">Услуга временно приостановлена</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-center text-muted-foreground mt-4 px-2">
|
<p className="text-xs text-center text-muted-foreground mt-4 px-2">
|
||||||
Нажимая кнопку, вы принимаете <a href="#" className="underline">пользовательское соглашение</a>
|
Мастер должен принять заказ в течение 60 минут
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +326,7 @@ export default function PublicOfferPage() {
|
|||||||
{offer.title} - Похожее предложение #{i}
|
{offer.title} - Похожее предложение #{i}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-end justify-between mt-4">
|
<div className="flex items-end justify-between mt-4">
|
||||||
<span className="font-bold text-primary px-2 py-0.5 bg-primary/10 rounded">от {offer.price.amount} ₽</span>
|
<span className="font-bold text-primary px-2 py-0.5 bg-primary/10 rounded">от {formatPrice(offer.price.amount)}</span>
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||||
4.8
|
4.8
|
||||||
@@ -326,6 +337,18 @@ export default function PublicOfferPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Модальное окно оформления заказа */}
|
||||||
|
{offer && (
|
||||||
|
<CreateOrderModal
|
||||||
|
open={isOrderModalOpen}
|
||||||
|
onOpenChange={setIsOrderModalOpen}
|
||||||
|
performerId={offer.performerId}
|
||||||
|
performerName="Мастер"
|
||||||
|
offers={[offer]}
|
||||||
|
preselectedOfferId={offer.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react"
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react"
|
||||||
import { Search, User, Filter, MapPin, Briefcase } from "lucide-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 { Input } from "@/components/ui/input"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
@@ -323,7 +324,7 @@ export default function SearchPage() {
|
|||||||
<h4 className="font-bold text-lg leading-tight group-hover:text-primary transition-colors line-clamp-2">{item.offer.title}</h4>
|
<h4 className="font-bold text-lg leading-tight group-hover:text-primary transition-colors line-clamp-2">{item.offer.title}</h4>
|
||||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
<span className="text-sm font-bold text-primary whitespace-nowrap bg-primary/10 px-2 py-0.5 rounded-md shadow-sm">
|
<span className="text-sm font-bold text-primary whitespace-nowrap bg-primary/10 px-2 py-0.5 rounded-md shadow-sm">
|
||||||
{item.price} ₽
|
{item.price > 0 ? formatPrice(item.price) : "Цена не указана"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-muted-foreground whitespace-nowrap bg-muted/70 px-2 py-0.5 rounded-md flex items-center gap-1">
|
<span className="text-xs font-medium text-muted-foreground whitespace-nowrap bg-muted/70 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||||
<MapPin className="h-3 w-3 shrink-0" />
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useEffect, useState, useCallback, useRef } from "react"
|
|||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet"
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet"
|
||||||
import L from "leaflet"
|
import L from "leaflet"
|
||||||
import { SearchResultItem } from "@/shared/api/search"
|
import { SearchResultItem } from "@/shared/api/search"
|
||||||
|
import { Offer } from "@/shared/api/catalog"
|
||||||
|
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
||||||
|
|
||||||
// Фикс иконок Leaflet для webpack/Next.js
|
// Фикс иконок Leaflet для webpack/Next.js
|
||||||
const defaultIcon = L.icon({
|
const defaultIcon = L.icon({
|
||||||
@@ -63,8 +65,6 @@ function getRadiusFromZoom(map: L.Map): number {
|
|||||||
/** Компонент, который центрирует карту на пользователе */
|
/** Компонент, который центрирует карту на пользователе */
|
||||||
function LocateUser({ onLocated }: { onLocated: (lat: number, lon: number) => void }) {
|
function LocateUser({ onLocated }: { onLocated: (lat: number, lon: number) => void }) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
|
|
||||||
// Используем useRef/useState, чтобы вызвать только 1 раз при старте
|
|
||||||
const [located, setLocated] = useState(false)
|
const [located, setLocated] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,14 +125,13 @@ function ActivePerformerHandler({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activePerformerId) return;
|
if (!activePerformerId) return;
|
||||||
|
|
||||||
// Перемещаем карту только если изменился centerTrigger (явный клик в списке)
|
|
||||||
if (centerTrigger !== undefined && prevTriggerRef.current !== centerTrigger) {
|
if (centerTrigger !== undefined && prevTriggerRef.current !== centerTrigger) {
|
||||||
const target = performers.find(p => p.performerId === activePerformerId)
|
const target = performers.find(p => p.performerId === activePerformerId)
|
||||||
if (target) {
|
if (target) {
|
||||||
map.flyTo([target.latitude, target.longitude], 15, { animate: true, duration: 1 })
|
map.flyTo([target.latitude, target.longitude], 15, { animate: true, duration: 1 })
|
||||||
const marker = markerRefs.current[activePerformerId]
|
const marker = markerRefs.current[activePerformerId]
|
||||||
if (marker) {
|
if (marker) {
|
||||||
setTimeout(() => marker.openPopup(), 500) // Задержка для анимации карты
|
setTimeout(() => marker.openPopup(), 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevTriggerRef.current = centerTrigger;
|
prevTriggerRef.current = centerTrigger;
|
||||||
@@ -142,6 +141,17 @@ function ActivePerformerHandler({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Состояние модального окна для заказа ────────────────────────────────────
|
||||||
|
|
||||||
|
interface OrderModalState {
|
||||||
|
open: boolean
|
||||||
|
performerId: string
|
||||||
|
performerName?: string
|
||||||
|
offers: Offer[]
|
||||||
|
userLat?: number
|
||||||
|
userLon?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface LiveMapProps {
|
interface LiveMapProps {
|
||||||
performers: SearchResultItem[]
|
performers: SearchResultItem[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@@ -158,11 +168,46 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
|||||||
const [userPos, setUserPos] = useState<[number, number] | null>(null)
|
const [userPos, setUserPos] = useState<[number, number] | null>(null)
|
||||||
const markerRefs = useRef<Record<string, L.Marker | null>>({})
|
const markerRefs = useRef<Record<string, L.Marker | null>>({})
|
||||||
|
|
||||||
|
// Состояние модального окна заказа
|
||||||
|
const [orderModal, setOrderModal] = useState<OrderModalState>({
|
||||||
|
open: false,
|
||||||
|
performerId: "",
|
||||||
|
offers: [],
|
||||||
|
})
|
||||||
|
|
||||||
const handleUserLocated = useCallback((lat: number, lon: number) => {
|
const handleUserLocated = useCallback((lat: number, lon: number) => {
|
||||||
setUserPos([lat, lon])
|
setUserPos([lat, lon])
|
||||||
onUserLocated?.(lat, lon)
|
onUserLocated?.(lat, lon)
|
||||||
}, [onUserLocated])
|
}, [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 (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
@@ -212,22 +257,35 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-sm min-w-[140px]">
|
<div className="text-sm min-w-[180px] space-y-2">
|
||||||
|
<div>
|
||||||
<p className="font-semibold">{p.name}</p>
|
<p className="font-semibold">{p.name}</p>
|
||||||
<p className={`text-xs font-medium mt-1 ${isOnline ? "text-green-600" : "text-gray-500"}`}>
|
<p className={`text-xs font-medium mt-0.5 ${isOnline ? "text-green-600" : "text-gray-500"}`}>
|
||||||
● {p.status}
|
● {p.status}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{p.matchedOffers.length > 0 && (
|
{p.matchedOffers.length > 0 && (
|
||||||
<div className="mt-2 text-xs">
|
<div className="text-xs">
|
||||||
<b>Услуги ({p.matchedOffers.length}):</b>
|
<b>Услуги ({p.matchedOffers.length}):</b>
|
||||||
<ul className="list-disc pl-4 mt-1">
|
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||||
{p.matchedOffers.slice(0, 2).map((o) => (
|
{p.matchedOffers.slice(0, 2).map((o) => (
|
||||||
<li key={o.id} className="truncate max-w-[150px]">{o.title}</li>
|
<li key={o.id} className="truncate max-w-[150px]">{o.title}</li>
|
||||||
))}
|
))}
|
||||||
{p.matchedOffers.length > 2 && <li>и еще {p.matchedOffers.length - 2}...</li>}
|
{p.matchedOffers.length > 2 && <li>и ещё {p.matchedOffers.length - 2}...</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ─── Кнопка «Заказать» ─── */}
|
||||||
|
{p.matchedOffers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => openOrderModal(p)}
|
||||||
|
className="w-full mt-1 px-3 py-1.5 text-xs font-semibold rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Заказать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -249,6 +307,20 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно создания заказа */}
|
||||||
|
<CreateOrderModal
|
||||||
|
open={orderModal.open}
|
||||||
|
onOpenChange={(open) => setOrderModal((s) => ({ ...s, open }))}
|
||||||
|
performerId={orderModal.performerId}
|
||||||
|
performerName={orderModal.performerName}
|
||||||
|
offers={orderModal.offers}
|
||||||
|
userLocation={
|
||||||
|
orderModal.userLat
|
||||||
|
? { lat: orderModal.userLat, lon: orderModal.userLon! }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<number | string, string> = {
|
||||||
|
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<string>(
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[520px] p-8">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
|
<ShoppingBag className="h-5 w-5 text-primary" />
|
||||||
|
Оформить заказ
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Мастер получит уведомление и должен принять заказ в течение{" "}
|
||||||
|
<strong className="text-foreground">60 минут</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-4">
|
||||||
|
{/* Выбор услуги */}
|
||||||
|
{offers.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Выберите услугу</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{offers.map((offer) => (
|
||||||
|
<button
|
||||||
|
key={offer.id}
|
||||||
|
onClick={() => setSelectedOfferId(offer.id)}
|
||||||
|
className={`w-full text-left p-3 rounded-xl border-2 transition-all ${selectedOfferId === offer.id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/40 hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm line-clamp-1">{offer.title}</span>
|
||||||
|
<span className="text-primary font-bold text-sm shrink-0">
|
||||||
|
{formatOfferPrice(offer)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{PRICE_TYPE_LABEL[offer.price.type]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Детали выбранной услуги */}
|
||||||
|
{selectedOffer && (
|
||||||
|
<div className="rounded-xl border bg-muted/30 p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{selectedOffer.title}</p>
|
||||||
|
{selectedOffer.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
|
{selectedOffer.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Стоимость</span>
|
||||||
|
<span className="font-bold text-primary text-base">{selectedOffer ? formatOfferPrice(selectedOffer) : ""}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Тип оплаты</span>
|
||||||
|
<Badge variant="secondary">{PRICE_TYPE_LABEL[selectedOffer.price.type]}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Адрес */}
|
||||||
|
{userLocation?.address && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-muted-foreground bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-100 dark:border-blue-900/50">
|
||||||
|
<MapPin className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
|
||||||
|
<span>{userLocation.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SLA-предупреждение */}
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-amber-50 dark:bg-amber-900/20 rounded-lg p-3 border border-amber-100 dark:border-amber-900/50">
|
||||||
|
<Clock className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
После оформления заказа мастеру даётся <strong>60 минут</strong> на подтверждение.
|
||||||
|
Если мастер не ответит — заказ автоматически отменится.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleOrder}
|
||||||
|
disabled={isLoading || !selectedOffer || !user}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md shadow-blue-600/20"
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Оформить заказ
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
@@ -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<Order[]> => {
|
||||||
|
const response = await api.get<Order[]>(`/orders/as-customer/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Получить заказы, где я — исполнитель */
|
||||||
|
export const getMyOrdersAsPerformer = async (userId: string): Promise<Order[]> => {
|
||||||
|
const response = await api.get<Order[]>(`/orders/as-performer/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Создать заказ */
|
||||||
|
export const createOrder = async (payload: CreateOrderPayload): Promise<string> => {
|
||||||
|
const response = await api.post<string>("/orders", payload);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Принять заказ (исполнитель) */
|
||||||
|
export const acceptOrder = async (orderId: string, performerId: string): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/accept`, { performerId });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Завершить заказ */
|
||||||
|
export const completeOrder = async (orderId: string, requesterId: string): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/complete`, { requesterId });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Отменить заказ */
|
||||||
|
export const cancelOrder = async (
|
||||||
|
orderId: string,
|
||||||
|
requesterId: string,
|
||||||
|
reason?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/cancel`, { requesterId, reason });
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
+11
-9
@@ -4,7 +4,7 @@ import Link from "next/link"
|
|||||||
import { ModeToggle } from "@/components/theme/mode-toggle"
|
import { ModeToggle } from "@/components/theme/mode-toggle"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -52,10 +52,11 @@ export function Header() {
|
|||||||
Поиск
|
Поиск
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/orders"
|
href="/dashboard/cabinet"
|
||||||
className="hover:text-blue-600"
|
className="flex items-center gap-2 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
Мои заказы
|
<ShoppingBag className="h-4 w-4" />
|
||||||
|
Личный кабинет
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,9 +107,9 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/orders" className="cursor-pointer w-full flex items-center">
|
<Link href="/dashboard/cabinet" className="cursor-pointer w-full flex items-center">
|
||||||
<Search className="mr-2 h-4 w-4" />
|
<ShoppingBag className="mr-2 h-4 w-4" />
|
||||||
<span>Мои заказы</span>
|
<span>Личный кабинет</span>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isMasterOrCompany && (
|
{isMasterOrCompany && (
|
||||||
@@ -119,6 +120,7 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive cursor-pointer" onClick={() => logout()}>
|
<DropdownMenuItem className="text-destructive focus:text-destructive cursor-pointer" onClick={() => logout()}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
@@ -136,8 +138,8 @@ export function Header() {
|
|||||||
)}
|
)}
|
||||||
{isMasterOrCompany ? (
|
{isMasterOrCompany ? (
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" asChild>
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" asChild>
|
||||||
<Link href="/orders">
|
<Link href="/dashboard/cabinet">
|
||||||
Подобрать заказы
|
Личный кабинет
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user