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

@@ -1,5 +1,7 @@
import type {
AllAmenitiesData,
CreateTourRes,
DetailAmenitiesData,
GetAllTours,
GetDetailTours,
GetHotelRes,
@@ -19,6 +21,7 @@ import type {
} from "@/pages/tours/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import {
AMENITIES,
GET_TICKET,
HOTEL,
HOTEL_BADGE,
@@ -444,13 +447,71 @@ const hotelFeatureTypeDelete = async ({ id }: { id: number }) => {
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 {
addedPopularTours,
amenitiesCreate,
amenitiesUpdate,
createHotel,
createTours,
deleteAmenities,
deleteTours,
editHotel,
getAllAmenities,
getAllTours,
getDetailAmenities,
getDetailToursId,
getHotel,
getOneTours,

View File

@@ -1,6 +1,7 @@
"use client";
import type {
AllAmenitiesDataRes,
Badge,
HotelFeatures,
HotelFeaturesType,
@@ -10,6 +11,8 @@ import type {
} from "@/pages/tours/lib/type";
import { Button } from "@/shared/ui/button";
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 { 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
.array(z.union([z.instanceof(File), z.string()]))
.min(1, { message: "Kamida bitta rasm yuklang." }),
amenities: z
.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." }),
amenities: z.array(z.number()).optional(),
// 🔹 Quyidagilar endi ixtiyoriy (required emas)
hotel_services: z

View File

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

View File

@@ -75,14 +75,7 @@ export interface GetOneTours {
image: string;
},
];
ticket_amenities: [
{
name: string;
name_ru: string;
name_uz: string;
icon_name: string;
},
];
ticket_amenities: number[];
ticket_included_services: [
{
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 {
createTours,
getAllAmenities,
hotelBadge,
hotelTarif,
hotelTransport,
@@ -29,14 +30,12 @@ import {
FormMessage,
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import IconSelect from "@/shared/ui/iocnSelect";
import { Label } from "@/shared/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/shared/ui/radio-group";
import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import * as LucideIcons from "lucide-react";
import { ChevronDownIcon, SquareCheckBig, XIcon } from "lucide-react";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form";
@@ -105,7 +104,7 @@ const StepOne = ({
},
});
const { addAmenity, setId } = useTicketStore();
const { addAmenity, setId, removeAmenity } = useTicketStore();
useEffect(() => {
if (!isEditMode || !data?.data) return;
@@ -158,14 +157,7 @@ const StepOne = ({
}
// 🔹 Qulayliklar (amenities)
form.setValue(
"amenities",
tour.ticket_amenities?.map((a) => ({
name: a.name ?? "",
name_ru: a.name_ru ?? "",
icon_name: a.icon_name ?? "",
})) ?? [],
);
form.setValue("amenities", tour.ticket_amenities);
// 🔹 Xizmatlar (hotel_services)
form.setValue(
@@ -258,7 +250,6 @@ const StepOne = ({
const { watch, setValue } = form;
const selectedDate = watch("departureDateTime.date");
const selectedDateTravel = watch("travelDateTime.date");
const [selectedIcon, setSelectedIcon] = useState("");
const { mutate: create } = useMutation({
mutationFn: (body: FormData) => {
@@ -341,24 +332,20 @@ const StepOne = ({
value.badges?.forEach((e, i) => {
formData.append(`badge[${i}]`, String(e));
});
value.amenities?.forEach((e, i) => {
formData.append(`ticket_amenities[${i}]`, String(e));
});
value.images.forEach((e) => {
if (e instanceof File) {
formData.append("ticket_images", e);
}
});
value.amenities.forEach((e, i) => {
formData.append(`ticket_amenities[${i}]name`, e.name);
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.amenities?.forEach((e, i) => {
formData.append(`badge[${i}]`, String(e));
});
value.hotel_services &&
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}]title`, e.title);
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);
}
});
value.ticket_itinerary.forEach((e, i) => {
e.ticket_itinerary_image.forEach((l, f) => {
if (e instanceof File) {
formData.append(`ticket_itinerary[${i}]title`, e.title);
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
value.ticket_itinerary?.forEach((itinerary, i) => {
formData.append(`ticket_itinerary[${i}]title`, itinerary.title);
formData.append(`ticket_itinerary[${i}]title_ru`, itinerary.title_ru);
formData.append(
`ticket_itinerary[${i}]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(
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`,
l.image,
`ticket_itinerary[${i}]ticket_itinerary_destinations[${k}]name`,
dest.name,
);
e.ticket_itinerary_destinations.forEach((e, f) => {
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`,
String(e.name),
);
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
String(e.name_ru),
);
});
}
});
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${k}]name_ru`,
dest.name_ru,
);
});
}
});
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}]name`, e.title);
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({
queryKey: ["all_badge"],
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({
queryKey: ["all_tarif"],
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
control={form.control}
name="tarif"
@@ -1005,9 +1115,11 @@ const StepOne = ({
onChange={(e) => {
const raw = e.target.value.replace(/\D/g, "");
const num = Number(raw);
const currentTarifs = form.getValues("tarif") || [];
const updatedTransport = currentTarifs.map((t, i) =>
i === idx ? { ...t, price: num } : t,
const currentTarifs =
form.getValues("tarif") || [];
const updatedTransport = currentTarifs.map(
(t, i) =>
i === idx ? { ...t, price: num } : t,
);
form.setValue("tarif", updatedTransport);
@@ -1057,7 +1169,9 @@ const StepOne = ({
<CommandGroup heading={t("Mavjud tariflar")}>
{tariff?.data?.data?.results?.map((item: any) => {
const currentTarifs = form.getValues("tarif") || [];
const selected = currentTarifs.some((t) => t.tariff === item.id);
const selected = currentTarifs.some(
(t) => t.tariff === item.id,
);
return (
<CommandItem
@@ -1137,9 +1251,11 @@ const StepOne = ({
onChange={(e) => {
const raw = e.target.value.replace(/\D/g, "");
const num = Number(raw);
const currentTransports = form.getValues("transport") || [];
const updatedTransport = currentTransports.map((t, i) =>
i === idx ? { ...t, price: num } : t,
const currentTransports =
form.getValues("transport") || [];
const updatedTransport = currentTransports.map(
(t, i) =>
i === idx ? { ...t, price: num } : t,
);
form.setValue("transport", updatedTransport);
@@ -1190,14 +1306,18 @@ const StepOne = ({
<CommandList>
<CommandGroup heading={t("Mavjud transportlar")}>
{transport?.data?.data?.results?.map((item: any) => {
const currentTransports = form.getValues("transport") || [];
const selected = currentTransports.some((t) => t.transport === item.id);
const currentTransports =
form.getValues("transport") || [];
const selected = currentTransports.some(
(t) => t.transport === item.id,
);
return (
<CommandItem
key={item.id}
onSelect={() => {
const current = form.getValues("transport") || [];
const current =
form.getValues("transport") || [];
if (selected) {
form.setValue(
"transport",
@@ -1279,99 +1399,6 @@ const StepOne = ({
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
control={form.control}
name="extra_service"

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import {
getAllAmenities,
hotelBadge,
hotelFeature,
hotelFeatureType,
@@ -8,6 +9,7 @@ import {
hotelTransport,
hotelType,
} from "@/pages/tours/lib/api";
import Amenities from "@/pages/tours/ui/Amenities";
import BadgeTable from "@/pages/tours/ui/BadgeTable";
import FeaturesTable from "@/pages/tours/ui/FeaturesTable";
import FeaturesTableType from "@/pages/tours/ui/FeaturesTableType";
@@ -120,13 +122,31 @@ const ToursSetting: React.FC = () => {
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 (
isLoading ||
tarifLoad ||
transportLoad ||
typeLoad ||
featureLoad ||
featureTypeLoad
featureTypeLoad ||
amenitiesLoad
) {
return (
<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 ||
typeError ||
featureError ||
featureTypeError
featureTypeError ||
amenitiesError
) {
return (
<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();
featureRef();
featureTypeRef();
amenitiesRef();
}}
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="tarif">{t("Tariflar")}</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_features">
{t("Otel sharoitlari")}
@@ -211,6 +233,13 @@ const ToursSetting: React.FC = () => {
pageSize={pageSizeTransport}
/>
</TabsContent>
<TabsContent value="meal" className="space-y-4">
<Amenities
data={amenitiesData}
page={pageAmenities}
pageSize={pageSizeAmenities}
/>
</TabsContent>
<TabsContent value="hotel_type" className="space-y-4">
<MealTable
data={typeData}