api ulandi

This commit is contained in:
Samandar Turgunboyev
2025-10-25 18:42:01 +05:00
parent 1a08775451
commit 05b752daf2
84 changed files with 11179 additions and 3724 deletions

View File

@@ -1,240 +0,0 @@
"use client";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import {
ArrowLeft,
ChevronRight,
DollarSign,
Package,
Percent,
TrendingUp,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
type Tour = {
id: number;
name: string;
description: string;
sold: number;
profit: number;
status: "faol" | "nofaol";
};
type Agency = {
id: number;
name: string;
owner: string;
status: "faol" | "nofaol";
profitPercent: number;
totalTours: number;
soldTours: number;
totalProfit: number;
tours: Tour[];
};
export default function AgencyDetailPage() {
const params = useParams();
const router = useNavigate();
const [agency, setAgency] = useState<Agency | null>(null);
useEffect(() => {
// Mock data - replace with actual API call
setAgency({
id: Number(params.id),
name: "Silk Road Travel",
owner: "Ali Karimov",
status: "faol",
profitPercent: 15,
totalTours: 12,
soldTours: 56,
totalProfit: 8900000,
tours: [
{
id: 1,
name: "Dubai Tour",
description: "7 kunlik hashamatli sayohat",
sold: 23,
profit: 3450000,
status: "faol",
},
{
id: 2,
name: "Bali Adventure",
description: "10 kunlik ekzotik sayohat",
sold: 33,
profit: 5450000,
status: "faol",
},
{
id: 3,
name: "Istanbul Express",
description: "5 kunlik madaniy sayohat",
sold: 0,
profit: 0,
status: "nofaol",
},
],
});
}, [params.id]);
if (!agency) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<p className="text-gray-400">Yuklanmoqda...</p>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 w-full">
<div className="container mx-auto px-4 py-8 max-w-full">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => router("/")}
className="rounded-full border-gray-700 bg-gray-800 hover:bg-gray-700"
>
<ArrowLeft className="w-5 h-5 text-gray-300" />
</Button>
<div>
<h1 className="text-4xl font-bold text-white">{agency.name}</h1>
<p className="text-gray-400 mt-1">Egasi: {agency.owner}</p>
</div>
</div>
<Badge
variant={agency.status === "faol" ? "default" : "secondary"}
className="text-base px-4 py-2"
>
{agency.status === "faol" ? "Faol" : "No-faol"}
</Badge>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* Total Tours */}
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
Jami turlar
</CardTitle>
<Package className="w-5 h-5 text-blue-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{agency.totalTours}
</div>
<p className="text-xs text-gray-400 mt-1">
Qo'shilgan turlar soni
</p>
</CardContent>
</Card>
{/* Sold Tours */}
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
Sotilgan turlar
</CardTitle>
<TrendingUp className="w-5 h-5 text-green-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{agency.soldTours}
</div>
<p className="text-xs text-gray-400 mt-1">Jami sotilgan turlar</p>
</CardContent>
</Card>
{/* Profit Percent */}
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
Ulush foizi
</CardTitle>
<Percent className="w-5 h-5 text-purple-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{agency.profitPercent}%
</div>
<p className="text-xs text-gray-400 mt-1">Har bir sotuvdan</p>
</CardContent>
</Card>
{/* Total Profit */}
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
Umumiy daromad
</CardTitle>
<DollarSign className="w-5 h-5 text-yellow-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{(agency.totalProfit / 1000).toLocaleString()}
</div>
<p className="text-xs text-gray-400 mt-1">so'm daromad</p>
</CardContent>
</Card>
</div>
{/* Tours List */}
<Card className="border border-gray-700 shadow-lg bg-gray-800">
<CardHeader>
<CardTitle className="text-2xl text-white">
Qo'shilgan turlar
</CardTitle>
<p className="text-gray-400">
Firma tomonidan qo'shilgan barcha turlar ro'yxati
</p>
</CardHeader>
<CardContent>
<div className="space-y-3">
{agency.tours.map((tour) => (
<Link key={tour.id} to={`/tours/${tour.id}`} className="block">
<div className="p-5 border border-gray-700 rounded-xl hover:bg-gray-700 transition-all cursor-pointer group bg-gray-800">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
{tour.name}
</h3>
</div>
<p className="text-sm text-gray-400 mb-3">
{tour.description}
</p>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-gray-400" />
<span className="text-gray-400">Sotilgan:</span>
<span className="font-semibold text-white">
{tour.sold} ta
</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-400" />
<span className="text-gray-400">Daromad:</span>
<span className="font-semibold text-yellow-400">
{(tour.profit / 1000).toLocaleString()} so'm
</span>
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-blue-400 transition-colors" />
</div>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import type {
GetAllAgencyData,
GetDetailAgencyData,
} from "@/pages/agencies/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import { GET_ALL_AGENCY } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const getAllAgency = async ({
page,
page_size,
}: {
page: number;
page_size: number;
}): Promise<AxiosResponse<GetAllAgencyData>> => {
const response = await httpClient.get(GET_ALL_AGENCY, {
params: { page, page_size },
});
return response;
};
const getDetailAgency = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<GetDetailAgencyData>> => {
const response = await httpClient.get(`${GET_ALL_AGENCY}${id}/`);
return response;
};
const deleteAgency = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${GET_ALL_AGENCY}${id}/`);
return response;
};
const updateAgencyStatus = async ({
id,
body,
}: {
id: number;
body: {
status: "pending" | "approved" | "cancelled";
custom_id?: string;
share_percentage?: number;
name?: string;
addres?: string;
email?: string;
phone?: string;
web_site?: string;
ticket_sold_count?: number;
total_income?: number;
};
}) => {
const response = await httpClient.patch(`${GET_ALL_AGENCY}${id}/`, body);
return response;
};
export { deleteAgency, getAllAgency, getDetailAgency, updateAgencyStatus };

View File

@@ -0,0 +1,58 @@
export interface GetAllAgencyData {
status: boolean;
data: {
current_page: number;
links: {
next: string;
previous: string;
};
total_items: number;
total_pages: number;
page_size: number;
results: {
activate_tour_agency: number;
all_tickets: number;
all_tour_agency: number;
total_income: number;
list: {
id: number;
custom_id: string;
name: string;
owner_user: string;
share_percentage: number;
status: "pending" | "approved" | "cancelled";
ticket_sold_count: number;
total_income: number;
tour_count: number;
}[];
};
};
}
export interface GetDetailAgencyData {
status: boolean;
data: {
status: "pending" | "approved" | "cancelled";
custom_id: string;
share_percentage: number;
name: string;
addres: string;
email: string;
phone: string;
web_site: string;
owner_user: string;
tour_count: string;
ticket_sold_count: number;
total_income: number;
tickets: [
{
id: number;
title: string;
departure: string;
destination: string;
sold_count: number;
total_income: number;
},
];
};
}

View File

@@ -1,110 +1,91 @@
import { deleteAgency, getAllAgency } from "@/pages/agencies/lib/api";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
Building2,
ChevronLeft,
ChevronRight,
Eye,
Loader2,
Package,
Pencil,
Trash2,
TrendingUp,
UserIcon,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
type Agency = {
id: number;
name: string;
owner: string;
status: "faol" | "nofaol";
profitPercent: number;
totalTours: number;
soldTours: number;
totalProfit: number;
};
import { toast } from "sonner";
export default function TourAgenciesPage() {
const [agencies, setAgencies] = useState<Agency[]>([
{
id: 1,
name: "Silk Road Travel",
owner: "Ali Karimov",
status: "faol",
profitPercent: 15,
totalTours: 12,
soldTours: 56,
totalProfit: 8900000,
},
{
id: 2,
name: "UzTour Plus",
owner: "Madina Qodirova",
status: "nofaol",
profitPercent: 10,
totalTours: 5,
soldTours: 0,
totalProfit: 0,
},
{
id: 3,
name: "Orient Express",
owner: "Sardor Rahimov",
status: "faol",
profitPercent: 12,
totalTours: 8,
soldTours: 34,
totalProfit: 5600000,
},
{
id: 4,
name: "Golden Voyage",
owner: "Dilnoza Azimova",
status: "faol",
profitPercent: 18,
totalTours: 15,
soldTours: 89,
totalProfit: 12400000,
},
{
id: 5,
name: "SkyLine Tours",
owner: "Rustam Qobilov",
status: "nofaol",
profitPercent: 8,
totalTours: 4,
soldTours: 0,
totalProfit: 0,
},
{
id: 6,
name: "Desert Adventures",
owner: "Kamola Saidova",
status: "faol",
profitPercent: 20,
totalTours: 11,
soldTours: 42,
totalProfit: 6700000,
},
]);
const { t } = useTranslation();
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 4;
const navigate = useNavigate();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleStatusChange = (id: number, newStatus: "faol" | "nofaol") => {
setAgencies((prev) =>
prev.map((a) => (a.id === id ? { ...a, status: newStatus } : a)),
);
const { data, refetch, isLoading, isError } = useQuery({
queryKey: ["all_agency", currentPage],
queryFn: () => getAllAgency({ page: currentPage, page_size: itemsPerPage }),
});
const { mutate } = useMutation({
mutationFn: ({ id }: { id: number }) => {
return deleteAgency({ id });
},
onSuccess: () => {
refetch();
setIsDialogOpen(false);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const handleDelete = async (id: number) => {
mutate({ id: id });
};
const totalPages = Math.ceil(agencies.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedAgencies = agencies.slice(
startIndex,
startIndex + itemsPerPage,
);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
const activeCount = agencies.filter((a) => a.status === "faol").length;
const totalTours = agencies.reduce((sum, a) => sum + a.totalTours, 0);
const totalRevenue = agencies.reduce((sum, a) => sum + a.totalProfit, 0);
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => refetch()}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
return (
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
@@ -116,40 +97,57 @@ export default function TourAgenciesPage() {
<Building2 className="w-8 h-8 text-white" />
</div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
Tur firmalari
{t("Tur firmalari")}
</h1>
</div>
<p className="text-slate-400 text-lg ml-14">
Firmalarni karta ko'rinishida boshqaring va statistikani kuzating
{t(
"Tur firmalaringizni boshqaring va ularning faoliyatini kuzatib boring.",
)}
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-12">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-12 h-full">
<StatCard
title="Jami firmalar"
value={agencies.length.toString()}
title={t("Jami firmalar")}
value={
data ? data.data.data.results.all_tour_agency?.toString() : "0"
}
icon={<Package className="w-6 h-6" />}
gradient="from-blue-600 to-blue-400"
shadowColor="blue"
/>
<StatCard
title="Faol firmalar"
value={activeCount.toString()}
title={t("Faol firmalar")}
value={
data && data.data.data.results.activate_tour_agency === 0
? data.data.data.results.activate_tour_agency?.toString()
: "0"
}
icon={<TrendingUp className="w-6 h-6" />}
gradient="from-green-600 to-emerald-400"
shadowColor="green"
/>
<StatCard
title="Jami turlar"
value={totalTours.toString()}
title={t("Jami turlar")}
value={data ? data.data.data.results.all_tickets?.toString() : "0"}
icon={<Package className="w-6 h-6" />}
gradient="from-amber-600 to-yellow-400"
shadowColor="amber"
/>
<StatCard
title="Umumiy daromad"
value={`${(totalRevenue / 1_000_000).toFixed(1)}M`}
title={t("Umumiy daromad")}
value={
data
? data.data.data.results.total_income === 0
? "0"
: formatPrice(
data.data.data.results.total_income?.toString(),
true,
)
: "0"
}
icon={<TrendingUp className="w-6 h-6" />}
gradient="from-purple-600 to-pink-400"
shadowColor="purple"
@@ -158,21 +156,21 @@ export default function TourAgenciesPage() {
{/* Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-10">
{paginatedAgencies.map((agency) => (
{data?.data.data.results.list.map((agency) => (
<div
key={agency.id}
key={agency.custom_id}
className="group relative hover:scale-105 transition-transform duration-300"
>
<div
className={`absolute inset-0 bg-gradient-to-r ${
agency.status === "faol"
agency.status === "pending"
? "from-blue-600/20 to-cyan-600/20"
: "from-slate-600/20 to-slate-500/20"
} rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100`}
/>
<div
className={`relative bg-gradient-to-br ${
agency.status === "faol"
agency.status === "pending"
? "from-slate-700 to-slate-800"
: "from-slate-800 to-slate-900"
} border border-slate-600/50 rounded-2xl p-6 shadow-2xl hover:shadow-2xl transition-all backdrop-blur-sm hover:border-slate-500/70`}
@@ -185,17 +183,23 @@ export default function TourAgenciesPage() {
</h2>
<div className="flex gap-2 items-center">
<UserIcon className="text-slate-400 size-5" />
<p className="text-slate-400">{agency.owner}</p>
<p className="text-slate-400">{agency.owner_user}</p>
</div>
</div>
<div
className={`px-3 py-1 rounded-full text-sm font-semibold whitespace-nowrap ${
agency.status === "faol"
? "bg-green-500/20 text-green-300 border border-green-500/50"
: "bg-red-500/20 text-red-300 border border-red-500/50"
agency.status === "pending"
? "bg-cyan-500/30"
: agency.status === "approved"
? "bg-green-500/30"
: "bg-red-500/30"
}`}
>
{agency.status === "faol" ? "Faol" : "No-faol"}
{agency.status === "pending"
? t("Kutilmoqda")
: agency.status === "approved"
? t("Faol")
: agency.status === "cancelled" && t("Cancelled")}
</div>
</div>
@@ -203,64 +207,97 @@ export default function TourAgenciesPage() {
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
Komissiya
{t("Komissiya")}
</p>
<p className="text-2xl font-bold text-blue-300">
{agency.profitPercent}%
{agency.share_percentage}%
</p>
</div>
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
Jami tur
{t("Jami tur")}
</p>
<p className="text-2xl font-bold text-cyan-300">
{agency.totalTours}
{agency.tour_count}
</p>
</div>
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
Sotilgan
{t("Sotilgan tur")}
</p>
<p className="text-2xl font-bold text-yellow-300">
{agency.soldTours}
{agency.ticket_sold_count}
</p>
</div>
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
Daromad
{t("Daromad")}
</p>
<p className="text-lg font-bold text-green-300">
{(agency.totalProfit / 1_000_000).toFixed(1)}M
{agency.total_income === 0
? 0
: formatPrice(agency.total_income, true)}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-4 border-t border-slate-600/50">
<select
value={agency.status}
onChange={(e) =>
handleStatusChange(
agency.id,
e.target.value as "faol" | "nofaol",
)
}
className="flex-1 bg-slate-700/50 border border-slate-600 rounded-lg px-3 py-2 text-slate-300 text-sm hover:bg-slate-600/50 transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="faol" className="bg-slate-800">
Faol
</option>
<option value="nofaol" className="bg-slate-800">
No-faol
</option>
</select>
<div className="grid grid-cols-1 gap-2 pt-4 border-t border-slate-600/50">
<button
onClick={() => navigate(`/agencies/${agency.id}`)}
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-cyan-500/50"
>
<Eye className="w-4 h-4" />
Ko'rish
{t("Ko'rish")}
</button>
<button
onClick={() => navigate(`/agency/${agency.id}/edit`)}
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-cyan-500/50"
>
<Pencil />
{t("Tahrirlash")}
</button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<button
onClick={() => {
setIsDialogOpen(true);
}}
className="flex-1 bg-red-600/80 hover:bg-red-600 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-red-500/40"
>
<Trash2 className="w-4 h-4" />
{t("O'chirish")}
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-slate-900 border border-slate-700 text-white">
<DialogHeader>
<DialogTitle>
{t("Haqiqatan ham ochirmoqchimisiz?")}
</DialogTitle>
<DialogDescription className="text-slate-400">
{t(
"Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga qaytarib bo'lmaydi.",
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="border-slate-600 text-slate-300 hover:bg-slate-800"
>
{t("Bekor qilish")}
</Button>
<Button
onClick={() => handleDelete(agency.id)}
className="bg-red-600 hover:bg-red-700 text-white"
>
{t("O'chirish")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</div>
@@ -277,7 +314,7 @@ export default function TourAgenciesPage() {
<ChevronLeft className="w-5 h-5" />
</button>
{[...Array(totalPages)].map((_, i) => (
{[...Array(data?.data.data.total_pages)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
@@ -292,8 +329,12 @@ export default function TourAgenciesPage() {
))}
<button
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
disabled={currentPage === data?.data.data.total_pages}
onClick={() =>
setCurrentPage((p) =>
Math.min(p + 1, data ? data?.data.data.total_pages : 0),
)
}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
>
<ChevronRight className="w-5 h-5" />
@@ -317,7 +358,7 @@ function StatCard({
shadowColor: string;
}) {
return (
<div className="group relative hover:scale-105 transition-transform duration-300">
<div className="group relative hover:scale-105 transition-transform duration-300 h-full">
<div
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
/>

View File

@@ -0,0 +1,398 @@
"use client";
import { getDetailAgency, updateAgencyStatus } from "@/pages/agencies/lib/api";
import formatPhone from "@/shared/lib/formatPhone";
import formatPrice from "@/shared/lib/formatPrice";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
ChevronRight,
DollarSign,
Loader2,
Package,
Pencil,
Percent,
TrendingUp,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
export default function AgencyDetailPage() {
const params = useParams();
const router = useNavigate();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data, isLoading, refetch, isError } = useQuery({
queryKey: ["detail_agency"],
queryFn: () => getDetailAgency({ id: Number(params.id) }),
});
const statusMutation = useMutation({
mutationFn: (newStatus: "pending" | "approved" | "cancelled") =>
updateAgencyStatus({
id: Number(params.id),
body: { status: newStatus },
}),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_agency"] });
queryClient.refetchQueries({ queryKey: ["all_agency"] });
toast.success(t("Status muvaffaqiyatli o'zgartirildi"));
},
onError: () => {
toast.error(t("Statusni o'zgartirishda xatolik yuz berdi"));
},
});
const handleStatusChange = (
newStatus: "pending" | "approved" | "cancelled",
) => {
statusMutation.mutate(newStatus);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => refetch()}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
const getStatusBadge = (status: string) => {
switch (status) {
case "pending":
return (
<Badge className="bg-yellow-500/20 text-yellow-300 border border-yellow-500/40">
{t("Kutilmoqda")}
</Badge>
);
case "approved":
return (
<Badge className="bg-green-500/20 text-green-300 border border-green-500/40">
{t("Faol")}
</Badge>
);
case "cancelled":
return (
<Badge className="bg-red-500/20 text-red-300 border border-red-500/40">
{t("Cancelled")}
</Badge>
);
default:
return null;
}
};
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 justify-between mb-8">
<div className="flex items-center gap-4">
<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>
<div className="flex gap-2">
<h1 className="text-4xl font-bold text-white">
{data?.data.data.name}
</h1>
<p className="text-md text-muted-foreground">
{data?.data.data.custom_id}
</p>
</div>
<p className="text-gray-400 mt-1">
{t("Egasi")}: {data?.data.data.owner_user}
</p>
</div>
</div>
{/* Status Select */}
<div className="flex items-center gap-3">
{data && getStatusBadge(data?.data.data.status)}
<Select
value={data?.data.data.status}
onValueChange={handleStatusChange}
disabled={statusMutation.isPending}
>
<SelectTrigger className="w-[180px] border-gray-700 bg-gray-800 text-white hover:bg-gray-700 focus:ring-blue-500">
<SelectValue placeholder={t("Status tanlang")} />
</SelectTrigger>
<SelectContent className="bg-gray-800 border-gray-700">
<SelectItem
value="pending"
className="text-yellow-300 focus:bg-gray-700 focus:text-yellow-300"
>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
{t("Kutilmoqda")}
</div>
</SelectItem>
<SelectItem
value="approved"
className="text-green-300 focus:bg-gray-700 focus:text-green-300"
>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
{t("Faol")}
</div>
</SelectItem>
<SelectItem
value="cancelled"
className="text-red-300 focus:bg-gray-700 focus:text-red-300"
>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
{t("Cancelled")}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
{t("Jami turlar")}
</CardTitle>
<Package className="w-5 h-5 text-blue-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{data?.data.data.tour_count}
</div>
<p className="text-xs text-gray-400 mt-1">
{t("Qo'shilgan turlar soni")}
</p>
</CardContent>
</Card>
{/* Sold Tours */}
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
{t("Sotilgan turlar soni")}
</CardTitle>
<TrendingUp className="w-5 h-5 text-green-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{data?.data.data.ticket_sold_count}
</div>
<p className="text-xs text-gray-400 mt-1">
{t("Jami sotilgan turlar")}
</p>
</CardContent>
</Card>
{/* Profit Percent */}
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
{t("Ulush foizi")}
</CardTitle>
<Percent className="w-5 h-5 text-purple-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{data?.data.data.share_percentage}%
</div>
<p className="text-xs text-gray-400 mt-1">
{t("Har bir sotuvdan")}
</p>
</CardContent>
</Card>
{/* Total Profit */}
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-400">
{t("Umumiy daromad")}
</CardTitle>
<DollarSign className="w-5 h-5 text-yellow-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{data && data.data.data.total_income !== 0
? formatPrice(data?.data.data.total_income, true)
: 0}
</div>
<p className="text-xs text-gray-400 mt-1">{t("so'm daromad")}</p>
</CardContent>
</Card>
</div>
<Card className="border border-gray-700 shadow-lg bg-gray-800 mb-8">
<CardHeader>
<CardTitle className="text-2xl text-white flex items-center justify-between">
<p>{t("Umumiy ma'lumot")}</p>
<Button
onClick={() => router(`/agency/${params.id}/edit`)}
className="bg-gradient-to-r cursor-pointer from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white shadow-lg shadow-blue-500/20"
>
<Pencil />
{t("Tahrirlash")}
</Button>
</CardTitle>
<p className="text-gray-400">
{t("Agentlik haqida batafsil ma'lumot")}
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Address */}
<div className="space-y-2">
<p className="text-sm text-gray-400">{t("Manzil")}</p>
<p className="text-base font-medium text-white">
{data?.data.data.addres || t("Ma'lumot yo'q")}
</p>
</div>
{/* Email */}
<div className="space-y-2">
<p className="text-sm text-gray-400">{t("Email")}</p>
<p className="text-base font-medium text-white break-all">
{data?.data.data.email || t("Ma'lumot yo'q")}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-400">{t("Telefon raqami")}</p>
<p className="text-base font-medium text-white">
{data
? formatPhone(data?.data.data.phone)
: t("Ma'lumot yo'q")}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-400">{t("Veb-sayt")}</p>
{data?.data.data.web_site ? (
<a
href={data.data.data.web_site}
target="_blank"
rel="noopener noreferrer"
className="text-base font-medium text-blue-400 hover:text-blue-300 hover:underline break-all"
>
{data.data.data.web_site}
</a>
) : (
<p className="text-base font-medium text-white">
{t("Ma'lumot yo'q")}
</p>
)}
</div>
{/* Custom ID */}
<div className="space-y-2">
<p className="text-sm text-gray-400">{t("ID raqami")}</p>
<p className="text-base font-medium text-white">
{data?.data.data.custom_id}
</p>
</div>
{/* Share Percentage */}
<div className="space-y-2">
<p className="text-sm text-gray-400">{t("Komissiya")}</p>
<p className="text-base font-medium text-white">
{data?.data.data.share_percentage}%
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-gray-700 shadow-lg bg-gray-800">
<CardHeader>
<CardTitle className="text-2xl text-white">
{t("Qo'shilgan turlar")}
</CardTitle>
<p className="text-gray-400">
{t("Firma tomonidan qo'shilgan barcha turlar ro'yxati")}
</p>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data?.data.data.tickets.map((tour) => (
<Link key={tour.id} to={`/tours/${tour.id}`} className="block">
<div className="p-5 border border-gray-700 rounded-xl hover:bg-gray-700 transition-all cursor-pointer group bg-gray-800">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
{tour.title}
</h3>
</div>
<p className="text-sm text-gray-400 mb-3">
{tour.destination}
</p>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-gray-400" />
<span className="text-gray-400">
{t("Sotilgan")}:
</span>
<span className="font-semibold text-white">
{tour.total_income}
</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-400" />
<span className="text-gray-400">
{t("Daromad")}:
</span>
<span className="font-semibold text-yellow-400">
{tour.total_income === 0
? 0
: formatPrice(tour.total_income, true)}
</span>
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-blue-400 transition-colors" />
</div>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,273 @@
"use client";
import { getDetailAgency, updateAgencyStatus } from "@/pages/agencies/lib/api";
import formatPhone from "@/shared/lib/formatPhone";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
status: z.enum(["pending", "approved", "cancelled"]),
share_percentage: z
.number({ message: "Share percentage raqam bolishi kerak" })
.min(0)
.max(100),
name: z.string().min(1, "Nom kiritish shart"),
addres: z.string().min(1, "Manzil kiritish shart"),
email: z.string().email("Email notogri"),
phone: z.string().min(3, "Telefon raqami notogri"),
web_site: z.string().url("URL notogri"),
});
type FormData = z.infer<typeof formSchema>;
const EditAgency = () => {
const params = useParams();
const { t } = useTranslation();
const router = useNavigate();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
status: "pending",
share_percentage: 0,
name: "",
addres: "",
email: "",
phone: "+998",
web_site: "",
},
});
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: ["detail_agency", params.id],
queryFn: () => getDetailAgency({ id: Number(params.id) }),
});
const { mutate } = useMutation({
mutationFn: (body: {
status: "pending" | "approved" | "cancelled";
custom_id?: string;
share_percentage?: number;
name?: string;
addres?: string;
email?: string;
phone?: string;
web_site?: string;
ticket_sold_count?: number;
total_income?: number;
}) =>
updateAgencyStatus({
id: Number(params.id),
body,
}),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_agency"] });
queryClient.refetchQueries({ queryKey: ["all_agency"] });
router(-1);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
useEffect(() => {
if (data?.data?.data) {
const agency = data.data.data;
form.setValue("status", agency.status);
form.setValue("share_percentage", agency.share_percentage);
form.setValue("name", agency.name);
form.setValue("addres", agency.addres);
form.setValue("email", agency.email);
form.setValue("phone", agency.phone);
form.setValue("web_site", agency.web_site);
}
}, [data, form]);
const onSubmit = (values: FormData) => {
mutate({
status: values.status,
share_percentage: values.share_percentage,
name: values.name,
addres: values.addres,
email: values.email,
phone: values.phone,
web_site: values.web_site,
});
};
return (
<Card className="w-[80%] mx-auto mt-10 bg-gray-900">
<CardHeader>
<CardTitle className="text-xl font-semibold">
{t("Tahrirlash")}
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
noValidate
>
<FormField
control={form.control}
name="share_percentage"
render={({ field }) => (
<FormItem>
<FormLabel> {t("Komissiya")}</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={100}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<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="addres"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Manzil")}</FormLabel>
<FormControl>
<Input placeholder={t("Manzil")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Email")}</FormLabel>
<FormControl>
<Input
type="email"
placeholder="user@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Telefon raqam")}</FormLabel>
<FormControl>
<Input
placeholder="+998 90 123-45-67"
value={formatPhone(field.value)}
onChange={field.onChange}
onBlur={field.onBlur}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="web_site"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Veb-sayt")}</FormLabel>
<FormControl>
<Input placeholder="https://example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Status")}</FormLabel>
<Select
value={field.value}
key={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pending">{t("Kutilmoqda")}</SelectItem>
<SelectItem value="approved"> {t("Faol")}</SelectItem>
<SelectItem value="cancelled">
{t("Cancelled")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full cursor-pointer">
{isPending ? <Loader2 className="animate-spin" /> : t("Saqlash")}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
};
export default EditAgency;

View File

@@ -0,0 +1,8 @@
import z from "zod";
export const formSchema = z.object({
phone: z.string().min(17, { message: "To'liq telefon raqamini kiriting" }),
password: z
.string()
.min(4, { message: "Parol kamida 4 ta belgidan iborat bo'lishi kerak" }),
});

181
src/pages/auth/ui/Login.tsx Normal file
View File

@@ -0,0 +1,181 @@
"use client";
import { formSchema } from "@/pages/auth/lib/form";
import { authLogin, getMe } from "@/shared/config/api/auth/api";
import useUserStore from "@/shared/hooks/user";
import {
getAuthToken,
setAuthRefToken,
setAuthToken,
} from "@/shared/lib/authCookies";
import formatPhone from "@/shared/lib/formatPhone";
import onlyNumber from "@/shared/lib/onlyNumber";
import { Button } from "@/shared/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import LangToggle from "@/widgets/lang-toggle/ui/lang-toggle";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff, LoaderCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { z } from "zod";
const Login = () => {
const [showPassword, setShowPassword] = useState(false);
const { setUser } = useUserStore();
const queryClient = useQueryClient();
const navigate = useNavigate();
const token = getAuthToken();
const { mutate, isPending } = useMutation({
mutationFn: ({ password, phone }: { password: string; phone: string }) =>
authLogin({
password,
phone,
}),
onSuccess: (res) => {
setAuthToken(res.data.access);
setAuthRefToken(res.data.refresh);
queryClient.invalidateQueries({ queryKey: ["auth_get_me"] });
},
onError: () => {
toast.error("Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
const { data } = useQuery({
queryKey: ["auth_get_me"],
queryFn: () => getMe(),
enabled: !!token,
});
useEffect(() => {
if (data) {
setUser(data.data.data);
}
if (data && data.data.data.role !== "user") {
navigate("/");
}
}, [data]);
const { t } = useTranslation();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
password: "",
phone: "",
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
mutate({
password: values.password,
phone: onlyNumber(values.phone),
});
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4 w-full">
<div className="absolute top-4 right-4 flex gap-2">
<LangToggle />
</div>
<div className="w-full max-w-xl rounded-2xl bg-gray-800 p-8 shadow-lg">
<h2 className="mb-6 text-center text-3xl font-semibold text-white">
{t("Admin Panelga Kirish")}
</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-200">
{t("Telefon raqam")}
</FormLabel>
<FormControl>
<Input
placeholder="+998 __ ___-__-__"
{...field}
value={field.value || "+998"}
onChange={(e) =>
field.onChange(formatPhone(e.target.value))
}
maxLength={19}
className="h-[56px] text-lg rounded-xl bg-gray-700 border-gray-600 text-white placeholder-gray-400"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-200">{t("Parol")}</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder={t("Parolingizni kiriting")}
{...field}
className="h-[56px] text-lg rounded-xl bg-gray-700 border-gray-600 text-white placeholder-gray-400 pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full h-[56px] rounded-xl text-lg font-medium"
>
{isPending ? (
<LoaderCircle className="animate-spin" />
) : (
t("Kirish")
)}
</Button>
</form>
</Form>
<p className="mt-6 text-center text-sm text-gray-400">
&copy; {new Date().getFullYear()} {t("Admin Panel")}
</p>
</div>
</div>
);
};
export default Login;

View File

@@ -1,5 +1,6 @@
import formatPrice from "@/shared/lib/formatPrice";
import { Eye } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
type Booking = {
@@ -20,8 +21,8 @@ const initialBookings: Booking[] = [
tourName: "Ichan Qala - Xiva",
agentName: "Xiva Tours",
destination: "Xiva",
totalAmount: 1200,
paidAmount: 1200,
totalAmount: 1200000,
paidAmount: 1200000,
status: "Paid",
},
{
@@ -30,8 +31,8 @@ const initialBookings: Booking[] = [
tourName: "Samarqandning Qadimiy Go'zalligi",
agentName: "Samarqand Travel",
destination: "Samarqand",
totalAmount: 1500,
paidAmount: 800,
totalAmount: 1500000,
paidAmount: 800000,
status: "Partial",
},
{
@@ -40,8 +41,8 @@ const initialBookings: Booking[] = [
tourName: "Tog'li Chimyon Sayohati",
agentName: "Toshkent Explorer",
destination: "Toshkent V.",
totalAmount: 1000,
paidAmount: 0,
totalAmount: 1000000,
paidAmount: 400000,
status: "Pending",
},
];
@@ -60,19 +61,14 @@ const getStatusColor = (status: string) => {
};
const BookingsPanel = () => {
const [bookings, setBookings] = useState<Booking[]>(initialBookings);
const handleStatusChange = (id: number, newStatus: Booking["status"]) => {
setBookings((prev) =>
prev.map((b) => (b.id === id ? { ...b, status: newStatus } : b)),
);
};
const { t } = useTranslation();
const bookings = initialBookings;
return (
<div className="min-h-screen bg-gray-900 p-4 sm:p-8 font-[Inter] text-gray-100 w-full">
<div className="max-w-[90%] mx-auto">
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-400 mb-6">
Bronlar Paneli
{t("Bronlar Paneli")}
</h1>
<div className="bg-gray-800 shadow-2xl rounded-xl overflow-hidden">
@@ -81,22 +77,22 @@ const BookingsPanel = () => {
<thead className="bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
User
{t("Foydalanuvchi")}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
Tour (Agent)
{t("Tour (Agent)")}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
Destination
{t("Destination")}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
Total / Paid
{t("Total / Paid")}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
Status
{t("Status")}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
Ko'rish
{t("Ko'rish")}
</th>
</tr>
</thead>
@@ -119,30 +115,22 @@ const BookingsPanel = () => {
{booking.destination}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
${booking.paidAmount} / ${booking.totalAmount}
{formatPrice(booking.paidAmount, true)} /{" "}
{formatPrice(booking.totalAmount, true)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<select
<p
className={`px-3 py-1 text-xs font-semibold rounded-full border ${getStatusColor(
booking.status,
)}`}
value={booking.status}
onChange={(e) =>
handleStatusChange(
booking.id,
e.target.value as Booking["status"],
)
}
>
<option value="Paid">Paid</option>
<option value="Partial">Partial</option>
<option value="Pending">Pending</option>
</select>
{booking.status}
</p>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<Link to={`/bookings/${booking.id}`}>
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
<Eye className="w-4 h-4" /> Details
<Eye className="w-4 h-4" /> {t("Details")}
</button>
</Link>
</td>

View File

@@ -0,0 +1,74 @@
import type {
GetAllEmployeesData,
GetDetailEmployeesData,
} from "@/pages/employees/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import { GET_ALL_EMPLOYEES } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const getAllEmployees = async ({
page,
page_size,
}: {
page: number;
page_size: number;
}): Promise<AxiosResponse<GetAllEmployeesData>> => {
const response = await httpClient.get(GET_ALL_EMPLOYEES, {
params: { page, page_size },
});
return response;
};
const getDetailEmployees = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<GetDetailEmployeesData>> => {
const response = await httpClient.get(`${GET_ALL_EMPLOYEES}${id}/`);
return response;
};
const deleteEmployees = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${GET_ALL_EMPLOYEES}${id}/`);
return response;
};
const editEmployees = async ({
id,
body,
}: {
id: number;
body: {
first_name: string;
last_name: string;
phone: string;
email: string | null;
role: "buxgalter" | "operator";
};
}) => {
const response = await httpClient.patch(`${GET_ALL_EMPLOYEES}${id}/`, body);
return response;
};
const createEmployees = async ({
body,
}: {
body: {
first_name: string;
last_name: string;
phone: string;
email: string | null;
role: "buxgalter" | "operator";
};
}) => {
const response = await httpClient.post(`${GET_ALL_EMPLOYEES}`, body);
return response;
};
export {
createEmployees,
deleteEmployees,
editEmployees,
getAllEmployees,
getDetailEmployees,
};

View File

@@ -0,0 +1,35 @@
export interface GetAllEmployeesData {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: [
{
id: number;
first_name: string;
last_name: string;
phone: string;
email: string;
role: "buxgalter" | "operator";
},
];
};
}
export interface GetDetailEmployeesData {
status: boolean;
data: {
id: number;
first_name: string;
last_name: string;
phone: string;
email: string | null;
role: "buxgalter" | "operator";
};
}

View File

@@ -0,0 +1,349 @@
import {
createEmployees,
editEmployees,
getDetailEmployees,
} from "@/pages/employees/lib/api";
import formatPhone from "@/shared/lib/formatPhone";
import onlyNumber from "@/shared/lib/onlyNumber";
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader, X } from "lucide-react";
import { useEffect, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import z from "zod";
const roles = ["buxgalter", "operator"] as const;
// ✅ Conditional schema - create uchun password majburiy, edit uchun yo'q
const createEmployeeSchema = z.object({
firstname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
lastname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
phone: z.string().min(9, "Telefon raqam noto'g'ri"),
role: z.string().min(1, { message: "Majburiy maydon" }),
password: z
.string()
.min(6, "Parol kamida 6 ta belgidan iborat bo'lishi kerak"),
});
const editEmployeeSchema = z.object({
firstname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
lastname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
phone: z.string().min(9, "Telefon raqam noto'g'ri"),
role: z.string().min(1, { message: "Majburiy maydon" }),
});
type CreateEmployeeFormValues = z.infer<typeof createEmployeeSchema>;
type EditEmployeeFormValues = z.infer<typeof editEmployeeSchema>;
type EmployeeFormValues = CreateEmployeeFormValues | EditEmployeeFormValues;
const EditEmployees = ({
modalMode,
editId,
showModal,
setEditId,
setShowModal,
}: {
modalMode: "add" | "edit";
showModal: boolean;
setEditId: Dispatch<SetStateAction<number | null>>;
editId: number | null;
setShowModal: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// ✅ Dinamik schema - modalMode'ga qarab
const form = useForm<EmployeeFormValues>({
resolver: zodResolver(
modalMode === "add" ? createEmployeeSchema : editEmployeeSchema,
),
defaultValues: {
firstname: "",
lastname: "",
phone: "+998",
role: "",
...(modalMode === "add" && { password: "" }),
},
});
const { data } = useQuery({
queryKey: ["detail_employees", editId],
queryFn: () => getDetailEmployees({ id: editId! }),
select(data) {
return data.data.data;
},
enabled: !!editId,
});
useEffect(() => {
if (data && editId) {
form.reset({
firstname: data.first_name,
lastname: data.last_name,
phone: data.phone,
role: data.role,
});
} else if (!editId && showModal) {
form.reset({
firstname: "",
lastname: "",
phone: "+998",
role: "",
...(modalMode === "add" && { password: "" }),
});
}
}, [data, editId, showModal, modalMode]);
const { mutate: create, isPending } = useMutation({
mutationFn: ({
body,
}: {
body: {
first_name: string;
last_name: string;
phone: string;
email: string | null;
role: "buxgalter" | "operator";
password: string;
};
}) => createEmployees({ body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_employees"] });
queryClient.refetchQueries({ queryKey: ["employees"] });
setShowModal(false);
setEditId(null);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: edit, isPending: editPengding } = useMutation({
mutationFn: ({
body,
id,
}: {
id: number;
body: {
first_name: string;
last_name: string;
phone: string;
email: string | null;
role: "buxgalter" | "operator";
};
}) => editEmployees({ id, body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_employees"] });
queryClient.refetchQueries({ queryKey: ["employees"] });
setShowModal(false);
setEditId(null);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
function onSubmit(values: EmployeeFormValues) {
if (modalMode === "add" && editId === null) {
create({
body: {
email: null,
first_name: values.firstname,
last_name: values.lastname,
phone: onlyNumber(values.phone),
role: values.role as "buxgalter" | "operator",
password: (values as CreateEmployeeFormValues).password,
},
});
} else if (modalMode === "edit" && editId !== null) {
edit({
id: editId,
body: {
email: null,
first_name: values.firstname,
last_name: values.lastname,
phone: onlyNumber(values.phone),
role: values.role as "buxgalter" | "operator",
},
});
}
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full border border-gray-700 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">
{modalMode === "add"
? t("Xodim qo'shish")
: t("Xodimni tahrirlash")}
</h2>
<button
onClick={() => {
setShowModal(false);
setEditId(null);
form.reset();
}}
className="text-gray-400 hover:text-white transition-colors"
>
<X size={24} />
</button>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="firstname"
render={({ field }) => (
<FormItem>
<Label>{t("Ismi")}</Label>
<FormControl>
<Input placeholder="Ismi" {...field} className="h-12" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastname"
render={({ field }) => (
<FormItem>
<Label>{t("Familiyasi")}</Label>
<FormControl>
<Input
placeholder="Familiyasi"
{...field}
className="h-12"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<Label>{t("Telefon raqami")}</Label>
<FormControl>
<Input
placeholder="+998 90 123 45 67"
{...field}
value={formatPhone(field.value)}
className="h-12"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ✅ Password field - faqat "add" modeda ko'rinadi */}
{modalMode === "add" && (
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<Label>{t("Parol")}</Label>
<FormControl>
<Input
type="password"
placeholder="••••••"
{...field}
className="h-12"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<Label>{t("Role")}</Label>
<Select
key={field.value}
value={field.value}
onValueChange={(val) => field.onChange(val)}
>
<SelectTrigger className="w-full !h-12 cursor-pointer">
<SelectValue placeholder={t("Role tanlang")} />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r} value={r}>
{t(r)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-3 pt-4">
<button
type="submit"
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
>
{isPending || editPengding ? (
<Loader className="animate-spin" />
) : modalMode === "add" ? (
t("Qo'shish")
) : (
t("Saqlash")
)}
</button>
<button
type="button"
onClick={() => {
setEditId(null);
setShowModal(false);
form.reset();
}}
className="flex-1 bg-gray-700 cursor-pointer hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-semibold transition-all"
>
{t("Bekor qilish")}
</button>
</div>
</form>
</Form>
</div>
</div>
);
};
export default EditEmployees;

View File

@@ -1,134 +1,78 @@
"use client";
import { deleteEmployees, getAllEmployees } from "@/pages/employees/lib/api";
import EditEmployees from "@/pages/employees/ui/EditEmployees";
import formatPhone from "@/shared/lib/formatPhone";
import { Button } from "@/shared/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { Edit, Phone, Plus, Trash2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
const roles = ["Operator", "Bugalter", "Manager"] as const;
type Employee = {
id: number;
firstname: string;
lastname: string;
phone: string;
role: "Operator" | "Bugalter" | "Manager";
};
const initialEmployees: Employee[] = [
{
id: 1,
firstname: "Alisher",
lastname: "Karimov",
phone: "+998901234567",
role: "Operator",
},
{
id: 2,
firstname: "Nigora",
lastname: "Rahimova",
phone: "+998912345678",
role: "Bugalter",
},
{
id: 3,
firstname: "Nigora",
lastname: "Rahimova",
phone: "+998912345678",
role: "Manager",
},
];
const employeeSchema = z.object({
firstname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
lastname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
phone: z.string().min(9, "Telefon raqam noto'g'ri"),
role: z.enum(roles),
});
type EmployeeFormValues = z.infer<typeof employeeSchema>;
ChevronLeft,
ChevronRight,
Edit,
Loader2,
Phone,
Plus,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
const EmployeesManagement = () => {
const form = useForm<EmployeeFormValues>({
resolver: zodResolver(employeeSchema),
defaultValues: {
firstname: "",
lastname: "",
phone: "+998",
role: "Bugalter",
const { t } = useTranslation();
const [currentPage, setCurrentPage] = useState(1);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["employees", currentPage],
queryFn: () => getAllEmployees({ page: currentPage, page_size: 10 }),
select: (data) => data.data.data,
});
const { mutate: deleteMutate, isPending: isDeleting } = useMutation({
mutationFn: ({ id }: { id: number }) => {
return deleteEmployees({ id });
},
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["employees"] });
setIsDialogOpen(false);
toast.success(t("Xodim o'chirildi"), {
position: "top-center",
richColors: true,
});
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const [employees, setEmployees] = useState<Employee[]>(initialEmployees);
const [currentPage, setCurrentPage] = useState(1);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState("add");
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(
null,
);
useEffect(() => {
if (selectedEmployee) {
form.setValue("firstname", selectedEmployee.firstname);
form.setValue("lastname", selectedEmployee.lastname);
form.setValue("phone", selectedEmployee.phone);
form.setValue("role", selectedEmployee.role);
}
}, [selectedEmployee, form]);
const itemsPerPage = 6;
const totalPages = Math.ceil(employees.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const currentEmployees = employees.slice(
startIndex,
startIndex + itemsPerPage,
);
const [editId, setEditId] = useState<number | null>(null);
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
const handleAdd = () => {
setModalMode("add");
setShowModal(true);
setEditId(null);
};
const handleEdit = (employee: Employee) => {
setSelectedEmployee(employee);
const handleEdit = (id: number) => {
setShowModal(true);
setModalMode("edit");
};
const handleDelete = (id: number) => {
if (window.confirm("Ushbu xodimni o'chirishni xohlaysizmi?")) {
setEmployees(employees.filter((emp) => emp.id !== id));
}
};
const onSubmit = (values: EmployeeFormValues) => {
if (modalMode === "add") {
const newEmp: Employee = { ...values, id: Date.now() };
setEmployees([...employees, newEmp]);
} else if (selectedEmployee) {
setEmployees(
employees.map((emp) =>
emp.id === selectedEmployee.id ? { ...emp, ...values } : emp,
),
);
}
setShowModal(false);
setEditId(id);
};
return (
@@ -137,10 +81,10 @@ const EmployeesManagement = () => {
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Xodimlar
{t("Xodimlar")}
</h1>
<p className="text-gray-400 mt-2">
Jami {employees.length} ta xodim
{t("Jami")} {data?.total_items || 0} {t("ta xodim")}
</p>
</div>
<button
@@ -148,210 +92,175 @@ const EmployeesManagement = () => {
className="flex cursor-pointer items-center gap-2 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg hover:shadow-xl transform hover:scale-105"
>
<Plus size={20} />
Xodim qo'shish
<p className="max-lg:hidden">{t("Xodim qo'shish")}</p>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{currentEmployees.map((employee) => (
<div
key={employee.id}
className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700 hover:border-blue-500/50 transition-all shadow-lg hover:shadow-2xl transform hover:-translate-y-1"
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-4">
<div>
<h3 className="text-xl font-bold text-white">
{employee.firstname} {employee.lastname}
</h3>
<p className="text-blue-400 text-sm">{employee.role}</p>
{/* ✅ Loading State */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-12 h-12 animate-spin text-blue-500 mb-4" />
<p className="text-gray-400 text-lg">{t("Yuklanmoqda")}...</p>
</div>
) : data?.results && data.results.length > 0 ? (
// ✅ Data mavjud bo'lsa
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{data.results.map((employee) => (
<div
key={employee.id}
className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700 hover:border-blue-500/50 transition-all shadow-lg hover:shadow-2xl transform hover:-translate-y-1"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-white">
{employee.first_name} {employee.last_name}
</h3>
<p className="text-blue-400 text-sm">
{t(employee.role)}
</p>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-center gap-2 text-gray-300">
<Phone size={16} className="text-gray-500" />
<span className="text-sm">
{formatPhone(employee.phone)}
</span>
</div>
</div>
<div className="flex flex-col gap-2 pt-4 border-t border-gray-700">
<button
onClick={() => handleEdit(employee.id)}
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
>
<Edit size={16} />
{t("Tahrirlash")}
</button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<button className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-all font-medium">
<Trash2 size={16} />
{t("O'chirish")}
</button>
</DialogTrigger>
<DialogContent className="bg-gray-800 text-gray-100 border border-gray-700">
<DialogHeader>
<DialogTitle>
{t("Foydalanuvchini o'chirish")}
</DialogTitle>
<DialogDescription className="text-gray-400">
{t("Siz")}{" "}
<b>
{employee.first_name} {employee.last_name}
</b>{" "}
{t("foydalanuvchini o'chirmoqchimisiz?")}
<br />
{t("Ushbu amalni qaytarib bo'lmaydi")}.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-gray-700 hover:bg-gray-600 text-white"
onClick={() => setIsDialogOpen(false)}
disabled={isDeleting}
>
{t("Bekor qilish")}
</Button>
<Button
className="bg-red-600 hover:bg-red-700 text-white"
onClick={() => deleteMutate({ id: employee.id })}
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
</>
) : (
t("O'chirish")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-center gap-2 text-gray-300">
<Phone size={16} className="text-gray-500" />
<span className="text-sm">{formatPhone(employee.phone)}</span>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-gray-700">
<button
onClick={() => handleEdit(employee)}
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
>
<Edit size={16} />
Tahrirlash
</button>
<button
onClick={() => handleDelete(employee.id)}
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
>
<Trash2 size={16} />
O'chirish
</button>
</div>
))}
</div>
))}
</div>
<div className="flex justify-end items-center gap-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800/50 disabled:cursor-not-allowed text-white rounded-lg transition-all border border-gray-700"
>
Oldingi
</button>
{[...Array(totalPages)].map((_, i) => (
{/* Pagination */}
<div className="flex justify-end gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
>
<ChevronLeft className="w-5 h-5" />
</button>
{[...Array(data?.total_pages || 0)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
currentPage === i + 1
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
}`}
>
{i + 1}
</button>
))}
<button
disabled={currentPage === data?.total_pages}
onClick={() =>
setCurrentPage((p) =>
Math.min(p + 1, data ? data?.total_pages : 0),
)
}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</>
) : (
// ✅ Ma'lumot yo'q bo'lsa
<div className="flex flex-col items-center justify-center py-20">
<div className="bg-gray-800/50 rounded-full p-8 mb-4">
<Phone className="w-16 h-16 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-gray-300 mb-2">
{t("Xodimlar topilmadi")}
</h3>
<p className="text-gray-500 mb-6">
{t("Birinchi xodimni qo'shing")}
</p>
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg transition-all border ${
currentPage === i + 1
? "bg-gradient-to-r from-blue-500 to-purple-600 text-white border-transparent"
: "bg-gray-800 hover:bg-gray-700 text-white border-gray-700"
}`}
onClick={handleAdd}
className="flex items-center gap-2 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
>
{i + 1}
<Plus size={20} />
{t("Xodim qo'shish")}
</button>
))}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800/50 disabled:cursor-not-allowed text-white rounded-lg transition-all border border-gray-700"
>
Keyingi
</button>
</div>
</div>
)}
</div>
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full border border-gray-700 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">
{modalMode === "add" ? "Xodim qo'shish" : "Xodimni tahrirlash"}
</h2>
<button
onClick={() => setShowModal(false)}
className="text-gray-400 hover:text-white transition-colors"
>
<X size={24} />
</button>
</div>
<div className="space-y-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="firstname"
render={({ field }) => (
<FormItem>
<Label>First Name</Label>
<FormControl>
<Input
placeholder="First Name"
{...field}
className="h-12"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastname"
render={({ field }) => (
<FormItem>
<Label>Last Name</Label>
<FormControl>
<Input
placeholder="Last Name"
{...field}
className="h-12"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<Label>Phone</Label>
<FormControl>
<Input
placeholder="+998 90 123 45 67"
{...field}
value={formatPhone(field.value)}
className="h-12"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<Label>Role</Label>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val)}
>
<SelectTrigger className="w-full !h-12 cursor-pointer">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent className="cursor-pointer">
{roles.map((r) => (
<SelectItem
key={r}
value={r}
className="cursor-pointer"
>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-3 pt-4">
<button
type="submit"
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
>
{modalMode === "add" ? "Qo'shish" : "Saqlash"}
</button>
<button
onClick={() => {
setShowModal(false);
form.reset();
}}
className="flex-1 bg-gray-700 cursor-pointer hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-semibold transition-all"
>
Bekor qilish
</button>
</div>
</form>
</Form>
</div>
</div>
</div>
<EditEmployees
key={editId || "new"}
modalMode={modalMode}
showModal={showModal}
setShowModal={setShowModal}
editId={editId}
setEditId={setEditId}
/>
)}
</div>
);

View File

@@ -40,6 +40,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import z from "zod";
type FaqType = {
@@ -102,6 +103,7 @@ const faqForm = z.object({
const Faq = () => {
const [faqs, setFaqs] = useState<FaqType[]>(initialFaqs);
const [activeTab, setActiveTab] = useState("umumiy");
const { t } = useTranslation();
const [openModal, setOpenModal] = useState(false);
const [editFaq, setEditFaq] = useState<FaqType | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
@@ -147,7 +149,9 @@ const Faq = () => {
<div className="p-6 space-y-6 w-full">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">FAQ (Savol va javoblar)</h1>
<h1 className="text-2xl font-semibold">
{t("FAQ (Savol va javoblar)")}
</h1>
<Button
className="gap-2"
onClick={() => {
@@ -155,7 +159,7 @@ const Faq = () => {
setOpenModal(true);
}}
>
<PlusCircle className="w-4 h-4" /> Yangi qoshish
<PlusCircle className="w-4 h-4" /> {t("Yangi qoshish")}
</Button>
</div>
@@ -176,10 +180,10 @@ const Faq = () => {
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>Savol</TableHead>
<TableHead>Javob</TableHead>
<TableHead>{t("Savol")}</TableHead>
<TableHead>{t("Javob")}</TableHead>
<TableHead className="w-[120px] text-center">
Amallar
{t("Amallar")}
</TableHead>
</TableRow>
</TableHeader>
@@ -220,7 +224,7 @@ const Faq = () => {
</div>
) : (
<p className="text-gray-500 text-sm mt-4">
Bu bolimda savollar yoq.
{t("Bu bolimda savollar yoq.")}
</p>
)}
</TabsContent>
@@ -230,7 +234,7 @@ const Faq = () => {
<DialogContent className="sm:max-w-[500px] bg-gray-900">
<DialogHeader>
<DialogTitle>
{editFaq ? "FAQni tahrirlash" : "Yangi FAQ qoshish"}
{editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qoshish")}
</DialogTitle>
</DialogHeader>
@@ -241,18 +245,18 @@ const Faq = () => {
name="categories"
render={({ field }) => (
<FormItem>
<Label className="text-md">Kategoriya</Label>
<Label className="text-md">{t("Kategoriya")}</Label>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full !h-12 border-gray-700 text-white">
<SelectValue placeholder="Kategoriya tanlang" />
<SelectValue placeholder={t("Kategoriya tanlang")} />
</SelectTrigger>
<SelectContent className="border-gray-700 text-white">
<SelectGroup>
<SelectLabel>Kategoriyalar</SelectLabel>
<SelectLabel>{t("Kategoriyalar")}</SelectLabel>
{categories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
@@ -271,10 +275,10 @@ const Faq = () => {
name="title"
render={({ field }) => (
<FormItem>
<Label className="text-md">Savol</Label>
<Label className="text-md">{t("Savol")}</Label>
<FormControl>
<Input
placeholder="Savol"
placeholder={t("Savol")}
{...field}
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
/>
@@ -288,10 +292,10 @@ const Faq = () => {
name="answer"
render={({ field }) => (
<FormItem>
<Label className="text-md">Javob</Label>
<Label className="text-md">{t("Javob")}</Label>
<FormControl>
<Textarea
placeholder="Javob"
placeholder={t("Javob")}
{...field}
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
/>
@@ -309,14 +313,13 @@ const Faq = () => {
}}
className="bg-gray-600 px-5 py-5 hover:bg-gray-700 text-white mt-4 cursor-pointer"
>
Bekor qilish
{t("Bekor qilish")}
</Button>
<Button
type="submit"
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
>
{/* {isEditMode ? "Yangilikni saqlash" : "Keyingisi"} */}
Qo'shish
{t("Qo'shish")}
</Button>
</div>
</form>
@@ -327,14 +330,14 @@ const Faq = () => {
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Haqiqatan ham ochirmoqchimisiz?</DialogTitle>
<DialogTitle>{t("Haqiqatan ham ochirmoqchimisiz?")}</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>
Bekor qilish
{t("Bekor qilish")}
</Button>
<Button variant="destructive" onClick={handleDelete}>
Ochirish
{t("O'chirish")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -29,6 +29,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import z from "zod";
type FaqCategoryType = {
@@ -50,6 +51,7 @@ const categoryFormSchema = z.object({
});
const FaqCategory = () => {
const { t } = useTranslation();
const [categories, setCategories] =
useState<FaqCategoryType[]>(initialCategories);
const [openModal, setOpenModal] = useState(false);
@@ -105,7 +107,7 @@ const FaqCategory = () => {
return (
<div className="p-6 space-y-6 w-full">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">FAQ Kategoriyalar</h1>
<h1 className="text-2xl font-semibold">{t("FAQ Kategoriyalar")}</h1>
<Button
className="gap-2"
onClick={() => {
@@ -113,7 +115,7 @@ const FaqCategory = () => {
setOpenModal(true);
}}
>
<PlusCircle className="w-4 h-4" /> Yangi kategoriya
<PlusCircle className="w-4 h-4" /> {t("Yangi kategoriya")}
</Button>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import formatPrice from "@/shared/lib/formatPrice";
import {
CreditCard,
DollarSign,
@@ -11,6 +12,7 @@ import {
Users,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
type Purchase = {
@@ -102,6 +104,7 @@ const mockPurchases: Purchase[] = [
];
export default function FinancePage() {
const { t } = useTranslation();
const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
const [filterStatus, setFilterStatus] = useState<
"all" | "paid" | "pending" | "cancelled" | "refunded"
@@ -117,7 +120,7 @@ export default function FinancePage() {
className={`${base} bg-green-900 text-green-400 border border-green-700`}
>
<div className="w-2 h-2 rounded-full bg-green-400"></div>
Paid
{t("Paid")}
</span>
);
case "pending":
@@ -126,7 +129,7 @@ export default function FinancePage() {
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
>
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
Pending
{t("Pending")}
</span>
);
case "cancelled":
@@ -135,7 +138,7 @@ export default function FinancePage() {
className={`${base} bg-red-900 text-red-400 border border-red-700`}
>
<div className="w-2 h-2 rounded-full bg-red-400"></div>
Cancelled
{t("Cancelled")}
</span>
);
case "refunded":
@@ -187,14 +190,16 @@ export default function FinancePage() {
<div className="w-[90%] mx-auto py-6">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">Travel Finance Dashboard</h1>
<h1 className="text-3xl font-bold">
{t("Sayohat moliyasi boshqaruv paneli")}
</h1>
<p className="text-gray-400 mt-2">
Manage bookings, payments, and agency finances
{t("Bronlar, to'lovlar va agentlik moliyalari boshqaruvi")}
</p>
</div>
<div className="bg-gray-800 rounded-lg p-3 flex items-center">
<Plane className="text-blue-400 mr-2" size={20} />
<span className="font-medium text-gray-100">Travel Pro</span>
<span className="font-medium text-gray-100">Simple Travel</span>
</div>
</div>
@@ -209,7 +214,7 @@ export default function FinancePage() {
onClick={() => setTab("bookings")}
>
<CreditCard size={18} />
Bookings & Payments
{t("Bandlovlar va tolovlar")}
</button>
<button
className={`px-6 py-3 rounded-lg flex items-center gap-2 transition-all ${
@@ -220,7 +225,7 @@ export default function FinancePage() {
onClick={() => setTab("agencies")}
>
<Users size={18} />
Agency Reports
{t("Agentlik hisobotlari")}
</button>
</div>
@@ -248,14 +253,14 @@ export default function FinancePage() {
}
>
{s === "all"
? "All Bookings"
? t("Barcha bandlovlar")
: s === "paid"
? "Paid"
? t("To'langan")
: s === "pending"
? "Pending"
? t("Kutilmoqda")
: s === "cancelled"
? "Cancelled"
: "Refunded"}
? t("Bekor qilindi")
: t("Qaytarilgan")}
</button>
))}
</div>
@@ -264,32 +269,36 @@ export default function FinancePage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">Total Revenue</p>
<p className="text-gray-400 font-medium">
{t("Jami daromad")}
</p>
<DollarSign className="text-green-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-green-400 mt-3">
${(totalRevenue / 1000000).toFixed(1)}M
{formatPrice(totalRevenue, true)}
</p>
<p className="text-sm text-gray-500 mt-1">
From completed bookings
</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">Pending Payments</p>
<TrendingUp className="text-yellow-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-yellow-400 mt-3">
${(pendingRevenue / 1000000).toFixed(1)}M
</p>
<p className="text-sm text-gray-500 mt-1">
Awaiting confirmation
{t("Yakunlangan bandlovlardan")}
</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">
Confirmed Bookings
{t("Kutilayotgan tolovlar")}
</p>
<TrendingUp className="text-yellow-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-yellow-400 mt-3">
{formatPrice(pendingRevenue, true)}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("Tasdiqlash kutilmoqda")}
</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">
{t("Tasdiqlangan bandlovlar")}
</p>
<CreditCard className="text-blue-400 w-6 h-6" />
</div>
@@ -299,11 +308,15 @@ export default function FinancePage() {
.length
}
</p>
<p className="text-sm text-gray-500 mt-1">Paid and confirmed</p>
<p className="text-sm text-gray-500 mt-1">
{t("Tasdiqlangan bandlovlar")}
</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">Pending Bookings</p>
<p className="text-gray-400 font-medium">
{t("Kutilayotgan bandlovlar")}
</p>
<Hotel className="text-purple-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-purple-400 mt-3">
@@ -313,12 +326,14 @@ export default function FinancePage() {
).length
}
</p>
<p className="text-sm text-gray-500 mt-1">Awaiting payment</p>
<p className="text-sm text-gray-500 mt-1">
{t("Kutilayotgan tolovlar")}
</p>
</div>
</div>
{/* Booking Cards */}
<h2 className="text-xl font-bold mb-4">Recent Bookings</h2>
<h2 className="text-xl font-bold mb-4">{t("Oxirgi bandlovlar")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPurchases.map((p) => (
<div
@@ -341,15 +356,17 @@ export default function FinancePage() {
</div>
<div className="flex justify-between mt-3">
<div>
<p className="text-gray-500 text-sm">Travel Date</p>
<p className="text-gray-500 text-sm">
{t("Sayohat sanasi")}
</p>
<p className="text-gray-100 font-medium">
{p.travelDate}
</p>
</div>
<div className="text-right">
<p className="text-gray-500 text-sm">Amount</p>
<p className="text-gray-500 text-sm">{t("Miqdor")}</p>
<p className="text-green-400 font-bold">
${(p.amount / 1000000).toFixed(1)}M
{formatPrice(p.amount, true)}
</p>
</div>
</div>
@@ -358,7 +375,7 @@ export default function FinancePage() {
{getStatusBadge(p.paymentStatus)}
<Link to={`/bookings/${p.id}`}>
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
<Eye className="w-4 h-4" /> Details
<Eye className="w-4 h-4" /> {t("Ko'rish")}
</button>
</Link>
</div>

View File

@@ -4,12 +4,10 @@ import {
ArrowLeft,
Calendar,
DollarSign,
Download,
Eye,
Hotel,
MapPin,
Plane,
Share2,
Star,
TrendingUp,
Users,
@@ -200,16 +198,6 @@ export default function FinanceDetailTour() {
</p>
</div>
</div>
<div className="flex gap-3">
<button className="bg-gray-800 px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-2">
<Download className="w-4 h-4" />
Export Report
</button>
<button className="bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2">
<Share2 className="w-4 h-4" />
Share
</button>
</div>
</div>
{/* Tour Summary Cards */}

View File

@@ -1,19 +1,20 @@
"use client";
import formatPhone from "@/shared/lib/formatPhone";
import formatPrice from "@/shared/lib/formatPrice";
import {
ArrowLeft,
Calendar,
CreditCard,
DollarSign,
Download,
Mail,
MapPin,
Phone,
Share2,
TrendingUp,
User,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
type UserPurchase = {
@@ -108,6 +109,7 @@ const mockUserPurchases: UserPurchase[] = [
];
export default function FinanceDetailUser() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"bookings" | "details">(
"bookings",
);
@@ -122,7 +124,7 @@ export default function FinanceDetailUser() {
className={`${base} bg-green-900 text-green-400 border border-green-700`}
>
<div className="w-2 h-2 rounded-full bg-green-400"></div>
Paid
{t("Paid")}
</span>
);
case "pending":
@@ -131,7 +133,7 @@ export default function FinanceDetailUser() {
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
>
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
Pending
{t("Pending")}
</span>
);
case "cancelled":
@@ -140,7 +142,7 @@ export default function FinanceDetailUser() {
className={`${base} bg-red-900 text-red-400 border border-red-700`}
>
<div className="w-2 h-2 rounded-full bg-red-400"></div>
Cancelled
{t("Cancelled")}
</span>
);
case "refunded":
@@ -189,69 +191,69 @@ export default function FinanceDetailUser() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-3xl font-bold">User Financial Details</h1>
<h1 className="text-3xl font-bold">
{t("Foydalanuvchi moliyaviy tafsilotlari")}
</h1>
<p className="text-gray-400 mt-1">
Detailed financial overview for {mockUserData.userName}
{mockUserData.userName} {t("uchun batafsil moliyaviy sharh")}
</p>
</div>
</div>
<div className="flex gap-3">
<button className="bg-gray-800 px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-2">
<Download className="w-4 h-4" />
Export
</button>
<button className="bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2">
<Share2 className="w-4 h-4" />
Share Report
</button>
</div>
</div>
{/* User Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">Total Spent</p>
<p className="text-gray-400 font-medium">{t("Total Spent")}</p>
<DollarSign className="text-green-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-green-400 mt-3">
${(totalSpent / 1000000).toFixed(1)}M
{formatPrice(totalSpent, true)}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("All completed bookings")}
</p>
<p className="text-sm text-gray-500 mt-1">All completed bookings</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">Pending Payments</p>
<p className="text-gray-400 font-medium">
{t("Pending Payments")}
</p>
<TrendingUp className="text-yellow-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-yellow-400 mt-3">
${(pendingAmount / 1000000).toFixed(1)}M
{formatPrice(pendingAmount, true)}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("Awaiting confirmation")}
</p>
<p className="text-sm text-gray-500 mt-1">Awaiting confirmation</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">Total Bookings</p>
<p className="text-gray-400 font-medium">{t("Total Bookings")}</p>
<CreditCard className="text-blue-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-blue-400 mt-3">
{mockUserData.totalBookings}
</p>
<p className="text-sm text-gray-500 mt-1">All time bookings</p>
<p className="text-sm text-gray-500 mt-1">
{t("All time bookings")}
</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow">
{/* <div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">Member Level</p>
<p className="text-gray-400 font-medium">{t("Member Level")}</p>
<User className="text-purple-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-purple-400 mt-3">
{mockUserData.memberLevel}
</p>
<p className="text-sm text-gray-500 mt-1">Loyalty status</p>
</div>
<p className="text-sm text-gray-500 mt-1">{t("Loyalty status")}</p>
</div> */}
</div>
{/* Main Content */}
@@ -267,7 +269,7 @@ export default function FinanceDetailUser() {
onClick={() => setActiveTab("bookings")}
>
<CreditCard className="w-4 h-4" />
Booking History
{t("Booking History")}
</button>
<button
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
@@ -278,14 +280,16 @@ export default function FinanceDetailUser() {
onClick={() => setActiveTab("details")}
>
<User className="w-4 h-4" />
User Details
{t("User Details")}
</button>
</div>
<div className="p-6">
{activeTab === "bookings" && (
<div className="space-y-6">
<h2 className="text-xl font-bold mb-4">Booking History</h2>
<h2 className="text-xl font-bold mb-4">
{t("Booking History")}
</h2>
{mockUserPurchases.map((purchase) => (
<div
key={purchase.id}
@@ -297,7 +301,7 @@ export default function FinanceDetailUser() {
{purchase.tourName}
</h3>
<p className="text-gray-400 text-sm">
Booking Ref: {purchase.bookingReference}
{t("Booking Ref")}: {purchase.bookingReference}
</p>
</div>
{getStatusBadge(purchase.paymentStatus)}
@@ -307,7 +311,9 @@ export default function FinanceDetailUser() {
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">Destination</p>
<p className="text-sm text-gray-400">
{t("Destination")}
</p>
<p className="text-gray-100">
{purchase.destination}
</p>
@@ -317,7 +323,9 @@ export default function FinanceDetailUser() {
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">Travel Dates</p>
<p className="text-sm text-gray-400">
{t("Travel Dates")}
</p>
<p className="text-gray-100">
{purchase.travelDate} - {purchase.returnDate}
</p>
@@ -327,19 +335,19 @@ export default function FinanceDetailUser() {
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">Travelers</p>
<p className="text-gray-100">
{purchase.travelers} person(s)
<p className="text-sm text-gray-400">
{t("Travelers")}
</p>
<p className="text-gray-100">{purchase.travelers}</p>
</div>
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">Amount</p>
<p className="text-sm text-gray-400">{t("Amount")}</p>
<p className="text-green-400 font-bold">
${(purchase.amount / 1000000).toFixed(1)}M
{formatPrice(purchase.amount, true)}
</p>
</div>
</div>
@@ -347,7 +355,7 @@ export default function FinanceDetailUser() {
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
<div className="text-sm text-gray-400">
Booked on {purchase.purchaseDate} {" "}
{t("Booked on")} {purchase.purchaseDate}{" "}
{getPaymentMethod(purchase.paymentMethod)}
</div>
</div>
@@ -361,13 +369,15 @@ export default function FinanceDetailUser() {
{/* Personal Information */}
<div>
<h3 className="text-lg font-bold mb-4">
Personal Information
{t("Personal Information")}
</h3>
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<User className="w-5 h-5 text-blue-400" />
<div>
<p className="text-sm text-gray-400">Full Name</p>
<p className="text-sm text-gray-400">
{t("Full Name")}
</p>
<p className="text-gray-100">{mockUserData.userName}</p>
</div>
</div>
@@ -375,9 +385,11 @@ export default function FinanceDetailUser() {
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<Phone className="w-5 h-5 text-green-400" />
<div>
<p className="text-sm text-gray-400">Phone Number</p>
<p className="text-sm text-gray-400">
{t("Phone Number")}
</p>
<p className="text-gray-100">
{mockUserData.userPhone}
{formatPhone(mockUserData.userPhone)}
</p>
</div>
</div>
@@ -385,7 +397,9 @@ export default function FinanceDetailUser() {
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<Mail className="w-5 h-5 text-yellow-400" />
<div>
<p className="text-sm text-gray-400">Email Address</p>
<p className="text-sm text-gray-400">
{t("Email Address")}
</p>
<p className="text-gray-100">
{mockUserData.userEmail}
</p>
@@ -395,7 +409,9 @@ export default function FinanceDetailUser() {
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<Calendar className="w-5 h-5 text-purple-400" />
<div>
<p className="text-sm text-gray-400">Member Since</p>
<p className="text-sm text-gray-400">
{t("Member Since")}
</p>
<p className="text-gray-100">{mockUserData.joinDate}</p>
</div>
</div>
@@ -404,40 +420,38 @@ export default function FinanceDetailUser() {
{/* Travel Preferences */}
<div>
<h3 className="text-lg font-bold mb-4">Travel Statistics</h3>
<h3 className="text-lg font-bold mb-4">
{t("Travel Statistics")}
</h3>
<div className="space-y-4">
<div className="p-4 bg-gray-700 rounded-lg">
<p className="text-sm text-gray-400 mb-2">
Favorite Destination
{t("Favorite Destination")}
</p>
<p className="text-gray-100 font-medium">Dubai, UAE</p>
<p className="text-sm text-gray-400 mt-1">2 bookings</p>
<p className="text-sm text-gray-400 mt-1">
2 {t("bookings")}
</p>
</div>
<div className="p-4 bg-gray-700 rounded-lg">
<p className="text-sm text-gray-400 mb-2">
Preferred Agency
{t("Preferred Agency")}
</p>
<p className="text-gray-100 font-medium">
Silk Road Travel
</p>
<p className="text-sm text-gray-400 mt-1">
2 out of 3 bookings
2 {t("out of")} 3 {t("bookings")}
</p>
</div>
<div className="p-4 bg-gray-700 rounded-lg">
<p className="text-sm text-gray-400 mb-2">
Average Booking Value
{t("Average Booking Value")}
</p>
<p className="text-green-400 font-bold">
$
{(
totalSpent /
mockUserData.totalBookings /
1000000
).toFixed(1)}
M
{formatPrice(totalSpent, true)}
</p>
</div>
</div>

View File

@@ -1,32 +1,75 @@
import type { NewsType } from "@/pages/news/lib/type";
import type {
GetAllNewsCategory,
GetDetailNewsCategory,
NewsAll,
} from "@/pages/news/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import { NEWS, NEWS_CATEGORY } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const STORAGE_KEY = "news_data";
export const getAllNews = (): NewsType[] => {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
const getAllNews = async ({
page,
page_size,
}: {
page: number;
page_size: number;
}): Promise<AxiosResponse<NewsAll>> => {
const response = await httpClient.get(NEWS, { params: { page, page_size } });
return response;
};
export const addNews = (news: Omit<NewsType, "id" | "createdAt">) => {
const all = getAllNews();
const newNews: NewsType = {
id: "1",
createdAt: new Date().toISOString(),
...news,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify([newNews, ...all]));
const addNews = async (body: FormData) => {
const response = await httpClient.post(NEWS, body, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response;
};
export const updateNews = (id: string, updated: Partial<NewsType>) => {
const all = getAllNews().map((n) => (n.id === id ? { ...n, ...updated } : n));
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
// category news
const getAllNewsCategory = async (params: {
page: number;
page_size: number;
}): Promise<AxiosResponse<GetAllNewsCategory>> => {
const res = await httpClient.get(NEWS_CATEGORY, { params });
return res;
};
export const deleteNews = (id: string) => {
const filtered = getAllNews().filter((n) => n.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
const getDetailNewsCategory = async (
id: number,
): Promise<AxiosResponse<GetDetailNewsCategory>> => {
const res = await httpClient.get(`${NEWS_CATEGORY}${id}/`);
return res;
};
export const getNewsById = (id: string) => {
return getAllNews().find((n) => n.id === id);
const addNewsCategory = async (body: { name: string; name_ru: string }) => {
const res = await httpClient.post(NEWS_CATEGORY, body);
return res;
};
const updateNewsCategory = async ({
body,
id,
}: {
body: { name: string; name_ru: string };
id: number;
}) => {
const res = await httpClient.patch(`${NEWS_CATEGORY}${id}/`, body);
return res;
};
const deleteNewsCategory = async (id: number) => {
const res = await httpClient.delete(`${NEWS_CATEGORY}${id}/`);
return res;
};
export {
addNews,
addNewsCategory,
deleteNewsCategory,
getAllNews,
getAllNewsCategory,
getDetailNewsCategory,
updateNewsCategory,
};

View File

@@ -1,54 +1,40 @@
import type { NewsAll } from "./type";
// src/store/useNewsStore.ts
import { create } from "zustand";
export const fakeNewsData: NewsAll[] = [
{
id: 1,
short_title: "Yangi sayohat yonalishlari ochildi",
slug: "yangi-sayohat-yonalishlari",
image: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e",
category: { id: 1, name: "Turlar" },
short_text:
"Bu yozda yangi xalqaro yonalishlar ochilmoqda — Turkiya, Dubay, Malayziya va yana koplab manzillar.",
created: "2025-10-15T08:45:00Z",
interface NewsData {
title: string;
desc: string;
title_ru: string;
desc_ru: string;
category: string;
banner: File | undefined;
}
interface NewsStore {
stepOneData: NewsData;
setStepOneData: (data: NewsData) => void;
resetStepOneData: () => void;
}
export const useNewsStore = create<NewsStore>((set) => ({
stepOneData: {
title: "",
desc: "",
category: "",
banner: undefined,
desc_ru: "",
title_ru: "",
},
{
id: 2,
short_title: "Tur firmalar uchun yangi litsenziya tizimi",
slug: "litsenziya-tizimi",
image: "https://images.unsplash.com/photo-1488646953014-85cb44e25828",
category: { id: 2, name: "Yangiliklar" },
short_text:
"Turizm agentliklari uchun raqamli litsenziya olish tizimi ishga tushirildi. Endi barcha jarayon onlayn boladi.",
created: "2025-09-22T12:30:00Z",
},
{
id: 3,
short_title: "Sayohat bozorida narxlar pasaymoqda",
slug: "narxlar-pasaymoqda",
image: "https://images.unsplash.com/photo-1473625247510-8ceb1760943f",
category: { id: 3, name: "Blog" },
short_text:
"Songgi haftalarda xalqaro aviabiletlar narxi 15% gacha arzonlashgani kuzatildi. Mutaxassislar bunga tahlil beradi.",
created: "2025-10-10T09:15:00Z",
},
{
id: 4,
short_title: "Yangi mehmonxonalar tarmogi ish boshladi",
slug: "yangi-mehmonxonalar",
image: "https://images.unsplash.com/photo-1566073771259-6a8506099945",
category: { id: 4, name: "Yangiliklar" },
short_text:
"Ozbekistonda 5 ta yangi premium mehmonxona ochildi. Bu turizm industriyasi uchun muhim qadam.",
created: "2025-10-05T15:10:00Z",
},
{
id: 5,
short_title: "Sayyohlar uchun foydali maslahatlar",
slug: "sayyohlar-maslahatlar",
image: "https://images.unsplash.com/photo-1526778548025-fa2f459cd5c1",
category: { id: 5, name: "Blog" },
short_text:
"Chet elga chiqayotganlar uchun xavfsizlik, valyuta va mobil aloqa haqida 10 ta foydali maslahat.",
created: "2025-09-30T10:00:00Z",
},
];
setStepOneData: (data) => set({ stepOneData: data }),
resetStepOneData: () =>
set({
stepOneData: {
title: "",
desc: "",
category: "",
banner: undefined,
desc_ru: "",
title_ru: "",
},
}),
}));

View File

@@ -1,21 +1,50 @@
import z from "zod";
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
export const newsForm = z.object({
title: z.string().min(2, {
message: "Username must be at least 2 characters.",
message: "Kamida 2 ta belgidan iborat bolishi kerak.",
}),
title_ru: z.string().min(2, {
message: "Kamida 2 ta belgidan iborat bolishi kerak.",
}),
desc: z.string().min(2, {
message: "Username must be at least 2 characters.",
message: "Kamida 2 ta belgidan iborat bolishi kerak.",
}),
desc_ru: z.string().min(2, {
message: "Kamida 2 ta belgidan iborat bolishi kerak.",
}),
category: z.string().min(1, {
message: "Majburiy maydon",
}),
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
banner: fileSchema,
});
export const newsPostForm = z.object({
desc: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
desc: z
.string()
.min(2, { message: "Kamida 2 ta belgidan iborat bo'lishi kerak." }),
desc_ru: z
.string()
.min(2, { message: "Kamida 2 ta belgidan iborat bo'lishi kerak." }),
is_public: z.enum(["no", "yes"], { message: "Iltimos, tanlang" }),
sections: z
.array(
z.object({
image: fileSchema,
text: z.string().min(1, { message: "Matn bo'sh bo'lmasligi kerak." }),
text_ru: z
.string()
.min(1, { message: "Ruscha matn bo'sh bo'lmasligi kerak." }),
}),
)
.min(1, { message: "Kamida bitta bolim qoshing." }),
post_tags: z
.array(z.string().min(1, { message: "Teg bo'sh bo'lmasligi kerak." }))
.min(1, { message: "Kamida bitta teg kiriting." }),
});
export type NewsPostFormType = z.infer<typeof newsPostForm>;

View File

@@ -20,14 +20,67 @@ export interface NewsType {
}
export interface NewsAll {
id: number;
short_title: string;
slug: string;
image: string;
category: {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
id: number;
title: string;
title_ru: string;
image: string;
text: string;
text_ru: string;
is_public: true;
category: {
name: string;
name_ru: string;
};
tag: {
id: number;
name: string;
name_ru: string;
}[];
post_images: {
image: string;
text: string;
text_ru: string;
}[];
}[];
};
}
export interface GetAllNewsCategory {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: [
{
id: number;
name: string;
},
];
};
}
export interface GetDetailNewsCategory {
status: boolean;
data: {
id: number;
name: string;
name_ru: string;
};
short_text: string;
created: string;
}

View File

@@ -3,33 +3,35 @@ import StepOne from "@/pages/news/ui/StepOne";
import StepTwo from "@/pages/news/ui/StepTwo";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
const AddNews = () => {
const { id } = useParams<{ id: string }>();
const isEditMode = useMemo(() => !!id, [id]);
const [step, setStep] = useState(1);
const { t } = useTranslation();
return (
<div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg">
<h1 className="text-3xl font-bold mb-6">
{isEditMode ? "Yangilikni tahrirlash" : "Yangi yangilik qoshish"}
{isEditMode ? t("Yangilikni tahrirlash") : t("Yangi yangilik qoshish")}
</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. Yangilik sarlavhasi
1. {t("Yangilik sarlavhasi")}
</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. Yangilik ma'lumotlari
2. {t("Yangilik ma'lumotlari")}
</div>
</div>
{step === 1 && <StepOne isEditMode={isEditMode} setStep={setStep} />}
{step === 2 && <StepTwo isEditMode={isEditMode} setStep={setStep} />}
{step === 2 && <StepTwo />}
</div>
);
};

View File

@@ -1,7 +1,5 @@
"use client";
import { fakeNewsData } from "@/pages/news/lib/data";
import type { NewsAll } from "@/pages/news/lib/type";
import formatDate from "@/shared/lib/formatDate";
import { getAllNews } from "@/pages/news/lib/api";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
@@ -12,42 +10,44 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
import { Calendar, Edit, FolderOpen, PlusCircle, Trash2 } from "lucide-react";
import { Edit, FolderOpen, PlusCircle, Trash2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
const News = () => {
const [newsList, setNewsList] = useState<NewsAll[]>(fakeNewsData);
const loading = false;
const error = null;
const { t } = useTranslation();
const [deleteId, setDeleteId] = useState<number | null>(null);
const navigate = useNavigate();
const {
data: allNews,
isLoading,
isError,
} = useQuery({
queryKey: ["all_news"],
queryFn: () => getAllNews({ page: 1, page_size: 2 }),
});
const confirmDelete = () => {
if (deleteId !== null) {
setNewsList((prev) => prev.filter((t) => t.id !== deleteId));
setDeleteId(null);
}
};
const confirmDelete = () => {};
if (loading) {
if (isLoading) {
return (
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
<p className="text-lg">Yuklanmoqda...</p>
<p className="text-lg">{t("Yuklanmoqda...")}</p>
</div>
</div>
);
}
if (error) {
if (isError) {
return (
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
<div className="text-center">
<p className="text-xl text-red-400">{error}</p>
<Button className="mt-4">Qayta urinish</Button>
<Button className="mt-4">{t("Qayta urinish")}</Button>
</div>
</div>
);
@@ -58,9 +58,10 @@ const News = () => {
{/* Header */}
<div className="flex justify-between items-center mb-8 w-[90%] mx-auto">
<div>
<h1 className="text-4xl font-bold mb-2">Yangiliklar</h1>
<h1 className="text-4xl font-bold mb-2">{t("Yangiliklar")}</h1>
<p className="text-gray-400">
Jami {newsList.length} ta yangilik mavjud
{t("Jami")} {allNews?.data.data.total_items}{" "}
{t("ta yangilik mavjud")}
</p>
</div>
<Button
@@ -68,7 +69,7 @@ const News = () => {
className="flex items-center gap-2 cursor-pointer bg-blue-600 hover:bg-blue-700 text-white"
>
<PlusCircle size={18} />
Yangilik qo'shish
{t("Yangilik qo'shish")}
</Button>
</div>
@@ -76,12 +77,12 @@ const News = () => {
<div
className={clsx(
"gap-6 w-[90%] mx-auto",
newsList.length === 0
allNews?.data.data.total_items === 0
? "flex justify-center items-center min-h-[60vh]"
: "grid md:grid-cols-2 lg:grid-cols-3",
: "grid md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3",
)}
>
{newsList.length === 0 ? (
{allNews?.data.data.total_items === 0 ? (
<div className="text-center py-12">
<div className="mb-6">
<div className="w-24 h-24 bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
@@ -89,20 +90,20 @@ const News = () => {
</div>
</div>
<p className="text-2xl text-gray-400 mb-2 font-semibold">
Hozircha yangilik yo'q
{t("Hozircha yangilik yo'q")}
</p>
<p className="text-gray-500 mb-6">
Birinchi yangilikni qo'shib boshlang
{t("Birinchi yangilikni qo'shishni boshlang")}
</p>
<Button
onClick={() => navigate("/news/add")}
className="flex items-center gap-2 mx-auto bg-blue-600 hover:bg-blue-700 text-white"
>
<PlusCircle size={18} /> Yangilik qo'shish
<PlusCircle size={18} /> {t("Yangilik qo'shish")}
</Button>
</div>
) : (
newsList.map((item) => (
allNews?.data.data.results.map((item) => (
<Card
key={item.id}
className="overflow-hidden bg-neutral-900 hover:bg-neutral-800 transition-all duration-300 border border-neutral-800 hover:border-neutral-700 group"
@@ -111,7 +112,7 @@ const News = () => {
<div className="relative h-48 overflow-hidden">
<img
src={item.image}
alt={item.short_title}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={(e) => {
e.currentTarget.src =
@@ -131,25 +132,26 @@ const News = () => {
<div className="p-4 space-y-3">
{/* Title */}
<h2 className="text-xl font-bold line-clamp-2 group-hover:text-blue-400 transition-colors">
{item.short_title}
{item.title}
</h2>
{/* Short Text */}
<p className="text-sm text-gray-400 line-clamp-3 leading-relaxed">
{item.short_text}
{item.text}
</p>
{/* Date */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar size={14} />
<span>{formatDate.format(item.created, "DD.MM.YYYY")}</span>
<span>{item.is_public}</span>
</div>
{/* Slug */}
<div className="pt-2 border-t border-neutral-800">
<code className="text-xs text-gray-500 bg-neutral-800 px-2 py-1 rounded">
/{item.slug}
</code>
{item.tag?.map((e) => (
<code className="text-xs text-gray-500 bg-neutral-800 px-2 py-1 rounded">
/{e.name}
</code>
))}
</div>
{/* Actions */}
@@ -161,7 +163,7 @@ const News = () => {
className="hover:bg-neutral-700 hover:text-blue-400"
>
<Edit size={16} className="mr-1" />
Tahrirlash
{t("Tahrirlash")}
</Button>
<Button
size="sm"
@@ -170,7 +172,7 @@ const News = () => {
className="hover:bg-red-700"
>
<Trash2 size={16} className="mr-1" />
O'chirish
{t("O'chirish")}
</Button>
</div>
</div>
@@ -183,22 +185,23 @@ const News = () => {
<DialogContent className="sm:max-w-[425px] bg-gray-900">
<DialogHeader>
<DialogTitle className="text-xl">
Yangilikni o'chirishni tasdiqlang
{t("Yangilikni 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.
{t(
"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
{t("Bekor qilish")}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
<Trash2 className="w-4 h-4 mr-2" />
O'chirish
{t("O'chirish")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,6 +1,12 @@
"use client";
import { Badge } from "@/shared/ui/badge";
import {
addNewsCategory,
deleteNewsCategory,
getAllNewsCategory,
getDetailNewsCategory,
updateNewsCategory,
} from "@/pages/news/lib/api";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -26,85 +32,182 @@ import {
TableRow,
} from "@/shared/ui/table";
import { zodResolver } from "@hookform/resolvers/zod";
import { Edit, PlusCircle, Trash2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ChevronLeft,
ChevronRight,
Edit,
Loader2,
PlusCircle,
Trash2,
} 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";
type NewsCategoryType = {
id: number;
name: string;
count: number;
};
const fakeCategories: NewsCategoryType[] = [
{
id: 1,
name: "Blog",
count: 12,
},
{
id: 2,
name: "News",
count: 8,
},
{
id: 3,
name: "Tours",
count: 5,
},
];
const NewsCategory = () => {
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const newsForm = z.object({
title: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
title_ru: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
});
const [categories, setCategories] =
useState<NewsCategoryType[]>(fakeCategories);
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["news_category", currentPage],
queryFn: () =>
getAllNewsCategory({ page: currentPage, page_size: itemsPerPage }),
});
const [editItem, setEditItem] = useState<number | null>(null);
const { data: detail } = useQuery({
queryKey: ["news_category_detail", editItem],
queryFn: () => getDetailNewsCategory(editItem!),
enabled: editItem !== null,
});
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editItem, setEditItem] = useState<NewsCategoryType | null>(null);
const form = useForm<z.infer<typeof newsForm>>({
resolver: zodResolver(newsForm),
defaultValues: {
title: "",
title_ru: "",
},
});
useEffect(() => {
if (editItem) {
form.setValue("title", editItem.name);
if (detail) {
form.setValue("title", detail.data.data.name);
form.setValue("title_ru", detail.data.data.name_ru);
}
}, [editItem, form]);
}, [editItem, form, detail]);
const openDialog = () => {
setIsDialogOpen(true);
};
function onSubmit() {
setIsDialogOpen(false);
const { mutate: added } = useMutation({
mutationFn: (body: { name: string; name_ru: string }) =>
addNewsCategory(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["news_category"] });
queryClient.refetchQueries({ queryKey: ["news_category_detail"] });
setIsDialogOpen(false);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const { mutate: edit } = useMutation({
mutationFn: ({
body,
id,
}: {
id: number;
body: { name: string; name_ru: string };
}) => updateNewsCategory({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["news_category"] });
queryClient.refetchQueries({ queryKey: ["news_category_detail"] });
setIsDialogOpen(false);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const { mutate: deleteCategory } = useMutation({
mutationFn: (id: number) => deleteNewsCategory(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["news_category"] });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
function onSubmit(value: z.infer<typeof newsForm>) {
if (editItem === null) {
added({
name: value.title,
name_ru: value.title_ru,
});
} else if (editItem !== null) {
edit({
id: editItem,
body: {
name: value.title,
name_ru: value.title_ru,
},
});
}
}
const handleDelete = (id: number) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
deleteCategory(id);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => refetch()}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 w-full text-gray-100 p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-semibold tracking-tight">
News Categories
{t("News Categories")}
</h1>
<Button
onClick={() => {
openDialog();
form.reset();
setEditItem(null);
}}
className="flex items-center bg-blue-600 hover:bg-blue-700 text-white"
>
<PlusCircle className="w-4 h-4 mr-2" /> Yangi qoshish
<PlusCircle className="w-4 h-4 mr-2" /> {t("Yangi qoshish")}
</Button>
</div>
<div className="rounded-lg border border-gray-800 overflow-hidden bg-gray-900">
@@ -112,19 +215,21 @@ const NewsCategory = () => {
<TableHeader className="bg-gray-800/50">
<TableRow>
<TableHead className="text-gray-300 w-[60px]">#</TableHead>
<TableHead className="text-gray-300">Kategoriya nomi</TableHead>
<TableHead className="text-gray-300 text-center">
Yangiliklar soni
<TableHead className="text-gray-300">
{t("Kategoriya nomi")}
</TableHead>
{/* <TableHead className="text-gray-300 text-center">
{t("Yangiliklar soni")}
</TableHead> */}
<TableHead className="text-gray-300 text-right">
Harakatlar
{t("Harakatlar")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.length > 0 ? (
categories.map((cat, index) => (
{data && data?.data.data.total_items > 0 ? (
data?.data.data.results.map((cat, index) => (
<TableRow
key={cat.id}
className="border-b border-gray-800 hover:bg-gray-800/40 transition-colors"
@@ -138,11 +243,11 @@ const NewsCategory = () => {
</div>
</TableCell>
<TableCell className="text-center">
{/* <TableCell className="text-center">
<Badge variant="secondary" className="bg-gray-700">
{cat.count} ta
{cat.}
</Badge>
</TableCell>
</TableCell> */}
<TableCell className="text-right">
<div className="flex justify-end gap-2">
@@ -152,17 +257,17 @@ const NewsCategory = () => {
className="border-gray-700 text-gray-200 hover:bg-gray-800"
onClick={() => {
openDialog();
setEditItem(cat);
setEditItem(cat.id);
}}
>
<Edit className="w-4 h-4 mr-1" /> Edit
<Edit className="w-4 h-4 mr-1" /> {t("Tahrirlash")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(cat.id)}
>
<Trash2 className="w-4 h-4 mr-1" /> Ochirish
<Trash2 className="w-4 h-4 mr-1" /> {t("O'chirish")}
</Button>
</div>
</TableCell>
@@ -174,18 +279,26 @@ const NewsCategory = () => {
colSpan={4}
className="text-center py-8 text-gray-400"
>
Hech qanday kategoriya topilmadi
{t("Hech qanday kategoriya topilmadi")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
setEditItem(null);
}}
>
<DialogContent className="bg-gray-900 border border-gray-700 text-gray-100">
<DialogHeader>
<DialogTitle>
{editItem ? "Kategoriya tahrirlash" : "Yangi kategoriya qoshish"}
{editItem
? t("Kategoriya tahrirlash")
: t("Yangi kategoriya qoshish")}
</DialogTitle>
</DialogHeader>
@@ -200,10 +313,29 @@ const NewsCategory = () => {
name="title"
render={({ field }) => (
<FormItem>
<Label className="text-md">Yangilik nomi</Label>
<Label className="text-md">{t("Kategoriya nomi")}</Label>
<FormControl>
<Input
placeholder="Masalan: Yangi turistik joylar ochildi"
placeholder={t("Kategoriya nomi")}
{...field}
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title_ru"
render={({ field }) => (
<FormItem>
<Label className="text-md">
{t("Kategoriya nomi")} (ru)
</Label>
<FormControl>
<Input
placeholder={t("Kategoriya nomi")}
{...field}
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
/>
@@ -217,7 +349,7 @@ const NewsCategory = () => {
type="submit"
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
>
Saqlash
{t("Saqlash")}
</Button>
</div>
</form>
@@ -225,6 +357,41 @@ const NewsCategory = () => {
</div>
</DialogContent>
</Dialog>
<div className="flex justify-end gap-2 mt-10">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
>
<ChevronLeft className="w-5 h-5" />
</button>
{[...Array(data?.data.data.total_pages)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
currentPage === i + 1
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
}`}
>
{i + 1}
</button>
))}
<button
disabled={currentPage === data?.data.data.total_pages}
onClick={() =>
setCurrentPage((p) =>
Math.min(p + 1, data ? data?.data.data.total_pages : 0),
)
}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
);
};

View File

@@ -1,3 +1,5 @@
import { getAllNewsCategory } from "@/pages/news/lib/api";
import { useNewsStore } from "@/pages/news/lib/data";
import { newsForm } from "@/pages/news/lib/form";
import { Button } from "@/shared/ui/button";
import {
@@ -20,52 +22,102 @@ import {
} from "@/shared/ui/select";
import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { useInfiniteQuery } from "@tanstack/react-query";
import { XIcon } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
import { type Dispatch, type SetStateAction, useEffect } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type z from "zod";
const StepOne = ({
setStep,
isEditMode,
}: {
setStep: Dispatch<SetStateAction<number>>;
isEditMode: boolean;
}) => {
const categories = [
{ name: "Blog", id: "1" },
{ name: "Tours", id: "2" },
{ name: "News", id: "3" },
];
const { t } = useTranslation();
const { setStepOneData, stepOneData } = useNewsStore();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["news_category"],
queryFn: ({ pageParam = 1 }) =>
getAllNewsCategory({ page: pageParam, page_size: 5 }),
getNextPageParam: (lastPage) => {
const currentPage = lastPage.data.data.current_page;
const totalPages = lastPage.data.data.total_pages;
return currentPage < totalPages ? currentPage + 1 : undefined;
},
initialPageParam: 1,
});
const allCategories =
data?.pages.flatMap((page) => page.data.data.results) ?? [];
const form = useForm<z.infer<typeof newsForm>>({
resolver: zodResolver(newsForm),
defaultValues: {
title: "",
category: "",
banner: "",
desc: "",
title: stepOneData.title,
category: stepOneData.category,
banner: stepOneData.banner,
desc: stepOneData.desc,
desc_ru: stepOneData.desc_ru,
title_ru: stepOneData.title_ru,
},
});
function onSubmit() {
// ✅ Haqiqiy scroll elementni topish va scroll eventni qoshish
useEffect(() => {
const interval = setInterval(() => {
const viewport = document.querySelector(
"[data-radix-select-viewport]",
) as HTMLDivElement | null;
if (viewport) {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
if (
scrollHeight - scrollTop - clientHeight < 50 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
};
viewport.addEventListener("scroll", handleScroll);
clearInterval(interval);
return () => {
viewport.removeEventListener("scroll", handleScroll);
};
}
}, 200);
return () => clearInterval(interval);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function onSubmit(values: z.infer<typeof newsForm>) {
setStepOneData(values);
setStep(2);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 bg-gray-900"
>
{/* title */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<Label className="text-md">Yangilik nomi</Label>
<Label className="text-md">{t("Yangilik nomi")}</Label>
<FormControl>
<Input
placeholder="Masalan: Yangi turistik joylar ochildi"
placeholder={t("Yangilik nomi")}
{...field}
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
/>
@@ -74,15 +126,35 @@ const StepOne = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="title_ru"
render={({ field }) => (
<FormItem>
<Label className="text-md">{t("Yangilik nomi")} (ru)</Label>
<FormControl>
<Input
placeholder={t("Yangilik nomi") + " (ru)"}
{...field}
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* desc */}
<FormField
control={form.control}
name="desc"
render={({ field }) => (
<FormItem>
<Label className="text-md">Yangilik haqida</Label>
<Label className="text-md">{t("Yangilik haqida")}</Label>
<FormControl>
<Textarea
placeholder="Yangilik haqida"
placeholder={t("Yangilik haqida")}
{...field}
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
/>
@@ -91,25 +163,54 @@ const StepOne = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="desc_ru"
render={({ field }) => (
<FormItem>
<Label className="text-md">{t("Yangilik haqida")} (ru)</Label>
<FormControl>
<Textarea
placeholder={t("Yangilik haqida") + " (ru)"}
{...field}
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<Label className="text-md">Kategoriya</Label>
<Label className="text-md">{t("Kategoriya")}</Label>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full !h-12 bg-gray-800 border-gray-700 text-white">
<SelectValue placeholder="Kategoriya tanlang" />
<SelectValue placeholder={t("Kategoriya tanlang")} />
</SelectTrigger>
<SelectContent className="bg-gray-800 border-gray-700 text-white">
<SelectContent className="bg-gray-800 border-gray-700 text-white max-h-[180px] overflow-y-auto">
<SelectGroup>
<SelectLabel>Kategoriyalar</SelectLabel>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
<SelectLabel>{t("Kategoriyalar")}</SelectLabel>
{allCategories.map((cat) => (
<SelectItem key={cat.id} value={String(cat.id)}>
{cat.name}
</SelectItem>
))}
{isFetchingNextPage && (
<div className="text-center py-2 text-gray-400 text-sm">
{t("Yuklanmoqda...")}
</div>
)}
{!hasNextPage && allCategories.length > 0 && (
<div className="text-center py-2 text-gray-500 text-xs">
{t("Barcha kategoriyalar yuklandi")}
</div>
)}
</SelectGroup>
</SelectContent>
</Select>
@@ -118,53 +219,59 @@ const StepOne = ({
</FormItem>
)}
/>
{/* Banner */}
<FormField
control={form.control}
name="banner"
render={() => (
<FormItem>
<Label className="text-md">Banner rasmi</Label>
<Label className="text-md">{t("Banner rasmi")}</Label>
<FormControl>
<div className="flex flex-col gap-3 w-full">
<Input
type="file"
id="license-files"
id="banner-file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
form.setValue("banner", url);
form.setValue("banner", file, { shouldValidate: true });
}
}}
className="hidden"
/>
<label
htmlFor="license-files"
className="w-full border-2 border-dashed h-40 border-[#D3D3D3] flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer"
htmlFor="banner-file"
className="w-full border-2 border-dashed h-40 border-gray-500 flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer hover:bg-gray-800 transition"
>
<p className="font-semibold text-xl text-[#FFFF]">
Drag or select files
<p className="font-semibold text-xl text-white">
{t("Drag or select files")}
</p>
<p className="text-[#FFFF] text-sm">
Drop files here or click to browse
<p className="text-gray-400 text-sm">
{t("Drop files here or click to browse")}
</p>
</label>
{form.watch("banner") && (
<div className="relative size-24 rounded-md overflow-hidden border">
{/* ✅ Preview (URL.createObjectURL bilan) */}
{form.watch("banner") instanceof File && (
<div className="relative w-32 h-32 rounded-md overflow-hidden border border-gray-600">
<img
src={form.watch("banner")}
alt={`Nanner`}
src={URL.createObjectURL(form.watch("banner"))}
alt="Banner preview"
className="object-cover w-full h-full"
/>
<button
type="button"
onClick={() => form.setValue("banner", "")}
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
onClick={() =>
form.setValue("banner", null as any, {
shouldValidate: true,
})
}
className="absolute top-1 right-1 bg-black/70 rounded-full p-1 hover:bg-black/90"
>
<XIcon className="size-4 text-destructive" />
<XIcon className="size-4 text-red-400" />
</button>
</div>
)}
@@ -174,12 +281,13 @@ const StepOne = ({
</FormItem>
)}
/>
<div className="w-full flex justify-end">
<Button
type="submit"
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
>
{isEditMode ? "Yangilikni saqlash" : "Keyingisi"}
{t("Keyingisi")}
</Button>
</div>
</form>

View File

@@ -1,4 +1,8 @@
"use client";
import { addNews } from "@/pages/news/lib/api";
import { useNewsStore } from "@/pages/news/lib/data";
import { newsPostForm, type NewsPostFormType } from "@/pages/news/lib/form";
import { Button } from "@/shared/ui/button";
import {
Form,
@@ -11,180 +15,275 @@ import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusCircle, Trash2, XIcon } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ImagePlus, PlusCircle, Trash2 } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import z from "zod";
import { toast } from "sonner";
const newsItemSchema = z.object({
desc: z.string().min(2, {
message: "Yangilik matni kamida 2 belgidan iborat bolishi kerak",
}),
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
});
const StepTwo = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { stepOneData } = useNewsStore();
const queryClient = useQueryClient();
const newsListSchema = z.object({
items: z
.array(newsItemSchema)
.min(1, { message: "Kamida 1 ta yangilik kerak" }),
});
type NewsFormType = z.infer<typeof newsListSchema>;
const StepTwo = ({
setStep,
isEditMode,
}: {
setStep: Dispatch<SetStateAction<number>>;
isEditMode: boolean;
}) => {
const form = useForm<NewsFormType>({
resolver: zodResolver(newsListSchema),
const form = useForm<NewsPostFormType>({
resolver: zodResolver(newsPostForm),
defaultValues: {
items: [{ desc: "", banner: "" }],
desc: "",
desc_ru: "",
is_public: "yes",
sections: [{ image: undefined as any, text: "", text_ru: "" }],
post_tags: [""],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
name: "sections",
});
const navigator = useNavigate();
const { watch, setValue } = form;
const postTags = watch("post_tags");
const addTag = () => setValue("post_tags", [...postTags, ""]);
const removeTag = (i: number) =>
setValue(
"post_tags",
postTags.filter((_, idx) => idx !== i),
);
function onSubmit() {
navigator("/news");
}
const { mutate: added } = useMutation({
mutationFn: (body: FormData) => addNews(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_news"] });
navigate("/news");
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const handleImageChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const file = e.target.files?.[0];
if (file) form.setValue(`sections.${index}.image`, file);
};
const onSubmit = (values: NewsPostFormType) => {
const formData = new FormData();
formData.append("title", stepOneData.title);
formData.append("title_ru", stepOneData.title_ru);
formData.append("image", stepOneData.banner ?? "");
formData.append("text", stepOneData.desc);
formData.append("text_ru", stepOneData.desc_ru);
formData.append("is_public", values.is_public === "no" ? "false" : "true");
formData.append("category", stepOneData.category);
values.sections.forEach((section, i) => {
formData.append(`post_images[${i}]`, section.image);
formData.append(`post_text[${i}]`, section.text);
formData.append(`post_text_ru[${i}]`, section.text_ru);
});
values.post_tags.forEach((tag, i) => {
formData.append(`post_tags[${i}]`, tag);
});
added(formData);
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 bg-gray-900 p-6 rounded-2xl text-white"
className="space-y-6 bg-gray-900 p-6 rounded-2xl text-white"
>
<h2 className="text-2xl font-semibold">Yangiliklar royxati</h2>
<Label className="text-lg">{t("Yangilik bolimlari")}</Label>
{/* DESC (UZ) */}
<FormField
control={form.control}
name="desc"
render={({ field }) => (
<FormItem>
<Label>{t("Qisqacha ta'rif (UZ)")}</Label>
<FormControl>
<Textarea {...field} placeholder={t("Qisqacha ta'rif")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* DESC (RU) */}
<FormField
control={form.control}
name="desc_ru"
render={({ field }) => (
<FormItem>
<Label>{t("Qisqacha ta'rif (RU)")}</Label>
<FormControl>
<Textarea
{...field}
placeholder={t("Qisqacha ta'rif (rus tilida)")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<Label>{t("Teglar")}</Label>
{postTags.map((__, i) => (
<FormField
key={i}
control={form.control}
name={`post_tags.${i}`}
render={({ field }) => (
<FormItem>
<div className="relative">
{postTags.length > 1 && (
<button
type="button"
onClick={() => removeTag(i)}
className="absolute top-1 right-1 text-red-400 hover:text-red-500"
>
<Trash2 className="size-4" />
</button>
)}
<FormControl>
<Input {...field} placeholder={t("Masalan: sport")} />
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
))}
<Button
type="button"
onClick={addTag}
className="bg-gray-600 hover:bg-gray-700"
>
<PlusCircle className="size-5 mr-2" />
{t("Teg qo'shish")}
</Button>
</div>
{fields.map((field, index) => (
<div
key={field.id}
className="relative border border-gray-700 bg-gray-800 rounded-xl p-4 space-y-4"
className="border border-gray-700 rounded-lg p-4 space-y-4"
>
{/* O'chirish tugmasi */}
{fields.length > 1 && (
<div className="flex justify-between items-center">
<p className="text-sm text-gray-300">
{t("Bolim")} #{index + 1}
</p>
<button
type="button"
onClick={() => remove(index)}
className="absolute top-3 right-3 text-red-400 hover:text-red-500"
className="text-red-400 hover:text-red-500"
>
<Trash2 className="size-5" />
<Trash2 className="size-4" />
</button>
)}
</div>
{/* DESC FIELD */}
{/* Image */}
<div>
<Label>{t("Rasm")}</Label>
{form.watch(`sections.${index}.image`) ? (
<div className="relative mt-2 w-48">
<img
src={URL.createObjectURL(
form.watch(`sections.${index}.image`),
)}
alt="preview"
className="rounded-lg border border-gray-700 object-cover h-40 w-full"
/>
<button
type="button"
onClick={() =>
form.setValue(`sections.${index}.image`, undefined as any)
}
className="absolute top-1 right-1 bg-black/70 rounded-full p-1 hover:bg-black/90"
>
<Trash2 className="size-4 text-red-400" />
</button>
</div>
) : (
<>
<input
id={`section-img-${index}`}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleImageChange(e, index)}
/>
<label
htmlFor={`section-img-${index}`}
className="inline-flex items-center cursor-pointer bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg mt-2"
>
<ImagePlus className="size-5 mr-2" />
{t("Rasm tanlash")}
</label>
</>
)}
</div>
{/* Text (UZ) */}
<FormField
control={form.control}
name={`items.${index}.desc`}
name={`sections.${index}.text`}
render={({ field }) => (
<FormItem>
<Label className="text-md">Yangilik haqida</Label>
<Label>{t("Matn (UZ)")}</Label>
<FormControl>
<Textarea
placeholder="Yangilik haqida"
{...field}
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
/>
<Textarea {...field} placeholder={t("Matn kiriting")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* BANNER FIELD */}
{/* Text (RU) */}
<FormField
control={form.control}
name={`items.${index}.banner`}
render={() => (
name={`sections.${index}.text_ru`}
render={({ field }) => (
<FormItem>
<Label className="text-md">Banner rasmi</Label>
<Label>{t("Matn (RU)")}</Label>
<FormControl>
<div className="flex flex-col gap-3 w-full">
<Input
type="file"
id={`file-${index}`}
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
form.setValue(`items.${index}.banner`, url);
}
}}
className="hidden"
/>
<label
htmlFor={`file-${index}`}
className="w-full border-2 border-dashed h-40 border-gray-600 hover:border-gray-500 transition-all flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer"
>
<p className="font-semibold text-xl text-white">
Drag or select files
</p>
<p className="text-gray-300 text-sm">
Drop files here or click to browse
</p>
</label>
{form.watch(`items.${index}.banner`) && (
<div className="relative size-24 rounded-md overflow-hidden border border-gray-700">
<img
src={form.watch(`items.${index}.banner`)}
alt="Banner preview"
className="object-cover w-full h-full"
/>
<button
type="button"
onClick={() =>
form.setValue(`items.${index}.banner`, "")
}
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>
<Textarea {...field} placeholder={t("Matn (rus tilida)")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
<Button
type="button"
onClick={() =>
append({ image: undefined as any, text: "", text_ru: "" })
}
className="bg-gray-700 hover:bg-gray-600"
>
<PlusCircle className="size-5 mr-2" />
{t("Bolim qoshish")}
</Button>
<div className="flex justify-end">
<Button
type="button"
onClick={() => append({ desc: "", banner: "" })}
className="flex items-center px-6 py-5 text-lg gap-2 bg-gray-600 hover:bg-gray-700 text-white cursor-pointer"
>
<PlusCircle className="size-5" />
Qoshish
</Button>
</div>
{/* Navigatsiya tugmalari */}
<div className="w-full flex justify-between pt-4">
<Button
type="button"
onClick={() => setStep(1)}
className="bg-gray-600 hover:bg-gray-700 text-white"
>
Orqaga
</Button>
<Button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white"
className="mt-6 px-8 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 cursor-pointer"
>
{isEditMode ? "Yangiliklarni saqlash" : "Saqlash"}
{t("Saqlash")}
</Button>
</div>
</form>

425
src/pages/tours/lib/api.ts Normal file
View File

@@ -0,0 +1,425 @@
import type {
CreateTourRes,
GetAllTours,
GetOneTours,
Hotel_Badge,
Hotel_BadgeId,
Hotel_Tarif,
Hotel_TarifId,
Hotel_TranportId,
Hotel_Transport,
Hotel_Type,
Hotel_TypeId,
HotelAllFeatures,
HotelAllFeaturesType,
HotelFeaturesDetail,
HotelFeaturesTypeDetail,
} from "@/pages/tours/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import {
GET_TICKET,
HOTEL,
HOTEL_BADGE,
HOTEL_FEATURES,
HOTEL_FEATURES_TYPE,
HOTEL_TARIF,
HPTEL_TYPES,
TOUR_TRANSPORT,
} from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const getAllTours = async ({
page,
page_size,
}: {
page_size: number;
page: number;
}): Promise<AxiosResponse<GetAllTours>> => {
const response = await httpClient.get(GET_TICKET, {
params: {
page,
page_size,
},
});
return response;
};
const getOneTours = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<GetOneTours>> => {
const response = await httpClient.get(`${GET_TICKET}${id}`);
return response;
};
const createTours = async ({
body,
}: {
body: FormData;
}): Promise<AxiosResponse<CreateTourRes>> => {
const response = await httpClient.post(`${GET_TICKET}`, body, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response;
};
const createHotel = async ({ body }: { body: FormData }) => {
const response = await httpClient.post(`${HOTEL}`, body, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response;
};
const deleteTours = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${GET_TICKET}${id}/`);
return response;
};
// htoel_badge api
const hotelBadge = async ({
page,
page_size,
}: {
page_size: number;
page: number;
}): Promise<AxiosResponse<Hotel_Badge>> => {
const response = await httpClient.get(HOTEL_BADGE, {
params: { page, page_size },
});
return response;
};
const hotelBadgeCreate = async ({
body,
}: {
body: { name: string; color: string; name_ru: string };
}) => {
const response = await httpClient.post(`${HOTEL_BADGE}`, body);
return response;
};
const hotelBadgeUpdate = async ({
id,
body,
}: {
id: number;
body: { name: string; color: string; name_ru: string };
}) => {
const response = await httpClient.patch(`${HOTEL_BADGE}${id}/`, body);
return response;
};
const hotelBadgeDetail = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<Hotel_BadgeId>> => {
const response = await httpClient.get(`${HOTEL_BADGE}${id}/`);
return response;
};
const hotelBadgeDelete = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${HOTEL_BADGE}${id}/`);
return response;
};
// htoel_tarif api
const hotelTarif = async ({
page,
page_size,
}: {
page_size: number;
page: number;
}): Promise<AxiosResponse<Hotel_Tarif>> => {
const response = await httpClient.get(HOTEL_TARIF, {
params: { page, page_size },
});
return response;
};
const hotelTarifCreate = async ({
body,
}: {
body: {
name: string;
name_ru: string;
};
}) => {
const response = await httpClient.post(HOTEL_TARIF, body);
return response;
};
const hotelTarfiDetail = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<Hotel_TarifId>> => {
const response = await httpClient.get(`${HOTEL_TARIF}${id}/`);
return response;
};
const hotelTarifDelete = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${HOTEL_TARIF}${id}/`);
return response;
};
const hoteltarifUpdate = async ({
id,
body,
}: {
id: number;
body: { name: string; name_ru: string };
}) => {
const response = await httpClient.patch(`${HOTEL_TARIF}${id}/`, body);
return response;
};
// htoel_tarnsport api
const hotelTransport = async ({
page,
page_size,
}: {
page_size: number;
page: number;
}): Promise<AxiosResponse<Hotel_Transport>> => {
const response = await httpClient.get(TOUR_TRANSPORT, {
params: { page, page_size },
});
return response;
};
const hotelTranportCreate = async ({
body,
}: {
body: {
name: string;
name_ru: string;
icon_name: string;
};
}) => {
const response = await httpClient.post(TOUR_TRANSPORT, body);
return response;
};
const hotelTransportDelete = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${TOUR_TRANSPORT}${id}/`);
return response;
};
const hotelTransportDetail = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<Hotel_TranportId>> => {
const response = await httpClient.get(`${TOUR_TRANSPORT}${id}/`);
return response;
};
const hotelTransportUpdate = async ({
id,
body,
}: {
id: number;
body: { name: string; name_ru: string; icon_name: string };
}) => {
const response = await httpClient.patch(`${TOUR_TRANSPORT}${id}/`, body);
return response;
};
//hotel_type
const hotelType = async ({
page,
page_size,
}: {
page_size: number;
page: number;
}): Promise<AxiosResponse<Hotel_Type>> => {
const response = await httpClient.get(HPTEL_TYPES, {
params: { page, page_size },
});
return response;
};
const hotelTypeCreate = async ({
body,
}: {
body: {
name: string;
name_ru: string;
};
}) => {
const response = await httpClient.post(HPTEL_TYPES, body);
return response;
};
const hotelTypeDelete = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${HPTEL_TYPES}${id}/`);
return response;
};
const hotelTypeDetail = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<Hotel_TypeId>> => {
const response = await httpClient.get(`${HPTEL_TYPES}${id}/`);
return response;
};
const hotelTypeUpdate = async ({
id,
body,
}: {
id: number;
body: { name: string; name_ru: string };
}) => {
const response = await httpClient.patch(`${HPTEL_TYPES}${id}/`, body);
return response;
};
// hotel_features
const hotelFeature = async ({
page,
page_size,
}: {
page_size: number;
page: number;
}): Promise<AxiosResponse<HotelAllFeatures>> => {
const response = await httpClient.get(HOTEL_FEATURES, {
params: { page, page_size },
});
return response;
};
const hotelFeatureType = async ({
page,
page_size,
feature_type,
}: {
page_size: number;
page: number;
feature_type: number;
}): Promise<AxiosResponse<HotelAllFeaturesType>> => {
const response = await httpClient.get(HOTEL_FEATURES_TYPE, {
params: { page, page_size, feature_type },
});
return response;
};
const hotelFeatureCreate = async ({
body,
}: {
body: {
hotel_feature_type_name: string;
hotel_feature_type_name_ru: string;
};
}) => {
const response = await httpClient.post(`${HOTEL_FEATURES}`, body);
return response;
};
const hotelFeatureTypeCreate = async ({
body,
}: {
body: {
feature_name: string;
feature_name_ru: string;
feature_type: number;
};
}) => {
const response = await httpClient.post(`${HOTEL_FEATURES_TYPE}`, body);
return response;
};
const hotelFeatureUpdate = async ({
id,
body,
}: {
id: number;
body: { hotel_feature_type_name: string; hotel_feature_type_name_ru: string };
}) => {
const response = await httpClient.patch(`${HOTEL_FEATURES}${id}/`, body);
return response;
};
const hotelFeatureTypeUpdate = async ({
id,
body,
}: {
id: number;
body: { feature_name: string; feature_name_ru: string };
}) => {
const response = await httpClient.patch(`${HOTEL_FEATURES_TYPE}${id}/`, body);
return response;
};
const hotelFeatureDetail = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<HotelFeaturesDetail>> => {
const response = await httpClient.get(`${HOTEL_FEATURES}${id}/`);
return response;
};
const hotelFeatureTypeDetail = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<HotelFeaturesTypeDetail>> => {
const response = await httpClient.get(`${HOTEL_FEATURES_TYPE}${id}/`);
return response;
};
const hotelFeatureDelete = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${HOTEL_FEATURES}${id}/`);
return response;
};
const hotelFeatureTypeDelete = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${HOTEL_FEATURES_TYPE}${id}/`);
return response;
};
export {
createHotel,
createTours,
deleteTours,
getAllTours,
getOneTours,
hotelBadge,
hotelBadgeCreate,
hotelBadgeDelete,
hotelBadgeDetail,
hotelBadgeUpdate,
hotelFeature,
hotelFeatureCreate,
hotelFeatureDelete,
hotelFeatureDetail,
hotelFeatureType,
hotelFeatureTypeCreate,
hotelFeatureTypeDelete,
hotelFeatureTypeDetail,
hotelFeatureTypeUpdate,
hotelFeatureUpdate,
hotelTarfiDetail,
hotelTarif,
hotelTarifCreate,
hotelTarifDelete,
hoteltarifUpdate,
hotelTranportCreate,
hotelTransport,
hotelTransportDelete,
hotelTransportDetail,
hotelTransportUpdate,
hotelType,
hotelTypeCreate,
hotelTypeDelete,
hotelTypeDetail,
hotelTypeUpdate,
};

View File

@@ -0,0 +1,307 @@
"use client";
import type {
Badge,
HotelFeatures,
HotelFeaturesType,
Tarif,
Transport,
Type,
} from "@/pages/tours/lib/type";
import { Button } from "@/shared/ui/button";
import { type ColumnDef } from "@tanstack/react-table";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
export const BadgesColumns = (
onEdit: (id: number) => void,
onDelete: (id: number) => void,
t: (key: string) => string,
): ColumnDef<Badge>[] => [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <span>{row.original.id}</span>,
},
{
accessorKey: "name",
header: t("Nomi"),
cell: ({ row }) => <span>{row.original.name}</span>,
},
{
accessorKey: "color",
header: t("Rang"),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span
className="w-5 h-5 rounded-full border border-gray-600"
style={{ backgroundColor: row.original.color }}
></span>
<span>{row.original.color}</span>
</div>
),
},
{
id: "actions",
header: () => <div className="text-right">{t("Harakatlar")}</div>,
cell: ({ row }) => {
const { t } = useTranslation();
return (
<div className="flex justify-end gap-2">
{" "}
{/* ongga tekislangan tugmalar */}
<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>
);
},
},
];
export const TarifColumns = (
onEdit: (id: number) => void,
onDelete: (id: number) => void,
t: (key: string) => string,
): ColumnDef<Tarif>[] => [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <span>{row.original.id}</span>,
},
{
accessorKey: "name",
header: t("Nomi"),
cell: ({ row }) => <span>{row.original.name}</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"
disabled
onClick={() => onEdit(row.original.id)}
>
{t("Tahrirlash")}
</Button>
<Button
size="sm"
disabled
variant="destructive"
onClick={() => onDelete(row.original.id)}
>
{t("O'chirish")}
</Button>
</div>
);
},
},
];
export const TranportColumns = (
onEdit: (id: number) => void,
onDelete: (id: number) => void,
t: (key: string) => string,
): ColumnDef<Transport>[] => [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <span>{row.original.id}</span>,
},
{
accessorKey: "name",
header: t("Nomi"),
cell: ({ row }) => <span>{row.original.name}</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>
);
},
},
];
export const TypeColumns = (
onEdit: (id: number) => void,
onDelete: (id: number) => void,
t: (key: string) => string,
): ColumnDef<Type>[] => [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <span>{row.original.id}</span>,
},
{
accessorKey: "name",
header: t("Nomi"),
cell: ({ row }) => <span>{row.original.name}</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>
);
},
},
];
export const FeatureColumns = (
onEdit: (id: number) => void,
onDelete: (id: number) => void,
t: (key: string) => string,
setActiveTab: Dispatch<SetStateAction<string>>,
setFeatureId: Dispatch<SetStateAction<number | null>>,
): ColumnDef<HotelFeatures>[] => [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <span>{row.original.id}</span>,
},
{
accessorKey: "name",
header: t("Nomi"),
cell: ({ row }) => <span>{row.original.hotel_feature_type_name}</span>,
},
{
accessorKey: "name",
header: () => <div>{t("Kategoriya nomi")} (ru)</div>,
cell: ({ row }) => <span>{row.original.hotel_feature_type_name_ru}</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={() => {
setActiveTab("feature_type");
setFeatureId(row.original.id);
}}
>
{t("Ko'rish")}
</Button>
<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>
);
},
},
];
export const FeatureTypeColumns = (
onEdit: (id: number) => void,
onDelete: (id: number) => void,
t: (key: string) => string,
): ColumnDef<HotelFeaturesType>[] => [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <span>{row.original.id}</span>,
},
{
accessorKey: "name",
header: t("Nomi"),
cell: ({ row }) => <span>{row.original.feature_name}</span>,
},
{
accessorKey: "name",
header: () => <div>{t("Kategoriya nomi")}</div>,
cell: ({ row }) => (
<span>{row.original.feature_type.hotel_feature_type_name}</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>
);
},
},
];

188
src/pages/tours/lib/form.ts Normal file
View File

@@ -0,0 +1,188 @@
import z from "zod";
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
export const TourformSchema = z.object({
title: z.string().min(2, {
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
}),
title_ru: z.string().min(2, {
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
}),
hotel_info: z.string().min(2, {
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
}),
hotel_info_ru: z.string(),
hotel_meals_info: z.string().min(2, {
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
}),
hotel_meals_info_ru: z.string(),
price: z.number().min(1000, {
message: "Narx kamida 1000 UZS bo'lishi kerak.",
}),
passenger_count: z.number().min(1, {
message: "Kamida 1 yo'lovchi bo'lishi kerak.",
}),
min_person: z.number().min(1, {
message: "Kamida 1 yo'lovchi bo'lishi kerak.",
}),
max_person: z.number().min(1, {
message: "Kamida 1 yo'lovchi bo'lishi kerak.",
}),
departure: z.string().min(2, {
message: "Ketish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
}),
departure_ru: z.string().min(2, {
message: "Ketish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
}),
destination: z.string().min(2, {
message: "Borish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
}),
destination_ru: z.string().min(2, {
message: "Borish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
}),
location_name: z.string().min(2, {
message: "Eng kamida 2 ta belgidan iborat bo'lishi kerak.",
}),
location_name_ru: z.string().min(2, {
message: "Eng kamida 2 ta belgidan iborat bo'lishi kerak.",
}),
visa_required: z
.enum(["no", "yes"], { message: "Iltimos, visa talabliligini tanlang" })
.refine((val) => val === "no" || val === "yes", {
message: "Iltimos, visa talabliligini tanlang",
}),
departureDateTime: z.object({
date: z.date({ message: "Jonash vaqti majburiy" }),
time: z
.string()
.min(1, { message: "Jonash vaqti majburiy" })
.refine(
(val) => {
// HH:MM:SS formatini tekshirish
const parts = val.split(":");
if (parts.length !== 3) return false;
const [hour, minute, second] = parts.map(Number);
return (
!isNaN(hour) &&
!isNaN(minute) &&
!isNaN(second) &&
hour >= 0 &&
hour <= 23 &&
minute >= 0 &&
minute <= 59 &&
second >= 0 &&
second <= 59
);
},
{ message: "Yaroqli vaqt kiriting (masalan, 08:30:00)" },
),
}),
travelDateTime: z.object({
date: z.date({ message: "Jonash vaqti majburiy" }),
time: z
.string()
.min(1, { message: "Jonash vaqti majburiy" })
.refine(
(val) => {
// HH:MM:SS formatini tekshirish
const parts = val.split(":");
if (parts.length !== 3) return false;
const [hour, minute, second] = parts.map(Number);
return (
!isNaN(hour) &&
!isNaN(minute) &&
!isNaN(second) &&
hour >= 0 &&
hour <= 23 &&
minute >= 0 &&
minute <= 59 &&
second >= 0 &&
second <= 59
);
},
{ message: "Yaroqli vaqt kiriting (masalan, 08:30:00)" },
),
}),
languages: z.string().min(1, { message: "Majburiy maydon" }),
duration: z.number().min(1, { message: "Kamida 1kun bo'lishi kerak" }),
badges: z.array(z.number()).optional(),
tarif: z
.array(
z.object({
tariff: z.number().min(1, { message: "Transport ID majburiy" }),
price: z
.number()
.min(1000, { message: "Narx kamida 1000 UZS bo'lishi kerak" }),
}),
)
.min(1, { message: "Kamida bitta transport tanlang." }),
transport: z
.array(
z.object({
transport: z.number().min(1, { message: "Transport ID majburiy" }),
price: z
.number()
.min(1000, { message: "Narx kamida 1000 UZS bo'lishi kerak" }),
}),
)
.min(1, { message: "Kamida bitta transport tanlang." }),
banner: z.any().nullable(),
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: "Majburoy maydon" }),
name_ru: z.string().min(1, { message: "Majburoy maydon" }),
icon_name: z.string().min(1, { message: "Majburoy maydon" }),
}),
)
.min(1, { message: "Kamida bitta qulaylik kiriting." }),
hotel_services: z
.array(
z.object({
image: z.any().nullable(),
title: z.string().min(1, "Xizmat nomi majburiy"),
title_ru: z.string().min(1, { message: "Majburoy maydon" }),
description: z.string().min(1, "Tavsif majburiy"),
desc_ru: z.string().min(1, { message: "Majburoy maydon" }),
}),
)
.min(1, { message: "Kamida bitta xizmat kiriting." }),
hotel_meals: z
.array(
z.object({
image: z.any().nullable(),
title: z.string().min(1, "Xizmat nomi majburiy"),
title_ru: z.string().min(1, "Majburiy maydon"),
description: z.string().min(1, "Tavsif majburiy"),
desc_ru: z.string().min(1, "Majburiy maydon"),
}),
)
.min(1, { message: "Kamida bitta xizmat kiriting." }),
ticket_itinerary: z
.array(
z.object({
ticket_itinerary_image: z.array(
z.object({
image: fileSchema,
}),
),
title: z.string().min(1, "Sarlavha majburiy"),
title_ru: z.string().min(1, "Sarlavha (RU) majburiy"),
duration: z.number().min(1, { message: "Kamida 1 kun bo'lishi kerak" }),
ticket_itinerary_destinations: z.array(
z.object({
name: z.string().min(1, { message: "Majburiy maydon" }),
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
}),
),
}),
)
.min(1, { message: "Kamida bitta xizmat kiriting." }),
});

View File

@@ -0,0 +1,36 @@
import { create } from "zustand";
interface Amenity {
name: string;
name_ru: string;
icon_name: string;
}
interface TicketStore {
amenities: Amenity[];
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;
}
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) => ({
amenities: state.amenities.filter((_, i) => i !== index),
})),
updateAmenity: (index, updated) =>
set((state) => ({
amenities: state.amenities.map((a, i) =>
i === index ? { ...a, ...updated } : a,
),
})),
}));

302
src/pages/tours/lib/type.ts Normal file
View File

@@ -0,0 +1,302 @@
export interface GetAllTours {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
id: number;
destination: string;
duration_days: number;
hotel_name: string;
price: number;
hotel_rating: number;
hotel_aminates: string;
}[];
};
}
export interface GetOneTours {
status: boolean;
data: {
id: 0;
hotel_name: string;
hotel_rating: string;
hotel_amenities: string;
title: string;
price: number;
min_person: number;
max_person: number;
departure: string;
destination: string;
departure_time: string;
travel_time: string;
location_name: string;
passenger_count: number;
languages: string;
hotel_info: string;
duration_days: number;
rating: number;
hotel_meals: string;
slug: string;
visa_required: true;
image_banner: string;
badge: number[];
transports: [
{
price: number;
full_info: {
name: string;
icon_name: string;
};
},
];
ticket_images: [
{
image: string;
},
];
ticket_amenities: [
{
name: string;
name_ru: string;
icon_name: string;
},
];
ticket_included_services: [
{
image: string;
title: string;
title_ru: string;
desc: string;
desc_uz: string;
},
];
ticket_itinerary: [
{
title: string;
title_ru: string;
duration: number;
},
];
ticket_hotel_meals: [
{
image: string;
name: string;
name_ru: string;
desc: string;
desc_ru: string;
},
];
};
}
export interface CreateTourRes {
status: boolean;
data: {
id: number;
hotel_name: string;
hotel_rating: string;
hotel_amenities: string;
title: string;
title_ru: string;
price: number;
min_person: number;
max_person: number;
departure: string;
departure_ru: string;
destination: string;
destination_ru: string;
departure_time: string;
travel_time: string;
location_name: string;
location_name_ru: string;
passenger_count: number;
languages: string;
hotel_info: string;
hotel_info_ru: string;
duration_days: number;
rating: number;
hotel_meals: string;
hotel_meals_ru: string;
slug: string;
visa_required: true;
image_banner: string;
badge: number[];
};
}
export interface Hotel_Badge {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: Badge[];
};
}
export interface Hotel_BadgeId {
status: boolean;
data: Badge;
}
export interface Badge {
id: number;
name: string;
name_ru: string;
color: string;
}
export interface Hotel_Tarif {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: Tarif[];
};
}
export interface Hotel_TarifId {
status: boolean;
data: Tarif;
}
export interface Tarif {
id: number;
name: string;
}
export interface Hotel_Transport {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: Transport[];
};
}
export interface Hotel_TranportId {
status: boolean;
data: Transport;
}
export interface Transport {
id: number;
name: string;
name_ru: string;
icon_name: string;
}
export interface Hotel_Type {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: Type[];
};
}
export interface Hotel_TypeId {
status: boolean;
data: Type;
}
export interface Type {
id: number;
name: string;
name_ru: string;
}
export interface HotelAllFeatures {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: HotelFeatures[];
};
}
export interface HotelAllFeaturesType {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: HotelFeaturesType[];
};
}
export interface HotelFeaturesDetail {
status: boolean;
data: HotelFeatures;
}
export interface HotelFeatures {
id: number;
hotel_feature_type_name: string;
hotel_feature_type_name_ru: string;
}
export interface HotelFeaturesType {
id: number;
feature_name: string;
feature_name_ru: string;
feature_type: {
id: number;
hotel_feature_type_name: string;
hotel_feature_type_name_ru: string;
};
}
export interface HotelFeaturesTypeDetail {
status: boolean;
data: {
id: number;
feature_name: string;
feature_name_ru: string;
feature_type: {
id: number;
hotel_feature_type_name: string;
hotel_feature_type_name_ru: string;
};
};
}

View File

@@ -0,0 +1,353 @@
import {
hotelBadgeCreate,
hotelBadgeDelete,
hotelBadgeDetail,
hotelBadgeUpdate,
} from "@/pages/tours/lib/api";
import { BadgesColumns } from "@/pages/tours/lib/column";
import type { Badge } 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 {
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" }),
color: z.string().min(1, { message: "Majburiy maydon" }),
});
const BadgeTable = ({
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: Badge[];
}
| 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_badge", editId],
queryFn: () => hotelBadgeDetail({ id: editId! }),
enabled: !!editId,
});
const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => hotelBadgeDelete({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_badge"] });
queryClient.refetchQueries({ queryKey: ["all_badge"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const handleDelete = (id: number) => {
deleteMutate({ id });
};
const columns = BadgesColumns(handleEdit, handleDelete, t);
const { mutate: create, isPending } = useMutation({
mutationFn: ({
body,
}: {
body: { name: string; color: string; name_ru: string };
}) => hotelBadgeCreate({ body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_badge"] });
queryClient.refetchQueries({ queryKey: ["all_badge"] });
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; color: string; name_ru: string };
}) => hotelBadgeUpdate({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_badge"] });
queryClient.refetchQueries({ queryKey: ["all_badge"] });
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: "",
color: "#000000",
},
});
useEffect(() => {
if (badgeDetail) {
form.setValue("color", badgeDetail.data.data.color);
form.setValue("name", badgeDetail.data.data.name);
form.setValue("name_ru", badgeDetail.data.data.name_ru);
}
}, [editId, badgeDetail]);
function onSubmit(values: z.infer<typeof formSchema>) {
if (types === "create") {
create({
body: {
color: values.color,
name: values.name,
name_ru: values.name_ru,
},
});
} else if (types === "edit" && editId) {
update({
id: editId,
body: {
color: values.color,
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} />
<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="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>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Rang")}</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<Input
type="color"
{...field}
className="h-12 p-1 cursor-pointer rounded-md border border-gray-400"
/>
<span className="text-sm text-gray-400">
{field.value}
</span>
</div>
</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 BadgeTable;

View File

@@ -1,15 +1,28 @@
"use client";
import { getOneTours } from "@/pages/tours/lib/api";
import StepOne from "@/pages/tours/ui/StepOne";
import StepTwo from "@/pages/tours/ui/StepTwo";
import { useQuery } from "@tanstack/react-query";
import { Hotel, Plane } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
const CreateEditTour = () => {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const isEditMode = useMemo(() => !!id, [id]);
const [step, setStep] = useState(1);
const { data } = useQuery({
queryKey: ["tours_detail", id],
queryFn: () => {
return getOneTours({ id: Number(id) });
},
select(data) {
return data.data;
},
});
return (
<div className="p-8 w-full mx-auto bg-gray-900">
@@ -21,16 +34,18 @@ const CreateEditTour = () => {
<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" />
1. {t("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" />
2. {t("Mehmonxona")} <Hotel className="w-5 h-5 inline ml-2" />
</div>
</div>
{step === 1 && <StepOne setStep={setStep} />}
{step === 2 && <StepTwo setStep={setStep} />}
{step === 1 && (
<StepOne setStep={setStep} data={data} isEditMode={isEditMode} />
)}
{step === 2 && <StepTwo data={data} isEditMode={isEditMode} />}
</div>
);
};

View File

@@ -0,0 +1,351 @@
import {
hotelFeatureCreate,
hotelFeatureDelete,
hotelFeatureDetail,
hotelFeatureUpdate,
} from "@/pages/tours/lib/api";
import { FeatureColumns } from "@/pages/tours/lib/column";
import type { HotelFeatures } 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 {
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, type Dispatch, type SetStateAction } 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" }),
});
const FeaturesTable = ({
data,
page,
pageSize,
setActiveTab,
setFeatureId,
}: {
page: number;
pageSize: number;
setActiveTab: Dispatch<SetStateAction<string>>;
setFeatureId: Dispatch<SetStateAction<number | null>>;
data:
| {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: HotelFeatures[];
}
| 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_feature", editId],
queryFn: () => hotelFeatureDetail({ id: editId! }),
enabled: !!editId,
});
const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => hotelFeatureDelete({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_feature"] });
queryClient.refetchQueries({ queryKey: ["all_feature"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const handleDelete = (id: number) => {
deleteMutate({ id });
};
const columns = FeatureColumns(
handleEdit,
handleDelete,
t,
setActiveTab,
setFeatureId,
);
const { mutate: create, isPending } = useMutation({
mutationFn: ({
body,
}: {
body: {
hotel_feature_type_name: string;
hotel_feature_type_name_ru: string;
};
}) => hotelFeatureCreate({ body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_feature"] });
queryClient.refetchQueries({ queryKey: ["all_feature"] });
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: {
hotel_feature_type_name: string;
hotel_feature_type_name_ru: string;
};
}) => hotelFeatureUpdate({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_feature"] });
queryClient.refetchQueries({ queryKey: ["all_feature"] });
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("name", badgeDetail.data.data.hotel_feature_type_name);
form.setValue(
"name_ru",
badgeDetail.data.data.hotel_feature_type_name_ru,
);
}
}, [editId, badgeDetail]);
function onSubmit(values: z.infer<typeof formSchema>) {
if (types === "create") {
create({
body: {
hotel_feature_type_name: values.name,
hotel_feature_type_name_ru: values.name_ru,
},
});
} else if (types === "edit" && editId) {
update({
id: editId,
body: {
hotel_feature_type_name: values.name,
hotel_feature_type_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="pageFeature"
namePageSize="pageSizeFeature"
/>
<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="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 FeaturesTable;

View File

@@ -0,0 +1,342 @@
import {
hotelFeatureTypeCreate,
hotelFeatureTypeDelete,
hotelFeatureTypeDetail,
hotelFeatureTypeUpdate,
} from "@/pages/tours/lib/api";
import { FeatureTypeColumns } from "@/pages/tours/lib/column";
import type { HotelFeaturesType } 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 {
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" }),
});
const FeaturesTableType = ({
data,
page,
pageSize,
featureId,
}: {
page: number;
featureId: number | null;
pageSize: number;
data:
| {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: HotelFeaturesType[];
}
| 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_feature_type", editId],
queryFn: () => hotelFeatureTypeDetail({ id: editId! }),
enabled: !!editId,
});
const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => hotelFeatureTypeDelete({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_feature_type"] });
queryClient.refetchQueries({ queryKey: ["all_feature_type"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const handleDelete = (id: number) => {
deleteMutate({ id });
};
const columns = FeatureTypeColumns(handleEdit, handleDelete, t);
const { mutate: create, isPending } = useMutation({
mutationFn: ({
body,
}: {
body: {
feature_name: string;
feature_name_ru: string;
feature_type: number;
};
}) => hotelFeatureTypeCreate({ body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_feature_type"] });
queryClient.refetchQueries({ queryKey: ["all_feature_type"] });
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: {
feature_name: string;
feature_name_ru: string;
};
}) => hotelFeatureTypeUpdate({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_feature_type"] });
queryClient.refetchQueries({ queryKey: ["all_feature_type"] });
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("name", badgeDetail.data.data.feature_name);
form.setValue("name_ru", badgeDetail.data.data.feature_name_ru);
}
}, [editId, badgeDetail]);
function onSubmit(values: z.infer<typeof formSchema>) {
if (types === "create") {
create({
body: {
feature_name: values.name,
feature_name_ru: values.name_ru,
feature_type: featureId!,
},
});
} else if (types === "edit" && editId) {
update({
id: editId,
body: {
feature_name: values.name,
feature_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="pageFeature"
namePageSize="pageSizeFeature"
/>
<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="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 FeaturesTableType;

View File

@@ -0,0 +1,324 @@
import {
hotelTypeCreate,
hotelTypeDelete,
hotelTypeDetail,
hotelTypeUpdate,
} from "@/pages/tours/lib/api";
import { TypeColumns } from "@/pages/tours/lib/column";
import type { Type } 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 {
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" }),
});
const MealTable = ({
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: Type[];
}
| 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: typeDetail } = useQuery({
queryKey: ["detail_type", editId],
queryFn: () => hotelTypeDetail({ id: editId! }),
enabled: !!editId,
});
const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => hotelTypeDelete({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_type"] });
queryClient.refetchQueries({ queryKey: ["all_type"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const handleDelete = (id: number) => {
deleteMutate({ id });
};
const columns = TypeColumns(handleEdit, handleDelete, t);
const { mutate: create, isPending } = useMutation({
mutationFn: ({ body }: { body: { name: string; name_ru: string } }) =>
hotelTypeCreate({ body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_type"] });
queryClient.refetchQueries({ queryKey: ["all_type"] });
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 };
}) => hotelTypeUpdate({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["detail_type"] });
queryClient.refetchQueries({ queryKey: ["all_type"] });
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 (typeDetail) {
form.setValue("name", typeDetail.data.data.name);
form.setValue("name_ru", typeDetail.data.data.name_ru);
}
}, [editId, typeDetail]);
function onSubmit(values: z.infer<typeof formSchema>) {
if (types === "create") {
create({
body: {
name: values.name,
name_ru: values.name_ru,
},
});
} else if (types === "edit" && editId) {
update({
id: editId,
body: {
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} />
<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="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 MealTable;

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,18 @@
"use client";
import {
createHotel,
hotelFeature,
hotelFeatureType,
hotelType,
} from "@/pages/tours/lib/api";
import { useTicketStore } from "@/pages/tours/lib/store";
import type {
GetOneTours,
HotelFeatures,
HotelFeaturesType,
Type,
} from "@/pages/tours/lib/type";
import {
Form,
FormControl,
@@ -15,45 +30,64 @@ import {
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 { useMutation } from "@tanstack/react-query";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import z from "zod";
const formSchema = z.object({
title: z.string().min(2, {
message: "Sarlavha kamida 2 ta belgidan iborat bolishi kerak.",
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
}),
rating: z.number(),
rating: z.number().min(1).max(5),
mealPlan: z.string().min(1, { message: "Taom rejasi tanlanishi majburiy" }),
hotelType: z
.string()
.min(1, { message: "Mehmonxona turi tanlanishi majburiy" }),
.array(z.string())
.min(1, { message: "Kamida 1 ta mehmonxona turi tanlang" }),
hotelFeatures: z
.array(z.string())
.min(1, { message: "Kamida 1 ta xususiyat tanlanishi kerak" }),
.min(1, { message: "Kamida 1 ta xususiyat tanlang" }),
hotelFeaturesType: z
.array(z.string())
.min(1, { message: "Kamida 1 ta tur tanlang" }),
});
const StepTwo = ({
setStep,
data,
isEditMode,
}: {
setStep: Dispatch<SetStateAction<number>>;
data: GetOneTours | undefined;
isEditMode: boolean;
}) => {
const { amenities, id: ticketId } = useTicketStore();
const navigator = useNavigate();
const { t } = useTranslation();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
rating: 3.0,
mealPlan: "",
hotelType: "",
hotelType: [],
hotelFeatures: [],
hotelFeaturesType: [],
},
});
function onSubmit() {
navigator("tours");
}
useEffect(() => {
if (isEditMode && data?.data) {
const tourData = data.data;
form.setValue("title", tourData.hotel_name);
form.setValue("rating", Number(tourData.hotel_rating));
form.setValue("mealPlan", tourData.hotel_meals);
}
}, [isEditMode, data, form]);
const mealPlans = [
"Breakfast Only",
@@ -61,24 +95,221 @@ const StepTwo = ({
"Full Board",
"All Inclusive",
];
const hotelTypes = ["Hotel", "Resort", "Guest House", "Apartment"];
const [allHotelTypes, setAllHotelTypes] = useState<Type[]>([]);
const [allHotelFeature, setAllHotelFeature] = useState<HotelFeatures[]>([]);
const [allHotelFeatureType, setAllHotelFeatureType] = useState<
HotelFeaturesType[]
>([]);
const [featureTypeMapping, setFeatureTypeMapping] = useState<
Record<string, string[]>
>({});
const selectedHotelFeatures = form.watch("hotelFeatures");
useEffect(() => {
const loadAll = async () => {
try {
let page = 1;
let results: Type[] = [];
let hasNext = true;
while (hasNext) {
const res = await hotelType({ page, page_size: 50 });
const data = res.data.data;
results = [...results, ...data.results];
hasNext = !!data.links.next;
page++;
}
setAllHotelTypes(results);
} catch (err) {
console.error(err);
}
};
loadAll();
}, []);
useEffect(() => {
const loadAll = async () => {
try {
let page = 1;
let results: HotelFeatures[] = [];
let hasNext = true;
while (hasNext) {
const res = await hotelFeature({ page, page_size: 50 });
const data = res.data.data;
results = [...results, ...data.results];
hasNext = !!data.links.next;
page++;
}
setAllHotelFeature(results);
} catch (err) {
console.error(err);
}
};
loadAll();
}, []);
useEffect(() => {
if (selectedHotelFeatures.length === 0) {
setAllHotelFeatureType([]);
setFeatureTypeMapping({});
return;
}
const loadAll = async () => {
try {
const selectedFeatureIds = selectedHotelFeatures
.map((featureId) => Number(featureId))
.filter((id) => !isNaN(id));
if (selectedFeatureIds.length === 0) return;
let allResults: HotelFeaturesType[] = [];
const newMapping: Record<string, string[]> = {};
for (const featureId of selectedFeatureIds) {
let page = 1;
let hasNext = true;
const featureTypes: string[] = [];
while (hasNext) {
const res = await hotelFeatureType({
page,
page_size: 50,
feature_type: featureId,
});
const data = res.data.data;
allResults = [...allResults, ...data.results];
data.results.forEach((item: HotelFeaturesType) => {
featureTypes.push(String(item.id));
});
hasNext = !!data.links.next;
page++;
}
newMapping[String(featureId)] = featureTypes;
}
const uniqueResults = allResults.filter(
(item, index, self) =>
index === self.findIndex((t) => t.id === item.id),
);
setAllHotelFeatureType(uniqueResults);
setFeatureTypeMapping(newMapping);
} catch (err) {
console.error(err);
}
};
loadAll();
}, [selectedHotelFeatures]);
const { mutate, isPending } = useMutation({
mutationFn: (body: FormData) => createHotel({ body }),
onSuccess: () => {
navigator("/tours");
toast.success(t("Muvaffaqiyatli saqlandi"), {
richColors: true,
position: "top-center",
});
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const removeHotelType = (typeId: string) => {
const current = form.getValues("hotelType");
form.setValue(
"hotelType",
current.filter((val) => val !== typeId),
);
};
const onSubmit = (data: z.infer<typeof formSchema>) => {
const formData = new FormData();
formData.append("ticket", ticketId ? ticketId?.toString() : "");
formData.append("name", data.title);
formData.append("rating", String(data.rating));
formData.append(
"meal_plan",
data.mealPlan === "Breakfast Only"
? "breakfast"
: data.mealPlan === "All Inclusive"
? "all_inclusive"
: data.mealPlan === "Half Board"
? "half_board"
: data.mealPlan === "Full Board"
? "full_board"
: "all_inclusive",
);
data.hotelType.forEach((typeId) => {
formData.append("hotel_type", typeId);
});
data.hotelFeaturesType.forEach((typeId) => {
formData.append("hotel_features", typeId);
});
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);
});
mutate(formData);
};
const removeHotelFeature = (featureId: string) => {
const currentFeatures = form.getValues("hotelFeatures");
const currentFeatureTypes = form.getValues("hotelFeaturesType");
const typesToRemove = featureTypeMapping[featureId] || [];
form.setValue(
"hotelFeatures",
currentFeatures.filter((val) => val !== featureId),
);
form.setValue(
"hotelFeaturesType",
currentFeatureTypes.filter((val) => !typesToRemove.includes(val)),
);
};
const removeFeatureType = (typeId: string) => {
const currentValues = form.getValues("hotelFeaturesType");
form.setValue(
"hotelFeaturesType",
currentValues.filter((val) => val !== typeId),
);
};
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">
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1 items-end justify-end">
{/* Mehmonxona nomi */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<Label className="text-md">Mehmonxona nomi</Label>
<Label>{t("Mehmonxona nomi")}</Label>
<FormControl>
<Input
placeholder="Toshkent - Dubay"
{...field}
className="h-12 !text-md"
className="h-12"
/>
</FormControl>
<FormMessage />
@@ -86,25 +317,36 @@ const StepTwo = ({
)}
/>
{/* Mehmonxona rating */}
{/* Rating */}
<FormField
control={form.control}
name="rating"
render={({ field }) => (
<FormItem>
<Label className="text-md">Mehmonxona raytingi</Label>
<Label>{t("Mehmonxona reytingi")}</Label>
<FormControl>
<Input
type="text"
placeholder="3.0"
{...field}
className="h-12 !text-md"
value={field.value}
className="h-12"
onChange={(e) => {
const val = e.target.value;
if (/^\d*\.?\d*$/.test(val)) {
// Faqat raqam va nuqta kiritishga ruxsat berish
if (/^\d*\.?\d*$/.test(val) || val === "") {
field.onChange(val);
}
}}
onBlur={(e) => {
const val = e.target.value;
if (val && !isNaN(parseFloat(val))) {
// Agar 1 xonali bo'lsa, .0 qo'shish
const num = parseFloat(val);
if (val.indexOf(".") === -1) {
field.onChange(num.toFixed(1));
}
}
}}
/>
</FormControl>
<FormMessage />
@@ -116,31 +358,22 @@ const StepTwo = ({
<FormField
control={form.control}
name="mealPlan"
render={() => (
render={({ field }) => (
<FormItem>
<Label className="text-md">Meal Plan</Label>
<Label>{t("Taom rejasi")}</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>
)}
/>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="!h-12 w-full">
<SelectValue placeholder={t("Tanlang")} />
</SelectTrigger>
<SelectContent>
{mealPlans.map((plan) => (
<SelectItem key={plan} value={plan}>
{t(plan)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
@@ -151,134 +384,232 @@ const StepTwo = ({
<FormField
control={form.control}
name="hotelType"
render={() => (
render={({ field }) => (
<FormItem>
<Label className="text-md">Mehmonxona turi</Label>
<Label>{t("Mehmonxona turlari")}</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}
<div className="space-y-2">
{field.value.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border rounded-md">
{field.value.map((selectedValue) => {
const selectedItem = allHotelTypes.find(
(item) => String(item.id) === selectedValue,
);
return (
<div
key={selectedValue}
className="flex items-center gap-1 bg-purple-600 text-white px-3 py-1 rounded-md text-sm"
>
<span>{selectedItem?.name}</span>
<button
type="button"
onClick={() => removeHotelType(selectedValue)}
className="hover:bg-purple-700 rounded-full p-0.5"
>
<X size={14} />
</button>
</div>
);
})}
</div>
)}
<Select
value=""
onValueChange={(value) => {
if (!field.value.includes(value)) {
field.onChange([...field.value, value]);
}
}}
>
<SelectTrigger className="!h-12 w-full">
<SelectValue
placeholder={
field.value.length > 0
? t("Yana tanlang...")
: t("Tanlang")
}
/>
</SelectTrigger>
<SelectContent>
{allHotelTypes
.filter(
(type) => !field.value.includes(String(type.id)),
)
.map((type) => (
<SelectItem key={type.id} value={String(type.id)}>
{type.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</SelectContent>
</Select>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
{/* Hotel Features */}
<FormField
control={form.control}
name="hotelFeatures"
render={() => (
render={({ field }) => (
<FormItem>
<Label className="text-md">Mehmonxona qulayliklar</Label>
<Label>{t("Mehmonxona xususiyatlari")}</Label>
<FormControl>
<div className="space-y-2">
{field.value.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border rounded-md">
{field.value.map((selectedValue) => {
const selectedItem = allHotelFeature.find(
(item) => String(item.id) === selectedValue,
);
return (
<div
key={selectedValue}
className="flex items-center gap-1 bg-blue-600 text-white px-3 py-1 rounded-md text-sm"
>
<span>
{selectedItem?.hotel_feature_type_name}
</span>
<button
type="button"
onClick={() =>
removeHotelFeature(selectedValue)
}
className="hover:bg-blue-700 rounded-full p-0.5"
>
<X size={14} />
</button>
</div>
);
})}
</div>
)}
<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("");
<Select
value=""
onValueChange={(value) => {
if (!field.value.includes(value)) {
field.onChange([...field.value, value]);
}
}}
className="h-12"
>
Qoshish
</Button>
<SelectTrigger className="!h-12 w-full">
<SelectValue
placeholder={
field.value.length > 0
? t("Yana tanlang...")
: t("Tanlang")
}
/>
</SelectTrigger>
<SelectContent>
{allHotelFeature
.filter(
(type) => !field.value.includes(String(type.id)),
)
.map((type) => (
<SelectItem key={type.id} value={String(type.id)}>
{type.hotel_feature_type_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FormMessage />
</div>
</FormControl>
<FormMessage />
</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>
/>
{/* Hotel Feature Type */}
<FormField
control={form.control}
name="hotelFeaturesType"
render={({ field }) => (
<FormItem>
<Label>{t("Xususiyat turlari")}</Label>
<FormControl>
<div className="space-y-2">
{field.value.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border rounded-md">
{field.value.map((selectedValue) => {
const selectedItem = allHotelFeatureType.find(
(item) => String(item.id) === selectedValue,
);
return (
<div
key={selectedValue}
className="flex items-center gap-1 bg-green-600 text-white px-3 py-1 rounded-md text-sm"
>
<span>{selectedItem?.feature_name}</span>
<button
type="button"
onClick={() => removeFeatureType(selectedValue)}
className="hover:bg-green-700 rounded-full p-0.5"
>
<X size={14} />
</button>
</div>
);
})}
</div>
)}
<Select
value=""
onValueChange={(value) => {
if (!field.value.includes(value)) {
field.onChange([...field.value, value]);
}
}}
>
<SelectTrigger className="!h-12 w-full">
<SelectValue
placeholder={
selectedHotelFeatures.length === 0
? t("Avval xususiyat tanlang")
: field.value.length > 0
? t("Yana tanlang...")
: t("Tanlang")
}
/>
</SelectTrigger>
<SelectContent>
{allHotelFeatureType.length === 0 ? (
<div className="p-2 text-sm text-gray-500">
{t("Avval mehmonxona xususiyatini tanlang")}
</div>
) : (
allHotelFeatureType
.filter(
(type) => !field.value.includes(String(type.id)),
)
.map((type) => (
<SelectItem key={type.id} value={String(type.id)}>
{type.feature_name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<button
type="submit"
disabled={isPending}
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? t("Yuklanmoqda...") : t("Saqlash")}
</button>
</form>
</Form>
);

View File

@@ -0,0 +1,320 @@
import {
hotelTarfiDetail,
hotelTarifCreate,
hotelTarifDelete,
hoteltarifUpdate,
} from "@/pages/tours/lib/api";
import { TarifColumns } from "@/pages/tours/lib/column";
import type { Tarif } 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 {
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" }),
});
const TarifTable = ({
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: Tarif[];
}
| 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: tarifDetail } = useQuery({
queryKey: ["tarif_badge", editId],
queryFn: () => hotelTarfiDetail({ id: editId! }),
enabled: !!editId,
});
const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => hotelTarifDelete({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["tarif_badge"] });
queryClient.refetchQueries({ queryKey: ["all_tarif"] });
setOpen(false);
form.reset();
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const handleDelete = (id: number) => {
deleteMutate({ id });
};
const columns = TarifColumns(handleEdit, handleDelete, t);
const { mutate: create, isPending } = useMutation({
mutationFn: ({ body }: { body: { name: string; name_ru: string } }) =>
hotelTarifCreate({ body }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["tarif_badge"] });
queryClient.refetchQueries({ queryKey: ["all_tarif"] });
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 };
}) => hoteltarifUpdate({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["tarif_badge"] });
queryClient.refetchQueries({ queryKey: ["all_tarif"] });
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 (tarifDetail) {
form.setValue("name", tarifDetail.data.data.name);
}
}, [editId, tarifDetail]);
function onSubmit(values: z.infer<typeof formSchema>) {
if (types === "create") {
create({
body: {
name: values.name,
name_ru: values.name_ru,
},
});
} else if (types === "edit" && editId) {
update({
id: editId,
body: {
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" disabled>
<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="pageTarif"
namePageSize="pageTarifSize"
/>
<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="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 TarifTable;

View File

@@ -9,21 +9,37 @@ import {
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import { ImagePlus, XIcon } from "lucide-react";
import { useState } from "react";
import { useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
interface TicketsImagesModelProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
form: any; // React Hook Form control
name: string;
label?: string;
imageUrl?: string | string[] | undefined;
multiple?: boolean;
}
export default function TicketsImagesModel({
form,
name,
label = "Rasmlar",
multiple = true,
imageUrl,
}: TicketsImagesModelProps) {
const { t } = useTranslation();
const [previews, setPreviews] = useState<string[]>([]);
const inputId = useId();
useEffect(() => {
if (imageUrl) {
if (Array.isArray(imageUrl)) {
setPreviews(imageUrl);
} else {
setPreviews([imageUrl]);
}
}
}, [imageUrl]);
return (
<FormField
@@ -32,39 +48,61 @@ export default function TicketsImagesModel({
render={() => (
<FormItem>
<Label className="text-md">{label}</Label>
<FormControl>
<div className="flex flex-col gap-3">
{/* File Input */}
<Input
id="ticket-images"
id={inputId}
type="file"
accept="image/*"
multiple
multiple={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);
if (multiple) {
// ✅ Bir nechta rasm
const allFiles = [
...(form.getValues(name) || []),
...newFiles,
];
form.setValue(name, allFiles);
const urls = allFiles.map((file: File) =>
URL.createObjectURL(file),
);
setPreviews(urls);
} else {
// ✅ Faqat bitta rasm (banner)
const singleFile = newFiles[0] || null;
form.setValue(name, singleFile);
setPreviews(
singleFile ? [URL.createObjectURL(singleFile)] : [],
);
}
}}
/>
{/* Upload Zone */}
<label
htmlFor="ticket-images"
htmlFor={inputId}
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 className="font-semibold text-white">
{t("Rasmlarni tanlang")}
</p>
{multiple ? (
<p className="text-sm text-white">
{t("Bir nechta rasm yuklashingiz mumkin")}
</p>
) : (
<p className="text-sm text-white">
{t("Faqat bitta rasm yuklash mumkin")}
</p>
)}
</label>
{/* Preview Images */}
@@ -80,19 +118,29 @@ export default function TicketsImagesModel({
alt={`preview-${i}`}
className="object-cover w-full h-full"
/>
{/* Delete Button */}
<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);
if (multiple) {
// ✅ Kop rasm holati
const currentFiles = form.getValues(name) || [];
const newFiles = currentFiles.filter(
(_: File, idx: number) => idx !== i,
);
form.setValue(name, newFiles);
const newPreviews = previews.filter(
(_: string, idx: number) => idx !== i,
);
setPreviews(newPreviews);
} else {
// ✅ Bitta rasm holati
form.setValue(name, null);
setPreviews([]);
}
}}
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white transition"
>
<XIcon className="size-4 text-destructive" />
</button>
@@ -102,6 +150,7 @@ export default function TicketsImagesModel({
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}

View File

@@ -1,5 +1,6 @@
"use client";
import formatPrice from "@/shared/lib/formatPrice";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
@@ -19,6 +20,7 @@ import {
Utensils,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
type TourDetail = {
@@ -59,6 +61,7 @@ type TourDetail = {
};
export default function TourDetailPage() {
const { t } = useTranslation();
const params = useParams();
const router = useNavigate();
const [tour, setTour] = useState<TourDetail | null>(null);
@@ -251,10 +254,10 @@ export default function TourDetailPage() {
<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>
<p className="text-sm text-gray-400">{t("Narxi")}</p>
</div>
<p className="text-2xl font-bold text-white">
{tour.price.toLocaleString()} so'm
{formatPrice(tour.price, true)}
</p>
</CardContent>
</Card>
@@ -263,10 +266,10 @@ export default function TourDetailPage() {
<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>
<p className="text-sm text-gray-400">{t("Davomiyligi")}</p>
</div>
<p className="text-2xl font-bold text-white">
{tour.duration_days} kun
{tour.duration_days} {t("kun")}
</p>
</CardContent>
</Card>
@@ -275,10 +278,10 @@ export default function TourDetailPage() {
<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>
<p className="text-sm text-gray-400">{t("Yo'lovchilar")}</p>
</div>
<p className="text-2xl font-bold text-white">
{tour.passenger_count} kishi
{tour.passenger_count} {t("kishi")}
</p>
</CardContent>
</Card>
@@ -287,7 +290,7 @@ export default function TourDetailPage() {
<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>
<p className="text-sm text-gray-400">{t("Jo'nash sanasi")}</p>
</div>
<p className="text-xl font-bold text-white">
{new Date(tour.departure_date).toLocaleDateString("uz-UZ")}
@@ -302,31 +305,31 @@ export default function TourDetailPage() {
value="overview"
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
>
Umumiy
{t("Umumiy")}
</TabsTrigger>
<TabsTrigger
value="itinerary"
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
>
Marshshrut
{t("Marshshrut")}
</TabsTrigger>
<TabsTrigger
value="services"
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
>
Xizmatlar
{t("Xizmatlar")}
</TabsTrigger>
<TabsTrigger
value="hotel"
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
>
Mehmonxona
{t("Mehmonxona")}
</TabsTrigger>
<TabsTrigger
value="reviews"
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
>
Sharhlar
{t("Sharhlar")}
</TabsTrigger>
</TabsList>
@@ -334,7 +337,7 @@ export default function TourDetailPage() {
<Card className="border-gray-700 shadow-lg bg-gray-800">
<CardHeader>
<CardTitle className="text-2xl text-white">
Tur haqida ma'lumot
{t("Tur haqida ma'lumot")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
@@ -343,7 +346,9 @@ export default function TourDetailPage() {
<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="text-sm text-gray-400">
{t("Jo'nash joyi")}
</p>
<p className="font-semibold text-white">
{tour.departure}
</p>
@@ -353,7 +358,9 @@ export default function TourDetailPage() {
<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="text-sm text-gray-400">
{t("Yo'nalish")}
</p>
<p className="font-semibold text-white">
{tour.destination}
</p>
@@ -363,7 +370,7 @@ export default function TourDetailPage() {
<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="text-sm text-gray-400">{t("Tillar")}</p>
<p className="font-semibold text-white">
{tour.languages}
</p>
@@ -375,7 +382,9 @@ export default function TourDetailPage() {
<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="text-sm text-gray-400">
{t("Mehmonxona")}
</p>
<p className="font-semibold text-white">
{tour.hotel_info}
</p>
@@ -385,7 +394,9 @@ export default function TourDetailPage() {
<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="text-sm text-gray-400">
{t("Ovqatlanish")}
</p>
<p className="font-semibold text-white">
{tour.hotel_meals}
</p>
@@ -395,7 +406,7 @@ export default function TourDetailPage() {
<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="text-sm text-gray-400">{t("Tarif")}</p>
<p className="font-semibold text-white capitalize">
{tour.tariff[0]?.name}
</p>
@@ -406,7 +417,7 @@ export default function TourDetailPage() {
<div className="pt-6 border-t border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-white">
Qulayliklar
{t("Qulayliklar")}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{tour.ticket_amenities.map((amenity, idx) => (
@@ -430,7 +441,7 @@ export default function TourDetailPage() {
<Card className="border-gray-700 shadow-lg bg-gray-800">
<CardHeader>
<CardTitle className="text-2xl text-white">
Sayohat marshshruti
{t("Sayohat marshshruti")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
@@ -444,7 +455,7 @@ export default function TourDetailPage() {
variant="outline"
className="text-base border-gray-600 text-gray-300"
>
{day.duration}-kun
{day.duration}-{t("kun")}
</Badge>
<h3 className="text-xl font-semibold text-white">
{day.title}
@@ -492,7 +503,7 @@ export default function TourDetailPage() {
<Card className="border-gray-700 shadow-lg bg-gray-800">
<CardHeader>
<CardTitle className="text-2xl text-white">
Narxga kiritilgan xizmatlar
{t("Narxga kiritilgan xizmatlar")}
</CardTitle>
</CardHeader>
<CardContent>
@@ -526,7 +537,7 @@ export default function TourDetailPage() {
<Card className="border-gray-700 shadow-lg bg-gray-800">
<CardHeader>
<CardTitle className="text-2xl text-white">
Mehmonxona va ovqatlanish
{t("Mehmonxona va ovqatlanish")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
@@ -542,7 +553,7 @@ export default function TourDetailPage() {
<div>
<h3 className="text-lg font-semibold mb-4 text-white">
Ovqatlanish tafsilotlari
{t("Ovqatlanish tafsilotlari")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{tour.ticket_hotel_meals.map((meal, idx) => (
@@ -576,7 +587,7 @@ export default function TourDetailPage() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl text-white">
Mijozlar sharhlari
{t("Mijozlar sharhlari")}
</CardTitle>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
@@ -586,7 +597,7 @@ export default function TourDetailPage() {
{tour.rating}
</span>
<span className="text-gray-400">
({tour.ticket_comments.length} sharh)
({tour.ticket_comments.length} {t("sharh")})
</span>
</div>
</div>
@@ -621,9 +632,9 @@ export default function TourDetailPage() {
<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-sm text-gray-400 mb-1">{t("Tur firmasi")}</p>
<p className="text-xl font-semibold text-white">
Firma ID: {tour.travel_agency_id}
{t("Firma ID")}: {tour.travel_agency_id}
</p>
</div>
<Button
@@ -631,7 +642,7 @@ export default function TourDetailPage() {
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
{t("Firma sahifasiga o'tish")}
</Button>
</div>
</CardContent>

View File

@@ -1,5 +1,7 @@
"use client";
import { deleteTours, getAllTours } from "@/pages/tours/lib/api";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -16,65 +18,79 @@ import {
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Edit, Plane, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ChevronLeft,
ChevronRight,
Edit,
Loader2,
Plane,
PlusCircle,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
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 { t } = useTranslation();
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(3);
const [deleteId, setDeleteId] = useState<number | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
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 { data, isLoading, isError, refetch } = useQuery({
queryKey: ["all_tours", page],
queryFn: () => getAllTours({ page: page, page_size: 10 }),
});
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));
const { mutate } = useMutation({
mutationFn: (id: number) => deleteTours({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_tours"] });
setDeleteId(null);
}
},
});
const confirmDelete = (id: number) => {
mutate(id);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => {
refetch();
}}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
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>
<h1 className="text-3xl font-semibold">{t("Turlar ro'yxati")}</h1>
<Button onClick={() => navigate("/tours/create")} variant="default">
<PlusCircle className="w-5 h-5 mr-2" /> Yangi tur qo'shish
<PlusCircle className="w-5 h-5 mr-2" /> {t("Yangi tur qo'shish")}
</Button>
</div>
@@ -83,18 +99,19 @@ const Tours = () => {
<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]">{t("Manzil")}</TableHead>
<TableHead className="min-w-[120px]">
{t("Davomiyligi")}
</TableHead>
<TableHead className="min-w-[180px]">{t("Mehmonxona")}</TableHead>
<TableHead className="min-w-[200px]">{t("Narxi")}</TableHead>
<TableHead className="min-w-[150px] text-center">
Amallar
{t("Операции")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tours.map((tour, idx) => (
{data?.data.data.results.map((tour, idx) => (
<TableRow key={tour.id}>
<TableCell className="font-medium text-center">
{(page - 1) * 6 + idx + 1}
@@ -102,28 +119,25 @@ const Tours = () => {
<TableCell>
<div className="flex items-center gap-2 font-semibold">
<Plane className="w-4 h-4 text-primary" />
{tour.top_destinations}
{tour.destination}
</div>
</TableCell>
<TableCell className="text-sm text-primary font-medium">
{tour.top_duration}
{tour.duration_days} kun
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{tour.hotel_types}</span>
<span className="font-medium">{tour.hotel_name}</span>
<span className="text-xs text-muted-foreground">
{tour.tickets}
{tour.hotel_rating} {t("yulduzli mehmonxona")}
</span>
</div>
</TableCell>
<TableCell>
<span className="font-bold text-base text-green-600">
{tour.min_price} {tour.max_price}
{formatPrice(tour.price, true)}
</span>
</TableCell>
<TableCell className="text-sm">
{tour.hotel_amenities}
</TableCell>
<TableCell className="text-center">
<div className="flex gap-2 justify-center">
@@ -148,7 +162,7 @@ const Tours = () => {
size="sm"
onClick={() => navigate(`/tours/${tour.id}`)}
>
Batafsil
{t("Batafsil")}
</Button>
</div>
</TableCell>
@@ -158,50 +172,67 @@ const Tours = () => {
</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
{t("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.
{t(
"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
{t("Bekor qilish")}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
<Button
variant="destructive"
onClick={() => confirmDelete(deleteId!)}
>
<Trash2 className="w-4 h-4 mr-2" />
O'chirish
{t("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))}
<div className="flex justify-end mt-10 gap-3">
<button
disabled={page === 1}
onClick={() => setPage((p) => Math.max(p - 1, 1))}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
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}
<ChevronLeft className="w-5 h-5" />
</button>
{[...Array(data?.data.data.total_pages)].map((_, i) => (
<button
key={i}
onClick={() => setPage(i + 1)}
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
page === i + 1
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
}`}
>
{i + 1}
</button>
))}
<button
disabled={page === data?.data.data.total_pages}
onClick={() =>
setPage((p) =>
Math.min(p + 1, data ? data.data.data.total_pages : 1),
)
}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
Keyingi
</Button>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
);

View File

@@ -1,458 +1,241 @@
"use client";
import {
hotelBadge,
hotelFeature,
hotelFeatureType,
hotelTarif,
hotelTransport,
hotelType,
} from "@/pages/tours/lib/api";
import BadgeTable from "@/pages/tours/ui/BadgeTable";
import FeaturesTable from "@/pages/tours/ui/FeaturesTable";
import FeaturesTableType from "@/pages/tours/ui/FeaturesTableType";
import MealTable from "@/pages/tours/ui/MealTable";
import TarifTable from "@/pages/tours/ui/TarifTable";
import TransportTable from "@/pages/tours/ui/TransportTable";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } 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;
}
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
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 { t } = useTranslation();
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState("badge");
const [featureId, setFeatureId] = useState<number | null>(null);
const navigate = useNavigate();
const [badges, setBadges] = useState<Badge[]>([
{ id: 1, name: "Bestseller", color: "#FFD700" },
{ id: 2, name: "Yangi", color: "#4CAF50" },
]);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
const [tariffs, setTariffs] = useState<Tariff[]>([
{ id: 1, name: "Standart", price: 500 },
{ id: 2, name: "Premium", price: 1000 },
]);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["all_badge", page, pageSize],
queryFn: () => hotelBadge({ page, page_size: pageSize }),
select: (res) => res.data.data,
});
const [transports, setTransports] = useState<Transport[]>([
{ id: 1, name: "Avtobus", price: 200 },
{ id: 2, name: "Minivan", price: 500 },
]);
const pageTarif = parseInt(searchParams.get("pageTarif") || "1", 10);
const pageSizeTarif = parseInt(searchParams.get("pageTarifSize") || "10", 10);
const [mealPlans, setMealPlans] = useState<MealPlan[]>([
{ id: 1, name: "BB (Bed & Breakfast)" },
{ id: 2, name: "HB (Half Board)" },
{ id: 3, name: "FB (Full Board)" },
]);
const {
data: tarifData,
isLoading: tarifLoad,
isError: tarifError,
refetch: tarifRef,
} = useQuery({
queryKey: ["all_tarif", pageTarif, pageSizeTarif],
queryFn: () => hotelTarif({ page: pageTarif, page_size: pageSizeTarif }),
select: (res) => res.data.data,
});
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 pageTransport = parseInt(searchParams.get("pageTransport") || "1", 10);
const pageSizeTransport = parseInt(
searchParams.get("pageTransportSize") || "10",
10,
);
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 {
data: transportData,
isLoading: transportLoad,
isError: transportError,
refetch: transportRef,
} = useQuery({
queryKey: ["all_transport", pageTransport, pageSizeTransport],
queryFn: () =>
hotelTransport({ page: pageTransport, page_size: pageSizeTransport }),
select: (res) => res.data.data,
});
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 pageType = parseInt(searchParams.get("pageType") || "1", 10);
const pageSizeType = parseInt(searchParams.get("pageTypeSize") || "10", 10);
const closeModal = (): void => {
setIsModalOpen(false);
setFormData({});
setCurrentItem(null);
};
const {
data: typeData,
isLoading: typeLoad,
isError: typeError,
refetch: typeRef,
} = useQuery({
queryKey: ["all_type", pageType, pageSizeType],
queryFn: () => hotelType({ page: pageType, page_size: pageSizeType }),
select: (res) => res.data.data,
});
const handleSubmit = (): void => {
const setter = getSetterFunction();
const pageFeature = parseInt(searchParams.get("pageFeature") || "1", 10);
const pageSizeFeature = parseInt(
searchParams.get("pageSizeFeature") || "10",
10,
);
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 {
data: featureData,
isLoading: featureLoad,
isError: featureError,
refetch: featureRef,
} = useQuery({
queryKey: ["all_feature", pageFeature, pageSizeFeature],
queryFn: () =>
hotelFeature({ page: pageFeature, page_size: pageSizeFeature }),
select: (res) => res.data.data,
});
const handleDelete = (id: number): void => {
if (window.confirm("Rostdan ham o'chirmoqchimisiz?")) {
const setter = getSetterFunction();
setter(getCurrentData().filter((item) => item.id !== id));
}
};
const {
data: featureTypeData,
isLoading: featureTypeLoad,
isError: featureTypeError,
refetch: featureTypeRef,
} = useQuery({
queryKey: ["all_feature_type", pageFeature, pageSizeFeature, featureId],
queryFn: () =>
hotelFeatureType({
page: pageFeature,
page_size: pageSizeFeature,
feature_type: featureId!,
}),
select: (res) => res.data.data,
enabled: !!featureId,
});
const tabs: Tab[] = [
{ id: "badges", label: "Belgilar" },
{ id: "tariffs", label: "Tariflar" },
{ id: "transports", label: "Transportlar" },
{ id: "mealPlans", label: "Ovqatlanish" },
{ id: "hotelTypes", label: "Otel turlari" },
];
if (
isLoading ||
tarifLoad ||
transportLoad ||
typeLoad ||
featureLoad ||
featureTypeLoad
) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
const getFieldValue = (fieldName: string): string | number => {
return (formData as Record<string, string | number>)[fieldName] || "";
if (
isError ||
tarifError ||
transportError ||
typeError ||
featureError ||
featureTypeError
) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => {
refetch();
tarifRef();
transportRef();
typeRef();
featureRef();
featureTypeRef();
}}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
const handleTabChange = (value: string) => {
setActiveTab(value);
navigate({
pathname: window.location.pathname,
search: "",
});
};
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>
<div className="min-h-screen bg-gray-900 p-6 w-full text-white">
<div className="max-w-[90%] mx-auto space-y-6">
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as TabId);
setSearchTerm("");
}}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="grid w-full grid-cols-5">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
<TabsList className="w-full">
<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="hotel_type">{t("Otel turlari")}</TabsTrigger>
<TabsTrigger value="hotel_features">
{t("Otel sharoitlari")}
</TabsTrigger>
</TabsList>
<TabsContent value="badge" className="space-y-4">
<BadgeTable data={data} page={page} pageSize={pageSize} />
</TabsContent>
<TabsContent value="tarif" className="space-y-4">
<TarifTable
data={tarifData}
page={pageTarif}
pageSize={pageSizeTarif}
/>
</TabsContent>
<TabsContent value="transport" className="space-y-4">
<TransportTable
data={transportData}
page={pageTransport}
pageSize={pageSizeTransport}
/>
</TabsContent>
<TabsContent value="hotel_type" className="space-y-4">
<MealTable
data={typeData}
page={pageTransport}
pageSize={pageSizeTransport}
/>
</TabsContent>
<TabsContent value="hotel_features" className="space-y-4">
<FeaturesTable
data={featureData}
page={pageFeature}
pageSize={pageSizeFeature}
setActiveTab={setActiveTab}
setFeatureId={setFeatureId}
/>
</TabsContent>
<TabsContent value="feature_type" className="space-y-4">
<FeaturesTableType
data={featureTypeData}
page={pageFeature}
featureId={featureId}
pageSize={pageSizeFeature}
/>
</TabsContent>
</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>
);

View File

@@ -0,0 +1,334 @@
import {
hotelTranportCreate,
hotelTransportDelete,
hotelTransportDetail,
hotelTransportUpdate,
} from "@/pages/tours/lib/api";
import { TranportColumns } from "@/pages/tours/lib/column";
import type { Transport } 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 {
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 TransportTable = ({
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: Transport[];
}
| undefined;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [types, setTypes] = useState<"edit" | "create">("create");
const [selectedIcon, setSelectedIcon] = useState("Bus");
const queryClient = useQueryClient();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
name_ru: "",
icon_name: "",
},
});
useEffect(() => {
form.setValue("icon_name", selectedIcon);
}, [selectedIcon]);
const handleEdit = (id: number) => {
setTypes("edit");
setOpen(true);
setEditId(id);
};
const { data: transportDetail } = useQuery({
queryKey: ["detail_transport", editId],
queryFn: () => hotelTransportDetail({ id: editId! }),
enabled: !!editId,
});
useEffect(() => {
if (transportDetail) {
form.setValue("name", transportDetail.data.data.name);
form.setValue("name_ru", transportDetail.data.data.name_ru);
form.setValue("icon_name", transportDetail.data.data.icon_name);
setSelectedIcon(transportDetail.data.data.icon_name);
}
}, [transportDetail, editId, form]);
const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => hotelTransportDelete({ id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
toast.success(t("Ochirildi"), { position: "top-center" });
},
onError: () =>
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
});
const { mutate: create, isPending } = useMutation({
mutationFn: ({
body,
}: {
body: { name: string; name_ru: string; icon_name: string };
}) => hotelTranportCreate({ body }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
setOpen(false);
form.reset();
toast.success(t("Muvaffaqiyatli qoshildi"), { position: "top-center" });
},
onError: () =>
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
});
const { mutate: update, isPending: updatePending } = useMutation({
mutationFn: ({
body,
id,
}: {
id: number;
body: { name: string; name_ru: string; icon_name: string };
}) => hotelTransportUpdate({ body, id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
setOpen(false);
form.reset();
toast.success(t("Tahrirlandi"), { position: "top-center" });
},
onError: () =>
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
});
function onSubmit(values: z.infer<typeof formSchema>) {
const body = {
name: values.name,
name_ru: values.name_ru,
icon_name: selectedIcon || values.icon_name,
};
if (types === "create") create({ body });
if (types === "edit" && editId) update({ id: editId, body });
}
const handleDelete = (id: number) => deleteMutate({ id });
const columns = TranportColumns(handleEdit, handleDelete, t);
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();
setSelectedIcon("");
}}
>
<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="pageTransport"
namePageSize="pageTransportSize"
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<p className="text-xl font-semibold mb-4">
{types === "create"
? t("Yangi transport qoshish")
: t("Tahrirlash")}
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 p-2"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Nomi (uz)")}</FormLabel>
<FormControl>
<Input placeholder={t("Nomi (uz)")} {...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>
)}
/>
<FormField
control={form.control}
name="icon_name"
render={() => (
<FormItem>
<FormLabel>{t("Belgi (Icon)")}</FormLabel>
<FormControl className="w-full">
<IconSelect
setSelectedIcon={setSelectedIcon}
selectedIcon={selectedIcon}
/>
</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" disabled={isPending || updatePending}>
{isPending || updatePending ? (
<Loader className="animate-spin" />
) : types === "create" ? (
t("Saqlash")
) : (
t("Tahrirlash")
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
};
export default TransportTable;

View File

@@ -1,220 +0,0 @@
import formatPhone from "@/shared/lib/formatPhone";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import clsx from "clsx";
import { ArrowLeft, Mail, Phone, Save, User } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
export default function EditUser() {
const navigate = useNavigate();
const { id } = useParams();
const [formData, setFormData] = useState({
username: "",
email: "",
phone: "+998",
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
setFormData({
username: "john_doe",
email: "john@example.com",
phone: "+998901234567",
});
}, [id]);
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) {
newErrors.username = "Username majburiy";
} else if (formData.username.length < 3) {
newErrors.username =
"Username kamida 3 ta belgidan iborat bo'lishi kerak";
}
if (!formData.email.trim() && !formData.phone.trim()) {
newErrors.contact = "Email yoki telefon raqami kiritilishi shart";
}
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "Email formati noto'g'ri";
}
if (
formData.phone &&
!/^\+998\d{9}$/.test(formData.phone.replace(/\s/g, ""))
) {
newErrors.phone = "Telefon raqami formati: +998901234567";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
navigate("/");
}
};
return (
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
<div className="w-full mx-auto">
{/* Header */}
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate(-1)}
className="mb-4 -ml-2 text-slate-400 cursor-pointer hover:text-slate-100 hover:bg-slate-800/50"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Orqaga
</Button>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-600 to-teal-700 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<User className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">Tahrirlash</h1>
<p className="text-slate-400 text-sm">Ma'lumotlarni yangilang</p>
</div>
</div>
</div>
{/* Form Card */}
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6 md:p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Username */}
<div className="space-y-2">
<Label
htmlFor="username"
className="text-slate-300 font-medium text-sm"
>
Username <span className="text-red-400">*</span>
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
id="username"
value={formData.username}
onChange={(e) => {
setFormData((p) => ({ ...p, username: e.target.value }));
setErrors((p) => ({ ...p, username: "" }));
}}
placeholder="john_doe"
className={clsx(
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
errors.username
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
)}
/>
</div>
{errors.username && (
<p className="text-xs text-red-400">{errors.username}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label
htmlFor="email"
className="text-slate-300 font-medium text-sm"
>
Email
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => {
setFormData((p) => ({ ...p, email: e.target.value }));
setErrors((p) => ({ ...p, email: "", contact: "" }));
}}
placeholder="email@example.com"
className={clsx(
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
errors.email
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
)}
/>
</div>
{errors.email && (
<p className="text-xs text-red-400">{errors.email}</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label
htmlFor="phone"
className="text-slate-300 font-medium text-sm"
>
Telefon raqami
</Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
id="phone"
type="tel"
value={formatPhone(formData.phone)}
onChange={(e) => {
setFormData((p) => ({ ...p, phone: e.target.value }));
setErrors((p) => ({ ...p, phone: "", contact: "" }));
}}
placeholder="+998 90 123 45 67"
className={clsx(
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
errors.phone
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
)}
/>
</div>
{errors.phone && (
<p className="text-xs text-red-400">{errors.phone}</p>
)}
</div>
{/* Contact Error */}
{errors.contact && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3">
<p className="text-xs text-amber-400">{errors.contact}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
className="flex-1 h-11 rounded-lg border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 font-medium cursor-pointer"
>
Bekor qilish
</Button>
<Button
type="submit"
className="flex-1 h-11 rounded-lg bg-gradient-to-r cursor-pointer text-md from-emerald-600 to-teal-700 hover:from-emerald-700 hover:to-teal-800 text-white font-medium shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30 transition-all"
>
<Save className="!w-5 !h-5 mr-2" />
Yangilash
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,398 +0,0 @@
import { Button } from "@/shared/ui/button";
import {
AlertTriangle,
Calendar,
ChevronLeft,
ChevronRight,
Eye,
Mail,
Pencil,
Phone,
Plus,
Search,
Trash2,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
type User = {
id: number;
username: string;
email?: string;
phone?: string;
createdAt: string;
};
export default function UserList() {
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const usersPerPage = 6;
const navigate = useNavigate();
const [users, setUsers] = useState<User[]>([
{
id: 1,
username: "john_doe",
email: "john@example.com",
createdAt: "2024-01-15",
},
{
id: 2,
username: "jane_smith",
phone: "+998907654321",
createdAt: "2024-01-20",
},
{
id: 3,
username: "ali_karimov",
phone: "+998909876543",
createdAt: "2024-02-01",
},
{
id: 4,
username: "sara_johnson",
email: "sara@example.com",
phone: "+998901234567",
createdAt: "2024-02-10",
},
{
id: 5,
username: "murod_toshev",
email: "murod@example.com",
createdAt: "2024-02-15",
},
{
id: 6,
username: "aziza_sobirova",
email: "aziza@example.com",
createdAt: "2024-03-01",
},
{
id: 7,
username: "timur_ergashev",
phone: "+998907777777",
createdAt: "2024-03-10",
},
{
id: 8,
username: "odil_akbarov",
email: "odil@example.com",
createdAt: "2024-03-12",
},
{
id: 9,
username: "lola_nazarova",
phone: "+998909111222",
createdAt: "2024-04-05",
},
{
id: 10,
username: "bahrom_tursunov",
email: "bahrom@example.com",
createdAt: "2024-04-10",
},
]);
const [confirmDelete, setConfirmDelete] = useState<User | null>(null);
const handleDelete = (id: number) => {
setUsers((prev) => prev.filter((u) => u.id !== id));
setConfirmDelete(null);
};
const formatPhone = (phone: string) => {
if (phone.startsWith("+998")) {
return phone.replace(
/(\+998)(\d{2})(\d{3})(\d{2})(\d{2})/,
"$1 $2 $3 $4 $5",
);
}
return phone;
};
const filteredUsers = users.filter(
(user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.phone?.includes(searchQuery),
);
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
const startIndex = (currentPage - 1) * usersPerPage;
const paginatedUsers = filteredUsers.slice(
startIndex,
startIndex + usersPerPage,
);
const getInitials = (username: string) => username.slice(0, 2).toUpperCase();
const getAvatarGradient = (id: number) => {
const gradients = ["from-blue-600 to-cyan-500"];
return gradients[id % gradients.length];
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 py-12 px-4 w-full">
<div className="max-w-[90%] mx-auto">
<div className="flex justify-between items-center">
<div className="mb-12">
<div className="flex items-center gap-3 mb-3">
<div className="p-3 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20">
<Users className="w-8 h-8 text-white" />
</div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
Foydalanuvchilar
</h1>
</div>
<p className="text-slate-400 text-lg ml-14">
Jami {users.length} ta foydalanuvchini boshqaring
</p>
</div>
<Button
onClick={() => navigate("/users/create")}
className="py-5 px-4 bg-gradient-to-br text-white cursor-pointer from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20"
>
<Plus />
<p>Foydalanuvchi Qo'shish</p>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
<StatCard
title="Jami foydalanuvchilar"
value={users.length.toString()}
icon={<Users className="w-6 h-6" />}
gradient="from-blue-600 to-blue-400"
/>
<StatCard
title="Email bilan ro'yxatlangan"
value={users.filter((u) => u.email).length.toString()}
icon={<Mail className="w-6 h-6" />}
gradient="from-cyan-600 to-cyan-400"
/>
<StatCard
title="Telefon bilan ro'yxatlangan"
value={users.filter((u) => u.phone).length.toString()}
icon={<Phone className="w-6 h-6" />}
gradient="from-purple-600 to-pink-400"
/>
</div>
<div className="mb-10 relative">
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 rounded-2xl blur-xl" />
<div className="relative bg-slate-800/50 border border-slate-700/50 rounded-2xl p-6 backdrop-blur-sm">
<Search className="absolute left-8 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Username, email yoki telefon raqami bo'yicha qidirish..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-14 pr-4 py-3 bg-slate-700/30 border border-slate-600/50 text-white placeholder-slate-400 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
{paginatedUsers.map((user) => (
<div className="group relative hover:scale-105 transition-transform duration-300">
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100" />
<div className="relative h-full flex flex-col justify-between bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl p-6 shadow-2xl hover:border-slate-600/70 transition-all backdrop-blur-sm">
<div>
<div className="flex items-center gap-4 mb-4">
<div
className={`w-16 h-16 rounded-xl bg-gradient-to-br ${getAvatarGradient(
user.id,
)} flex items-center justify-center text-white text-xl font-bold shadow-lg`}
>
{getInitials(user.username)}
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-white truncate">
{user.username}
</h3>
<span className="inline-block mt-1 px-3 py-1 bg-green-500/20 text-green-300 text-xs font-semibold rounded-full border border-green-500/50">
Faol
</span>
</div>
</div>
<div className="space-y-3 pt-4 border-t border-slate-700/50">
{user.email && (
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-cyan-400" />
<span className="text-slate-300 text-sm truncate">
{user.email}
</span>
</div>
)}
{user.phone && (
<div className="flex items-center gap-3">
<Phone className="w-5 h-5 text-cyan-400" />
<span className="text-slate-300 text-sm">
{formatPhone(user.phone)}
</span>
</div>
)}
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-cyan-400" />
<span className="text-slate-300 text-sm">
{user.createdAt}
</span>
</div>
</div>
</div>
<div className="flex gap-2 pt-4 mt-4 border-t border-slate-700/50">
<button
onClick={() => navigate(`/users/${user.id}/`)}
className="flex-1 flex cursor-pointer items-center justify-center gap-2 px-4 py-2.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 font-medium rounded-lg transition-all border border-blue-500/30 hover:border-blue-500/50"
>
<Eye className="w-4 h-4" />
Ko'rish
</button>
<button
onClick={() => navigate(`/users/${user.id}/edit`)}
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-green-500/20 hover:bg-green-500/30 text-green-300 font-medium rounded-lg transition-all border border-green-500/30 hover:border-green-500/50"
>
<Pencil className="w-4 h-4" />
Tahrirlash
</button>
<button
onClick={() => setConfirmDelete(user)}
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-300 font-medium rounded-lg transition-all border border-red-500/30 hover:border-red-500/50"
>
<Trash2 className="w-4 h-4" />
O'chirish
</button>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<ChevronLeft className="w-5 h-5" />
</button>
{[...Array(totalPages)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
currentPage === i + 1
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
}`}
>
{i + 1}
</button>
))}
<button
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{confirmDelete && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-red-500/20 rounded-xl border border-red-500/30">
<Trash2 className="w-6 h-6 text-red-400" />
</div>
<h2 className="text-xl font-bold text-white">
Foydalanuvchini o'chirish
</h2>
</div>
<button
onClick={() => setConfirmDelete(null)}
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="py-4">
<p className="text-slate-300">
Siz{" "}
<span className="font-semibold text-white">
{confirmDelete.username}
</span>{" "}
foydalanuvchini o'chirmoqchimisiz?
</p>
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-300 font-medium flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span>Ushbu amalni qaytarib bo'lmaydi.</span>
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setConfirmDelete(null)}
className="flex-1 px-4 py-3 border border-slate-600 hover:bg-slate-700/50 text-slate-300 font-medium rounded-lg transition-all"
>
Bekor qilish
</button>
<button
onClick={() => handleDelete(confirmDelete.id)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-white font-medium rounded-lg transition-all shadow-lg"
>
<Trash2 className="w-4 h-4" />
<span>O'chirish</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}
function StatCard({
title,
value,
icon,
gradient,
}: {
title: string;
value: string;
icon: React.ReactNode;
gradient: string;
}) {
return (
<div className="group relative hover:scale-105 transition-transform duration-300">
<div
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
/>
<div
className={`relative bg-gradient-to-br ${gradient} bg-opacity-10 border border-white/10 backdrop-blur-sm rounded-xl p-6 shadow-xl hover:shadow-2xl transition-all hover:border-white/20`}
>
<div className="flex justify-between items-start mb-3">
<p className="text-slate-300 text-sm font-medium">{title}</p>
<div
className={`bg-gradient-to-br ${gradient} p-2 rounded-lg text-white shadow-lg`}
>
{icon}
</div>
</div>
<p className="text-3xl font-bold text-white">{value}</p>
</div>
</div>
);
}

View File

@@ -1,689 +0,0 @@
import { Button } from "@/shared/ui/button";
import {
ArrowLeft,
Bus,
Calendar,
Clock,
CreditCard,
DollarSign,
Download,
Edit,
Mail,
MapPin,
Package,
Phone,
Shield,
Ticket,
User,
Users as UsersIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
type PassportImage = {
id: number;
image: string;
};
type Companion = {
id: number;
first_name: string;
last_name: string;
birth_date: string;
phone_number: string;
gender: "male" | "female";
participant_pasport_image: PassportImage[];
};
type Participant = {
id: number;
first_name: string;
last_name: string;
gender: "male" | "female";
};
type TicketInfo = {
id: number;
title: string;
service_name: string;
location_name: string;
};
type ExtraService = {
id: number;
name: string;
};
type ExtraPaidService = {
id: number;
name: string;
price: number;
};
type BookingTicket = {
id: number;
departure: string;
destination: string;
departure_date: string;
arrival_time: string;
participant: Participant[];
ticket: TicketInfo;
tariff: string;
transport: string;
extra_service: ExtraService[];
extra_paid_service: ExtraPaidService[];
total_price: number;
order_status: "pending_payment" | "confirmed" | "cancelled";
};
type UserData = {
id: number;
username: string;
email?: string;
phone?: string;
createdAt: string;
status: "active" | "inactive";
companions: Companion[];
bookings: BookingTicket[];
};
const UserDetail = () => {
const navigate = useNavigate();
const { id } = useParams();
const [user, setUser] = useState<UserData | null>(null);
useEffect(() => {
// Backend'dan ma'lumot olish
setUser({
id: Number(id),
username: "john_doe",
email: "john@example.com",
phone: "+998901234567",
createdAt: "2024-01-15",
status: "active",
companions: [
{
id: 1,
first_name: "Aziza",
last_name: "Karimova",
birth_date: "1995-05-20",
phone_number: "+998901111111",
gender: "female",
participant_pasport_image: [
{ id: 1, image: "/images/passport1.jpg" },
],
},
{
id: 2,
first_name: "Sardor",
last_name: "Toshev",
birth_date: "1990-08-15",
phone_number: "+998902222222",
gender: "male",
participant_pasport_image: [
{ id: 2, image: "/images/passport2.jpg" },
],
},
],
bookings: [
{
id: 1,
departure: "Toshkent",
destination: "Samarqand",
departure_date: "2024-06-20",
arrival_time: "2024-06-20T18:30:00",
participant: [
{ id: 1, first_name: "John", last_name: "Doe", gender: "male" },
],
ticket: {
id: 1,
title: "Premium Class",
service_name: "Express Service",
location_name: "Central Station",
},
tariff: "Standard",
transport: "Bus",
extra_service: [
{ id: 1, name: "Wi-Fi" },
{ id: 2, name: "Refreshments" },
],
extra_paid_service: [{ id: 1, name: "Extra Luggage", price: 50000 }],
total_price: 150000,
order_status: "confirmed",
},
{
id: 2,
departure: "Samarqand",
destination: "Buxoro",
departure_date: "2024-06-25",
arrival_time: "2024-06-25T15:00:00",
participant: [
{ id: 2, first_name: "Jane", last_name: "Smith", gender: "female" },
],
ticket: {
id: 2,
title: "Economy Class",
service_name: "Standard Service",
location_name: "Main Terminal",
},
tariff: "Economy",
transport: "Train",
extra_service: [{ id: 3, name: "AC" }],
extra_paid_service: [],
total_price: 120000,
order_status: "confirmed",
},
{
id: 3,
departure: "Samarqand",
destination: "Buxoro",
departure_date: "2024-06-25",
arrival_time: "2024-06-25T15:00:00",
participant: [
{ id: 2, first_name: "Jane", last_name: "Smith", gender: "female" },
],
ticket: {
id: 2,
title: "Economy Class",
service_name: "Standard Service",
location_name: "Main Terminal",
},
tariff: "Economy",
transport: "Train",
extra_service: [{ id: 3, name: "AC" }],
extra_paid_service: [],
total_price: 120000,
order_status: "confirmed",
},
],
});
}, [id]);
const formatPhone = (phone: string) => {
if (phone.startsWith("+998")) {
return phone.replace(
/(\+998)(\d{2})(\d{3})(\d{2})(\d{2})/,
"$1 $2 $3 $4 $5",
);
}
return phone;
};
const formatPrice = (price: number) => {
return price.toLocaleString("uz-UZ") + " so'm";
};
const getStatusBadge = (status: string) => {
const badges = {
confirmed:
"bg-emerald-500/20 text-emerald-400 border border-emerald-500/30",
pending_payment:
"bg-amber-500/20 text-amber-400 border border-amber-500/30",
cancelled: "bg-red-500/20 text-red-400 border border-red-500/30",
};
const labels = {
confirmed: "Tasdiqlangan",
pending_payment: "To'lov kutilmoqda",
cancelled: "Bekor qilingan",
};
return {
class: badges[status as keyof typeof badges],
label: labels[status as keyof typeof labels],
};
};
const handleDownloadPDF = (bookingId: number) => {
console.log("Downloading PDF for booking:", bookingId);
};
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="mt-4 text-slate-400">Yuklanmoqda...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
<div className="w-full mx-auto">
{/* Header */}
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate("/")}
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Orqaga
</Button>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-lg shadow-blue-500/20">
<User className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-slate-100">
{user.username}
</h1>
<span
className={`inline-block mt-2 px-3 py-1 text-xs font-medium rounded-full border ${
user.status === "active"
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
: "bg-slate-700/50 text-slate-400 border-slate-600/30"
}`}
>
{user.status === "active" ? "Faol" : "Nofaol"}
</span>
</div>
</div>
<Button
onClick={() => navigate(`/users/${id}/edit`)}
className="bg-gradient-to-r cursor-pointer from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white shadow-lg shadow-blue-500/20"
>
<Edit className="w-4 h-4 mr-2" />
Tahrirlash
</Button>
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Info */}
<div className="lg:col-span-2 space-y-6">
{/* Contact Information */}
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-blue-400" />
Aloqa ma'lumotlari
</h2>
<div className="space-y-4">
{user.email && (
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0 border border-blue-500/30">
<Mail className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
Email
</p>
<p className="text-slate-200 font-medium">{user.email}</p>
</div>
</div>
)}
{user.phone && (
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center flex-shrink-0 border border-emerald-500/30">
<Phone className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
Telefon
</p>
<p className="text-slate-200 font-medium">
{formatPhone(user.phone)}
</p>
</div>
</div>
)}
</div>
</div>
{/* Account Information */}
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-indigo-400" />
Hisob ma'lumotlari
</h2>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0 border border-purple-500/30">
<User className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
Username
</p>
<p className="text-slate-200 font-medium">
{user.username}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0 border border-orange-500/30">
<Calendar className="w-5 h-5 text-orange-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
Yaratilgan sana
</p>
<p className="text-slate-200 font-medium">
{user.createdAt}
</p>
</div>
</div>
</div>
</div>
{/* Booking Tickets */}
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Ticket className="w-5 h-5 text-emerald-400" />
Sotib olingan chiptalar
<span className="ml-auto text-sm font-normal text-slate-500">
{user.bookings.length} ta
</span>
</h2>
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
{user.bookings.length > 0 ? (
user.bookings.map((booking) => {
const statusBadge = getStatusBadge(booking.order_status);
return (
<div
key={booking.id}
className="p-5 bg-slate-800/50 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition-colors space-y-4"
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-400" />
<span className="font-semibold text-slate-100 text-lg">
{booking.departure} {booking.destination}
</span>
</div>
<span
className={`px-3 py-1 text-xs font-medium rounded-full ${statusBadge.class}`}
>
{statusBadge.label}
</span>
</div>
{/* Ticket Info */}
<div className="grid grid-cols-2 gap-3 text-sm bg-slate-900/50 p-3 rounded-lg border border-slate-700/30">
<div>
<p className="text-xs text-slate-500">
Chipta turi
</p>
<p className="text-slate-200 font-medium">
{booking.ticket.title}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Xizmat</p>
<p className="text-slate-200 font-medium">
{booking.ticket.service_name}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Manzil</p>
<p className="text-slate-200 font-medium">
{booking.ticket.location_name}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Transport</p>
<p className="text-slate-200 font-medium flex items-center gap-1">
<Bus className="w-3 h-3" />
{booking.transport}
</p>
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">Jo'nash</p>
<p className="text-slate-200 font-medium">
{booking.departure_date}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">Yetish</p>
<p className="text-slate-200 font-medium">
{booking.arrival_time.split("T")[1].slice(0, 5)}
</p>
</div>
</div>
</div>
{/* Participants */}
<div>
<p className="text-xs text-slate-500 mb-2">
Yo'lovchilar:
</p>
<div className="flex flex-wrap gap-2">
{booking.participant.map((p) => (
<span
key={p.id}
className="px-3 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-full border border-blue-500/30"
>
{p.first_name} {p.last_name}
</span>
))}
</div>
</div>
{/* Services */}
{booking.extra_service.length > 0 && (
<div>
<p className="text-xs text-slate-500 mb-2">
Qo'shimcha xizmatlar:
</p>
<div className="flex flex-wrap gap-2">
{booking.extra_service.map((service) => (
<span
key={service.id}
className="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-xs rounded-lg flex items-center gap-1 border border-emerald-500/30"
>
<Package className="w-3 h-3" />
{service.name}
</span>
))}
</div>
</div>
)}
{/* Paid Services */}
{booking.extra_paid_service.length > 0 && (
<div>
<p className="text-xs text-slate-500 mb-2">
Pullik xizmatlar:
</p>
<div className="space-y-1">
{booking.extra_paid_service.map((service) => (
<div
key={service.id}
className="flex items-center justify-between text-xs bg-amber-500/10 px-3 py-2 rounded-lg border border-amber-500/20"
>
<span className="text-slate-300">
{service.name}
</span>
<span className="font-medium text-amber-400">
{formatPrice(service.price)}
</span>
</div>
))}
</div>
</div>
)}
{/* Total & Actions */}
<div className="flex items-center justify-between pt-3 border-t border-slate-700/50">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-emerald-400" />
<div>
<p className="text-xs text-slate-500">
Jami narx
</p>
<p className="text-lg font-bold text-emerald-400">
{formatPrice(booking.total_price)}
</p>
</div>
</div>
<Button
onClick={() => handleDownloadPDF(booking.id)}
variant="outline"
className="flex items-center gap-2 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 border-slate-700/50 hover:border-slate-600/50"
>
<Download className="w-4 h-4" />
PDF yuklab olish
</Button>
</div>
</div>
);
})
) : (
<p className="text-sm text-slate-500 text-center py-4">
Hozircha chiptalar mavjud emas
</p>
)}
</div>
</div>
{/* Companions */}
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<UsersIcon className="w-5 h-5 text-purple-400" />
Hamrohlar
<span className="ml-auto text-sm font-normal text-slate-500">
{user.companions.length} ta
</span>
</h2>
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
{user.companions.length > 0 ? (
user.companions.map((companion) => (
<div
key={companion.id}
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
>
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
<User className="w-7 h-7 text-white" />
</div>
<div className="flex-1 space-y-3">
<div>
<h3 className="font-semibold text-slate-100 text-lg">
{companion.first_name} {companion.last_name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span
className={`px-2 py-1 text-xs rounded-full border ${
companion.gender === "male"
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
}`}
>
{companion.gender === "male" ? "Erkak" : "Ayol"}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500">
Tug'ilgan sana
</p>
<p className="text-slate-200 font-medium">
{companion.birth_date}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Telefon</p>
<p className="text-slate-200 font-medium">
{formatPhone(companion.phone_number)}
</p>
</div>
</div>
{companion.participant_pasport_image.length > 0 && (
<div>
<p className="text-xs text-slate-500 mb-2">
Passport rasmlari:
</p>
<div className="flex gap-2">
{companion.participant_pasport_image.map(
(img) => (
<div
key={img.id}
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
>
<CreditCard className="w-8 h-8 text-slate-500" />
</div>
),
)}
</div>
</div>
)}
</div>
</div>
</div>
))
) : (
<p className="text-sm text-slate-500 text-center py-4">
Hozircha hamrohlar qo'shilmagan
</p>
)}
</div>
</div>
</div>
{/* Right Column - Stats */}
<div className="space-y-6">
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-emerald-400" />
Statistika
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
<span className="text-sm text-slate-300">Chiptalar</span>
<span className="text-sm font-semibold text-emerald-400">
{user.bookings.length} ta
</span>
</div>
<div className="flex items-center justify-between p-3 bg-purple-500/10 rounded-lg border border-purple-500/20">
<span className="text-sm text-slate-300">Hamrohlar</span>
<span className="text-sm font-semibold text-purple-400">
{user.companions.length} ta
</span>
</div>
<div className="flex items-center justify-between p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
<span className="text-sm text-slate-300">Status</span>
<span className="text-sm font-semibold text-blue-400">
{user.status === "active" ? "Faol" : "Nofaol"}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-orange-500/10 rounded-lg border border-orange-500/20">
<span className="text-sm text-slate-300">ID</span>
<span className="text-sm font-semibold text-orange-400">
#{user.id}
</span>
</div>
</div>
</div>
<div className="bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl shadow-xl shadow-blue-500/20 p-6 text-white border border-blue-500/20">
<h3 className="font-semibold mb-2">Qo'shimcha ma'lumot</h3>
<p className="text-sm text-blue-100">
Bu foydalanuvchi hozirda tizimda faol holatda. Barcha
ma'lumotlar to'liq va tasdiqlangan.
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default UserDetail;

View File

@@ -0,0 +1,64 @@
import type { UsersData, UsersDetaiData } from "@/pages/users/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import {
DOWNLOAD_PDF,
GET_ALL_USERS,
UPDATE_USER,
} from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const getAllUsers = async ({
page,
page_size,
search,
}: {
page: number;
page_size: number;
search: string;
}): Promise<AxiosResponse<UsersData>> => {
const response = await httpClient.get(GET_ALL_USERS, {
params: {
page,
page_size,
search,
},
});
return response;
};
const getUserDetail = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<UsersDetaiData>> => {
const response = await httpClient.get(`${GET_ALL_USERS}${id}`);
return response;
};
const downloadPdf = async (body: {
order_id: number | null;
lang: string;
}): Promise<AxiosResponse<any>> => {
const response = await httpClient.post(DOWNLOAD_PDF, body, {
responseType: "blob",
});
return response;
};
const updateUser = async ({
body,
id,
}: {
body: {
first_name: string;
last_name: string;
email: string | null;
phone: string | null;
};
id: number;
}) => {
const response = await httpClient.patch(`${UPDATE_USER}${id}/`, body);
return response;
};
export { downloadPdf, getAllUsers, getUserDetail, updateUser };

View File

@@ -0,0 +1,99 @@
export interface UsersData {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: string;
total_pages: number;
page_size: string;
current_page: string;
results: {
users: [
{
id: number;
phone: string;
first_name: string;
last_name: string;
email: string;
avatar: string;
validated_at: string;
},
];
user_register_phone: string;
user_register_email: string;
};
};
}
export interface UsersDetaiData {
status: boolean;
data: {
id: number;
phone: string;
email: string;
first_name: string;
last_name: string;
avatar: string;
validated_at: string;
orders: [
{
id: 0;
departure: string;
transport: string;
destination: string;
ticket: 0;
travel_agency_id: string;
tariff: string;
departure_date: string;
arrival_time: string;
location_name: string;
participant: [
{
first_name: string;
last_name: string;
},
];
extra_service: [
{
id: number;
name: string;
},
];
extra_paid_service: [
{
id: number;
name: string;
price: number;
},
];
total_price: number;
order_status:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed";
},
];
participant: [
{
id: number;
first_name: string;
last_name: string;
birth_date: string;
phone_number: string;
gender: "male" | "female";
participant_pasport_image: [
{
id: number;
image: string;
},
];
},
];
ticket_count: string;
participant_count: string;
};
}

View File

@@ -14,11 +14,12 @@ import {
User,
} from "lucide-react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export default function CreateUser() {
const navigate = useNavigate();
const { t } = useTranslation();
const [formData, setFormData] = useState({
username: "",
email: "",
@@ -62,8 +63,8 @@ export default function CreateUser() {
if (!formData.password) {
newErrors.password = "Parol majburiy";
} else if (formData.password.length < 6) {
newErrors.password = "Parol kamida 6 ta belgidan iborat bo'lishi kerak";
} else if (formData.password.length < 8) {
newErrors.password = "Parol kamida 8 ta belgidan iborat bo'lishi kerak";
}
if (formData.password !== formData.confirmPassword) {
@@ -99,7 +100,7 @@ export default function CreateUser() {
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Orqaga
{t("Orqaga")}
</Button>
<div className="flex items-center gap-3 mb-2">
@@ -108,10 +109,10 @@ export default function CreateUser() {
</div>
<div>
<h1 className="text-3xl font-bold text-slate-100">
Yangi foydalanuvchi
{t("Yangi foydalanuvchi")}
</h1>
<p className="text-slate-400 mt-1">
Ma'lumotlarni to'ldiring va saqlang
{t("Ma'lumotlarni to'ldiring va saqlang")}
</p>
</div>
</div>
@@ -127,7 +128,7 @@ export default function CreateUser() {
htmlFor="username"
className="text-slate-300 font-medium"
>
Username <span className="text-red-400">*</span>
{t("Ismi")}
</Label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
@@ -149,8 +150,7 @@ export default function CreateUser() {
</div>
{errors.username && (
<p className="text-sm text-red-400 flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-red-400"></span>
{errors.username}
{t(errors.username)}
</p>
)}
</div>
@@ -158,7 +158,7 @@ export default function CreateUser() {
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email" className="text-slate-300 font-medium">
Email
{t("Email")}
</Label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
@@ -181,8 +181,7 @@ export default function CreateUser() {
</div>
{errors.email && (
<p className="text-sm text-red-400 flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-red-400"></span>
{errors.email}
{t(errors.email)}
</p>
)}
</div>
@@ -190,7 +189,7 @@ export default function CreateUser() {
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone" className="text-slate-300 font-medium">
Telefon raqami
{t("Telefon raqami")}
</Label>
<div className="relative">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
@@ -213,8 +212,7 @@ export default function CreateUser() {
</div>
{errors.phone && (
<p className="text-sm text-red-400 flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-red-400"></span>
{errors.phone}
{t(errors.phone)}
</p>
)}
</div>
@@ -235,7 +233,7 @@ export default function CreateUser() {
htmlFor="password"
className="text-slate-300 font-medium"
>
Parol <span className="text-red-400">*</span>
{t("Parol")}
</Label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
@@ -271,8 +269,7 @@ export default function CreateUser() {
</div>
{errors.password && (
<p className="text-sm text-red-400 flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-red-400"></span>
{errors.password}
{t(errors.password)}
</p>
)}
</div>
@@ -283,7 +280,7 @@ export default function CreateUser() {
htmlFor="confirmPassword"
className="text-slate-300 font-medium"
>
Parolni tasdiqlang <span className="text-red-400">*</span>
{t("Parolni tasdiqlang")}
</Label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
@@ -322,8 +319,7 @@ export default function CreateUser() {
</div>
{errors.confirmPassword && (
<p className="text-sm text-red-400 flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-red-400"></span>
{errors.confirmPassword}
{t(errors.confirmPassword)}
</p>
)}
</div>
@@ -336,13 +332,13 @@ export default function CreateUser() {
onClick={() => navigate("/")}
className="flex-1 h-14 rounded-xl cursor-pointer border-2 border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 hover:border-slate-600/50 font-medium transition-all duration-200"
>
Bekor qilish
{t("Bekor qilish")}
</Button>
<Button
type="submit"
className="flex-1 h-14 rounded-xl cursor-pointer bg-gradient-to-r from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white font-medium shadow-lg shadow-blue-500/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200"
>
Saqlash
{t("Saqlash")}
</Button>
</div>
</form>

263
src/pages/users/ui/Edit.tsx Normal file
View File

@@ -0,0 +1,263 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { getUserDetail, updateUser } from "@/pages/users/lib/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import formatPhone from "@/shared/lib/formatPhone";
import onlyNumber from "@/shared/lib/onlyNumber";
import { Button } from "@/shared/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import { ArrowLeft, Mail, Phone, Save, User } from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
const formSchema = z
.object({
first_name: z
.string()
.min(3, "Ism kamida 3 ta belgidan iborat bolishi kerak")
.nonempty("Ism majburiy"),
last_name: z
.string()
.min(3, "Familiya kamida 3 ta belgidan iborat bolishi kerak")
.nonempty("Familiya majburiy"),
email: z.string().email("Email formati notogri").or(z.literal("")),
phone: z.string().min(9, "Telefon raqam toliq emas").or(z.literal("+998")),
})
.refine((data) => data.email || data.phone, {
message: "Email yoki telefon raqami kiritilishi shart",
path: ["contact"],
});
export default function EditUser() {
const navigate = useNavigate();
const { id } = useParams();
const { t } = useTranslation();
const { data } = useQuery({
queryKey: ["user_detail", id],
queryFn: () => getUserDetail({ id: Number(id) }),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
first_name: "",
last_name: "",
email: "",
phone: "+998",
},
});
useEffect(() => {
if (data) {
const u = data.data.data;
form.reset({
first_name: u.first_name,
last_name: u.last_name,
email: u.email || "",
phone: formatPhone(u.phone),
});
}
}, [data, form]);
const { mutate, isPending } = useMutation({
mutationFn: ({
body,
id,
}: {
body: {
first_name: string;
last_name: string;
email: string | null;
phone: string | null;
};
id: number;
}) => updateUser({ body, id }),
onSuccess: () => navigate("/user"),
});
const onSubmit = (values: z.infer<typeof formSchema>) => {
if (data) {
mutate({
body: {
first_name: values.first_name,
last_name: values.last_name,
email: values.email.length === 0 ? null : values.email,
phone: values.phone.length === 0 ? null : onlyNumber(values.phone),
},
id: data.data.data.id,
});
}
};
return (
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
<div className="w-full mx-auto">
{/* Header */}
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate(-1)}
className="mb-4 -ml-2 text-slate-400 cursor-pointer hover:text-slate-100 hover:bg-slate-800/50"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("Orqaga")}
</Button>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-600 to-teal-700 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<User className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">
{t("Tahrirlash")}
</h1>
<p className="text-slate-400 text-sm">
{t("Ma'lumotlarni yangilang")}
</p>
</div>
</div>
</div>
{/* Form */}
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6 md:p-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
{/* First Name */}
<FormField
control={form.control}
name="first_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-300">
{t("Ismi")}
</FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
{...field}
placeholder="John"
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Last Name */}
<FormField
control={form.control}
name="last_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-300">
{t("Familiyasi")}
</FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
{...field}
placeholder="Doe"
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-300">
{t("Email")}
</FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
{...field}
placeholder="email@example.com"
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Phone */}
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-300">
{t("Telefon raqami")}
</FormLabel>
<FormControl>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
{...field}
value={formatPhone(field.value)}
onChange={(e) => field.onChange(e.target.value)}
placeholder="+998 90 123 45 67"
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Submit buttons */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
className="flex-1 h-11 rounded-lg border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 font-medium"
>
{t("Bekor qilish")}
</Button>
<Button
type="submit"
disabled={isPending}
className="flex-1 h-11 rounded-lg bg-gradient-to-r from-emerald-600 to-teal-700 hover:from-emerald-700 hover:to-teal-800 text-white font-medium"
>
<Save className="w-5 h-5 mr-2" />
{t("Yangilash")}
</Button>
</div>
</form>
</Form>
</div>
</div>
</div>
);
}

350
src/pages/users/ui/User.tsx Normal file
View File

@@ -0,0 +1,350 @@
import { getAllUsers } from "@/pages/users/lib/api";
import formatPhone from "@/shared/lib/formatPhone";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar";
import { Button } from "@/shared/ui/button";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
Calendar,
ChevronLeft,
ChevronRight,
Eye,
Loader2,
Mail,
Pencil,
Phone,
Search,
Trash2,
Users,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export default function UserList() {
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const usersPerPage = 6;
const { t } = useTranslation();
const navigate = useNavigate();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["user_all", currentPage, searchQuery],
queryFn: () =>
getAllUsers({
page: currentPage,
page_size: usersPerPage,
search: searchQuery,
}),
});
const getAvatarGradient = (id: number) => {
const gradients = ["from-blue-600 to-cyan-500"];
return gradients[id % gradients.length];
};
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => refetch()}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 py-12 px-4 w-full">
<div className="max-w-[90%] mx-auto">
<div className="flex justify-between items-center">
<div className="mb-12">
<div className="flex items-center gap-3 mb-3">
<div className="p-3 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20">
<Users className="w-8 h-8 text-white" />
</div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
{t("Foydalanuvchilar")}
</h1>
</div>
<p className="text-slate-400 text-lg ml-14">
{t("Jami")} {data?.data.data.total_items}{" "}
{t("ta foydalanuvchini boshqaring")}
</p>
</div>
{/* <Button
onClick={() => navigate("/users/create")}
className="py-5 px-4 bg-gradient-to-br text-white cursor-pointer from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20"
>
<Plus />
<p>{t("Foydalanuvchi Qo'shish")}</p>
</Button> */}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
<StatCard
title={t("Jami foydalanuvchilar")}
value={data ? data.data.data.total_items : "0"}
icon={<Users className="w-6 h-6" />}
gradient="from-blue-600 to-blue-400"
/>
<StatCard
title={t("Email bilan ro'yxatlangan")}
value={data ? data.data.data.results.user_register_email : "0"}
icon={<Mail className="w-6 h-6" />}
gradient="from-cyan-600 to-cyan-400"
/>
<StatCard
title={t("Telefon bilan ro'yxatlangan")}
value={data ? data.data.data.results.user_register_phone : "0"}
icon={<Phone className="w-6 h-6" />}
gradient="from-purple-600 to-pink-400"
/>
</div>
<div className="mb-10 relative">
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 rounded-2xl blur-xl" />
<div className="relative bg-slate-800/50 border border-slate-700/50 rounded-2xl p-6 backdrop-blur-sm">
<Search className="absolute left-8 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder={t(
"Username, email yoki telefon raqami bo'yicha qidirish",
)}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-14 pr-4 py-3 bg-slate-700/30 border border-slate-600/50 text-white placeholder-slate-400 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
</div>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
) : (
<>
<div className="max-w-[90%] mx-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6 mb-10">
{data &&
data.data.data.results.users.map((e) => (
<div className="group relative hover:scale-105 transition-transform duration-300">
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100" />
<div className="relative h-full flex flex-col justify-between bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl p-6 shadow-2xl hover:border-slate-600/70 transition-all backdrop-blur-sm">
<div>
<div className="flex flex-col items-center gap-4 mb-4">
<Avatar
className={`w-16 h-16 rounded-xl bg-gradient-to-br ${getAvatarGradient(
e.id,
)} flex items-center justify-center text-white text-xl font-bold shadow-lg`}
>
<AvatarImage src={e.avatar} />
<AvatarFallback
className={`w-16 h-16 rounded-xl bg-gradient-to-br ${getAvatarGradient(
e.id,
)} flex items-center justify-center text-white text-xl font-bold shadow-lg`}
>
{e.first_name.slice(0, 1).toUpperCase()}
{e.last_name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-center">
<h3 className="text-xl font-bold text-white truncate">
{e.first_name} {e.last_name}
</h3>
</div>
</div>
<div className="space-y-3 pt-4 border-t border-slate-700/50">
{e.email && (
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-cyan-400" />
<span className="text-slate-300 text-sm truncate">
{e.email}
</span>
</div>
)}
{e.phone && (
<div className="flex items-center gap-3">
<Phone className="w-5 h-5 text-cyan-400" />
<span className="text-slate-300 text-sm">
{formatPhone(e.phone)}
</span>
</div>
)}
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-cyan-400" />
<span className="text-slate-300 text-sm">
{e.validated_at}
</span>
</div>
</div>
</div>
<div className="grid gap-2 pt-4 mt-4 border-t border-slate-700/50">
<button
onClick={() => navigate(`/users/${e.id}/`)}
className="flex-1 flex cursor-pointer items-center justify-center gap-2 px-4 py-2.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 font-medium rounded-lg transition-all border border-blue-500/30 hover:border-blue-500/50"
>
<Eye className="w-4 h-4" />
{t("Ko'rish")}
</button>
<button
onClick={() => navigate(`/users/${e.id}/edit`)}
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-green-500/20 hover:bg-green-500/30 text-green-300 font-medium rounded-lg transition-all border border-green-500/30 hover:border-green-500/50"
>
<Pencil className="w-4 h-4" />
{t("Tahrirlash")}
</button>
<button className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-300 font-medium rounded-lg transition-all border border-red-500/30 hover:border-red-500/50">
<Trash2 className="w-4 h-4" />
{t("O'chirish")}
</button>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<ChevronLeft className="w-5 h-5" />
</button>
{[...Array(data?.data.data.total_pages)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
currentPage === i + 1
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
}`}
>
{i + 1}
</button>
))}
<button
disabled={
data
? currentPage === data.data.data.total_pages
: currentPage === 1
}
onClick={() =>
setCurrentPage((p) =>
Math.min(p + 1, data ? data.data.data.total_pages : 1),
)
}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* {confirmDelete && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-red-500/20 rounded-xl border border-red-500/30">
<Trash2 className="w-6 h-6 text-red-400" />
</div>
<h2 className="text-xl font-bold text-white">
{t("Foydalanuvchini o'chirish")}
</h2>
</div>
<button
onClick={() => setConfirmDelete(null)}
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="py-4">
<p className="text-slate-300">
{t("Siz")}{" "}
<span className="font-semibold text-white">
{confirmDelete.username}
</span>{" "}
{t("foydalanuvchini o'chirmoqchimisiz?")}
</p>
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-300 font-medium flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span>{t("Ushbu amalni qaytarib bo'lmaydi")}</span>
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setConfirmDelete(null)}
className="flex-1 px-4 py-3 border border-slate-600 hover:bg-slate-700/50 text-slate-300 font-medium rounded-lg transition-all"
>
{t("Bekor qilish")}
</button>
<button
onClick={() => handleDelete(confirmDelete.id)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-white font-medium rounded-lg transition-all shadow-lg"
>
<Trash2 className="w-4 h-4" />
<span>{t("O'chirish")}</span>
</button>
</div>
</div>
</div>
)} */}
</>
)}
</div>
);
}
function StatCard({
title,
value,
icon,
gradient,
}: {
title: string;
value: string;
icon: React.ReactNode;
gradient: string;
}) {
return (
<div className="group relative hover:scale-105 transition-transform duration-300">
<div
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
/>
<div
className={`relative bg-gradient-to-br ${gradient} bg-opacity-10 border border-white/10 backdrop-blur-sm rounded-xl p-6 shadow-xl hover:shadow-2xl transition-all hover:border-white/20`}
>
<div className="flex justify-between items-start mb-3">
<p className="text-slate-300 text-sm font-medium">{title}</p>
<div
className={`bg-gradient-to-br ${gradient} p-2 rounded-lg text-white shadow-lg`}
>
{icon}
</div>
</div>
<p className="text-3xl font-bold text-white">{value}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,558 @@
import { downloadPdf, getUserDetail } from "@/pages/users/lib/api";
import i18n from "@/shared/config/i18n";
import formatPhone from "@/shared/lib/formatPhone";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
Calendar,
Clock,
DollarSign,
Download,
Edit,
Loader2,
Mail,
MapPin,
Package,
Phone,
Shield,
Ticket,
User,
Users as UsersIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
const UserDetail = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const locale = i18n.language;
const { id } = useParams();
const { data, isLoading } = useQuery({
queryKey: ["user_detail", id],
queryFn: () => getUserDetail({ id: Number(id) }),
});
const { mutate } = useMutation({
mutationFn: ({ id }: { id: number }) =>
downloadPdf({ lang: locale, order_id: id }),
onSuccess: (res) => {
const blob = new Blob([res.data], { type: "application/pdf" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `ticket-order-${id}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => {
toast.error("Xatolik yuz berdi");
},
});
const getStatusBadge = (status: string) => {
const badges = {
confirmed:
"bg-emerald-500/20 text-emerald-400 border border-emerald-500/30",
pending_payment:
"bg-amber-500/20 text-amber-400 border border-amber-500/30",
cancelled: "bg-red-500/20 text-red-400 border border-red-500/30",
};
const labels = {
confirmed: "Tasdiqlangan",
pending_payment: "To'lov kutilmoqda",
cancelled: "Bekor qilingan",
};
return {
class: badges[status as keyof typeof badges],
label: labels[status as keyof typeof labels],
};
};
const handleDownloadPDF = (bookingId: number) => {
mutate({
id: bookingId,
});
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
return (
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
{data && (
<>
<div className="w-full mx-auto">
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate("/")}
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("Orqaga")}
</Button>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-lg shadow-blue-500/20">
<User className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-slate-100">
{data?.data.data.first_name} {data?.data.data.last_name}
</h1>
{/* <span
className={`inline-block mt-2 px-3 py-1 text-xs font-medium rounded-full border ${
data?.data.data.status === "active"
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
: "bg-slate-700/50 text-slate-400 border-slate-600/30"
}`}
>
{user.status === "active" ? t("Faol") : t("Nofaol")}
</span> */}
</div>
</div>
<Button
onClick={() => navigate(`/users/${id}/edit`)}
className="bg-gradient-to-r cursor-pointer from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white shadow-lg shadow-blue-500/20"
>
<Edit className="w-4 h-4 mr-2" />
{t("Tahrirlash")}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-blue-400" />
{t("Aloqa ma'lumotlari")}
</h2>
<div className="space-y-4">
{data?.data.data.email && (
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0 border border-blue-500/30">
<Mail className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
{t("Email")}
</p>
<p className="text-slate-200 font-medium">
{data?.data.data.email}
</p>
</div>
</div>
)}
{data?.data.data.phone && (
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center flex-shrink-0 border border-emerald-500/30">
<Phone className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
{t("Telefon raqami")}
</p>
<p className="text-slate-200 font-medium">
{formatPhone(data?.data.data.phone)}
</p>
</div>
</div>
)}
</div>
</div>
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-indigo-400" />
{t("Hisob ma'lumotlari")}
</h2>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0 border border-purple-500/30">
<User className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
{t("Ismi")}
</p>
<p className="text-slate-200 font-medium">
{data?.data.data.first_name}{" "}
{data?.data.data.last_name}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0 border border-orange-500/30">
<Calendar className="w-5 h-5 text-orange-400" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">
{t("Yaratilgan sana")}
</p>
<p className="text-slate-200 font-medium">
{data?.data.data.validated_at}
</p>
</div>
</div>
</div>
</div>
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Ticket className="w-5 h-5 text-emerald-400" />
{t("Sotib olingan chiptalar")}
<span className="ml-auto text-sm font-normal text-slate-500">
{data?.data.data.orders.length} ta
</span>
</h2>
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
{data?.data.data.orders.length > 0 ? (
data?.data.data.orders.map((booking) => {
const statusBadge = getStatusBadge(
booking.order_status,
);
return (
<div
key={booking.id}
className="p-5 bg-slate-800/50 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition-colors space-y-4"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-400" />
<span className="font-semibold text-slate-100 text-lg">
{booking.departure} {booking.destination}
</span>
</div>
<span
className={`px-3 py-1 text-xs font-medium rounded-full ${statusBadge.class}`}
>
{statusBadge.label}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm bg-slate-900/50 p-3 rounded-lg border border-slate-700/30">
<div>
<p className="text-xs text-slate-500">
{t("Chipta turi")}
</p>
<p className="text-slate-200 font-medium">
{booking.tariff}
</p>
</div>
{booking.extra_service.length > 0 && (
<div>
<p className="text-xs text-slate-500">
{t("Xizmat")}
</p>
<>
{booking.extra_service.map((e) => (
<p
className="text-slate-200 font-medium"
key={e.id}
>
{e.name}
</p>
))}
</>
</div>
)}
<div>
<p className="text-xs text-slate-500">
{t("Manzil")}
</p>
<p className="text-slate-200 font-medium">
{booking.location_name}
</p>
</div>
<div>
<p className="text-xs text-slate-500">
{t("Transport")}
</p>
<p className="text-slate-200 font-medium flex items-center gap-1">
{booking.transport}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">
{t("Jo'nash")}
</p>
<p className="text-slate-200 font-medium">
{booking.departure_date}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">
{t("Yetish")}
</p>
<p className="text-slate-200 font-medium">
{booking.arrival_time}
</p>
</div>
</div>
</div>
<div>
<p className="text-xs text-slate-500 mb-2">
{t("Yo'lovchilar")}:
</p>
<div className="flex flex-wrap gap-2">
{booking.participant.map((p) => (
<span
key={p.first_name}
className="px-3 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-full border border-blue-500/30"
>
{p.first_name} {p.last_name}
</span>
))}
</div>
</div>
{booking.extra_service.length > 0 && (
<div>
<p className="text-xs text-slate-500 mb-2">
{t("Qo'shimcha xizmatlar")}:
</p>
<div className="flex flex-wrap gap-2">
{booking.extra_service.map((service) => (
<span
key={service.id}
className="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-xs rounded-lg flex items-center gap-1 border border-emerald-500/30"
>
<Package className="w-3 h-3" />
{service.name}
</span>
))}
</div>
</div>
)}
{booking.extra_paid_service.length > 0 && (
<div>
<p className="text-xs text-slate-500 mb-2">
{t("Pullik xizmatlar")}:
</p>
<div className="space-y-1">
{booking.extra_paid_service.map((service) => (
<div
key={service.id}
className="flex items-center justify-between text-xs bg-amber-500/10 px-3 py-2 rounded-lg border border-amber-500/20"
>
<span className="text-slate-300">
{service.name}
</span>
<span className="font-medium text-amber-400">
{formatPrice(service.price, true)}
</span>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between pt-3 border-t border-slate-700/50">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-emerald-400" />
<div>
<p className="text-xs text-slate-500">
{t("Jami narx")}
</p>
<p className="text-lg font-bold text-emerald-400">
{formatPrice(booking.total_price, true)}
</p>
</div>
</div>
<Button
onClick={() => handleDownloadPDF(booking.id)}
variant="outline"
className="flex items-center gap-2 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 border-slate-700/50 hover:border-slate-600/50"
>
<Download className="w-4 h-4" />
{t("PDF yuklab olish")}
</Button>
</div>
</div>
);
})
) : (
<p className="text-sm text-slate-500 text-center py-4">
{t("Hozircha chiptalar mavjud emas")}
</p>
)}
</div>
</div>
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<UsersIcon className="w-5 h-5 text-purple-400" />
{t("Hamrohlar")}
<span className="ml-auto text-sm font-normal text-slate-500">
{data.data.data.participant.length} ta
</span>
</h2>
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
{data.data.data.participant.length > 0 ? (
data.data.data.participant.map((companion) => (
<div
key={companion.id}
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
>
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
<User className="w-7 h-7 text-white" />
</div>
<div className="flex-1 space-y-3">
<div>
<h3 className="font-semibold text-slate-100 text-lg">
{companion.first_name} {companion.last_name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span
className={`px-2 py-1 text-xs rounded-full border ${
companion.gender === "male"
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
}`}
>
{companion.gender === "male"
? t("Erkak")
: t("Ayol")}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500">
{t("Tug'ilgan sana")}
</p>
<p className="text-slate-200 font-medium">
{companion.birth_date}
</p>
</div>
<div>
<p className="text-xs text-slate-500">
{t("Telefon raqami")}
</p>
<p className="text-slate-200 font-medium">
{formatPhone(companion.phone_number)}
</p>
</div>
</div>
{companion.participant_pasport_image.length >
0 && (
<div>
<p className="text-xs text-slate-500 mb-2">
{t("Passport rasmlari")}:
</p>
<div className="flex gap-2">
{companion.participant_pasport_image.map(
(img) => (
<div
key={img.id}
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
>
<img
alt="passport image"
src={img.image}
className="w-full h-full"
/>
</div>
),
)}
</div>
</div>
)}
</div>
</div>
</div>
))
) : (
<p className="text-sm text-slate-500 text-center py-4">
{t("Hozircha hamrohlar qo'shilmagan")}
</p>
)}
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-emerald-400" />
{t("Statistika")}
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
<span className="text-sm text-slate-300">
{t("Chiptalar")}
</span>
<span className="text-sm font-semibold text-emerald-400">
{data.data.data.orders.length}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-purple-500/10 rounded-lg border border-purple-500/20">
<span className="text-sm text-slate-300">
{t("Hamrohlar")}
</span>
<span className="text-sm font-semibold text-purple-400">
{data.data.data.participant_count}
</span>
</div>
{/* <div className="flex items-center justify-between p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
<span className="text-sm text-slate-300">
{t("Status")}
</span>
</div> */}
<div className="flex items-center justify-between p-3 bg-orange-500/10 rounded-lg border border-orange-500/20">
<span className="text-sm text-slate-300">{t("ID")}</span>
<span className="text-sm font-semibold text-orange-400">
#{data.data.data.id}
</span>
</div>
</div>
</div>
<div className="bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl shadow-xl shadow-blue-500/20 p-6 text-white border border-blue-500/20">
<h3 className="font-semibold mb-2">
{t("Qo'shimcha ma'lumot")}
</h3>
<p className="text-sm text-blue-100">
{t(
"Bu foydalanuvchi hozirda tizimda faol holatda. Barcha ma'lumotlar to'liq va tasdiqlangan.",
)}
</p>
</div>
</div>
</div>
</div>
</>
)}
</div>
);
};
export default UserDetail;