first commit
This commit is contained in:
38
src/pages/tours/ui/CreateEditTour.tsx
Normal file
38
src/pages/tours/ui/CreateEditTour.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import StepOne from "@/pages/tours/ui/StepOne";
|
||||
import StepTwo from "@/pages/tours/ui/StepTwo";
|
||||
import { Hotel, Plane } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const CreateEditTour = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = useMemo(() => !!id, [id]);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full mx-auto bg-gray-900">
|
||||
<h1 className="text-3xl font-bold mb-6 text-center">
|
||||
{isEditMode ? "Turni Tahrirlash" : "Yangi Tur Qo'shish"}
|
||||
</h1>
|
||||
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div
|
||||
className={`flex-1 text-center py-2 rounded-l-lg ${step === 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||
>
|
||||
1. Tur ma'lumotlari <Plane className="w-5 h-5 inline ml-2" />
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 text-center py-2 rounded-r-lg ${step === 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||
>
|
||||
2. Mehmonxona <Hotel className="w-5 h-5 inline ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
{step === 1 && <StepOne setStep={setStep} />}
|
||||
{step === 2 && <StepTwo setStep={setStep} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateEditTour;
|
||||
1103
src/pages/tours/ui/StepOne.tsx
Normal file
1103
src/pages/tours/ui/StepOne.tsx
Normal file
File diff suppressed because it is too large
Load Diff
287
src/pages/tours/ui/StepTwo.tsx
Normal file
287
src/pages/tours/ui/StepTwo.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Sarlavha kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||
}),
|
||||
rating: z.number(),
|
||||
mealPlan: z.string().min(1, { message: "Taom rejasi tanlanishi majburiy" }),
|
||||
hotelType: z
|
||||
.string()
|
||||
.min(1, { message: "Mehmonxona turi tanlanishi majburiy" }),
|
||||
hotelFeatures: z
|
||||
.array(z.string())
|
||||
.min(1, { message: "Kamida 1 ta xususiyat tanlanishi kerak" }),
|
||||
});
|
||||
|
||||
const StepTwo = ({
|
||||
setStep,
|
||||
}: {
|
||||
setStep: Dispatch<SetStateAction<number>>;
|
||||
}) => {
|
||||
const navigator = useNavigate();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
rating: 3.0,
|
||||
mealPlan: "",
|
||||
hotelType: "",
|
||||
hotelFeatures: [],
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
navigator("tours");
|
||||
}
|
||||
|
||||
const mealPlans = [
|
||||
"Breakfast Only",
|
||||
"Half Board",
|
||||
"Full Board",
|
||||
"All Inclusive",
|
||||
];
|
||||
const hotelTypes = ["Hotel", "Resort", "Guest House", "Apartment"];
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1 items-start">
|
||||
{/* Mehmonxona nomi */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona nomi</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Toshkent - Dubay"
|
||||
{...field}
|
||||
className="h-12 !text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Mehmonxona rating */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rating"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona raytingi</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="3.0"
|
||||
{...field}
|
||||
className="h-12 !text-md"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (/^\d*\.?\d*$/.test(val)) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Meal Plan */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mealPlan"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Meal Plan</Label>
|
||||
<FormControl>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="mealPlan"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="!h-12 w-full">
|
||||
<SelectValue placeholder="Taom rejasini tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mealPlans.map((plan) => (
|
||||
<SelectItem key={plan} value={plan}>
|
||||
{plan}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Hotel Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hotelType"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona turi</Label>
|
||||
<FormControl>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="hotelType"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="!h-12 w-full">
|
||||
<SelectValue placeholder="Mehmonxona turini tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hotelTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="hotelFeatures"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Mehmonxona qulayliklar</Label>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.watch("amenities").map((item, idx) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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-center">
|
||||
<IconSelect
|
||||
setSelectedIcon={setSelectedIcon}
|
||||
selectedIcon={selectedIcon}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="amenity_name"
|
||||
placeholder="Qulaylik nomi (masalan: Wi-Fi)"
|
||||
className="h-12 !text-md flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nameInput = document.getElementById(
|
||||
"amenity_name",
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (selectedIcon && nameInput.value) {
|
||||
const current = form.getValues("amenities");
|
||||
form.setValue("amenities", [
|
||||
...current,
|
||||
{
|
||||
icon_name: selectedIcon,
|
||||
name: nameInput.value,
|
||||
},
|
||||
]);
|
||||
nameInput.value = "";
|
||||
setSelectedIcon("");
|
||||
}
|
||||
}}
|
||||
className="h-12"
|
||||
>
|
||||
Qo‘shish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className="mt-6 px-6 py-3 bg-gray-600 text-white rounded-md"
|
||||
>
|
||||
Ortga
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md"
|
||||
>
|
||||
Saqlash
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepTwo;
|
||||
110
src/pages/tours/ui/TicketsImagesModel.tsx
Normal file
110
src/pages/tours/ui/TicketsImagesModel.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { ImagePlus, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TicketsImagesModelProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: any;
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function TicketsImagesModel({
|
||||
form,
|
||||
name,
|
||||
label = "Rasmlar",
|
||||
}: TicketsImagesModelProps) {
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">{label}</Label>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
id="ticket-images"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const newFiles = e.target.files
|
||||
? Array.from(e.target.files)
|
||||
: [];
|
||||
const existingFiles = form.getValues(name) || [];
|
||||
const allFiles = [...existingFiles, ...newFiles];
|
||||
|
||||
form.setValue(name, allFiles);
|
||||
const urls = allFiles.map((file) =>
|
||||
URL.createObjectURL(file),
|
||||
);
|
||||
setPreviews(urls);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Upload Zone */}
|
||||
<label
|
||||
htmlFor="ticket-images"
|
||||
className="border-2 border-dashed border-gray-300 h-40 rounded-2xl flex flex-col justify-center items-center cursor-pointer hover:bg-muted/20 transition"
|
||||
>
|
||||
<ImagePlus className="size-8 text-muted-foreground mb-2" />
|
||||
<p className="font-semibold text-white">Rasmlarni tanlang</p>
|
||||
<p className="text-sm text-white">
|
||||
Bir nechta rasm yuklashingiz mumkin
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{/* Preview Images */}
|
||||
{previews.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{previews.map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative size-24 rounded-md overflow-hidden border"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={`preview-${i}`}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newFiles = form
|
||||
.getValues(name)
|
||||
.filter((_: File, idx: number) => idx !== i);
|
||||
const newPreviews = previews.filter(
|
||||
(_: string, idx: number) => idx !== i,
|
||||
);
|
||||
form.setValue(name, newFiles);
|
||||
setPreviews(newPreviews);
|
||||
}}
|
||||
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
|
||||
>
|
||||
<XIcon className="size-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
642
src/pages/tours/ui/TourDetail.tsx
Normal file
642
src/pages/tours/ui/TourDetail.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Globe,
|
||||
Heart,
|
||||
Hotel,
|
||||
MapPin,
|
||||
Star,
|
||||
Users,
|
||||
Utensils,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
type TourDetail = {
|
||||
id: number;
|
||||
title: string;
|
||||
price: number;
|
||||
departure_date: string;
|
||||
departure: string;
|
||||
destination: string;
|
||||
passenger_count: number;
|
||||
languages: string;
|
||||
rating: number;
|
||||
hotel_info: string;
|
||||
duration_days: number;
|
||||
hotel_meals: string;
|
||||
ticket_images: Array<{ image: string }>;
|
||||
ticket_amenities: Array<{ name: string; icon_name: string }>;
|
||||
ticket_included_services: Array<{
|
||||
image: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}>;
|
||||
ticket_itinerary: Array<{
|
||||
title: string;
|
||||
duration: number;
|
||||
ticket_itinerary_image: Array<{ image: string }>;
|
||||
ticket_itinerary_destinations: Array<{ name: string }>;
|
||||
}>;
|
||||
ticket_hotel_meals: Array<{ image: string; name: string; desc: string }>;
|
||||
travel_agency_id: string;
|
||||
ticket_comments: Array<{
|
||||
user: { id: number; username: string };
|
||||
text: string;
|
||||
rating: number;
|
||||
}>;
|
||||
tariff: Array<{ name: string }>;
|
||||
is_liked: string;
|
||||
};
|
||||
|
||||
export default function TourDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useNavigate();
|
||||
const [tour, setTour] = useState<TourDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTour({
|
||||
id: Number(params.id),
|
||||
title: "Dubai Hashamatli Sayohati",
|
||||
price: 1500000,
|
||||
departure_date: "2025-11-15",
|
||||
departure: "Toshkent, O'zbekiston",
|
||||
destination: "Dubai, BAA",
|
||||
passenger_count: 30,
|
||||
languages: "O'zbek, Rus, Ingliz",
|
||||
rating: 4.8,
|
||||
hotel_info: "5 yulduzli Atlantis The Palm mehmonxonasi",
|
||||
duration_days: 7,
|
||||
hotel_meals: "Nonushta va kechki ovqat kiritilgan",
|
||||
ticket_images: [
|
||||
{ image: "/dubai-burj-khalifa.png" },
|
||||
{ image: "/dubai-palm-jumeirah.jpg" },
|
||||
{ image: "/dubai-marina.jpg" },
|
||||
{ image: "/dubai-desert-safari.png" },
|
||||
],
|
||||
ticket_amenities: [
|
||||
{ name: "Wi-Fi", icon_name: "wifi" },
|
||||
{ name: "Konditsioner", icon_name: "air-vent" },
|
||||
{ name: "Basseyn", icon_name: "waves" },
|
||||
{ name: "Fitnes zal", icon_name: "dumbbell" },
|
||||
{ name: "Spa markaz", icon_name: "sparkles" },
|
||||
{ name: "Restoran", icon_name: "utensils" },
|
||||
],
|
||||
ticket_included_services: [
|
||||
{
|
||||
image: "/airplane-ticket.jpg",
|
||||
title: "Aviachiptalar",
|
||||
desc: "Toshkent-Dubai-Toshkent yo'nalishi bo'yicha qatnov chiptalar",
|
||||
},
|
||||
{
|
||||
image: "/comfortable-hotel-room.png",
|
||||
title: "Mehmonxona",
|
||||
desc: "5 yulduzli mehmonxonada 6 kecha turar joy",
|
||||
},
|
||||
{
|
||||
image: "/diverse-tour-group.png",
|
||||
title: "Gid xizmati",
|
||||
desc: "Professional gid bilan barcha ekskursiyalar",
|
||||
},
|
||||
{
|
||||
image: "/transfer-car.jpg",
|
||||
title: "Transfer",
|
||||
desc: "Aeroport-mehmonxona-aeroport transferi",
|
||||
},
|
||||
],
|
||||
ticket_itinerary: [
|
||||
{
|
||||
title: "Dubayga kelish va mehmonxonaga joylashish",
|
||||
duration: 1,
|
||||
ticket_itinerary_image: [{ image: "/dubai-airport.jpg" }],
|
||||
ticket_itinerary_destinations: [
|
||||
{ name: "Dubai Xalqaro Aeroporti" },
|
||||
{ name: "Atlantis The Palm" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Burj Khalifa va Dubai Mall sayohati",
|
||||
duration: 1,
|
||||
ticket_itinerary_image: [{ image: "/burj-khalifa-inside.jpg" }],
|
||||
ticket_itinerary_destinations: [
|
||||
{ name: "Burj Khalifa" },
|
||||
{ name: "Dubai Mall" },
|
||||
{ name: "Dubai Fountain" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Sahro safari va beduinlar lageri",
|
||||
duration: 1,
|
||||
ticket_itinerary_image: [{ image: "/dubai-desert-safari.png" }],
|
||||
ticket_itinerary_destinations: [
|
||||
{ name: "Dubai sahro" },
|
||||
{ name: "Beduinlar lageri" },
|
||||
],
|
||||
},
|
||||
],
|
||||
ticket_hotel_meals: [
|
||||
{
|
||||
image: "/breakfast-buffet.png",
|
||||
name: "Nonushta",
|
||||
desc: "Xalqaro bufet nonushtasi har kuni ertalab",
|
||||
},
|
||||
{
|
||||
image: "/dinner-restaurant.jpg",
|
||||
name: "Kechki ovqat",
|
||||
desc: "Mehmonxona restoranida 3 xil menyu tanlovli kechki ovqat",
|
||||
},
|
||||
],
|
||||
travel_agency_id: "1",
|
||||
ticket_comments: [
|
||||
{
|
||||
user: { id: 1, username: "Aziza Karimova" },
|
||||
text: "Ajoyib sayohat bo'ldi! Barcha xizmatlar yuqori darajada. Gid juda professional va mehribon edi.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
user: { id: 2, username: "Sardor Rahimov" },
|
||||
text: "Mehmonxona va ovqatlar juda yaxshi. Faqat transfer biroz kechikdi, lekin umuman olganda juda yoqdi.",
|
||||
rating: 4,
|
||||
},
|
||||
{
|
||||
user: { id: 3, username: "Nilufar Toshmatova" },
|
||||
text: "Hayotimning eng yaxshi sayohati! Barcha narsani juda yaxshi tashkil qilishgan. Rahmat!",
|
||||
rating: 5,
|
||||
},
|
||||
],
|
||||
tariff: [{ name: "standart" }],
|
||||
is_liked: "true",
|
||||
});
|
||||
}, [params.id]);
|
||||
|
||||
if (!tour) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<p className="text-gray-400">Yuklanmoqda...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${i < rating ? "fill-yellow-400 text-yellow-400" : "text-gray-600"}`}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 w-full">
|
||||
<div className="container mx-auto px-4 py-8 max-w-full">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router(-1)}
|
||||
className="rounded-full border-gray-700 bg-gray-800 hover:bg-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-300" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-4xl font-bold text-white">{tour.title}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full hover:bg-gray-800"
|
||||
>
|
||||
<Heart
|
||||
className={`w-6 h-6 ${tour.is_liked === "true" ? "fill-red-500 text-red-500" : "text-gray-400"}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
{renderStars(Math.floor(tour.rating))}
|
||||
<span className="ml-2 font-semibold">{tour.rating}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>{tour.ticket_comments.length} sharh</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{tour.ticket_images.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative aspect-video rounded-lg overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={img.image || "/placeholder.svg"}
|
||||
alt={`${tour.title} ${idx + 1}`}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<DollarSign className="w-5 h-5 text-green-400" />
|
||||
<p className="text-sm text-gray-400">Narxi</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{tour.price.toLocaleString()} so'm
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Clock className="w-5 h-5 text-blue-400" />
|
||||
<p className="text-sm text-gray-400">Davomiyligi</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{tour.duration_days} kun
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
<p className="text-sm text-gray-400">Yo'lovchilar</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{tour.passenger_count} kishi
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Calendar className="w-5 h-5 text-yellow-400" />
|
||||
<p className="text-sm text-gray-400">Jo'nash sanasi</p>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{new Date(tour.departure_date).toLocaleDateString("uz-UZ")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5 lg:w-auto bg-gray-800 border-gray-700">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Umumiy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="itinerary"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Marshshrut
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="services"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Xizmatlar
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="hotel"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Mehmonxona
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="reviews"
|
||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||
>
|
||||
Sharhlar
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Tur haqida ma'lumot
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-green-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Jo'nash joyi</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.departure}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-blue-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Yo'nalish</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.destination}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Globe className="w-5 h-5 text-purple-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Tillar</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.languages}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Hotel className="w-5 h-5 text-yellow-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Mehmonxona</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.hotel_info}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Utensils className="w-5 h-5 text-green-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Ovqatlanish</p>
|
||||
<p className="font-semibold text-white">
|
||||
{tour.hotel_meals}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Tarif</p>
|
||||
<p className="font-semibold text-white capitalize">
|
||||
{tour.tariff[0]?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4 text-white">
|
||||
Qulayliklar
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{tour.ticket_amenities.map((amenity, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<CheckCircle2 className="w-6 h-6 text-green-400" />
|
||||
<p className="text-sm text-center font-medium text-white">
|
||||
{amenity.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="itinerary" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Sayohat marshshruti
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{tour.ticket_itinerary.map((day, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-l-4 border-green-400 pl-6 pb-6 last:pb-0"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-base border-gray-600 text-gray-300"
|
||||
>
|
||||
{day.duration}-kun
|
||||
</Badge>
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
{day.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{day.ticket_itinerary_image.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{day.ticket_itinerary_image.map((img, imgIdx) => (
|
||||
<div
|
||||
key={imgIdx}
|
||||
className="relative aspect-video rounded-lg overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={img.image || "/placeholder.svg"}
|
||||
alt={day.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{day.ticket_itinerary_destinations.map(
|
||||
(dest, destIdx) => (
|
||||
<Badge
|
||||
key={destIdx}
|
||||
variant="secondary"
|
||||
className="text-sm bg-gray-700 text-gray-300"
|
||||
>
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
{dest.name}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="services" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Narxga kiritilgan xizmatlar
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{tour.ticket_included_services.map((service, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-gray-700 rounded-lg overflow-hidden hover:shadow-xl transition-shadow bg-gray-800"
|
||||
>
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={service.image || "/placeholder.svg"}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold mb-2 text-white">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">{service.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hotel" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Mehmonxona va ovqatlanish
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="p-6 bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Hotel className="w-6 h-6 text-yellow-400" />
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
{tour.hotel_info}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-400">{tour.hotel_meals}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-white">
|
||||
Ovqatlanish tafsilotlari
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{tour.ticket_hotel_meals.map((meal, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-gray-700 rounded-lg overflow-hidden bg-gray-800"
|
||||
>
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={meal.image || "/placeholder.svg"}
|
||||
alt={meal.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">
|
||||
{meal.name}
|
||||
</h4>
|
||||
<p className="text-gray-400 text-sm">{meal.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reviews" className="space-y-6">
|
||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl text-white">
|
||||
Mijozlar sharhlari
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{renderStars(Math.floor(tour.rating))}
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{tour.rating}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
({tour.ticket_comments.length} sharh)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{tour.ticket_comments.map((comment, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-b border-gray-700 pb-6 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-lg text-white">
|
||||
{comment.user.username}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{renderStars(comment.rating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
{comment.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Card className="border-gray-700 shadow-lg mt-8 bg-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Tur firmasi</p>
|
||||
<p className="text-xl font-semibold text-white">
|
||||
Firma ID: {tour.travel_agency_id}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router(`/agencies/${tour.travel_agency_id}`)}
|
||||
className="border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-300"
|
||||
>
|
||||
Firma sahifasiga o'tish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
src/pages/tours/ui/Tours.tsx
Normal file
210
src/pages/tours/ui/Tours.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Edit, Plane, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type Tour = {
|
||||
id: number;
|
||||
image?: string;
|
||||
tickets: string;
|
||||
min_price: string;
|
||||
max_price: string;
|
||||
top_duration: string;
|
||||
top_destinations: string;
|
||||
hotel_features_by_type: string;
|
||||
hotel_types: string;
|
||||
hotel_amenities: string;
|
||||
};
|
||||
|
||||
const Tours = () => {
|
||||
const [tours, setTours] = useState<Tour[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(3);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const mockData: Tour[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
image: `/dubai-marina.jpg`,
|
||||
tickets: `Bilet turi ${i + 1}`,
|
||||
min_price: `${200 + i * 50}$`,
|
||||
max_price: `${400 + i * 70}$`,
|
||||
top_duration: `${3 + i} kun`,
|
||||
top_destinations: `Shahar ${i + 1}`,
|
||||
hotel_features_by_type: "Spa, Wi-Fi, Pool",
|
||||
hotel_types: "5 yulduzli mehmonxona",
|
||||
hotel_amenities: "Nonushta, Parking, Bar",
|
||||
}));
|
||||
|
||||
const itemsPerPage = 6;
|
||||
const start = (page - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
|
||||
setTotalPages(Math.ceil(mockData.length / itemsPerPage));
|
||||
setTours(mockData.slice(start, end));
|
||||
}, [page]);
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId !== null) {
|
||||
setTours((prev) => prev.filter((t) => t.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-foreground py-10 px-5 w-full">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-semibold">Turlar ro'yxati</h1>
|
||||
<Button onClick={() => navigate("/tours/create")} variant="default">
|
||||
<PlusCircle className="w-5 h-5 mr-2" /> Yangi tur qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead className="min-w-[150px]">Manzil</TableHead>
|
||||
<TableHead className="min-w-[120px]">Davomiyligi</TableHead>
|
||||
<TableHead className="min-w-[180px]">Mehmonxona</TableHead>
|
||||
<TableHead className="min-w-[200px]">Narx Oralig'i</TableHead>
|
||||
<TableHead className="min-w-[200px]">Imkoniyatlar</TableHead>
|
||||
<TableHead className="min-w-[150px] text-center">
|
||||
Amallar
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tours.map((tour, idx) => (
|
||||
<TableRow key={tour.id}>
|
||||
<TableCell className="font-medium text-center">
|
||||
{(page - 1) * 6 + idx + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Plane className="w-4 h-4 text-primary" />
|
||||
{tour.top_destinations}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-primary font-medium">
|
||||
{tour.top_duration}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{tour.hotel_types}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tour.tickets}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-bold text-base text-green-600">
|
||||
{tour.min_price} – {tour.max_price}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{tour.hotel_amenities}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/tours/${tour.id}/edit`)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(tour.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tours/${tour.id}`)}
|
||||
>
|
||||
Batafsil
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog - Faqat bitta */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
Turni o'chirishni tasdiqlang
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-muted-foreground">
|
||||
Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga
|
||||
qaytarib bo'lmaydi.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-4 flex">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
O'chirish
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex justify-center mt-10 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Oldingi
|
||||
</Button>
|
||||
<span className="text-sm flex items-center">
|
||||
Sahifa {page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Keyingi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tours;
|
||||
461
src/pages/tours/ui/ToursSetting.tsx
Normal file
461
src/pages/tours/ui/ToursSetting.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent } from "@/shared/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Edit2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface Badge {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Tariff {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface Transport {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface MealPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HotelType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type TabId = "badges" | "tariffs" | "transports" | "mealPlans" | "hotelTypes";
|
||||
|
||||
type DataItem = Badge | Tariff | Transport | MealPlan | HotelType;
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "color" | "textarea" | "select";
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ToursSetting: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("badges");
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||
const [currentItem, setCurrentItem] = useState<DataItem | null>(null);
|
||||
|
||||
const [badges, setBadges] = useState<Badge[]>([
|
||||
{ id: 1, name: "Bestseller", color: "#FFD700" },
|
||||
{ id: 2, name: "Yangi", color: "#4CAF50" },
|
||||
]);
|
||||
|
||||
const [tariffs, setTariffs] = useState<Tariff[]>([
|
||||
{ id: 1, name: "Standart", price: 500 },
|
||||
{ id: 2, name: "Premium", price: 1000 },
|
||||
]);
|
||||
|
||||
const [transports, setTransports] = useState<Transport[]>([
|
||||
{ id: 1, name: "Avtobus", price: 200 },
|
||||
{ id: 2, name: "Minivan", price: 500 },
|
||||
]);
|
||||
|
||||
const [mealPlans, setMealPlans] = useState<MealPlan[]>([
|
||||
{ id: 1, name: "BB (Bed & Breakfast)" },
|
||||
{ id: 2, name: "HB (Half Board)" },
|
||||
{ id: 3, name: "FB (Full Board)" },
|
||||
]);
|
||||
|
||||
const [hotelTypes, setHotelTypes] = useState<HotelType[]>([
|
||||
{ id: 1, name: "3 Yulduz" },
|
||||
{ id: 2, name: "5 Yulduz" },
|
||||
]);
|
||||
|
||||
const [formData, setFormData] = useState<Partial<DataItem>>({});
|
||||
|
||||
const getCurrentData = (): DataItem[] => {
|
||||
switch (activeTab) {
|
||||
case "badges":
|
||||
return badges;
|
||||
case "tariffs":
|
||||
return tariffs;
|
||||
case "transports":
|
||||
return transports;
|
||||
case "mealPlans":
|
||||
return mealPlans;
|
||||
case "hotelTypes":
|
||||
return hotelTypes;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getSetterFunction = (): React.Dispatch<
|
||||
React.SetStateAction<DataItem[]>
|
||||
> => {
|
||||
switch (activeTab) {
|
||||
case "badges":
|
||||
return setBadges as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
case "tariffs":
|
||||
return setTariffs as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
case "transports":
|
||||
return setTransports as React.Dispatch<
|
||||
React.SetStateAction<DataItem[]>
|
||||
>;
|
||||
case "mealPlans":
|
||||
return setMealPlans as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
case "hotelTypes":
|
||||
return setHotelTypes as React.Dispatch<
|
||||
React.SetStateAction<DataItem[]>
|
||||
>;
|
||||
default:
|
||||
return (() => {}) as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = getCurrentData().filter((item) =>
|
||||
Object.values(item).some((val) =>
|
||||
val?.toString().toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
const getFormFields = (): FormField[] => {
|
||||
switch (activeTab) {
|
||||
case "badges":
|
||||
return [
|
||||
{ name: "name", label: "Nomi", type: "text", required: true },
|
||||
{ name: "color", label: "Rang", type: "color", required: true },
|
||||
];
|
||||
case "tariffs":
|
||||
return [
|
||||
{ name: "name", label: "Tarif nomi", type: "text", required: true },
|
||||
{ name: "price", label: "Narx", type: "number", required: true },
|
||||
];
|
||||
case "transports":
|
||||
return [
|
||||
{
|
||||
name: "name",
|
||||
label: "Transport nomi",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{ name: "capacity", label: "Sig'im", type: "number", required: true },
|
||||
];
|
||||
case "mealPlans":
|
||||
return [{ name: "name", label: "Nomi", type: "text", required: true }];
|
||||
case "hotelTypes":
|
||||
return [
|
||||
{ name: "name", label: "Tur nomi", type: "text", required: true },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (
|
||||
mode: "add" | "edit",
|
||||
item: DataItem | null = null,
|
||||
): void => {
|
||||
setModalMode(mode);
|
||||
setCurrentItem(item);
|
||||
if (mode === "edit" && item) {
|
||||
setFormData(item);
|
||||
} else {
|
||||
setFormData({});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = (): void => {
|
||||
setIsModalOpen(false);
|
||||
setFormData({});
|
||||
setCurrentItem(null);
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const setter = getSetterFunction();
|
||||
|
||||
if (modalMode === "add") {
|
||||
const newId = Math.max(...getCurrentData().map((i) => i.id), 0) + 1;
|
||||
setter([...getCurrentData(), { ...formData, id: newId } as DataItem]);
|
||||
} else {
|
||||
setter(
|
||||
getCurrentData().map((item) =>
|
||||
item.id === currentItem?.id ? { ...item, ...formData } : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleDelete = (id: number): void => {
|
||||
if (window.confirm("Rostdan ham o'chirmoqchimisiz?")) {
|
||||
const setter = getSetterFunction();
|
||||
setter(getCurrentData().filter((item) => item.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: "badges", label: "Belgilar" },
|
||||
{ id: "tariffs", label: "Tariflar" },
|
||||
{ id: "transports", label: "Transportlar" },
|
||||
{ id: "mealPlans", label: "Ovqatlanish" },
|
||||
{ id: "hotelTypes", label: "Otel turlari" },
|
||||
];
|
||||
|
||||
const getFieldValue = (fieldName: string): string | number => {
|
||||
return (formData as Record<string, string | number>)[fieldName] || "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 p-6 w-full">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<h1 className="text-3xl font-bold">Tur Sozlamalari</h1>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as TabId);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center">
|
||||
<div className="relative w-full sm:w-96">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground"
|
||||
size={20}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Qidirish..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => openModal("add")}
|
||||
className="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Yangi qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-full">
|
||||
<div className="border-b">
|
||||
<div className="flex">
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-20">
|
||||
ID
|
||||
</div>
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider flex-1">
|
||||
Nomi
|
||||
</div>
|
||||
{activeTab === "badges" && (
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-48">
|
||||
Rang
|
||||
</div>
|
||||
)}
|
||||
{(activeTab === "tariffs" || activeTab === "transports") && (
|
||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-32">
|
||||
Narx
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider w-32">
|
||||
Amallar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{filteredData.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-muted-foreground">
|
||||
Ma'lumot topilmadi
|
||||
</div>
|
||||
) : (
|
||||
filteredData.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="px-6 py-4 w-20">{item.id}</div>
|
||||
<div className="px-6 py-4 font-medium flex-1">
|
||||
{item.name}
|
||||
</div>
|
||||
{activeTab === "badges" && (
|
||||
<div className="px-6 py-4 w-48">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: (item as Badge).color }}
|
||||
/>
|
||||
<span>{(item as Badge).color}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(activeTab === "tariffs" ||
|
||||
activeTab === "transports") && (
|
||||
<div className="px-6 py-4 w-32">
|
||||
{(item as Tariff).price}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-4 w-32">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openModal("edit", item)}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
<Trash2 size={18} className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{modalMode === "add" ? "Yangi qo'shish" : "Tahrirlash"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{getFormFields().map((field) => (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
value={getFieldValue(field.name) as string}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[field.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
required={field.required}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
value={getFieldValue(field.name) as string}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, [field.name]: value })
|
||||
}
|
||||
required={field.required}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Tanlang" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.type}
|
||||
value={getFieldValue(field.name)}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
[field.name]:
|
||||
field.type === "number"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeModal}
|
||||
className="flex-1"
|
||||
>
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} className="flex-1">
|
||||
Saqlash
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToursSetting;
|
||||
Reference in New Issue
Block a user