Создание заказов
This commit is contained in:
Generated
+27
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 { 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() {
|
||||
<p className="text-sm text-foreground/80 line-clamp-3 mb-4 leading-relaxed">
|
||||
{offer.description}
|
||||
</p>
|
||||
<div className="font-semibold text-lg flex items-center gap-1">
|
||||
{offer.price.amount} ₽
|
||||
<div className="font-semibold text-lg flex items-center gap-1.5">
|
||||
{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">
|
||||
{offer.price.type === 0 ? "фиксированно" : offer.price.type === 1 ? "в час" : "договорная"}
|
||||
{offer.price.type === 1 ? "в час" : "фиксированно"}
|
||||
</span>
|
||||
</>}
|
||||
</div>
|
||||
</CardContent>
|
||||
<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 { Toaster } from "@/components/ui/sonner";
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||
import { QueryProvider } from "@/components/providers/QueryProvider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin", "cyrillic"] });
|
||||
|
||||
@@ -28,6 +29,7 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Header />
|
||||
@@ -35,6 +37,7 @@ export default function RootLayout({
|
||||
</div>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<Offer | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeImageIndex, setActiveImageIndex] = useState<number>(0)
|
||||
const [isOrderModalOpen, setIsOrderModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
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 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>
|
||||
<span className="text-muted-foreground font-medium bg-muted px-2 py-1 rounded">
|
||||
{priceTypeStr}
|
||||
@@ -278,11 +281,19 @@ export default function PublicOfferPage() {
|
||||
</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>
|
||||
{!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">
|
||||
Нажимая кнопку, вы принимаете <a href="#" className="underline">пользовательское соглашение</a>
|
||||
Мастер должен принять заказ в течение 60 минут
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +326,7 @@ export default function PublicOfferPage() {
|
||||
{offer.title} - Похожее предложение #{i}
|
||||
</h4>
|
||||
<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">
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
4.8
|
||||
@@ -326,6 +337,18 @@ export default function PublicOfferPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно оформления заказа */}
|
||||
{offer && (
|
||||
<CreateOrderModal
|
||||
open={isOrderModalOpen}
|
||||
onOpenChange={setIsOrderModalOpen}
|
||||
performerId={offer.performerId}
|
||||
performerName="Мастер"
|
||||
offers={[offer]}
|
||||
preselectedOfferId={offer.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<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">
|
||||
<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 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" />
|
||||
|
||||
@@ -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<Record<string, L.Marker | null>>({})
|
||||
|
||||
// Состояние модального окна заказа
|
||||
const [orderModal, setOrderModal] = useState<OrderModalState>({
|
||||
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 (
|
||||
<div className="relative w-full h-full">
|
||||
<MapContainer
|
||||
@@ -212,22 +257,35 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
||||
}}
|
||||
>
|
||||
<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={`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>
|
||||
</div>
|
||||
|
||||
{p.matchedOffers.length > 0 && (
|
||||
<div className="mt-2 text-xs">
|
||||
<div className="text-xs">
|
||||
<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) => (
|
||||
<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>
|
||||
</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>
|
||||
</Popup>
|
||||
</Marker>
|
||||
@@ -249,6 +307,20 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
||||
{error}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { 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() {
|
||||
Поиск
|
||||
</Link>
|
||||
<Link
|
||||
href="/orders"
|
||||
className="hover:text-blue-600"
|
||||
href="/dashboard/cabinet"
|
||||
className="flex items-center gap-2 hover:text-blue-600"
|
||||
>
|
||||
Мои заказы
|
||||
<ShoppingBag className="h-4 w-4" />
|
||||
Личный кабинет
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -106,9 +107,9 @@ export function Header() {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/orders" className="cursor-pointer w-full flex items-center">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<span>Мои заказы</span>
|
||||
<Link href="/dashboard/cabinet" className="cursor-pointer w-full flex items-center">
|
||||
<ShoppingBag className="mr-2 h-4 w-4" />
|
||||
<span>Личный кабинет</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isMasterOrCompany && (
|
||||
@@ -119,6 +120,7 @@ export function Header() {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive cursor-pointer" onClick={() => logout()}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
@@ -136,8 +138,8 @@ export function Header() {
|
||||
)}
|
||||
{isMasterOrCompany ? (
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" asChild>
|
||||
<Link href="/orders">
|
||||
Подобрать заказы
|
||||
<Link href="/dashboard/cabinet">
|
||||
Личный кабинет
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user