diff --git a/package-lock.json b/package-lock.json
index 947c747..85bdf66 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@tanstack/react-query": "^5.90.21",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
@@ -1891,6 +1892,32 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.20",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
+ "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.21",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
+ "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@tiptap/core": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
diff --git a/package.json b/package.json
index a5a1da5..90ac086 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@tanstack/react-query": "^5.90.21",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
diff --git a/src/app/dashboard/cabinet/page.tsx b/src/app/dashboard/cabinet/page.tsx
new file mode 100644
index 0000000..982ce93
--- /dev/null
+++ b/src/app/dashboard/cabinet/page.tsx
@@ -0,0 +1,648 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import {
+ Loader2,
+ Clock,
+ CheckCircle2,
+ XCircle,
+ AlertCircle,
+ Package,
+ Timer,
+ RefreshCw,
+ ShoppingBag,
+ User,
+ MapPin,
+ ArrowRight,
+ CreditCard,
+} from "lucide-react"
+import { useSessionStore } from "@/entities/session/store"
+import { formatPrice } from "@/shared/lib/formatPrice"
+import { Badge } from "@/components/ui/badge"
+import {
+ Order,
+ OrderStatus,
+ getMyOrdersAsCustomer,
+ getMyOrdersAsPerformer,
+ acceptOrder,
+ completeOrder,
+ cancelOrder,
+} from "@/shared/api/orders"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+// ─── SLA Countdown ──────────────────────────────────────────────────────────
+
+function SlaCountdown({ slaSecondsLeft }: { slaSecondsLeft: number }) {
+ const [seconds, setSeconds] = React.useState(slaSecondsLeft)
+
+ React.useEffect(() => {
+ setSeconds(slaSecondsLeft)
+ if (slaSecondsLeft <= 0) return
+ const interval = setInterval(() => setSeconds((p) => Math.max(0, p - 1)), 1000)
+ return () => clearInterval(interval)
+ }, [slaSecondsLeft])
+
+ const minutes = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ const isUrgent = seconds < 300 // < 5 минут — мигает красным
+
+ return (
+
+
+ {String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")}
+
+ )
+}
+
+// ─── Статус-бейдж ────────────────────────────────────────────────────────────
+
+const STATUS_CONFIG: Record<
+ OrderStatus,
+ { label: string; className: string; icon: React.ReactNode }
+> = {
+ Created: {
+ label: "Создан",
+ className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
+ icon: ,
+ },
+ Published: {
+ label: "Опубликован",
+ className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
+ icon: ,
+ },
+ PendingAcceptance: {
+ label: "Ожидает мастера",
+ className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
+ icon: ,
+ },
+ InProgress: {
+ label: "В работе",
+ className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
+ icon: ,
+ },
+ Completed: {
+ label: "Завершён",
+ className:
+ "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
+ icon: ,
+ },
+ Cancelled: {
+ label: "Отменён",
+ className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400",
+ icon: ,
+ },
+ Expired: {
+ label: "Время истекло",
+ className:
+ "bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
+ icon: ,
+ },
+}
+
+function StatusBadge({ status }: { status: OrderStatus }) {
+ const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created
+ return (
+
+ {cfg.icon}
+ {cfg.label}
+
+ )
+}
+
+// ─── Карточка заказа ─────────────────────────────────────────────────────────
+
+interface OrderCardProps {
+ order: Order
+ userId: string
+ isPerformerRole: boolean
+}
+
+function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
+ const qc = useQueryClient()
+ const router = useRouter()
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ["orders"] })
+ }
+
+ const acceptMutation = useMutation({
+ mutationFn: () => acceptOrder(order.id, userId),
+ onSuccess: () => {
+ toast.success("Заказ принят!")
+ invalidate()
+ },
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"),
+ })
+
+ const completeMutation = useMutation({
+ mutationFn: () => completeOrder(order.id, userId),
+ onSuccess: () => {
+ toast.success("Заказ завершён!")
+ invalidate()
+ },
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"),
+ })
+
+ const cancelMutation = useMutation({
+ mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
+ onSuccess: () => {
+ toast.success("Заказ отменён")
+ invalidate()
+ },
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"),
+ })
+
+ const isLoading =
+ acceptMutation.isPending ||
+ completeMutation.isPending ||
+ cancelMutation.isPending
+
+ const isCustomer = order.customerId === userId
+
+ const formatDate = (date: string) =>
+ new Date(date).toLocaleString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+
+ return (
+
+ {/* Верхняя полоска с ролью */}
+
+
+
+ {isCustomer ? "Покупка (Я Клиент)" : "Продажа (Я Мастер)"}
+
+
ID {order.id.slice(0, 8)}
+
+
+
+ {/* Шапка: Статус и Таймер */}
+
+
+
+
+ {formatDate(order.createdAt)}
+
+
+ {order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && (
+
+
+
+ )}
+
+
+ {/* Блок услуги (карточка) */}
+
router.push(`/offers/${order.serviceId}`)}
+ className="relative rounded-2xl border border-border/40 bg-muted/20 p-5 space-y-4 cursor-pointer hover:bg-muted/40 hover:border-primary/20 transition-all group/card"
+ >
+
+
+
+ {order.serviceTitle}
+
+
+
+
+
+ {isCustomer
+ ? <>Исполнитель: {order.performerName || "Ожидание выбора..."}>
+ : <>Заказчик: {order.customerName}>
+ }
+
+
+ {order.address && (
+
+
+ {order.address}
+
+ )}
+
+
+
+
+
+ {formatPrice(order.priceAmount, order.priceType)}
+
+
+
+
+
+
+
+
+ {/* Доп. инфо (отмена) */}
+ {order.cancellationReason && (
+
+
+
+ Причина отмены
+ {order.cancellationReason}
+
+
+ )}
+
+ {/* Кнопки действий */}
+
+ {/* Для мастера: Принять/Отклонить */}
+ {!isCustomer && order.status === "PendingAcceptance" && (
+
+
+
+
+ )}
+
+ {/* Завершить работу (мастер или клиент) */}
+ {order.status === "InProgress" && (
+
+ )}
+
+ {/* Клиент: Отменить (пока мастер не принял) */}
+ {isCustomer && order.status === "PendingAcceptance" && (
+
+ )}
+
+
+
+ )
+}
+
+// ─── Главная страница ─────────────────────────────────────────────────────────
+
+const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"]
+const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"]
+const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"]
+
+type OrderTab = "new" | "process" | "done"
+
+export default function OrdersDashboardPage() {
+ const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
+ const router = useRouter()
+
+ const [customerTab, setCustomerTab] = React.useState("new")
+ const [performerTab, setPerformerTab] = React.useState("new")
+
+ // Редирект если не авторизован
+ React.useEffect(() => {
+ if (isInitialized && !isAuthLoading && !isAuth) {
+ router.push("/auth/login")
+ }
+ }, [isAuth, isAuthLoading, isInitialized, router])
+
+ const isMaster = !!user?.roles?.some((r) => ["Master", "Company", "Candidate", "Admin"].includes(r))
+
+ // ─── React Query с polling каждые 10 секунд ─────────────────────────────
+
+ const customerQuery = useQuery({
+ queryKey: ["orders", "customer", user?.id],
+ queryFn: () => getMyOrdersAsCustomer(user!.id),
+ enabled: !!user?.id,
+ refetchInterval: 10_000, // polling каждые 10 секунд
+ })
+
+ const performerQuery = useQuery({
+ queryKey: ["orders", "performer", user?.id],
+ queryFn: () => getMyOrdersAsPerformer(user!.id),
+ enabled: !!user?.id && isMaster,
+ refetchInterval: 10_000, // polling каждые 10 секунд
+ })
+
+ // Показываем уведомление при поступлении новых заказов
+ const notifiedOrdersRef = React.useRef>(new Set())
+ React.useEffect(() => {
+ if (performerQuery.data && performerQuery.data.length > 0) {
+ const pendingOrders = performerQuery.data.filter(
+ (o) => o.status === "PendingAcceptance"
+ )
+
+ let hasNew = false
+ pendingOrders.forEach((o) => {
+ if (!notifiedOrdersRef.current.has(o.id)) {
+ notifiedOrdersRef.current.add(o.id)
+ hasNew = true
+ }
+ })
+
+ if (hasNew) {
+ toast("У вас новый заказ!", {
+ description: "Загляните во вкладку «Активные», чтобы принять его.",
+ icon: ,
+ duration: 10000,
+ })
+ }
+ }
+ }, [performerQuery.data])
+
+ const qc = useQueryClient()
+ const refetch = () => {
+ qc.invalidateQueries({ queryKey: ["orders"] })
+ }
+
+ // Объединяем заказы без дублей
+ const allOrdersMap = new Map<
+ string,
+ { order: Order; isPerformerRole: boolean }
+ >()
+ ; (customerQuery.data ?? []).forEach((o) =>
+ allOrdersMap.set(o.id, { order: o, isPerformerRole: false })
+ )
+ ; (performerQuery.data ?? []).forEach((o) => {
+ if (!allOrdersMap.has(o.id)) {
+ allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
+ }
+ })
+ const allOrders = Array.from(allOrdersMap.values())
+
+ const isLoading =
+ (customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
+ const isFetching = customerQuery.isFetching || performerQuery.isFetching
+ const error = customerQuery.error || performerQuery.error
+
+ const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
+ if (!data) return []
+ if (tab === "new") return data.filter(o => NEW_STATUSES.includes(o.status))
+ if (tab === "process") return data.filter(o => PROGRESS_STATUSES.includes(o.status))
+ return data.filter(o => DONE_STATUSES.includes(o.status))
+ }
+
+ const getCounts = (data: Order[] | undefined) => {
+ if (!data) return { new: 0, process: 0, done: 0 }
+ return {
+ new: data.filter(o => NEW_STATUSES.includes(o.status)).length,
+ process: data.filter(o => PROGRESS_STATUSES.includes(o.status)).length,
+ done: data.filter(o => DONE_STATUSES.includes(o.status)).length,
+ }
+ }
+
+ const customerCounts = getCounts(customerQuery.data)
+ const performerCounts = getCounts(performerQuery.data)
+
+ // Ждём инициализации сессии
+ if (!isInitialized || isAuthLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!user) return null
+
+ return (
+
+ {/* Заголовок */}
+
+
+
+
+ Личный кабинет
+
+
+ Управление вашими заказами и услугами
+
+
+
+ {isFetching && (
+
+
+ Sync
+
+ )}
+
+
+
+
+
+ {/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
+
+
+
+
+
+ Заказы
+
+
Мои заказы
+
+
+
+
+
+ {[
+ { id: "new" as OrderTab, label: "Новые", count: customerCounts.new },
+ { id: "process" as OrderTab, label: "В работе", count: customerCounts.process },
+ { id: "done" as OrderTab, label: "Завершенные", count: customerCounts.done },
+ ].map(t => (
+
+ ))}
+
+
+
+
+ {customerQuery.isLoading ? (
+
+
+ Загрузка ваших заказов...
+
+ ) : (
+ getFilteredOrders(customerQuery.data, customerTab).length === 0 ? (
+
+
+
Здесь пока ничего нет
+ {customerTab === "new" && (
+
+ )}
+
+ ) : (
+ getFilteredOrders(customerQuery.data, customerTab).map(order => (
+
+ ))
+ )
+ )}
+
+
+
+ {/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */}
+
+
+
+
+
+ Услуги
+
+
Заказы клиентов
+
+
+
+ {!isMaster ? (
+
+
+
+
+
+ Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую.
+
+
+
+ ) : (
+ <>
+
+
+ {[
+ { id: "new" as OrderTab, label: "Новые", count: performerCounts.new },
+ { id: "process" as OrderTab, label: "В работе", count: performerCounts.process },
+ { id: "done" as OrderTab, label: "Завершенные", count: performerCounts.done },
+ ].map(t => (
+
+ ))}
+
+
+
+
+ {performerQuery.isLoading ? (
+
+
+ Синхронизация заказов...
+
+ ) : (
+ getFilteredOrders(performerQuery.data, performerTab).length === 0 ? (
+
+
+
+
+
Новых заявок пока нет
+
+ ) : (
+ getFilteredOrders(performerQuery.data, performerTab).map(order => (
+
+ ))
+ )
+ )}
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/src/app/dashboard/offers/page.tsx b/src/app/dashboard/offers/page.tsx
index 17a5b39..fe35de3 100644
--- a/src/app/dashboard/offers/page.tsx
+++ b/src/app/dashboard/offers/page.tsx
@@ -9,6 +9,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle, Image as ImageIcon } from "lucide-react";
import { useSessionStore } from "@/entities/session/store";
+import { formatPrice } from "@/shared/lib/formatPrice";
export default function MyOffersPage() {
const router = useRouter();
@@ -133,11 +134,15 @@ export default function MyOffersPage() {
{offer.description}
-
- {offer.price.amount} ₽
-
- {offer.price.type === 0 ? "фиксированно" : offer.price.type === 1 ? "в час" : "договорная"}
-
+
+ {offer.price.type === 2
+ ? Договорная
+ : <>
+ {formatPrice(offer.price.amount)}
+
+ {offer.price.type === 1 ? "в час" : "фиксированно"}
+
+ >}
diff --git a/src/app/dashboard/orders/page.tsx b/src/app/dashboard/orders/page.tsx
new file mode 100644
index 0000000..1ebe84e
--- /dev/null
+++ b/src/app/dashboard/orders/page.tsx
@@ -0,0 +1,633 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import {
+ Loader2,
+ Clock,
+ CheckCircle2,
+ XCircle,
+ AlertCircle,
+ Package,
+ Timer,
+ RefreshCw,
+ ShoppingBag,
+ User,
+ MapPin,
+ ArrowRight,
+ CreditCard,
+} from "lucide-react"
+import { useSessionStore } from "@/entities/session/store"
+import { formatPrice } from "@/shared/lib/formatPrice"
+import { Badge } from "@/components/ui/badge"
+import {
+ Order,
+ OrderStatus,
+ getMyOrdersAsCustomer,
+ getMyOrdersAsPerformer,
+ acceptOrder,
+ completeOrder,
+ cancelOrder,
+} from "@/shared/api/orders"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+// ─── SLA Countdown ──────────────────────────────────────────────────────────
+
+function SlaCountdown({ slaSecondsLeft }: { slaSecondsLeft: number }) {
+ const [seconds, setSeconds] = React.useState(slaSecondsLeft)
+
+ React.useEffect(() => {
+ setSeconds(slaSecondsLeft)
+ if (slaSecondsLeft <= 0) return
+ const interval = setInterval(() => setSeconds((p) => Math.max(0, p - 1)), 1000)
+ return () => clearInterval(interval)
+ }, [slaSecondsLeft])
+
+ const minutes = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ const isUrgent = seconds < 300 // < 5 минут — мигает красным
+
+ return (
+
+
+ {String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")}
+
+ )
+}
+
+// ─── Статус-бейдж ────────────────────────────────────────────────────────────
+
+const STATUS_CONFIG: Record<
+ OrderStatus,
+ { label: string; className: string; icon: React.ReactNode }
+> = {
+ Created: {
+ label: "Создан",
+ className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
+ icon: ,
+ },
+ Published: {
+ label: "Опубликован",
+ className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
+ icon: ,
+ },
+ PendingAcceptance: {
+ label: "Ожидает мастера",
+ className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
+ icon: ,
+ },
+ InProgress: {
+ label: "В работе",
+ className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
+ icon: ,
+ },
+ Completed: {
+ label: "Завершён",
+ className:
+ "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
+ icon: ,
+ },
+ Cancelled: {
+ label: "Отменён",
+ className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400",
+ icon: ,
+ },
+ Expired: {
+ label: "Время истекло",
+ className:
+ "bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
+ icon: ,
+ },
+}
+
+function StatusBadge({ status }: { status: OrderStatus }) {
+ const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created
+ return (
+
+ {cfg.icon}
+ {cfg.label}
+
+ )
+}
+
+// ─── Карточка заказа ─────────────────────────────────────────────────────────
+
+interface OrderCardProps {
+ order: Order
+ userId: string
+ isPerformerRole: boolean
+}
+
+function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
+ const qc = useQueryClient()
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ["orders"] })
+ }
+
+ const acceptMutation = useMutation({
+ mutationFn: () => acceptOrder(order.id, userId),
+ onSuccess: () => {
+ toast.success("Заказ принят!")
+ invalidate()
+ },
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"),
+ })
+
+ const completeMutation = useMutation({
+ mutationFn: () => completeOrder(order.id, userId),
+ onSuccess: () => {
+ toast.success("Заказ завершён!")
+ invalidate()
+ },
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"),
+ })
+
+ const cancelMutation = useMutation({
+ mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
+ onSuccess: () => {
+ toast.success("Заказ отменён")
+ invalidate()
+ },
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"),
+ })
+
+ const isLoading =
+ acceptMutation.isPending ||
+ completeMutation.isPending ||
+ cancelMutation.isPending
+
+ const isCustomer = order.customerId === userId
+ // Мастер считается исполнителем, если он SelectedPerformerId (для Direct)
+ // или если он уже принял заказ (статус перешел в InProgress)
+ const isPerformer = order.performerId === userId
+
+ const formatDate = (date: string) =>
+ new Date(date).toLocaleString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+
+ return (
+
+ {/* Верхняя полоска с ролью */}
+
+
+ {isCustomer ? "🛒 Мой заказ (я клиент)" : "🛠 Заказ услуги (я мастер)"}
+
+
#{order.id.slice(0, 8)}
+
+
+
+ {/* Шапка: Статус и Таймер */}
+
+
+
+
+ {formatDate(order.createdAt)}
+
+
+ {order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && (
+
+
+
+ )}
+
+
+ {/* Блок услуги (карточка) */}
+
+
+
+
+ {order.serviceTitle}
+
+
+
+
+ {isCustomer
+ ? `Исполнитель: ${order.performerName || "Ожидание..."}`
+ : `Заказчик: ${order.customerName}`}
+
+
+
+
+
+ {formatPrice(order.priceAmount, order.priceType)}
+
+
+
+
+ {order.address && (
+
+
+ {order.address}
+
+ )}
+
+
+ {/* Доп. инфо (отмена) */}
+ {order.cancellationReason && (
+
+
+
Причина отмены: {order.cancellationReason}
+
+ )}
+
+ {/* Кнопки действий */}
+ {/* Для мастера: Принять/Отклонить */}
+ {!isCustomer && order.status === "PendingAcceptance" && (
+
+
+
+
+ )}
+
+ {/* Завершить работу (мастер или клиент) */}
+ {order.status === "InProgress" && (
+
+ )}
+
+ {/* Клиент: Отменить (пока мастер не принял) */}
+ {isCustomer && order.status === "PendingAcceptance" && (
+
+ )}
+
+
+ )
+}
+
+// ─── Главная страница ─────────────────────────────────────────────────────────
+
+const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"]
+const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"]
+const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"]
+
+type OrderTab = "new" | "process" | "done"
+
+export default function OrdersDashboardPage() {
+ const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
+ const router = useRouter()
+
+ const [customerTab, setCustomerTab] = React.useState("new")
+ const [performerTab, setPerformerTab] = React.useState("new")
+
+ // Редирект если не авторизован
+ React.useEffect(() => {
+ if (isInitialized && !isAuthLoading && !isAuth) {
+ router.push("/auth/login")
+ }
+ }, [isAuth, isAuthLoading, isInitialized, router])
+
+ const isMaster = !!user?.roles?.some((r) => ["Master", "Company", "Candidate", "Admin"].includes(r))
+
+ // ─── React Query с polling каждые 10 секунд ─────────────────────────────
+
+ const customerQuery = useQuery({
+ queryKey: ["orders", "customer", user?.id],
+ queryFn: () => getMyOrdersAsCustomer(user!.id),
+ enabled: !!user?.id,
+ refetchInterval: 10_000, // polling каждые 10 секунд
+ })
+
+ const performerQuery = useQuery({
+ queryKey: ["orders", "performer", user?.id],
+ queryFn: () => getMyOrdersAsPerformer(user!.id),
+ enabled: !!user?.id && isMaster,
+ refetchInterval: 10_000, // polling каждые 10 секунд
+ })
+
+ // Показываем уведомление при поступлении новых заказов
+ const notifiedOrdersRef = React.useRef>(new Set())
+ React.useEffect(() => {
+ if (performerQuery.data && performerQuery.data.length > 0) {
+ const pendingOrders = performerQuery.data.filter(
+ (o) => o.status === "PendingAcceptance"
+ )
+
+ let hasNew = false
+ pendingOrders.forEach((o) => {
+ if (!notifiedOrdersRef.current.has(o.id)) {
+ notifiedOrdersRef.current.add(o.id)
+ hasNew = true
+ }
+ })
+
+ if (hasNew) {
+ toast("У вас новый заказ!", {
+ description: "Загляните во вкладку «Активные», чтобы принять его.",
+ icon: ,
+ duration: 10000,
+ })
+ }
+ }
+ }, [performerQuery.data])
+
+ const qc = useQueryClient()
+ const refetch = () => {
+ qc.invalidateQueries({ queryKey: ["orders"] })
+ }
+
+ // Объединяем заказы без дублей
+ const allOrdersMap = new Map<
+ string,
+ { order: Order; isPerformerRole: boolean }
+ >()
+ ; (customerQuery.data ?? []).forEach((o) =>
+ allOrdersMap.set(o.id, { order: o, isPerformerRole: false })
+ )
+ ; (performerQuery.data ?? []).forEach((o) => {
+ if (!allOrdersMap.has(o.id)) {
+ allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
+ }
+ })
+ const allOrders = Array.from(allOrdersMap.values())
+
+ const isLoading =
+ (customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
+ const isFetching = customerQuery.isFetching || performerQuery.isFetching
+ const error = customerQuery.error || performerQuery.error
+
+ const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
+ if (!data) return []
+ if (tab === "new") return data.filter(o => NEW_STATUSES.includes(o.status))
+ if (tab === "process") return data.filter(o => PROGRESS_STATUSES.includes(o.status))
+ return data.filter(o => DONE_STATUSES.includes(o.status))
+ }
+
+ const getCounts = (data: Order[] | undefined) => {
+ if (!data) return { new: 0, process: 0, done: 0 }
+ return {
+ new: data.filter(o => NEW_STATUSES.includes(o.status)).length,
+ process: data.filter(o => PROGRESS_STATUSES.includes(o.status)).length,
+ done: data.filter(o => DONE_STATUSES.includes(o.status)).length,
+ }
+ }
+
+ const customerCounts = getCounts(customerQuery.data)
+ const performerCounts = getCounts(performerQuery.data)
+
+ // Ждём инициализации сессии
+ if (!isInitialized || isAuthLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!user) return null
+
+ return (
+
+ {/* Заголовок */}
+
+
+
+
+ Личный кабинет
+
+
+ Управление вашими заказами и услугами
+
+
+
+ {isFetching && (
+
+
+ Sync
+
+ )}
+
+
+
+
+
+ {/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
+
+
+
+
+
+ Заказы
+
+
Мои заказы
+
+
+
+
+
+ {[
+ { id: "new" as OrderTab, label: "Новые", count: customerCounts.new },
+ { id: "process" as OrderTab, label: "В работе", count: customerCounts.process },
+ { id: "done" as OrderTab, label: "Завершенные", count: customerCounts.done },
+ ].map(t => (
+
+ ))}
+
+
+
+
+ {customerQuery.isLoading ? (
+
+
+ Загрузка ваших заказов...
+
+ ) : (
+ getFilteredOrders(customerQuery.data, customerTab).length === 0 ? (
+
+
+
Здесь пока ничего нет
+ {customerTab === "new" && (
+
+ )}
+
+ ) : (
+ getFilteredOrders(customerQuery.data, customerTab).map(order => (
+
+ ))
+ )
+ )}
+
+
+
+ {/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */}
+
+
+
+
+
+ Услуги
+
+
Заказы клиентов
+
+
+
+ {!isMaster ? (
+
+
+
+
+
+ Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую.
+
+
+
+ ) : (
+ <>
+
+
+ {[
+ { id: "new" as OrderTab, label: "Новые", count: performerCounts.new },
+ { id: "process" as OrderTab, label: "В работе", count: performerCounts.process },
+ { id: "done" as OrderTab, label: "Завершенные", count: performerCounts.done },
+ ].map(t => (
+
+ ))}
+
+
+
+
+ {performerQuery.isLoading ? (
+
+
+ Синхронизация заказов...
+
+ ) : (
+ getFilteredOrders(performerQuery.data, performerTab).length === 0 ? (
+
+
+
+
+
Новых заявок пока нет
+
+ ) : (
+ getFilteredOrders(performerQuery.data, performerTab).map(order => (
+
+ ))
+ )
+ )}
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7312f35..ab29d50 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,6 +6,7 @@ import { ThemeProvider } from "@/components/theme/theme-provider";
import { Header } from "@/widgets/Header";
import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/components/auth/AuthProvider";
+import { QueryProvider } from "@/components/providers/QueryProvider";
const inter = Inter({ subsets: ["latin", "cyrillic"] });
@@ -28,13 +29,15 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
-
-
-
- {children}
-
-
-
+
+
+
+
+ {children}
+
+
+
+