This commit is contained in:
Samandar Turgunboyev
2025-11-01 16:18:36 +05:00
parent 0a61399e3d
commit 4e9b2f3bd8
19 changed files with 959 additions and 424 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/Logo_blue.png" /> <link rel="icon" href="/Logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple Travel</title> <title>Simple Travel</title>
</head> </head>

4
public/Logo.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="219" height="224" viewBox="0 0 219 224" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M75.0372 0.24821C65.181 2.14821 52.5935 7.01696 45.706 11.6482C42.9747 13.4295 40.2435 14.9732 39.6497 14.9732C39.056 14.9732 33.7122 19.6045 28.0122 25.3045C12.8122 40.3857 5.33097 54.042 1.05597 75.1795C-1.08153 85.0357 0.224719 109.498 3.19347 119.473C7.70597 134.198 15.0685 145.836 27.656 158.542C41.5497 172.317 52.7122 178.967 70.881 183.598C93.3247 189.536 122.893 185.142 141.775 173.267C144.268 171.842 146.762 170.536 147.356 170.536C147.831 170.536 159.112 181.104 172.412 193.929C203.525 224.329 206.256 226.229 213.856 222.192C216.943 220.648 218.725 216.729 218.725 211.386C218.725 210.436 206.493 197.492 191.412 182.529C176.331 167.567 164.1 154.623 164.1 153.673C164.1 152.723 166.237 149.636 168.731 146.904C171.343 144.054 175.618 137.761 178.112 132.654C200.793 88.9545 181.912 31.8357 137.618 9.74821C122.418 2.14821 115.531 0.60446 95.2247 0.12946C85.7247 -0.10804 76.6997 0.0107099 75.0372 0.24821ZM105.318 19.9607C118.856 21.8607 133.581 29.5795 144.981 40.8607C160.537 56.2982 166.95 71.9732 166.95 95.1295C166.95 107.123 166.593 109.023 163.15 117.692C153.412 142.036 133.343 158.304 107.575 162.817C94.5122 165.073 89.0497 165.073 77.7685 162.698C51.7622 157.354 30.8622 136.573 23.9747 109.023C19.6997 92.042 21.1247 79.217 29.1997 61.8795C38.8185 41.3357 56.5122 26.492 77.7685 20.9107C87.1497 18.417 92.731 18.1795 105.318 19.9607Z" fill="#084FE3"/>
<path d="M122.537 62.5919C118.975 64.7294 113.631 68.0544 110.662 70.1919C101.043 76.9607 101.518 76.8419 87.981 73.6357C81.2122 72.0919 70.5247 69.7169 64.3497 68.4107L53.0685 66.0357L51.0497 68.5294C47.3685 72.9232 51.406 77.0794 67.556 85.7482C72.306 88.2419 76.4622 91.0919 76.8185 91.9232C77.6497 94.0607 70.0497 101.661 67.1997 101.661C65.8935 101.661 61.2622 100.473 56.8685 99.0482C50.8122 97.1482 48.1997 96.7919 46.7747 97.6232C44.3997 99.1669 43.4497 104.748 45.231 106.886C47.4872 109.498 65.0622 119.473 67.556 119.473C70.406 119.473 82.5185 111.992 112.562 91.5669C136.668 75.1794 136.906 75.0607 139.043 71.0232C140.706 67.9357 139.993 65.0857 136.906 61.4044C133.937 57.9607 130.256 58.3169 122.537 62.5919Z" fill="#084FE3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -33,6 +33,7 @@ import UserDetail from "@/pages/users/ui/UserDetail";
import { getMe } from "@/shared/config/api/auth/api"; import { getMe } from "@/shared/config/api/auth/api";
import "@/shared/config/i18n"; import "@/shared/config/i18n";
import { getAuthToken } from "@/shared/lib/authCookies"; import { getAuthToken } from "@/shared/lib/authCookies";
import { cn } from "@/shared/lib/utils";
import { Sidebar } from "@/widgets/sidebar/ui/Sidebar"; import { Sidebar } from "@/widgets/sidebar/ui/Sidebar";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -77,47 +78,54 @@ const App = () => {
<div className="flex max-lg:flex-col bg-gray-900"> <div className="flex max-lg:flex-col bg-gray-900">
{shouldShowSidebar && <Sidebar role={user ? user.role : "admin"} />} {shouldShowSidebar && <Sidebar role={user ? user.role : "admin"} />}
<Routes> <main
<Route path="/" element={<Navigate to={"/user"} />} /> className={cn(
<Route path="/user" element={<UserList />} /> "flex-1 min-h-screen bg-gray-900 transition-all",
<Route path="/login" element={<Login />} /> shouldShowSidebar ? "lg:ml-64" : "ml-0"
<Route path="/users/create" element={<CreateUser />} /> )}
<Route path="/users/:id/edit" element={<EditUser />} /> >
<Route path="/users/:id/" element={<UserDetail />} /> <Routes>
<Route path="/agencies" element={<Agencies />} /> <Route path="/" element={<Navigate to={"/user"} />} />
<Route path="/agencies/:id" element={<AgencyDetail />} /> <Route path="/user" element={<UserList />} />
<Route path="/agency/:id/edit" element={<EditAgecy />} /> <Route path="/login" element={<Login />} />
<Route path="/tours/:id" element={<TourDetail />} /> <Route path="/users/create" element={<CreateUser />} />
<Route path="/employees" element={<Employees />} /> <Route path="/users/:id/edit" element={<EditUser />} />
<Route <Route path="/users/:id/" element={<UserDetail />} />
path="/finance" <Route path="/agencies" element={<Agencies />} />
element={<FinancePage user={user ? user.role : "moderator"} />} <Route path="/agencies/:id" element={<AgencyDetail />} />
/> <Route path="/agency/:id/edit" element={<EditAgecy />} />
<Route path="/purchases/:id/" element={<PurchaseDetailPage />} /> <Route path="/tours/:id" element={<TourDetail />} />
<Route path="/travel/booking/:id/" element={<FinanceDetailTour />} /> <Route path="/employees" element={<Employees />} />
<Route path="/bookings/:id/" element={<FinanceDetailUsers />} /> <Route
<Route path="/finance"
path="/tours" element={<FinancePage user={user ? user.role : "moderator"} />}
element={<Tours user={user ? user.role : "moderator"} />} />
/> <Route path="/purchases/:id/" element={<PurchaseDetailPage />} />
<Route path="/tours/setting" element={<ToursSetting />} /> <Route path="/travel/booking/:id/" element={<FinanceDetailTour />} />
<Route path="/tours/:id/edit" element={<CreateEditTour />} /> <Route path="/bookings/:id/" element={<FinanceDetailUsers />} />
<Route path="/tours/create" element={<CreateEditTour />} /> <Route
<Route path="/bookings" element={<Bookings />} /> path="/tours"
<Route path="/news" element={<News />} /> element={<Tours user={user ? user.role : "moderator"} />}
<Route path="/news/add" element={<AddNews />} /> />
<Route path="/news/edit/:id" element={<AddNews />} /> <Route path="/tours/setting" element={<ToursSetting />} />
<Route path="/news/categories" element={<NewsCategory />} /> <Route path="/tours/:id/edit" element={<CreateEditTour />} />
<Route path="/faq" element={<Faq />} /> <Route path="/tours/create" element={<CreateEditTour />} />
<Route path="/faq/categories" element={<FaqCategory />} /> <Route path="/bookings" element={<Bookings />} />
<Route path="/support/tours" element={<SupportAgency />} /> <Route path="/news" element={<News />} />
<Route path="/support/user" element={<SupportTours />} /> <Route path="/news/add" element={<AddNews />} />
<Route path="/site-seo" element={<Seo />} /> <Route path="/news/edit/:id" element={<AddNews />} />
<Route path="/site-pages/" element={<SitePage />} /> <Route path="/news/categories" element={<NewsCategory />} />
<Route path="/site-help/" element={<PolicyCrud />} /> <Route path="/faq" element={<Faq />} />
<Route path="/site-settings/" element={<TourSettings />} /> <Route path="/faq/categories" element={<FaqCategory />} />
<Route path="/site-banner/" element={<SiteBannerAdmin />} /> <Route path="/support/tours" element={<SupportAgency />} />
</Routes> <Route path="/support/user" element={<SupportTours />} />
<Route path="/site-seo" element={<Seo />} />
<Route path="/site-pages/" element={<SitePage />} />
<Route path="/site-help/" element={<PolicyCrud />} />
<Route path="/site-settings/" element={<TourSettings />} />
<Route path="/site-banner/" element={<SiteBannerAdmin />} />
</Routes>
</main>
</div> </div>
</> </>
); );

View File

@@ -1,5 +1,7 @@
import type { import type {
AllAmenitiesData,
CreateTourRes, CreateTourRes,
DetailAmenitiesData,
GetAllTours, GetAllTours,
GetDetailTours, GetDetailTours,
GetHotelRes, GetHotelRes,
@@ -19,6 +21,7 @@ import type {
} from "@/pages/tours/lib/type"; } from "@/pages/tours/lib/type";
import httpClient from "@/shared/config/api/httpClient"; import httpClient from "@/shared/config/api/httpClient";
import { import {
AMENITIES,
GET_TICKET, GET_TICKET,
HOTEL, HOTEL,
HOTEL_BADGE, HOTEL_BADGE,
@@ -444,13 +447,71 @@ const hotelFeatureTypeDelete = async ({ id }: { id: number }) => {
return response; return response;
}; };
//Amenities
const getAllAmenities = async ({
page,
page_size,
}: {
page_size: number;
page: number;
}): Promise<AxiosResponse<AllAmenitiesData>> => {
const response = await httpClient.get(AMENITIES, {
params: { page, page_size },
});
return response;
};
const amenitiesCreate = async ({
body,
}: {
body: {
name: string;
name_ru: string;
icon_name: string;
};
}) => {
const response = await httpClient.post(AMENITIES, body);
return response;
};
const deleteAmenities = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${AMENITIES}${id}/`);
return response;
};
const getDetailAmenities = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<DetailAmenitiesData>> => {
const response = await httpClient.get(`${AMENITIES}${id}/`);
return response;
};
const amenitiesUpdate = async ({
id,
body,
}: {
id: number;
body: { name: string; name_ru: string; icon_name: string };
}) => {
const response = await httpClient.patch(`${AMENITIES}${id}/`, body);
return response;
};
export { export {
addedPopularTours, addedPopularTours,
amenitiesCreate,
amenitiesUpdate,
createHotel, createHotel,
createTours, createTours,
deleteAmenities,
deleteTours, deleteTours,
editHotel, editHotel,
getAllAmenities,
getAllTours, getAllTours,
getDetailAmenities,
getDetailToursId, getDetailToursId,
getHotel, getHotel,
getOneTours, getOneTours,

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import type { import type {
AllAmenitiesDataRes,
Badge, Badge,
HotelFeatures, HotelFeatures,
HotelFeaturesType, HotelFeaturesType,
@@ -10,6 +11,8 @@ import type {
} from "@/pages/tours/lib/type"; } from "@/pages/tours/lib/type";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import * as LucideIcons from "lucide-react";
import { XIcon } from "lucide-react";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -305,3 +308,59 @@ export const FeatureTypeColumns = (
}, },
}, },
]; ];
export const AmenitiesColumns = (
onEdit: (id: number) => void,
onDelete: (id: number) => void,
t: (key: string) => string,
): ColumnDef<AllAmenitiesDataRes>[] => [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
return <span>{row.original.id}</span>;
},
},
{
accessorKey: "name",
header: t("Nomi"),
cell: ({ row }) => <span>{row.original.name}</span>,
},
{
accessorKey: "icon_name",
header: t("Icon"),
cell: ({ row }) => {
const Icon = (LucideIcons as any)[row.original.icon_name] || XIcon;
return (
<span>
<Icon className="size-4" />
</span>
);
},
},
{
id: "actions",
header: () => <div className="text-right">{t("Harakatlar")}</div>,
cell: ({ row }) => {
const { t } = useTranslation();
return (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onEdit(row.original.id)}
>
{t("Tahrirlash")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(row.original.id)}
>
{t("O'chirish")}
</Button>
</div>
);
},
},
];

View File

@@ -127,15 +127,7 @@ export const TourformSchema = z.object({
images: z images: z
.array(z.union([z.instanceof(File), z.string()])) .array(z.union([z.instanceof(File), z.string()]))
.min(1, { message: "Kamida bitta rasm yuklang." }), .min(1, { message: "Kamida bitta rasm yuklang." }),
amenities: z amenities: z.array(z.number()).optional(),
.array(
z.object({
name: z.string().min(1, { message: "Majburiy maydon" }),
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
icon_name: z.string().min(1, { message: "Majburiy maydon" }),
}),
)
.min(1, { message: "Kamida bitta qulaylik kiriting." }),
// 🔹 Quyidagilar endi ixtiyoriy (required emas) // 🔹 Quyidagilar endi ixtiyoriy (required emas)
hotel_services: z hotel_services: z

View File

@@ -1,36 +1,41 @@
import { create } from "zustand"; import { create } from "zustand";
interface Amenity {
name: string;
name_ru: string;
icon_name: string;
}
interface TicketStore { interface TicketStore {
amenities: Amenity[]; amenities: number[];
id: number | null; id: number | null;
setId: (id: number) => void; setId: (id: number) => void;
setAmenities: (amenities: Amenity[]) => void; setAmenities: (amenities: number[]) => void;
addAmenity: (amenity: Amenity) => void; addAmenity: (amenity: number) => void;
removeAmenity: (index: number) => void; removeAmenity: (id: number) => void;
updateAmenity: (index: number, updated: Partial<Amenity>) => void; updateAmenity: (index: number, updated: number) => void;
} }
export const useTicketStore = create<TicketStore>((set) => ({ export const useTicketStore = create<TicketStore>((set) => ({
amenities: [], amenities: [],
id: null, id: null,
setId: (id) => set({ id }), setId: (id) => set({ id }),
setAmenities: (amenities) => set({ amenities }), setAmenities: (amenities) => set({ amenities }),
addAmenity: (amenity) => addAmenity: (amenity) =>
set((state) => ({ amenities: [...state.amenities, amenity] })), set((state) => {
removeAmenity: (index) => // agar qulaylik allaqachon mavjud bolsa, qoshmaydi
if (state.amenities.includes(amenity)) return state;
return { amenities: [...state.amenities, amenity] };
}),
removeAmenity: (id) =>
set((state) => ({ set((state) => ({
amenities: state.amenities.filter((_, i) => i !== index), amenities: state.amenities.filter((a) => a !== id),
})), })),
updateAmenity: (index, updated) => updateAmenity: (index, updated) =>
set((state) => ({ set((state) => {
amenities: state.amenities.map((a, i) => const newAmenities = [...state.amenities];
i === index ? { ...a, ...updated } : a, if (index >= 0 && index < newAmenities.length) {
), newAmenities[index] = updated;
})), }
return { amenities: newAmenities };
}),
})); }));

View File

@@ -75,14 +75,7 @@ export interface GetOneTours {
image: string; image: string;
}, },
]; ];
ticket_amenities: [ ticket_amenities: number[];
{
name: string;
name_ru: string;
name_uz: string;
icon_name: string;
},
];
ticket_included_services: [ ticket_included_services: [
{ {
image: string; image: string;
@@ -530,3 +523,37 @@ export interface GetHotelRes {
]; ];
}; };
} }
export interface AllAmenitiesData {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: AllAmenitiesDataRes[];
};
}
export interface AllAmenitiesDataRes {
id: number;
name: string;
name_ru: string;
name_uz: string;
icon_name: string;
}
export interface DetailAmenitiesData {
status: boolean;
data: {
id: number;
name: string;
name_ru: string;
name_uz: string;
icon_name: string;
};
}

View File

@@ -0,0 +1,357 @@
import {
amenitiesCreate,
amenitiesUpdate,
deleteAmenities,
getDetailAmenities,
} from "@/pages/tours/lib/api";
import { AmenitiesColumns } from "@/pages/tours/lib/column";
import type { AllAmenitiesDataRes } from "@/pages/tours/lib/type";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent } from "@/shared/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import IconSelect from "@/shared/ui/iocnSelect";
import { Label } from "@/shared/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import RealPagination from "@/widgets/real-pagination/ui/RealPagination";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Loader, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import z from "zod";
const formSchema = z.object({
name: z.string().min(1, { message: "Majburiy maydon" }),
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
icon_name: z.string().min(1, { message: "Majburiy maydon" }),
});
const Amenities = ({
data,
page,
pageSize,
}: {
page: number;
pageSize: number;
data:
| {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: AllAmenitiesDataRes[];
}
| undefined;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState<boolean>(false);
const [editId, setEditId] = useState<number | null>(null);
const queryClient = useQueryClient();
const [types, setTypes] = useState<"edit" | "create">("create");
const handleEdit = (id: number) => {
setTypes("edit");
setOpen(true);
setEditId(id);
};
const { data: badgeDetail } = useQuery({
queryKey: ["detail_amenities", editId],
queryFn: () => getDetailAmenities({ id: editId! }),
enabled: !!editId,
});
const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => deleteAmenities({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_amenities"] });
queryClient.refetchQueries({ queryKey: ["all_amenities"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const handleDelete = (id: number) => {
deleteMutate({ id });
};
const columns = AmenitiesColumns(handleEdit, handleDelete, t);
const { mutate: create, isPending } = useMutation({
mutationFn: ({
body,
}: {
body: { name: string; name_ru: string; icon_name: string };
}) => amenitiesCreate({ body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_amenities"] });
queryClient.refetchQueries({ queryKey: ["all_amenities"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: update, isPending: updatePending } = useMutation({
mutationFn: ({
body,
id,
}: {
id: number;
body: { name: string; name_ru: string; icon_name: string };
}) => amenitiesUpdate({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_amenities"] });
queryClient.refetchQueries({ queryKey: ["all_amenities"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
name_ru: "",
},
});
useEffect(() => {
if (badgeDetail) {
form.setValue("icon_name", badgeDetail.data.data.icon_name);
form.setValue("name", badgeDetail.data.data.name_uz);
form.setValue("name_ru", badgeDetail.data.data.name_ru);
}
}, [editId, badgeDetail]);
function onSubmit(values: z.infer<typeof formSchema>) {
if (types === "create") {
create({
body: {
icon_name: values.icon_name,
name: values.name,
name_ru: values.name_ru,
},
});
} else if (types === "edit" && editId) {
update({
id: editId,
body: {
icon_name: values.icon_name,
name: values.name,
name_ru: values.name_ru,
},
});
}
}
const table = useReactTable({
data: data?.results ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: data?.total_pages ?? 0,
state: {
pagination: {
pageIndex: page - 1,
pageSize: pageSize,
},
},
});
return (
<>
<div className="flex justify-end">
<Button
variant="default"
onClick={() => {
setOpen(true);
setTypes("create");
form.reset();
}}
>
<PlusIcon className="mr-2" />
{t("Qoshish")}
</Button>
</div>
<div className="border border-gray-700 rounded-md overflow-hidden mt-4">
<Table key={data?.current_page}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-gray-400"
>
{t("Ma'lumot topilmadi")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<RealPagination
table={table}
totalPages={data?.total_pages}
namePage="pageAmenities"
namePageSize="pageSizeAmenities"
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<p className="text-xl">
{types === "create" ? t("Saqlash") : t("Tahrirlash")}
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 p-2"
>
<FormField
control={form.control}
name="icon_name"
render={({ field }) => (
<FormItem>
<Label className="text-md">{t("Qulayliklar")}</Label>
<div className="flex flex-col gap-4 w-full">
<IconSelect
selectedIcon={field.value ?? ""}
setSelectedIcon={(val: string) => field.onChange(val)}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Nomi")}</FormLabel>
<FormControl>
<Input placeholder={t("Nomi")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name_ru"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Nomi (ru)")}</FormLabel>
<FormControl>
<Input placeholder={t("Nomi (ru)")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
onClick={() => setOpen(false)}
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
>
{t("Bekor qilish")}
</Button>
<Button type="submit">
{isPending || updatePending ? (
<Loader className="animate-spin" />
) : types === "create" ? (
t("Saqlash")
) : (
t("Tahrirlash")
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
};
export default Amenities;

View File

@@ -2,6 +2,7 @@
import { import {
createTours, createTours,
getAllAmenities,
hotelBadge, hotelBadge,
hotelTarif, hotelTarif,
hotelTransport, hotelTransport,
@@ -29,14 +30,12 @@ import {
FormMessage, FormMessage,
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import IconSelect from "@/shared/ui/iocnSelect";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/shared/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/shared/ui/radio-group";
import { Textarea } from "@/shared/ui/textarea"; import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import * as LucideIcons from "lucide-react";
import { ChevronDownIcon, SquareCheckBig, XIcon } from "lucide-react"; import { ChevronDownIcon, SquareCheckBig, XIcon } from "lucide-react";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -105,7 +104,7 @@ const StepOne = ({
}, },
}); });
const { addAmenity, setId } = useTicketStore(); const { addAmenity, setId, removeAmenity } = useTicketStore();
useEffect(() => { useEffect(() => {
if (!isEditMode || !data?.data) return; if (!isEditMode || !data?.data) return;
@@ -158,14 +157,7 @@ const StepOne = ({
} }
// 🔹 Qulayliklar (amenities) // 🔹 Qulayliklar (amenities)
form.setValue( form.setValue("amenities", tour.ticket_amenities);
"amenities",
tour.ticket_amenities?.map((a) => ({
name: a.name ?? "",
name_ru: a.name_ru ?? "",
icon_name: a.icon_name ?? "",
})) ?? [],
);
// 🔹 Xizmatlar (hotel_services) // 🔹 Xizmatlar (hotel_services)
form.setValue( form.setValue(
@@ -258,7 +250,6 @@ const StepOne = ({
const { watch, setValue } = form; const { watch, setValue } = form;
const selectedDate = watch("departureDateTime.date"); const selectedDate = watch("departureDateTime.date");
const selectedDateTravel = watch("travelDateTime.date"); const selectedDateTravel = watch("travelDateTime.date");
const [selectedIcon, setSelectedIcon] = useState("");
const { mutate: create } = useMutation({ const { mutate: create } = useMutation({
mutationFn: (body: FormData) => { mutationFn: (body: FormData) => {
@@ -341,24 +332,20 @@ const StepOne = ({
value.badges?.forEach((e, i) => { value.badges?.forEach((e, i) => {
formData.append(`badge[${i}]`, String(e)); formData.append(`badge[${i}]`, String(e));
}); });
value.amenities?.forEach((e, i) => {
formData.append(`ticket_amenities[${i}]`, String(e));
});
value.images.forEach((e) => { value.images.forEach((e) => {
if (e instanceof File) { if (e instanceof File) {
formData.append("ticket_images", e); formData.append("ticket_images", e);
} }
}); });
value.amenities.forEach((e, i) => { value.amenities?.forEach((e, i) => {
formData.append(`ticket_amenities[${i}]name`, e.name); formData.append(`badge[${i}]`, String(e));
formData.append(`ticket_amenities[${i}]name_ru`, e.name_ru);
formData.append(`ticket_amenities[${i}]icon_name`, e.icon_name);
addAmenity({
icon_name: e.icon_name,
name: e.name,
name_ru: e.name_ru,
});
}); });
value.hotel_services && value.hotel_services &&
value.hotel_services.forEach((e, i) => { value.hotel_services.forEach((e, i) => {
if (e instanceof File) { if (e.image instanceof File) {
formData.append(`ticket_included_services[${i}]image`, e.image); formData.append(`ticket_included_services[${i}]image`, e.image);
formData.append(`ticket_included_services[${i}]title`, e.title); formData.append(`ticket_included_services[${i}]title`, e.title);
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru); formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru);
@@ -366,31 +353,43 @@ const StepOne = ({
formData.append(`ticket_included_services[${i}]desc`, e.description); formData.append(`ticket_included_services[${i}]desc`, e.description);
} }
}); });
value.ticket_itinerary.forEach((e, i) => { value.ticket_itinerary?.forEach((itinerary, i) => {
e.ticket_itinerary_image.forEach((l, f) => { formData.append(`ticket_itinerary[${i}]title`, itinerary.title);
if (e instanceof File) { formData.append(`ticket_itinerary[${i}]title_ru`, itinerary.title_ru);
formData.append(`ticket_itinerary[${i}]title`, e.title); formData.append(
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru); `ticket_itinerary[${i}]duration`,
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration)); String(itinerary.duration),
);
// Rasmlar
if (Array.isArray(itinerary.ticket_itinerary_image)) {
itinerary.ticket_itinerary_image.forEach((img, j) => {
const file = img instanceof File ? img : img.image;
if (file) {
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_image[${j}]image`,
file,
);
}
});
}
// Destinations
if (Array.isArray(itinerary.ticket_itinerary_destinations)) {
itinerary.ticket_itinerary_destinations.forEach((dest, k) => {
formData.append( formData.append(
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`, `ticket_itinerary[${i}]ticket_itinerary_destinations[${k}]name`,
l.image, dest.name,
); );
e.ticket_itinerary_destinations.forEach((e, f) => { formData.append(
formData.append( `ticket_itinerary[${i}]ticket_itinerary_destinations[${k}]name_ru`,
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`, dest.name_ru,
String(e.name), );
); });
formData.append( }
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
String(e.name_ru),
);
});
}
});
}); });
value.hotel_meals.forEach((e, i) => { value.hotel_meals.forEach((e, i) => {
if (e instanceof File) { if (e.image instanceof File) {
formData.append(`ticket_hotel_meals[${i}]image`, e.image); formData.append(`ticket_hotel_meals[${i}]image`, e.image);
formData.append(`ticket_hotel_meals[${i}]name`, e.title); formData.append(`ticket_hotel_meals[${i}]name`, e.title);
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru); formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru);
@@ -419,13 +418,16 @@ const StepOne = ({
} }
} }
console.log(form.formState.errors);
const { data: badge } = useQuery({ const { data: badge } = useQuery({
queryKey: ["all_badge"], queryKey: ["all_badge"],
queryFn: () => hotelBadge({ page: 1, page_size: 10 }), queryFn: () => hotelBadge({ page: 1, page_size: 10 }),
}); });
const { data: amenitiesData } = useQuery({
queryKey: ["all_amenities"],
queryFn: () => getAllAmenities({ page: 1, page_size: 10 }),
});
const { data: tariff } = useQuery({ const { data: tariff } = useQuery({
queryKey: ["all_tarif"], queryKey: ["all_tarif"],
queryFn: () => hotelTarif({ page: 1, page_size: 10 }), queryFn: () => hotelTarif({ page: 1, page_size: 10 }),
@@ -973,6 +975,114 @@ const StepOne = ({
)} )}
/> />
<FormField
control={form.control}
name="amenities"
render={() => (
<FormItem>
<Label className="text-md">{t("Qulayliklar")}</Label>
<div className="flex flex-wrap gap-2 mb-3">
{(form.watch("amenities") ?? []).length > 0 &&
(form.watch("amenities") ?? []).map((badgeId: number) => {
const badgeItem =
amenitiesData?.data?.data?.results?.find(
(b: any) => b.id === badgeId,
);
return (
<Badge
key={badgeId}
variant="secondary"
className="px-3 py-1 text-sm flex items-center gap-2"
>
{badgeItem?.name || `Qulaylilar #${badgeId}`}
<button
type="button"
onClick={() => {
const current = form.getValues("amenities") ?? [];
form.setValue(
"amenities",
current.filter((b: number) => b !== badgeId),
);
}}
className="ml-1 text-muted-foreground hover:text-destructive"
>
<XIcon className="size-4" />
</button>
</Badge>
);
})}
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-12 justify-between font-normal"
>
{t("Qulaylik tanlang")}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[250px]" align="start">
<Command>
<CommandList>
<CommandGroup heading={t("Mavjud qulayliklar")}>
{badge?.data?.data?.results?.map((item: any) => {
const currentBadges =
form.getValues("amenities") ?? [];
const selected = currentBadges.includes(item.id);
return (
<CommandItem
key={item.id}
onSelect={() => {
const selected = currentBadges.includes(
item.id,
);
if (selected) {
removeAmenity(item.id);
form.setValue(
"amenities",
currentBadges.filter(
(b: number) => b !== item.id,
),
);
} else {
addAmenity(item.id);
form.setValue("amenities", [
...currentBadges,
item.id,
]);
}
}}
className="flex items-center gap-2"
>
<SquareCheckBig
className={`size-4 ${
selected
? "text-green-500"
: "text-muted-foreground"
}`}
/>
{item.name}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="tarif" name="tarif"
@@ -1005,9 +1115,11 @@ const StepOne = ({
onChange={(e) => { onChange={(e) => {
const raw = e.target.value.replace(/\D/g, ""); const raw = e.target.value.replace(/\D/g, "");
const num = Number(raw); const num = Number(raw);
const currentTarifs = form.getValues("tarif") || []; const currentTarifs =
const updatedTransport = currentTarifs.map((t, i) => form.getValues("tarif") || [];
i === idx ? { ...t, price: num } : t, const updatedTransport = currentTarifs.map(
(t, i) =>
i === idx ? { ...t, price: num } : t,
); );
form.setValue("tarif", updatedTransport); form.setValue("tarif", updatedTransport);
@@ -1057,7 +1169,9 @@ const StepOne = ({
<CommandGroup heading={t("Mavjud tariflar")}> <CommandGroup heading={t("Mavjud tariflar")}>
{tariff?.data?.data?.results?.map((item: any) => { {tariff?.data?.data?.results?.map((item: any) => {
const currentTarifs = form.getValues("tarif") || []; const currentTarifs = form.getValues("tarif") || [];
const selected = currentTarifs.some((t) => t.tariff === item.id); const selected = currentTarifs.some(
(t) => t.tariff === item.id,
);
return ( return (
<CommandItem <CommandItem
@@ -1137,9 +1251,11 @@ const StepOne = ({
onChange={(e) => { onChange={(e) => {
const raw = e.target.value.replace(/\D/g, ""); const raw = e.target.value.replace(/\D/g, "");
const num = Number(raw); const num = Number(raw);
const currentTransports = form.getValues("transport") || []; const currentTransports =
const updatedTransport = currentTransports.map((t, i) => form.getValues("transport") || [];
i === idx ? { ...t, price: num } : t, const updatedTransport = currentTransports.map(
(t, i) =>
i === idx ? { ...t, price: num } : t,
); );
form.setValue("transport", updatedTransport); form.setValue("transport", updatedTransport);
@@ -1190,14 +1306,18 @@ const StepOne = ({
<CommandList> <CommandList>
<CommandGroup heading={t("Mavjud transportlar")}> <CommandGroup heading={t("Mavjud transportlar")}>
{transport?.data?.data?.results?.map((item: any) => { {transport?.data?.data?.results?.map((item: any) => {
const currentTransports = form.getValues("transport") || []; const currentTransports =
const selected = currentTransports.some((t) => t.transport === item.id); form.getValues("transport") || [];
const selected = currentTransports.some(
(t) => t.transport === item.id,
);
return ( return (
<CommandItem <CommandItem
key={item.id} key={item.id}
onSelect={() => { onSelect={() => {
const current = form.getValues("transport") || []; const current =
form.getValues("transport") || [];
if (selected) { if (selected) {
form.setValue( form.setValue(
"transport", "transport",
@@ -1279,99 +1399,6 @@ const StepOne = ({
imageUrl={data?.data.ticket_images?.map((img) => img.image)} imageUrl={data?.data.ticket_images?.map((img) => img.image)}
/> />
<FormField
control={form.control}
name="amenities"
render={() => (
<FormItem>
<Label className="text-md">{t("Qulayliklar")}</Label>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{form.watch("amenities").map((item, idx) => {
const Icon = (LucideIcons as any)[item.icon_name] || XIcon;
return (
<Badge
key={idx}
variant="secondary"
className="px-3 py-1 text-sm flex items-center gap-2"
>
<Icon className="size-4" />
<span>{item.name}</span>
<button
type="button"
onClick={() => {
const current = form.getValues("amenities");
form.setValue(
"amenities",
current.filter((_, i: number) => i !== idx),
);
}}
className="ml-1 text-muted-foreground hover:text-destructive"
>
<XIcon className="size-4" />
</button>
</Badge>
);
})}
</div>
<div className="flex gap-3 items-end flex-wrap">
<div className="flex-1 min-w-[200px]">
<IconSelect
setSelectedIcon={setSelectedIcon}
selectedIcon={selectedIcon}
/>
</div>
<Input
id="amenity_name"
placeholder={t("Qulaylik nomi (masalan: Wi-Fi)")}
className="h-12 !text-md flex-1 min-w-[200px]"
/>
<Input
id="amenity_name_ru"
placeholder={t("Qulaylik nomi (ru)")}
className="h-12 !text-md flex-1 min-w-[200px]"
/>
<Button
type="button"
onClick={() => {
const nameInput = document.getElementById(
"amenity_name",
) as HTMLInputElement;
const nameRuInput = document.getElementById(
"amenity_name_ru",
) as HTMLInputElement;
if (selectedIcon && nameInput.value) {
const current = form.getValues("amenities");
form.setValue("amenities", [
...current,
{
icon_name: selectedIcon,
name: nameInput.value,
name_ru: nameRuInput.value,
},
]);
nameInput.value = "";
nameRuInput.value = "";
setSelectedIcon("");
}
}}
className="h-12"
>
{t("Qo'shish")}
</Button>
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="extra_service" name="extra_service"

View File

@@ -47,15 +47,9 @@ const formSchema = z.object({
}), }),
rating: z.string().min(1).max(5), rating: z.string().min(1).max(5),
mealPlan: z.string().min(1, { message: "Taom rejasi tanlanishi majburiy" }), mealPlan: z.string().min(1, { message: "Taom rejasi tanlanishi majburiy" }),
hotelType: z hotelType: z.array(z.string()).optional(),
.array(z.string()) hotelFeatures: z.array(z.string()).optional(),
.min(1, { message: "Kamida 1 ta mehmonxona turi tanlang" }), hotelFeaturesType: z.array(z.string()).optional(),
hotelFeatures: z
.array(z.string())
.min(1, { message: "Kamida 1 ta xususiyat tanlang" }),
hotelFeaturesType: z
.array(z.string())
.min(1, { message: "Kamida 1 ta tur tanlang" }),
}); });
const StepTwo = ({ const StepTwo = ({
@@ -179,18 +173,20 @@ const StepTwo = ({
// 🔹 Feature type'larni yuklash (tanlangan feature boyicha) // 🔹 Feature type'larni yuklash (tanlangan feature boyicha)
useEffect(() => { useEffect(() => {
if (selectedHotelFeatures.length === 0) { if (selectedHotelFeatures && selectedHotelFeatures.length === 0) {
setAllHotelFeatureType([]); setAllHotelFeatureType([]);
setFeatureTypeMapping({}); setFeatureTypeMapping({});
return; return;
} }
const loadFeatureTypes = async () => { const loadFeatureTypes = async () => {
const selectedIds = selectedHotelFeatures.map(Number).filter(Boolean); const selectedIds =
selectedHotelFeatures &&
selectedHotelFeatures.map(Number).filter(Boolean);
let allResults: HotelFeaturesType[] = []; let allResults: HotelFeaturesType[] = [];
const mapping: Record<string, string[]> = {}; const mapping: Record<string, string[]> = {};
for (const id of selectedIds) { for (const id of selectedIds!) {
let page = 1; let page = 1;
let hasNext = true; let hasNext = true;
const featureTypes: string[] = []; const featureTypes: string[] = [];
@@ -253,12 +249,12 @@ const StepTwo = ({
const removeHotelType = (id: string) => const removeHotelType = (id: string) =>
form.setValue( form.setValue(
"hotelType", "hotelType",
form.getValues("hotelType").filter((v) => v !== id), (form.getValues("hotelType") ?? []).filter((v) => v !== id),
); );
const removeHotelFeature = (id: string) => { const removeHotelFeature = (id: string) => {
const current = form.getValues("hotelFeatures"); const current = form.getValues("hotelFeatures") ?? [];
const types = form.getValues("hotelFeaturesType"); const types = form.getValues("hotelFeaturesType") ?? [];
const toRemove = featureTypeMapping[id] || []; const toRemove = featureTypeMapping[id] || [];
form.setValue( form.setValue(
@@ -274,7 +270,7 @@ const StepTwo = ({
const removeFeatureType = (id: string) => const removeFeatureType = (id: string) =>
form.setValue( form.setValue(
"hotelFeaturesType", "hotelFeaturesType",
form.getValues("hotelFeaturesType").filter((v) => v !== id), (form.getValues("hotelFeaturesType") ?? []).filter((v) => v !== id),
); );
// 🧩 Submit // 🧩 Submit
@@ -284,6 +280,9 @@ const StepTwo = ({
formData.append("ticket", ticketId ? String(ticketId) : ""); formData.append("ticket", ticketId ? String(ticketId) : "");
formData.append("name", data.title); formData.append("name", data.title);
formData.append("rating", data.rating); formData.append("rating", data.rating);
amenities.forEach((e, i) => {
formData.append(`hotel_amenities[${i}]`, String(e));
});
const mealPlan = const mealPlan =
data.mealPlan === "Breakfast Only" data.mealPlan === "Breakfast Only"
@@ -295,14 +294,10 @@ const StepTwo = ({
: "all_inclusive"; : "all_inclusive";
formData.append("meal_plan", mealPlan); formData.append("meal_plan", mealPlan);
data.hotelType.forEach((id) => formData.append("hotel_type", id)); data.hotelType &&
data.hotelFeatures.forEach((id) => formData.append("hotel_features", id)); data.hotelType.forEach((id) => formData.append("hotel_type", id));
data.hotelFeatures &&
amenities.forEach((e, i) => { data.hotelFeatures.forEach((id) => formData.append("hotel_features", id));
formData.append(`hotel_amenities[${i}]name`, e.name);
formData.append(`hotel_amenities[${i}]name_ru`, e.name_ru);
formData.append(`hotel_amenities[${i}]icon_name`, e.icon_name);
});
if (isEditMode && hotelDetail) { if (isEditMode && hotelDetail) {
edit({ edit({
@@ -416,7 +411,7 @@ const StepTwo = ({
<Label>{t("Mehmonxona turlari")}</Label> <Label>{t("Mehmonxona turlari")}</Label>
<FormControl> <FormControl>
<div className="space-y-2"> <div className="space-y-2">
{field.value.length > 0 && ( {field.value && field.value.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border rounded-md"> <div className="flex flex-wrap gap-2 p-2 border rounded-md">
{field.value.map((selectedValue) => { {field.value.map((selectedValue) => {
const selectedItem = allHotelTypes.find( const selectedItem = allHotelTypes.find(
@@ -444,7 +439,7 @@ const StepTwo = ({
<Select <Select
value="" value=""
onValueChange={(value) => { onValueChange={(value) => {
if (!field.value.includes(value)) { if (field.value && !field.value.includes(value)) {
field.onChange([...field.value, value]); field.onChange([...field.value, value]);
} }
}} }}
@@ -452,7 +447,7 @@ const StepTwo = ({
<SelectTrigger className="!h-12 w-full"> <SelectTrigger className="!h-12 w-full">
<SelectValue <SelectValue
placeholder={ placeholder={
field.value.length > 0 field.value && field.value.length > 0
? t("Yana tanlang...") ? t("Yana tanlang...")
: t("Tanlang") : t("Tanlang")
} }
@@ -461,7 +456,9 @@ const StepTwo = ({
<SelectContent> <SelectContent>
{allHotelTypes {allHotelTypes
.filter( .filter(
(type) => !field.value.includes(String(type.id)), (type) =>
field.value &&
!field.value.includes(String(type.id)),
) )
.map((type) => ( .map((type) => (
<SelectItem key={type.id} value={String(type.id)}> <SelectItem key={type.id} value={String(type.id)}>
@@ -486,7 +483,7 @@ const StepTwo = ({
<Label>{t("Mehmonxona xususiyatlari")}</Label> <Label>{t("Mehmonxona xususiyatlari")}</Label>
<FormControl> <FormControl>
<div className="space-y-2"> <div className="space-y-2">
{field.value.length > 0 && ( {field.value && field.value.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border rounded-md"> <div className="flex flex-wrap gap-2 p-2 border rounded-md">
{field.value.map((selectedValue) => { {field.value.map((selectedValue) => {
const selectedItem = allHotelFeature.find( const selectedItem = allHotelFeature.find(
@@ -520,7 +517,7 @@ const StepTwo = ({
<Select <Select
value="" value=""
onValueChange={(value) => { onValueChange={(value) => {
if (!field.value.includes(value)) { if (field.value && !field.value.includes(value)) {
field.onChange([...field.value, value]); field.onChange([...field.value, value]);
} }
}} }}
@@ -528,7 +525,7 @@ const StepTwo = ({
<SelectTrigger className="!h-12 w-full"> <SelectTrigger className="!h-12 w-full">
<SelectValue <SelectValue
placeholder={ placeholder={
field.value.length > 0 field.value && field.value.length > 0
? t("Yana tanlang...") ? t("Yana tanlang...")
: t("Tanlang") : t("Tanlang")
} }
@@ -537,7 +534,9 @@ const StepTwo = ({
<SelectContent> <SelectContent>
{allHotelFeature {allHotelFeature
.filter( .filter(
(type) => !field.value.includes(String(type.id)), (type) =>
field.value &&
!field.value.includes(String(type.id)),
) )
.map((type) => ( .map((type) => (
<SelectItem key={type.id} value={String(type.id)}> <SelectItem key={type.id} value={String(type.id)}>
@@ -562,7 +561,7 @@ const StepTwo = ({
<Label>{t("Xususiyat turlari")}</Label> <Label>{t("Xususiyat turlari")}</Label>
<FormControl> <FormControl>
<div className="space-y-2"> <div className="space-y-2">
{field.value.length > 0 && ( {field.value && field.value.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border rounded-md"> <div className="flex flex-wrap gap-2 p-2 border rounded-md">
{field.value.map((selectedValue) => { {field.value.map((selectedValue) => {
const selectedItem = allHotelFeatureType.find( const selectedItem = allHotelFeatureType.find(
@@ -590,7 +589,7 @@ const StepTwo = ({
<Select <Select
value="" value=""
onValueChange={(value) => { onValueChange={(value) => {
if (!field.value.includes(value)) { if (field.value && !field.value.includes(value)) {
field.onChange([...field.value, value]); field.onChange([...field.value, value]);
} }
}} }}
@@ -598,9 +597,10 @@ const StepTwo = ({
<SelectTrigger className="!h-12 w-full"> <SelectTrigger className="!h-12 w-full">
<SelectValue <SelectValue
placeholder={ placeholder={
selectedHotelFeatures &&
selectedHotelFeatures.length === 0 selectedHotelFeatures.length === 0
? t("Avval xususiyat tanlang") ? t("Avval xususiyat tanlang")
: field.value.length > 0 : field.value && field.value.length > 0
? t("Yana tanlang...") ? t("Yana tanlang...")
: t("Tanlang") : t("Tanlang")
} }
@@ -614,7 +614,9 @@ const StepTwo = ({
) : ( ) : (
allHotelFeatureType allHotelFeatureType
.filter( .filter(
(type) => !field.value.includes(String(type.id)), (type) =>
field.value &&
!field.value.includes(String(type.id)),
) )
.map((type) => ( .map((type) => (
<SelectItem key={type.id} value={String(type.id)}> <SelectItem key={type.id} value={String(type.id)}>

View File

@@ -26,6 +26,7 @@ import { useNavigate, useParams } from "react-router-dom";
export default function TourDetailPage() { export default function TourDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const params = useParams(); const params = useParams();
const router = useNavigate(); const router = useNavigate();
const { const {

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
getAllAmenities,
hotelBadge, hotelBadge,
hotelFeature, hotelFeature,
hotelFeatureType, hotelFeatureType,
@@ -8,6 +9,7 @@ import {
hotelTransport, hotelTransport,
hotelType, hotelType,
} from "@/pages/tours/lib/api"; } from "@/pages/tours/lib/api";
import Amenities from "@/pages/tours/ui/Amenities";
import BadgeTable from "@/pages/tours/ui/BadgeTable"; import BadgeTable from "@/pages/tours/ui/BadgeTable";
import FeaturesTable from "@/pages/tours/ui/FeaturesTable"; import FeaturesTable from "@/pages/tours/ui/FeaturesTable";
import FeaturesTableType from "@/pages/tours/ui/FeaturesTableType"; import FeaturesTableType from "@/pages/tours/ui/FeaturesTableType";
@@ -120,13 +122,31 @@ const ToursSetting: React.FC = () => {
enabled: !!featureId, enabled: !!featureId,
}); });
const pageAmenities = parseInt(searchParams.get("pageAmenities") || "1", 10);
const pageSizeAmenities = parseInt(
searchParams.get("pageSizeAmenities") || "10",
10,
);
const {
data: amenitiesData,
isLoading: amenitiesLoad,
isError: amenitiesError,
refetch: amenitiesRef,
} = useQuery({
queryKey: ["all_amenities", page, pageSize],
queryFn: () => getAllAmenities({ page, page_size: pageSize }),
select: (res) => res.data.data,
});
if ( if (
isLoading || isLoading ||
tarifLoad || tarifLoad ||
transportLoad || transportLoad ||
typeLoad || typeLoad ||
featureLoad || featureLoad ||
featureTypeLoad featureTypeLoad ||
amenitiesLoad
) { ) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full"> <div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
@@ -142,7 +162,8 @@ const ToursSetting: React.FC = () => {
transportError || transportError ||
typeError || typeError ||
featureError || featureError ||
featureTypeError featureTypeError ||
amenitiesError
) { ) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4"> <div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
@@ -158,6 +179,7 @@ const ToursSetting: React.FC = () => {
typeRef(); typeRef();
featureRef(); featureRef();
featureTypeRef(); featureTypeRef();
amenitiesRef();
}} }}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90" className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
> >
@@ -187,7 +209,7 @@ const ToursSetting: React.FC = () => {
<TabsTrigger value="badge">{t("Belgilar (Badge)")}</TabsTrigger> <TabsTrigger value="badge">{t("Belgilar (Badge)")}</TabsTrigger>
<TabsTrigger value="tarif">{t("Tariflar")}</TabsTrigger> <TabsTrigger value="tarif">{t("Tariflar")}</TabsTrigger>
<TabsTrigger value="transport">{t("Transport")}</TabsTrigger> <TabsTrigger value="transport">{t("Transport")}</TabsTrigger>
{/* <TabsTrigger value="meal">{t("Ovqatlanish")}</TabsTrigger> */} <TabsTrigger value="meal">{t("Qulayliklar")}</TabsTrigger>
<TabsTrigger value="hotel_type">{t("Otel turlari")}</TabsTrigger> <TabsTrigger value="hotel_type">{t("Otel turlari")}</TabsTrigger>
<TabsTrigger value="hotel_features"> <TabsTrigger value="hotel_features">
{t("Otel sharoitlari")} {t("Otel sharoitlari")}
@@ -211,6 +233,13 @@ const ToursSetting: React.FC = () => {
pageSize={pageSizeTransport} pageSize={pageSizeTransport}
/> />
</TabsContent> </TabsContent>
<TabsContent value="meal" className="space-y-4">
<Amenities
data={amenitiesData}
page={pageAmenities}
pageSize={pageSizeAmenities}
/>
</TabsContent>
<TabsContent value="hotel_type" className="space-y-4"> <TabsContent value="hotel_type" className="space-y-4">
<MealTable <MealTable
data={typeData} data={typeData}

View File

@@ -15,6 +15,7 @@ const HOTEL_FEATURES_TYPE = "dashboard/dashboard-ticket-hotel-feature/";
const HOTEL_TARIF = "dashboard/dashboard-tickets-settings-tariff/"; const HOTEL_TARIF = "dashboard/dashboard-tickets-settings-tariff/";
const TOUR_TRANSPORT = "dashboard/dashboard-tickets-settings-transport/"; const TOUR_TRANSPORT = "dashboard/dashboard-tickets-settings-transport/";
const HPTEL_TYPES = "dashboard/dashboard-tickets-settings-hotel-type/"; const HPTEL_TYPES = "dashboard/dashboard-tickets-settings-hotel-type/";
const AMENITIES = "dashboard/dashboard-ticket-settings-amenities/";
const NEWS = "dashboard/dashboard-post/"; const NEWS = "dashboard/dashboard-post/";
const NEWS_CATEGORY = "dashboard/dashboard-category/"; const NEWS_CATEGORY = "dashboard/dashboard-category/";
const HOTEL = "dashboard/dashboard-hotel/"; const HOTEL = "dashboard/dashboard-hotel/";
@@ -34,6 +35,7 @@ const TOUR_ADMIN = "dashboard/dashboard-tour-admin/";
export { export {
AGENCY_ORDERS, AGENCY_ORDERS,
AMENITIES,
AUTH_LOGIN, AUTH_LOGIN,
BANNER, BANNER,
BASE_URL, BASE_URL,

View File

@@ -491,5 +491,6 @@
"Booking Date": "Дата бронирования", "Booking Date": "Дата бронирования",
"Yangi foydalanuvchi ma'lumotlari": "Данные нового пользователя", "Yangi foydalanuvchi ma'lumotlari": "Данные нового пользователя",
"Agentlik uchun tizimga kirish ma'lumotlari": "Входные данные для агентства", "Agentlik uchun tizimga kirish ma'lumotlari": "Входные данные для агентства",
"Ikona tanlang": "Выберите иконку0",
"Haqiqatan ham bu foydalanuvchini o'chirmoqchimisiz? Bu amalni qaytarib bo'lmaydi.": "Вы уверены, что хотите удалить этого пользователя? Это действие необратимо." "Haqiqatan ham bu foydalanuvchini o'chirmoqchimisiz? Bu amalni qaytarib bo'lmaydi.": "Вы уверены, что хотите удалить этого пользователя? Это действие необратимо."
} }

View File

@@ -491,6 +491,7 @@
"Travel Date": "Sayohat sanasi", "Travel Date": "Sayohat sanasi",
"Booking Date": "Bandlov sanasi", "Booking Date": "Bandlov sanasi",
"Yangi foydalanuvchi ma'lumotlari": "Yangi foydalanuvchi ma'lumotlari", "Yangi foydalanuvchi ma'lumotlari": "Yangi foydalanuvchi ma'lumotlari",
"Ikona tanlang": "Ikona tanlang",
"Agentlik uchun tizimga kirish ma'lumotlari": "Agentlik uchun tizimga kirish ma'lumotlari", "Agentlik uchun tizimga kirish ma'lumotlari": "Agentlik uchun tizimga kirish ma'lumotlari",
"Haqiqatan ham bu foydalanuvchini o'chirmoqchimisiz? Bu amalni qaytarib bo'lmaydi.": "Haqiqatan ham bu foydalanuvchini o'chirmoqchimisiz? Bu amalni qaytarib bo'lmaydi." "Haqiqatan ham bu foydalanuvchini o'chirmoqchimisiz? Bu amalni qaytarib bo'lmaydi.": "Haqiqatan ham bu foydalanuvchini o'chirmoqchimisiz? Bu amalni qaytarib bo'lmaydi."
} }

View File

@@ -0,0 +1,70 @@
export const hotelIcons = [
{ name: "Wifi", uz: "Wi-Fi", ru: "Wi-Fi" },
{ name: "Bus", uz: "Avtobus", ru: "Автобус" },
{ name: "CarFront", uz: "Auto", ru: "Авто" },
{ name: "PlaneTakeoff", uz: "Avia", ru: "Авиа" },
{ name: "WifiOff", uz: "Wi-Fi mavjud emas", ru: "Нет Wi-Fi" },
{ name: "Tv", uz: "Televizor", ru: "Телевизор" },
{ name: "TvMinimalPlay", uz: "Smart TV", ru: "Смарт ТВ" },
{ name: "AirVent", uz: "Ventilyatsiya", ru: "Вентиляция" },
{ name: "Wind", uz: "Konditsioner", ru: "Кондиционер" },
{ name: "Thermometer", uz: "Isitish tizimi", ru: "Отопление" },
{ name: "Snowflake", uz: "Sovutgich", ru: "Холодильник" },
{ name: "Fan", uz: "Ventilyator", ru: "Вентилятор" },
{ name: "Lightbulb", uz: "Yoritish", ru: "Освещение" },
{ name: "Plug", uz: "Rozetka", ru: "Розетка" },
{ name: "BatteryFull", uz: "Zaryadlash", ru: "Зарядка" },
{ name: "Bed", uz: "Yotoq", ru: "Кровать" },
{ name: "BedSingle", uz: "Yagona yotoq", ru: "Односпальная кровать" },
{ name: "BedDouble", uz: "Ikki kishilik yotoq", ru: "Двуспальная кровать" },
{ name: "Bath", uz: "Vanna", ru: "Ванна" },
{ name: "ShowerHead", uz: "Dush", ru: "Душ" },
{ name: "Toilet", uz: "Hojatxona", ru: "Туалет" },
{ name: "Towel", uz: "Sochiqlar", ru: "Полотенца" },
{ name: "Soap", uz: "Gigiyena vositalari", ru: "Средства гигиены" },
{ name: "CupSoda", uz: "Ichimliklar", ru: "Напитки" },
{ name: "Coffee", uz: "Kofe / nonushta", ru: "Кофе / завтрак" },
{ name: "Utensils", uz: "Restoran", ru: "Ресторан" },
{ name: "UtensilsCrossed", uz: "Oshxona", ru: "Кухня" },
{ name: "Wine", uz: "Ichimliklar", ru: "Вино" },
{ name: "Beer", uz: "Pivo", ru: "Пиво" },
{ name: "Salad", uz: "Salatlar", ru: "Салаты" },
{ name: "Cake", uz: "Shirinliklar", ru: "Десерты" },
{ name: "Gift", uz: "Sovga dokoni", ru: "Сувенирный магазин" },
{ name: "Dumbbell", uz: "Sport zali", ru: "Тренажёрный зал" },
{ name: "Swim", uz: "Basseyn", ru: "Бассейн" },
{ name: "Spa", uz: "Spa markazi", ru: "Спа центр" },
{ name: "Heart", uz: "Masaj xizmati", ru: "Массаж" },
{ name: "ShieldCheck", uz: "Xavfsizlik", ru: "Безопасность" },
{ name: "Camera", uz: "Kuzatuv kamerasi", ru: "Видеонаблюдение" },
{ name: "Lock", uz: "Saqlash joyi", ru: "Сейф" },
{ name: "ConciergeBell", uz: "Resepshn", ru: "Ресепшн" },
{ name: "BaggageClaim", uz: "Bagaj saqlash", ru: "Хранение багажа" },
{ name: "Elevator", uz: "Lift", ru: "Лифт" },
{ name: "CarFront", uz: "Avtoturargoh", ru: "Парковка" },
{ name: "ParkingSquare", uz: "Parkovka", ru: "Стоянка" },
{ name: "Taxi", uz: "Taksi xizmati", ru: "Такси" },
{ name: "MapPin", uz: "Joylashuv", ru: "Расположение" },
{ name: "Mountain", uz: "Tog manzarasi", ru: "Горный вид" },
{ name: "TreePalm", uz: "Bog / palma", ru: "Пальмы" },
{ name: "Sun", uz: "Quyoshli joy", ru: "Солнечная сторона" },
{ name: "Moon", uz: "Tun xizmati", ru: "Ночная смена" },
{ name: "Users", uz: "Kop orinli xona", ru: "Многоместный номер" },
{ name: "Baby", uz: "Bolalar uchun", ru: "Для детей" },
{
name: "Wheelchair",
uz: "Nogironlar uchun qulay",
ru: "Доступ для инвалидов",
},
{ name: "Dog", uz: "Uy hayvoni mumkin", ru: "Можно с животными" },
{ name: "CigaretteOff", uz: "Chekish taqiqlangan", ru: "Курение запрещено" },
{ name: "Cigarette", uz: "Chekish joyi", ru: "Место для курения" },
{ name: "Shirt", uz: "Tozalash xizmati", ru: "Химчистка" },
{ name: "WashingMachine", uz: "Kir yuvish mashinasi", ru: "Прачечная" },
{ name: "Iron", uz: "Dazmol", ru: "Утюг" },
{ name: "Phone", uz: "Telefon", ru: "Телефон" },
{ name: "CreditCard", uz: "Kartali tolov", ru: "Оплата картой" },
{ name: "Wallet", uz: "Naqd tolov", ru: "Наличные" },
{ name: "AlarmClock", uz: "Budilnik", ru: "Будильник" },
{ name: "Clock", uz: "Soat", ru: "Часы" },
];

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import Icon from "@/shared/ui/icon"; import { hotelIcons } from "@/shared/lib/iconTranslations";
import { Input } from "@/shared/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -9,164 +8,54 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/ui/select"; } from "@/shared/ui/select";
import { HelpCircle, Search } from "lucide-react"; import * as Icons from "lucide-react";
import React, { import { useState } from "react";
Suspense,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type LazyExoticComponent,
} from "react";
import { useTranslation } from "react-i18next";
// 🔹 Lazy icon faqat tanlangan icon uchun type IconSelectProps = {
const LazyIcon: React.FC<{ name: string }> = ({ name }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const IconComp: LazyExoticComponent<ComponentType<any>> = React.lazy(
async () => {
const icons = await import("lucide-react");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { default: (icons as any)[name] || HelpCircle };
},
);
return (
<Suspense fallback={<div className="w-4 h-4" />}>
<IconComp className="w-4 h-4" />
</Suspense>
);
};
interface IconSelectProps {
selectedIcon?: string; selectedIcon?: string;
defaultIcon?: string;
setSelectedIcon: (value: string) => void; setSelectedIcon: (value: string) => void;
}
const IconSelect: React.FC<IconSelectProps> = ({
selectedIcon,
defaultIcon = "HelpCircle",
setSelectedIcon,
}) => {
const [icons, setIcons] = useState<string[]>([]);
const { t } = useTranslation();
const [visibleIcons, setVisibleIcons] = useState<string[]>([]);
const [chunkSize] = useState(100);
const [index, setIndex] = useState(1);
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const loaderRef = useRef<HTMLDivElement | null>(null);
const deferredSearch = useDeferredValue(searchTerm);
useEffect(() => {
if (!isOpen) return;
const loadIcons = async () => {
const mod = await import("lucide-react");
const allIcons = Object.keys(mod).filter((k) => /^[A-Z]/.test(k));
setIcons(allIcons);
setVisibleIcons(allIcons.slice(0, chunkSize));
setIndex(1);
};
loadIcons();
}, [isOpen, chunkSize]);
useEffect(() => {
if (!containerEl || !loaderRef.current || !isOpen) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
const start = index * chunkSize;
const end = start + chunkSize;
const next = icons.slice(start, end);
if (next.length > 0) {
setVisibleIcons((p) => [...p, ...next]);
setIndex((p) => p + 1);
}
}
},
{ root: containerEl, threshold: 1.0 },
);
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [containerEl, icons, index, chunkSize, isOpen]);
const filteredIcons = useMemo(() => {
const term = deferredSearch.trim().toLowerCase();
if (!term) return visibleIcons;
return icons.filter((n) => n.toLowerCase().includes(term));
}, [icons, visibleIcons, deferredSearch]);
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setVisibleIcons([]);
setIcons([]);
setIndex(1);
setSearchTerm("");
}
};
return (
<Select
value={selectedIcon}
onValueChange={setSelectedIcon}
onOpenChange={handleOpenChange}
>
<SelectTrigger className="!h-12 w-[220px] text-md">
<SelectValue placeholder={t("Ikonka tanlang")}>
{selectedIcon ? (
<div className="flex items-center gap-2">
<LazyIcon name={selectedIcon} />
{selectedIcon}
</div>
) : (
<div className="flex items-center gap-2 text-gray-500">
<LazyIcon name={defaultIcon} />
{defaultIcon}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent ref={setContainerEl} className="max-h-80 overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-neutral-900 z-10 p-2 border-b flex items-center gap-2">
<Search className="w-4 h-4 text-gray-500" />
<Input
placeholder="Qidiruv..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-8 text-sm"
/>
</div>
{filteredIcons.map((iconName) => (
<SelectItem key={iconName} value={iconName}>
<div className="flex items-center gap-2 text-sm">
<Icon name={iconName} />
{iconName}
</div>
</SelectItem>
))}
{!searchTerm && isOpen && (
<div ref={loaderRef} className="h-6 flex justify-center items-center">
{visibleIcons.length < icons.length && (
<span className="text-xs text-gray-400">
{t("Yuklanmoqda...")}
</span>
)}
</div>
)}
</SelectContent>
</Select>
);
}; };
export default IconSelect; export default function IconSelect({
selectedIcon,
setSelectedIcon,
}: IconSelectProps) {
const [search, setSearch] = useState("");
const filteredIcons = hotelIcons.filter(
(icon) =>
icon.uz.toLowerCase().includes(search.toLowerCase()) ||
icon.ru.toLowerCase().includes(search.toLowerCase()) ||
icon.name.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="flex flex-col gap-2">
<Select value={selectedIcon} onValueChange={setSelectedIcon}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Ikona tanlang" />
</SelectTrigger>
<SelectContent className="max-h-64 overflow-y-auto p-2">
<input
type="text"
placeholder="Qidirish..."
className="w-full border px-2 py-1 text-sm mb-2 rounded-md outline-none"
onChange={(e) => setSearch(e.target.value)}
/>
{filteredIcons.map(({ name, uz, ru }) => {
const LucideIcon = (Icons as any)[name];
if (!LucideIcon) return null;
return (
<SelectItem key={name} value={name}>
<div className="flex items-center gap-2">
<LucideIcon className="w-4 h-4" />
{uz} <span className="text-gray-400">({ru})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -268,7 +268,7 @@ export function Sidebar({ role }: SidebarProps) {
); );
return ( return (
<div className="lg:border"> <div className="lg:border fixed">
{/* Mobil versiya */} {/* Mobil versiya */}
<div className="lg:hidden flex items-center justify-end bg-gray-900 p-4 sticky top-0 z-50"> <div className="lg:hidden flex items-center justify-end bg-gray-900 p-4 sticky top-0 z-50">
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}> <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>