Создание заказов

This commit is contained in:
Халимов Рустам
2026-03-08 00:55:59 +03:00
parent 612aebbce2
commit 878934e705
14 changed files with 1820 additions and 41 deletions
+27
View File
@@ -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",
+1
View File
@@ -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",
+648
View File
@@ -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>
)
}
+10 -5
View File
@@ -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-sm font-normal text-muted-foreground relative top-0.5"> ? <span className="text-muted-foreground text-base">Договорная</span>
{offer.price.type === 0 ? "фиксированно" : offer.price.type === 1 ? "в час" : "договорная"} : <>
</span> <span>{formatPrice(offer.price.amount)}</span>
<span className="text-sm font-normal text-muted-foreground relative top-0.5">
{offer.price.type === 1 ? "в час" : "фиксированно"}
</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">
+633
View File
@@ -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>
)
}
+10 -7
View File
@@ -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,13 +29,15 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider> <QueryProvider>
<div className="relative flex min-h-screen flex-col"> <AuthProvider>
<Header /> <div className="relative flex min-h-screen flex-col">
<main className="flex-1">{children}</main> <Header />
</div> <main className="flex-1">{children}</main>
<Toaster /> </div>
</AuthProvider> <Toaster />
</AuthProvider>
</QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+29 -6
View File
@@ -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 -2
View File
@@ -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" />
+84 -12
View File
@@ -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">
<p className="font-semibold">{p.name}</p> <div>
<p className={`text-xs font-medium mt-1 ${isOnline ? "text-green-600" : "text-gray-500"}`}> <p className="font-semibold">{p.name}</p>
{p.status} <p className={`text-xs font-medium mt-0.5 ${isOnline ? "text-green-600" : "text-gray-500"}`}>
</p> {p.status}
</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>
) )
} }
+223
View File
@@ -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>
}
+90
View File
@@ -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 });
};
+26
View File
@@ -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
View File
@@ -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>
) : ( ) : (