list foods
This commit is contained in:
+39
-83
@@ -1,10 +1,31 @@
|
|||||||
|
|
||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import Table from '@/components/Table'
|
import Table from '@/components/Table'
|
||||||
import { Add, Eye } from 'iconsax-react'
|
import Filters from '@/components/Filters'
|
||||||
|
import { Add } from 'iconsax-react'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
|
import { useGetFoods } from './hooks/useFoodData'
|
||||||
|
import { useFoodFilters } from './hooks/useFoodFilters'
|
||||||
|
import { getFoodTableColumns } from './components/FoodTableColumns'
|
||||||
|
import { useFoodFiltersFields } from './components/FoodFiltersFields'
|
||||||
|
|
||||||
const FoodsList: FC = () => {
|
const FoodsList: FC = () => {
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
} = useFoodFilters()
|
||||||
|
|
||||||
|
const { data: foodsData, isLoading } = useGetFoods(apiParams)
|
||||||
|
|
||||||
|
const foods = foodsData?.data || []
|
||||||
|
const columns = getFoodTableColumns()
|
||||||
|
const filterFields = useFoodFiltersFields()
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(foods.length / limit) || 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
@@ -18,89 +39,24 @@ const FoodsList: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-8'>
|
<div className='mt-8'>
|
||||||
<Table
|
<Filters
|
||||||
columns={[
|
fields={filterFields}
|
||||||
{
|
onChange={handleFiltersChange}
|
||||||
key: 'image',
|
initialValues={filters}
|
||||||
title: 'تصویر',
|
searchField="search"
|
||||||
render: (item) => {
|
|
||||||
return (
|
|
||||||
<div className='w-10 h-10 rounded-full overflow-hidden'>
|
|
||||||
<img src={item.image} alt={item.name} className='w-full h-full object-cover' />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
title: 'نام غذا',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'category',
|
|
||||||
title: 'دسته بندی',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'price',
|
|
||||||
title: 'قیمت ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'preparationTime',
|
|
||||||
title: 'زمان آماده سازی',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
title: 'وضعیت',
|
|
||||||
render: (item) => {
|
|
||||||
return (
|
|
||||||
<div className='h-6 w-fit flex items-center bg-[#FFEDCA] text-[#FF7B00] rounded-full px-2.5 text-xs'>
|
|
||||||
{item.status}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'view',
|
|
||||||
title: '',
|
|
||||||
render: () => {
|
|
||||||
return (
|
|
||||||
<div className='flex gap-2 items-center'>
|
|
||||||
<Eye size={20} color='#8C90A3' />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
image: 'https://picsum.photos/seed/food1/200',
|
|
||||||
name: 'کباب کوبیده',
|
|
||||||
category: 'غذای ایرانی',
|
|
||||||
price: '۱۵۰,۰۰۰ تومان',
|
|
||||||
preparationTime: '۳۰ دقیقه',
|
|
||||||
status: 'موجود',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
image: 'https://picsum.photos/seed/food2/200',
|
|
||||||
name: 'پیتزا پپرونی',
|
|
||||||
category: 'فست فود',
|
|
||||||
price: '۲۰۰,۰۰۰ تومان',
|
|
||||||
preparationTime: '۴۵ دقیقه',
|
|
||||||
status: 'موجود',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
image: 'https://picsum.photos/seed/food3/200',
|
|
||||||
name: 'سالاد سزار',
|
|
||||||
category: 'پیش غذا',
|
|
||||||
price: '۸۰,۰۰۰ تومان',
|
|
||||||
preparationTime: '۱۵ دقیقه',
|
|
||||||
status: 'ناموجود',
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={foods}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { FieldType } from '@/components/Filters'
|
||||||
|
|
||||||
|
export const useFoodFiltersFields = (): FieldType[] => {
|
||||||
|
return useMemo(() => [
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'search',
|
||||||
|
placeholder: 'جستجو',
|
||||||
|
},
|
||||||
|
], [])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Eye } from 'iconsax-react'
|
||||||
|
import type { ColumnType } from '@/components/types/TableTypes'
|
||||||
|
import type { Food } from '../types/Types'
|
||||||
|
import { formatPrice, formatTime } from '../utils/formatters'
|
||||||
|
|
||||||
|
export const getFoodTableColumns = (): ColumnType<Food>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'image',
|
||||||
|
title: 'تصویر',
|
||||||
|
render: (item: Food) => {
|
||||||
|
const imageUrl = item.images && item.images.length > 0 ? item.images[0] : ''
|
||||||
|
return (
|
||||||
|
<div className='w-10 h-10 rounded-full overflow-hidden bg-gray-200'>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt={item.title} className='w-full h-full object-cover' />
|
||||||
|
) : (
|
||||||
|
<div className='w-full h-full flex items-center justify-center text-gray-400 text-xs'>
|
||||||
|
بدون تصویر
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
title: 'نام غذا',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'categories',
|
||||||
|
title: 'دسته بندی',
|
||||||
|
render: (item: Food) => {
|
||||||
|
return item.categories && item.categories.length > 0
|
||||||
|
? item.categories.map(cat => cat.title).join(', ')
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
title: 'قیمت',
|
||||||
|
render: (item: Food) => {
|
||||||
|
return formatPrice(item.price)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prepareTime',
|
||||||
|
title: 'زمان آماده سازی',
|
||||||
|
render: (item: Food) => {
|
||||||
|
return formatTime(item.prepareTime)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
title: 'وضعیت',
|
||||||
|
render: (item: Food) => {
|
||||||
|
return (
|
||||||
|
<div className={`h-6 w-fit flex items-center rounded-full px-2.5 text-xs ${
|
||||||
|
item.isActive
|
||||||
|
? 'bg-[#FFEDCA] text-[#FF7B00]'
|
||||||
|
: 'bg-gray-200 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{item.isActive ? 'فعال' : 'غیرفعال'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
title: '',
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<Eye size={20} color='#8C90A3' />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import * as api from "../service/FoodService";
|
import * as api from "../service/FoodService";
|
||||||
|
import type { GetFoodsParams } from "../service/FoodService";
|
||||||
|
|
||||||
export const useCreateFood = () => {
|
export const useCreateFood = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -7,6 +8,13 @@ export const useCreateFood = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGetFoods = (params?: GetFoodsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["foods", params],
|
||||||
|
queryFn: () => api.getFoods(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useGetCategories = () => {
|
export const useGetCategories = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["categories"],
|
queryKey: ["categories"],
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import type { FilterValues } from '@/components/Filters'
|
||||||
|
import type { GetFoodsParams } from '../service/FoodService'
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = 1
|
||||||
|
const DEFAULT_LIMIT = 10
|
||||||
|
|
||||||
|
export const useFoodFilters = () => {
|
||||||
|
const [filters, setFilters] = useState<FilterValues>({})
|
||||||
|
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE)
|
||||||
|
const [limit] = useState(DEFAULT_LIMIT)
|
||||||
|
|
||||||
|
const apiParams = useMemo<GetFoodsParams>(() => {
|
||||||
|
const params: GetFoodsParams = {
|
||||||
|
page: currentPage,
|
||||||
|
limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
params.search = filters.search
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}, [filters, currentPage, limit])
|
||||||
|
|
||||||
|
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||||
|
setFilters(newFilters)
|
||||||
|
setCurrentPage(DEFAULT_PAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import type { CreateFoodType, GetCategoriesResponseType } from "../types/Types";
|
import type {
|
||||||
|
CreateFoodType,
|
||||||
|
GetCategoriesResponseType,
|
||||||
|
GetFoodsResponseType,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
export const createFood = async (params: CreateFoodType) => {
|
export const createFood = async (params: CreateFoodType) => {
|
||||||
const { data } = await axios.post(`/foods`, params);
|
const { data } = await axios.post(`/foods`, params);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetFoodsParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
orderBy?: string;
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
categoryId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFoods = async (
|
||||||
|
params?: GetFoodsParams
|
||||||
|
): Promise<GetFoodsResponseType> => {
|
||||||
|
const { data } = await axios.get<GetFoodsResponseType>(`/foods`, { params });
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const getCategories = async (): Promise<GetCategoriesResponseType> => {
|
export const getCategories = async (): Promise<GetCategoriesResponseType> => {
|
||||||
const { data } = await axios.get<GetCategoriesResponseType>(`/categories`);
|
const { data } = await axios.get<GetCategoriesResponseType>(`/categories`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -29,13 +29,43 @@ export type CreateFoodType = {
|
|||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
id: string;
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
restId: string;
|
restId: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Food = {
|
||||||
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
foods: unknown[];
|
deletedAt: string | null;
|
||||||
|
title: string;
|
||||||
|
desc: string | null;
|
||||||
|
content: string[];
|
||||||
|
price: number;
|
||||||
|
points: number;
|
||||||
|
order: number | null;
|
||||||
|
prepareTime: number;
|
||||||
|
sat: boolean;
|
||||||
|
sun: boolean;
|
||||||
|
mon: boolean;
|
||||||
|
breakfast: boolean;
|
||||||
|
noon: boolean;
|
||||||
|
dinner: boolean;
|
||||||
|
stock: number;
|
||||||
|
stockDefault: number;
|
||||||
|
isActive: boolean;
|
||||||
|
images: string[];
|
||||||
|
inPlaceServe: boolean;
|
||||||
|
pickupServe: boolean;
|
||||||
|
rate: number;
|
||||||
|
discount: number;
|
||||||
|
categories: Category[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCategoriesResponseType = IResponse<Category[]>;
|
export type GetCategoriesResponseType = IResponse<Category[]>;
|
||||||
|
export type GetFoodsResponseType = IResponse<Food[]>;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const formatPrice = (price: number): string => {
|
||||||
|
return new Intl.NumberFormat('fa-IR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IRR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatTime = (minutes: number): string => {
|
||||||
|
return `${minutes} دقیقه`
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user