diff --git a/@types/prisma.ts b/@types/prisma.ts index 0aeef84..4f04073 100644 --- a/@types/prisma.ts +++ b/@types/prisma.ts @@ -1,3 +1,3 @@ -import { Adt, Category, City, User } from "@prisma/client"; +import { Adt, Category, City, Country, User } from "@prisma/client"; -export type AdtWithRelations = Adt & { category: Category; city: City; user: User }; \ No newline at end of file +export type AdtWithRelations = Adt & { category: Category; city: City; country: Country; user: User }; \ No newline at end of file diff --git a/app/(admin)/admin/categories/columns.tsx b/app/(admin)/admin/categories/columns.tsx new file mode 100644 index 0000000..e4ec049 --- /dev/null +++ b/app/(admin)/admin/categories/columns.tsx @@ -0,0 +1,81 @@ +"use client" + +import { ColumnDef } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { ArrowUpDown, MoreHorizontal } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { deleteCategory } from "@/app/actions/categories" +import CategoryForm from "@/components/admin/CategoryForm" + +export type Category = { + id: string + nameEn: string + nameAr: string + slug: string + icon?: string + _count: { + adts: number + } +} + +export const categoryColumns: ColumnDef[] = [ + { + accessorKey: "nameEn", + header: ({ column }) => { + return ( + + ) + }, + }, + { + accessorKey: "icon", + header: "Иконка", + cell: ({ row }) => row.getValue("icon") || "—", + }, + { + accessorKey: "_count.adts", + header: "Количество объявлений", + }, + { + id: "actions", + cell: ({ row }) => { + const category = row.original + + return ( + + + + + + + + + { + if (confirm("Вы уверены, что хотите удалить эту категорию?")) { + deleteCategory(category.id) + } + }} + > + Удалить + + + + ) + }, + }, +] \ No newline at end of file diff --git a/app/(admin)/admin/categories/page.tsx b/app/(admin)/admin/categories/page.tsx new file mode 100644 index 0000000..4d1cafb --- /dev/null +++ b/app/(admin)/admin/categories/page.tsx @@ -0,0 +1,19 @@ +// import { getCategoriesWithCount } from "@/app/actions/categories"; +import CategoryForm from "@/components/admin/CategoryForm"; +import { DataTable } from "@/components/admin/DataTable"; +import { Category, categoryColumns } from "./columns"; +import { getCategoriesWithCount } from "@/app/actions/categories"; + +export default async function CategoriesPage() { + const categories = await getCategoriesWithCount(); + + return ( +
+
+

Управление категориями

+ +
+ +
+ ); +} \ No newline at end of file diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..764c0a5 --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -0,0 +1,61 @@ +import { getStats } from "@/app/actions/stats" +import { Card } from "@/components/ui/card" +// import { getStats } from "@/app/actions/stats" + +export default async function AdminPage() { + const stats = await getStats() + + return ( +
+

Панель управления

+ +
+ +
Всего объявлений
+
{stats.totalAds}
+
+ + +
Активные объявления
+
{stats.activeAds}
+
+ + +
Всего пользователей
+
{stats.totalUsers}
+
+ + +
Новые пользователи (30 дней)
+
{stats.newUsers}
+
+
+ +
+ +

Популярные категории

+
+ {stats.topCategories.map((category) => ( +
+ {category.name} + {category.count} +
+ ))} +
+
+ + +

Популярные города

+
+ {stats.topCities.map((city) => ( +
+ {city.name} + {city.count} +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx new file mode 100644 index 0000000..8633f95 --- /dev/null +++ b/app/(admin)/layout.tsx @@ -0,0 +1,30 @@ +// import { auth } from "@clerk/nextjs"; +import AdminSidebar from "@/components/admin/AdminSidebar"; +import { getUserSession } from "@/lib/get-user-session"; +import { redirect } from "next/navigation"; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { +// const { userId } = auth(); + + const user = await getUserSession() + + if (!user) { + redirect('/sign-in'); + } + + // Здесь можно добавить проверку на роль админа + // TODO: Добавить проверку isAdmin из базы данных + + return ( +
+ +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/app/(root)/adt/[id]/page.tsx b/app/(root)/adt/[id]/page.tsx index 82ecce8..af3e556 100644 --- a/app/(root)/adt/[id]/page.tsx +++ b/app/(root)/adt/[id]/page.tsx @@ -2,13 +2,14 @@ import React from 'react'; // import { useParams } from 'next/navigation'; -import { MapPin, Calendar, Phone, MessageCircle, Share2, Flag, Heart, Tag, Building, ChevronRight } from 'lucide-react'; +import { MapPin, Calendar, Phone, MessageCircle, Share2, Flag, Heart, Tag, Building, ChevronRight, Pencil } from 'lucide-react'; import { prisma } from '@/prisma/prisma-client'; import { notFound } from 'next/navigation'; import { ShowNumberModal } from '@/components/shared/modals/show-number'; import { getUserSession } from '@/lib/get-user-session'; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; import { BreadcrumbsCategory } from '@/components/shared/breadcrumbs-category'; +import Link from 'next/link'; type Params = Promise<{ id: string }> @@ -37,6 +38,8 @@ export default async function AdtPage(props: { params: Params }) { const user = adt.user + const isOwner = session?.id === String(adt.userId); + return ( <>
@@ -74,6 +77,15 @@ export default async function AdtPage(props: { params: Params }) {

+ {isOwner && ( + + + Редактировать + + )} + + + + + {mode === "create" ? "Новая категория" : "Редактировать категорию"} + + +
+ + + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/components/admin/DataTable.tsx b/components/admin/DataTable.tsx new file mode 100644 index 0000000..9cdd05b --- /dev/null +++ b/components/admin/DataTable.tsx @@ -0,0 +1,77 @@ +"use client" + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + Нет данных + + + )} + +
+
+ ) +} \ No newline at end of file diff --git a/components/shared/adt-edit/adt-edit-form.tsx b/components/shared/adt-edit/adt-edit-form.tsx new file mode 100644 index 0000000..becac0a --- /dev/null +++ b/components/shared/adt-edit/adt-edit-form.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useState, useRef, useMemo, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { formAdtEditSchema, type TFormAdtEditValues } from './schemas'; +import Image from 'next/image'; +import { Category, Country, City, Adt } from '@prisma/client'; +import { useRouter } from 'next/navigation'; +import toast from 'react-hot-toast'; + +interface AdtEditFormProps { + adt: Adt & { + price: string; + category: Category & { + parent: Category | null; + }; + }; + categories: (Category & { + children: Category[]; + parent: Category | null; + })[]; + countries: Country[]; + cities: City[]; +} + +export default function AdtEditForm({ adt, categories, countries, cities }: AdtEditFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [preview, setPreview] = useState(adt.image); + const fileInputRef = useRef(null); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(formAdtEditSchema), + defaultValues: { + title: adt.title, + description: adt.description || '', + price: String(adt.price) || '', + categoryId: adt.categoryId, + countryId: String(adt.countryId), + cityId: String(adt.cityId), + address: adt.address || '', + image: adt.image || '', + } + }); + + const [selectedCountry, setSelectedCountry] = useState(adt.countryId); + const [filteredCities, setFilteredCities] = useState([]); + const [selectedParentCategory, setSelectedParentCategory] = useState( + adt.category.parentId || adt.category.id + ); + const [filteredSubCategories, setFilteredSubCategories] = useState([]); + + const parentCategories = useMemo(() => { + return categories.filter(cat => !cat.parentId); + }, [categories]); + + useEffect(() => { + if (selectedCountry) { + const countryCities = cities.filter(city => city.countryId === selectedCountry); + setFilteredCities(countryCities); + } + }, [selectedCountry, cities]); + + useEffect(() => { + if (selectedParentCategory) { + const subCategories = categories.filter(cat => cat.parentId === selectedParentCategory); + setFilteredSubCategories(subCategories); + } + }, [selectedParentCategory, categories]); + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setPreview(reader.result as string); + form.setValue('image', reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const onSubmit = async (values: TFormAdtEditValues) => { + try { + setIsSubmitting(true); + + const response = await fetch('/api/adt', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ...values, id: String(adt.id) }), + }); + + if (!response.ok) { + const error = await response.text(); + toast.error("Ошибка при обновлении объявления: " + error); + throw new Error('Error updating adt'); + } + + const updatedAdt = await response.json(); + toast.success("Объявление успешно обновлено"); + router.push(`/adt/${updatedAdt.id}`); + + } catch (error) { + console.error('Error:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleCountryChange = (countryId: string) => { + setSelectedCountry(countryId); + form.setValue('countryId', countryId); + }; + + const handleCategoryParentChange = (categoryId: string) => { + setSelectedParentCategory(categoryId); + }; + + return ( +
+
+ + + {form.formState.errors.title && ( +

{form.formState.errors.title.message}

+ )} +
+ +
+ +