api ulangan
This commit is contained in:
23
src/features/auth/lib/api.ts
Normal file
23
src/features/auth/lib/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
interface LoginRes {
|
||||
status_code: number;
|
||||
status: string;
|
||||
message: string;
|
||||
data: {
|
||||
access: string;
|
||||
refresh: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const auth_pai = {
|
||||
async login(body: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<AxiosResponse<LoginRes>> {
|
||||
const res = await httpClient.post(API_URLS.LOGIN, body);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
160
src/features/auth/ui/AuthLogin.tsx
Normal file
160
src/features/auth/ui/AuthLogin.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { auth_pai } from "@/features/auth/lib/api";
|
||||
import { saveToken } from "@/shared/lib/cookie";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const AuthLogin = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: { username: string; password: string }) =>
|
||||
auth_pai.login(body),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.data.message, {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
console.log(res.data.data.access);
|
||||
|
||||
saveToken(res.data.data.access);
|
||||
navigate("dashboard");
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = err.response?.data as { message: string };
|
||||
const messageText = errMessage.message;
|
||||
|
||||
toast.error(messageText || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const FormSchema = z.object({
|
||||
email: z.string().min(1, "Login kiriting"),
|
||||
password: z.string().min(1, "Parolni kiriting"),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof FormSchema>) => {
|
||||
mutate({
|
||||
password: values.password,
|
||||
username: values.email,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
Admin panelga kirish
|
||||
</CardTitle>
|
||||
<CardDescription className="text-md">
|
||||
Login va parolingizni kiriting.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Login</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="admin"
|
||||
{...field}
|
||||
className="!h-12 !text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Parol</Label>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="12345"
|
||||
{...field}
|
||||
className="!h-12 !text-md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-black"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={20} />
|
||||
) : (
|
||||
<Eye size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-lg"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : "Kirish"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLogin;
|
||||
29
src/features/districts/lib/api.ts
Normal file
29
src/features/districts/lib/api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Category } from "@/features/plans/lib/data";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const categories_api = {
|
||||
async list(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<AxiosResponse<Category>> {
|
||||
const res = await httpClient.get(`${API_URLS.CategoryList}`, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async create(body: FormData) {
|
||||
const res = await httpClient.post(`${API_URLS.CategoryCreate}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async update({ body, id }: { id: string; body: FormData }) {
|
||||
const res = await httpClient.patch(`${API_URLS.CategoryUpdate(id)}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const res = await httpClient.delete(`${API_URLS.CategoryDelete(id)}`);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
5
src/features/districts/lib/data.ts
Normal file
5
src/features/districts/lib/data.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface CategoryCreate {
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
image: File;
|
||||
}
|
||||
12
src/features/districts/lib/form.ts
Normal file
12
src/features/districts/lib/form.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import z from "zod";
|
||||
|
||||
export const addDistrict = z.object({
|
||||
name_uz: z.string().min(2, "Tuman nomi kamida 2 ta harf bo‘lishi kerak"),
|
||||
name_ru: z.string().min(2, "Tuman nomi kamida 2 ta harf bo‘lishi kerak"),
|
||||
image: z
|
||||
.file()
|
||||
.refine((file) => file.size <= 5 * 1024 * 1024, {
|
||||
message: "Fayl hajmi 5MB dan oshmasligi kerak",
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
177
src/features/districts/ui/AddCategories.tsx
Normal file
177
src/features/districts/ui/AddCategories.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { categories_api } from "@/features/districts/lib/api";
|
||||
import { addDistrict } from "@/features/districts/lib/form";
|
||||
import type { CategoryItem } from "@/features/plans/lib/data";
|
||||
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";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
type FormValues = z.infer<typeof addDistrict>;
|
||||
|
||||
interface Props {
|
||||
initialValues: CategoryItem | null;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function AddDistrict({ initialValues, setDialogOpen }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: FormData) => categories_api.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Kategoriyani qo'shishda xatolik yuz berdi");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateMutate, isPending: isUpdatePending } = useMutation({
|
||||
mutationFn: ({ body, id }: { body: FormData; id: string }) =>
|
||||
categories_api.update({ body, id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Kategoriyani qo'shishda xatolik yuz berdi");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(addDistrict),
|
||||
defaultValues: {
|
||||
name_uz: initialValues?.name_uz ?? "",
|
||||
name_ru: initialValues?.name_ru ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
const formData = new FormData();
|
||||
formData.append("name_uz", values.name_uz);
|
||||
formData.append("name_ru", values.name_ru);
|
||||
if (values.image) {
|
||||
formData.append("image", values.image);
|
||||
}
|
||||
|
||||
if (initialValues) {
|
||||
updateMutate({ body: formData, id: initialValues.id });
|
||||
} else {
|
||||
mutate(formData);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedImage = useWatch({
|
||||
control: form.control,
|
||||
name: "image",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
name="name_uz"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Nomi (uz)</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Ichimlik"
|
||||
className="h-12 text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="name_ru"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Nomi (ru)</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Напиток"
|
||||
className="h-12 text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Rasm</Label>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="picture"
|
||||
className="w-full h-32 border rounded-xl flex flex-col justify-center items-center cursor-pointer"
|
||||
>
|
||||
<Upload className="text-gray-400 size-12" />
|
||||
<p className="text-gray-400 ">Rasmni yuklang</p>
|
||||
</Label>
|
||||
{selectedImage && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Tanlangan fayl:{" "}
|
||||
<span className="font-medium">{selectedImage.name}</span>
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
id="picture"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
field.onChange(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-blue-700 hover:bg-blue-800"
|
||||
disabled={isPending || isUpdatePending}
|
||||
>
|
||||
{isPending || isUpdatePending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : initialValues ? (
|
||||
"Tahrirlash"
|
||||
) : (
|
||||
"Qo'shish"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
81
src/features/districts/ui/CategoriesList.tsx
Normal file
81
src/features/districts/ui/CategoriesList.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { categories_api } from "@/features/districts/lib/api";
|
||||
import DeleteDiscrit from "@/features/districts/ui/DeleteCategories";
|
||||
import FilterCategory from "@/features/districts/ui/Filter";
|
||||
import TableDistrict from "@/features/districts/ui/TableCategories";
|
||||
import type { CategoryItem } from "@/features/plans/lib/data";
|
||||
import Pagination from "@/shared/ui/pagination";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const CategoriesList = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [editingDistrict, setEditingDistrict] = useState<CategoryItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["categories", currentPage, search],
|
||||
queryFn: () =>
|
||||
categories_api.list({
|
||||
page: currentPage,
|
||||
page_size: 20,
|
||||
}),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const [disricDelete, setDiscritDelete] = useState<CategoryItem | null>(null);
|
||||
const [opneDelete, setOpenDelete] = useState<boolean>(false);
|
||||
|
||||
const handleDelete = (user: CategoryItem) => {
|
||||
setDiscritDelete(user);
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
||||
<h1 className="text-2xl font-bold">Kategoriyalar ro‘yxati</h1>
|
||||
|
||||
<FilterCategory
|
||||
dialogOpen={dialogOpen}
|
||||
setDialogOpen={setDialogOpen}
|
||||
editing={editingDistrict}
|
||||
setEditing={setEditingDistrict}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TableDistrict
|
||||
data={data ? data.results : []}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingDistrict={setEditingDistrict}
|
||||
handleDelete={handleDelete}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
totalPages={data?.total_pages || 1}
|
||||
/>
|
||||
|
||||
<DeleteDiscrit
|
||||
discrit={disricDelete}
|
||||
setDiscritDelete={setDiscritDelete}
|
||||
opneDelete={opneDelete}
|
||||
setOpenDelete={setOpenDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriesList;
|
||||
88
src/features/districts/ui/DeleteCategories.tsx
Normal file
88
src/features/districts/ui/DeleteCategories.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { categories_api } from "@/features/districts/lib/api";
|
||||
import type { CategoryItem } from "@/features/plans/lib/data";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2, Trash, X } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
opneDelete: boolean;
|
||||
setOpenDelete: Dispatch<SetStateAction<boolean>>;
|
||||
setDiscritDelete: Dispatch<SetStateAction<CategoryItem | null>>;
|
||||
discrit: CategoryItem | null;
|
||||
}
|
||||
|
||||
const DeleteDiscrit = ({
|
||||
opneDelete,
|
||||
setOpenDelete,
|
||||
setDiscritDelete,
|
||||
discrit,
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: deleteDiscrict, isPending } = useMutation({
|
||||
mutationFn: (id: string) => categories_api.delete(id),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["categories"] });
|
||||
toast.success(`Kategoriya o'chirildi`);
|
||||
setOpenDelete(false);
|
||||
setDiscritDelete(null);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = err.response?.data as { message: string };
|
||||
const messageText = errMessage.message;
|
||||
toast.error(messageText || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kategoriyani o'chirish</DialogTitle>
|
||||
<DialogDescription className="text-md font-semibold">
|
||||
Siz rostan ham {discrit?.name_uz} kategoriyani o'chirmoqchimisiz
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
|
||||
onClick={() => setOpenDelete(false)}
|
||||
>
|
||||
<X />
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => discrit && deleteDiscrict(discrit.id)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Trash />
|
||||
O'chirish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteDiscrit;
|
||||
55
src/features/districts/ui/Filter.tsx
Normal file
55
src/features/districts/ui/Filter.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import AddDistrict from "@/features/districts/ui/AddCategories";
|
||||
import type { CategoryItem } from "@/features/plans/lib/data";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Plus } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
setSearch: Dispatch<SetStateAction<string>>;
|
||||
dialogOpen: boolean;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
editing: CategoryItem | null;
|
||||
setEditing: Dispatch<SetStateAction<CategoryItem | null>>;
|
||||
}
|
||||
|
||||
const FilterCategory = ({
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
setEditing,
|
||||
editing,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setEditing(null)}
|
||||
className="bg-blue-500 h-12 cursor-pointer hover:bg-blue-500"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-1" /> Kategoriya qo‘shish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{editing ? "Kategoriyani tahrirlash" : "Kategoriya qo‘shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<AddDistrict initialValues={editing} setDialogOpen={setDialogOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterCategory;
|
||||
115
src/features/districts/ui/TableCategories.tsx
Normal file
115
src/features/districts/ui/TableCategories.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { CategoryItem } from "@/features/plans/lib/data";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Edit, Loader2, Trash } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
data: CategoryItem[] | [];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
handleDelete: (user: CategoryItem) => void;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setEditingDistrict: Dispatch<SetStateAction<CategoryItem | null>>;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
const TableDistrict = ({
|
||||
data,
|
||||
isError,
|
||||
isLoading,
|
||||
handleDelete,
|
||||
setDialogOpen,
|
||||
setEditingDistrict,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<div className="h-full flex items-center justify-center bg-white/70 z-10">
|
||||
<span className="text-lg font-medium">
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="h-full flex items-center justify-center z-10">
|
||||
<span className="text-lg font-medium text-red-600">
|
||||
Ma'lumotlarni olishda xatolik yuz berdi.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && !isLoading && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Rasmi</TableHead>
|
||||
<TableHead>Nomi (uz)</TableHead>
|
||||
<TableHead>Nomi (ru)</TableHead>
|
||||
<TableHead className="text-right">Harakatlar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{data.map((d, index) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<img
|
||||
src={API_URLS.BASE_URL + d.image}
|
||||
alt={d.name_uz}
|
||||
className="w-10 h-10 object-cover rounded-md"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{d.name_uz}</TableCell>
|
||||
<TableCell>{d.name_ru}</TableCell>
|
||||
|
||||
<TableCell className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingDistrict(d);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
className="bg-blue-500 text-white hover:text-white hover:bg-blue-500 cursor-pointer"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(d)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-6">
|
||||
Hech qanday tuman topilmadi
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableDistrict;
|
||||
29
src/features/faq/lib/api.ts
Normal file
29
src/features/faq/lib/api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Faq, FaqBody } from "@/features/faq/lib/type";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const faq_api = {
|
||||
async getFaqs(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<AxiosResponse<Faq>> {
|
||||
const res = await httpClient.get(API_URLS.FaqList, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async createFaq(body: FaqBody) {
|
||||
const res = httpClient.post(API_URLS.FaqCreate, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async updateFaq({ body, id }: { id: string; body: FaqBody }) {
|
||||
const res = httpClient.patch(API_URLS.FaqUpdate(id), body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async deleteFaq(id: string) {
|
||||
const res = await httpClient.delete(API_URLS.FaqDelete(id));
|
||||
return res;
|
||||
},
|
||||
};
|
||||
8
src/features/faq/lib/form.ts
Normal file
8
src/features/faq/lib/form.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
export const faqForm = z.object({
|
||||
question_uz: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
question_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
answer_uz: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
answer_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
});
|
||||
24
src/features/faq/lib/type.ts
Normal file
24
src/features/faq/lib/type.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface Faq {
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
results: FaqItem[];
|
||||
}
|
||||
|
||||
export interface FaqItem {
|
||||
answer_ru: string;
|
||||
answer_uz: string;
|
||||
id: string;
|
||||
question_ru: string;
|
||||
question_uz: string;
|
||||
}
|
||||
|
||||
export interface FaqBody {
|
||||
question_uz: string;
|
||||
question_ru: string;
|
||||
answer_uz: string;
|
||||
answer_ru: string;
|
||||
}
|
||||
190
src/features/faq/ui/CreateFaq.tsx
Normal file
190
src/features/faq/ui/CreateFaq.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { faq_api } from "@/features/faq/lib/api";
|
||||
import { faqForm } from "@/features/faq/lib/form";
|
||||
import type { FaqBody, FaqItem } from "@/features/faq/lib/type";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type z from "zod";
|
||||
|
||||
interface Props {
|
||||
initialValues: FaqItem | null;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const CreateFaq = ({ initialValues, setDialogOpen }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: FaqBody) => faq_api.createFaq(body),
|
||||
onSuccess: () => {
|
||||
setDialogOpen(false);
|
||||
toast.success("Faq qo'shildi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
queryClient.refetchQueries({ queryKey: ["faqs"] });
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
toast.error((err.response?.data as string) || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: update, isPending: updateLoad } = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: FaqBody }) =>
|
||||
faq_api.updateFaq({ body, id }),
|
||||
onSuccess: () => {
|
||||
setDialogOpen(false);
|
||||
toast.success("Faq yangilandi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
queryClient.refetchQueries({ queryKey: ["faqs"] });
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
toast.error((err.response?.data as string) || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof faqForm>>({
|
||||
resolver: zodResolver(faqForm),
|
||||
defaultValues: {
|
||||
answer_ru: initialValues?.answer_ru || "",
|
||||
answer_uz: initialValues?.answer_uz || "",
|
||||
question_ru: initialValues?.question_ru || "",
|
||||
question_uz: initialValues?.question_uz || "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof faqForm>) {
|
||||
if (initialValues) {
|
||||
update({
|
||||
body: {
|
||||
answer_ru: values.answer_ru,
|
||||
answer_uz: values.answer_uz,
|
||||
question_ru: values.question_ru,
|
||||
question_uz: values.question_uz,
|
||||
},
|
||||
id: initialValues.id,
|
||||
});
|
||||
} else if (!initialValues) {
|
||||
mutate({
|
||||
answer_ru: values.answer_ru,
|
||||
answer_uz: values.answer_uz,
|
||||
question_ru: values.question_ru,
|
||||
question_uz: values.question_uz,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="question_uz"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Savol (uz)</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Savol (uz)"
|
||||
{...field}
|
||||
className="min-h-32 max-h-44"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="question_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Savol (ru)</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Savol (ru)"
|
||||
{...field}
|
||||
className="min-h-32 max-h-44"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="answer_uz"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Javob (uz)</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Javob (uz)"
|
||||
{...field}
|
||||
className="min-h-32 max-h-44"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="answer_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Javob (ru)</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Javob (ru)"
|
||||
{...field}
|
||||
className="min-h-32 max-h-44"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer"
|
||||
type="submit"
|
||||
>
|
||||
{isPending || updateLoad ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : initialValues ? (
|
||||
"Tahrirlash"
|
||||
) : (
|
||||
"Qo'shish"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateFaq;
|
||||
143
src/features/faq/ui/FaqList.tsx
Normal file
143
src/features/faq/ui/FaqList.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { faq_api } from "@/features/faq/lib/api";
|
||||
import type { FaqItem } from "@/features/faq/lib/type";
|
||||
import CreateFaq from "@/features/faq/ui/CreateFaq";
|
||||
import FaqTable from "@/features/faq/ui/FaqTable";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2, Plus, Trash, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const FaqList = () => {
|
||||
const [faqDelete, setFaqDelete] = useState<FaqItem | null>(null);
|
||||
const [openDelete, setOpenDelete] = useState<boolean>(false);
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [editingFaq, setEditingFaq] = useState<FaqItem | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: faq,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["faqs"],
|
||||
queryFn: async () => faq_api.getFaqs({ page: 1, page_size: 20 }),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (user: FaqItem) => {
|
||||
setFaqDelete(user);
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (id: string) => faq_api.deleteFaq(id),
|
||||
onSuccess: () => {
|
||||
toast.success("Faq o'chirildi", {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
queryClient.refetchQueries({ queryKey: ["faqs"] });
|
||||
setOpenDelete(false);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
toast.success((err.response?.data as string) || "Xatolik yu berdi", {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<h1 className="text-2xl font-bold">FAQ</h1>
|
||||
|
||||
<div className="flex justify-end gap-2 w-full">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-blue-500 cursor-pointer hover:bg-blue-500"
|
||||
onClick={() => setEditingFaq(null)}
|
||||
>
|
||||
<Plus className="!h-5 !w-5" /> Qo'shish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-x-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{editingFaq ? "Tahrirlash" : "Faq qo'shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CreateFaq
|
||||
initialValues={editingFaq}
|
||||
setDialogOpen={setDialogOpen}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FaqTable
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
faq={faq ? faq.results : []}
|
||||
handleDelete={handleDelete}
|
||||
isFetching={isFetching}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingFaq={setEditingFaq}
|
||||
/>
|
||||
|
||||
<Dialog open={openDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Faqni o'chirish</DialogTitle>
|
||||
<DialogDescription className="text-md font-semibold">
|
||||
Siz rostan ham faqni o'chirmoqchimisiz
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
|
||||
onClick={() => setOpenDelete(false)}
|
||||
>
|
||||
<X />
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => faqDelete && mutate(faqDelete.id)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Trash />
|
||||
O'chirish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqList;
|
||||
117
src/features/faq/ui/FaqTable.tsx
Normal file
117
src/features/faq/ui/FaqTable.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { FaqItem } from "@/features/faq/lib/type";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
setEditingFaq: Dispatch<SetStateAction<FaqItem | null>>;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
faq: FaqItem[] | [];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
handleDelete: (user: FaqItem) => void;
|
||||
}
|
||||
|
||||
const FaqTable = ({
|
||||
faq,
|
||||
handleDelete,
|
||||
isError,
|
||||
isFetching,
|
||||
isLoading,
|
||||
setDialogOpen,
|
||||
setEditingFaq,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{(isLoading || isFetching) && (
|
||||
<div className="h-full flex items-center justify-center bg-white/70 z-10">
|
||||
<span className="text-lg font-medium">
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="h-full flex items-center justify-center z-10">
|
||||
<span className="text-lg font-medium text-red-600">
|
||||
Ma'lumotlarni olishda xatolik yuz berdi.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Savol (uz)</TableHead>
|
||||
<TableHead>Savol (ru)</TableHead>
|
||||
<TableHead>Javob (uz)</TableHead>
|
||||
<TableHead>Javob (ru)</TableHead>
|
||||
<TableHead className="text-right">Amallar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{faq.length > 0 ? (
|
||||
faq.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{item.question_uz}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{item.question_ru}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{item.answer_uz}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{item.answer_uz}
|
||||
</TableCell>
|
||||
<TableCell className="text-right flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-blue-600 text-white cursor-pointer hover:bg-blue-600 hover:text-white"
|
||||
onClick={() => {
|
||||
setEditingFaq(item);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-4 text-lg">
|
||||
Birlik topilmadi.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqTable;
|
||||
11
src/features/home/lib/api.ts
Normal file
11
src/features/home/lib/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { StatisticResponse } from "@/features/home/lib/type";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const statisticApi = {
|
||||
async fetchStatistics(): Promise<AxiosResponse<StatisticResponse>> {
|
||||
const response = await httpClient.get(API_URLS.Statistica);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
16
src/features/home/lib/type.ts
Normal file
16
src/features/home/lib/type.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface Statistic {
|
||||
status_code: number;
|
||||
status: string;
|
||||
message: string;
|
||||
data: StatisticResponse;
|
||||
}
|
||||
|
||||
export interface StatisticResponse {
|
||||
products: number;
|
||||
users: number;
|
||||
orders: number;
|
||||
total_price: {
|
||||
total_price: null | number;
|
||||
};
|
||||
categories: number;
|
||||
}
|
||||
104
src/features/home/ui/Home.tsx
Normal file
104
src/features/home/ui/Home.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { statisticApi } from "@/features/home/lib/api";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["fetch_statistics"],
|
||||
queryFn: () => statisticApi.fetchStatistics(),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full space-y-4">
|
||||
{/* Title & Welcome Text */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{t("dashboard")}</h1>
|
||||
<p className="text-muted-foreground">{t("welcomeMessage")}</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title={t("totalProducts")}
|
||||
value={isLoading ? "..." : (data?.products.toLocaleString() ?? "0")}
|
||||
description={t("activeProducts")}
|
||||
icon={Package}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("totalUsers")}
|
||||
value={isLoading ? "..." : (data?.users.toLocaleString() ?? "0")}
|
||||
description={t("registeredUsers")}
|
||||
icon={Users}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("totalOrders")}
|
||||
value={isLoading ? "..." : (data?.orders.toLocaleString() ?? "0")}
|
||||
description={t("ordersThisMonth")}
|
||||
icon={ShoppingCart}
|
||||
/>
|
||||
{data && (
|
||||
<StatCard
|
||||
title={t("revenue")}
|
||||
value={
|
||||
isLoading
|
||||
? "..."
|
||||
: `${formatPrice(data.total_price.total_price === null ? 0 : data.total_price.total_price, true)}`
|
||||
}
|
||||
description={t("totalRevenue")}
|
||||
icon={DollarSign}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Secondary Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard
|
||||
title={t("categories")}
|
||||
value={isLoading ? "..." : (data?.categories.toLocaleString() ?? "0")}
|
||||
description={t("productCategories")}
|
||||
icon={Package}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ title, value, description, icon: Icon }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
29
src/features/objects/lib/api.ts
Normal file
29
src/features/objects/lib/api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { BannersListData } from "@/features/objects/lib/data";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const banner_api = {
|
||||
async list(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<AxiosResponse<BannersListData>> {
|
||||
const res = await httpClient.get(`${API_URLS.BannersList}`, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async create(body: FormData) {
|
||||
const res = await httpClient.post(`${API_URLS.BannerCreate}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
// async update({ body, id }: { id: number; body: ObjectUpdate }) {
|
||||
// const res = await httpClient.patch(`${API_URLS.OBJECT}${id}/update/`, body);
|
||||
// return res;
|
||||
// },
|
||||
|
||||
async delete(id: string) {
|
||||
const res = await httpClient.delete(`${API_URLS.BannerDelete(id)}`);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
14
src/features/objects/lib/data.ts
Normal file
14
src/features/objects/lib/data.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface BannersListData {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
results: BannerListItem[];
|
||||
}
|
||||
|
||||
export interface BannerListItem {
|
||||
id: string;
|
||||
banner: string;
|
||||
}
|
||||
5
src/features/objects/lib/form.ts
Normal file
5
src/features/objects/lib/form.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import z from "zod";
|
||||
|
||||
export const BannerForm = z.object({
|
||||
banner: z.file().min(1, { message: "Majburiy maydon" }),
|
||||
});
|
||||
117
src/features/objects/ui/AddedBanners.tsx
Normal file
117
src/features/objects/ui/AddedBanners.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { banner_api } from "@/features/objects/lib/api";
|
||||
import type { BannerListItem } from "@/features/objects/lib/data";
|
||||
import { BannerForm } from "@/features/objects/lib/form";
|
||||
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";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type z from "zod";
|
||||
|
||||
interface Props {
|
||||
initialValues: BannerListItem | null;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function AddedBanner({ initialValues, setDialogOpen }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<z.infer<typeof BannerForm>>({
|
||||
resolver: zodResolver(BannerForm),
|
||||
defaultValues: {},
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: async (data: FormData) => banner_api.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["banner_list"] });
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Banner qo'shishda xatolik yuz berdi.");
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof BannerForm>) {
|
||||
const formData = new FormData();
|
||||
if (values.banner) {
|
||||
formData.append("banner", values.banner);
|
||||
}
|
||||
|
||||
mutate(formData);
|
||||
}
|
||||
|
||||
const selectedImage = useWatch({
|
||||
control: form.control,
|
||||
name: "banner",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="banner"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Rasm</Label>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="picture"
|
||||
className="w-full h-32 border rounded-xl flex flex-col justify-center items-center cursor-pointer"
|
||||
>
|
||||
<Upload className="text-gray-400 size-12" />
|
||||
<p className="text-gray-400 ">Rasmni yuklang</p>
|
||||
</Label>
|
||||
{selectedImage && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Tanlangan fayl:{" "}
|
||||
<span className="font-medium">{selectedImage.name}</span>
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
id="picture"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
field.onChange(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer"
|
||||
type="submit"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : initialValues ? (
|
||||
"Tahrirlash"
|
||||
) : (
|
||||
"Qo'shish"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
56
src/features/objects/ui/BannersFilter.tsx
Normal file
56
src/features/objects/ui/BannersFilter.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { BannerListItem } from "@/features/objects/lib/data";
|
||||
import AddedBanner from "@/features/objects/ui/Addedbanners";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
dialogOpen: boolean;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setEditingPlan: Dispatch<SetStateAction<BannerListItem | null>>;
|
||||
editingPlan: BannerListItem | null;
|
||||
}
|
||||
|
||||
const ObjectFilter = ({
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
editingPlan,
|
||||
setEditingPlan,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap w-full md:w-auto">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-blue-500 cursor-pointer hover:bg-blue-500"
|
||||
onClick={() => setEditingPlan(null)}
|
||||
>
|
||||
<Plus className="!h-5 !w-5" /> Qo'shish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{editingPlan ? "Obyektni tahrirlash" : "Yangi Banner qo'shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<AddedBanner
|
||||
initialValues={editingPlan}
|
||||
setDialogOpen={setDialogOpen}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectFilter;
|
||||
77
src/features/objects/ui/BannersList.tsx
Normal file
77
src/features/objects/ui/BannersList.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { banner_api } from "@/features/objects/lib/api";
|
||||
import type { BannerListItem } from "@/features/objects/lib/data";
|
||||
import ObjectFilter from "@/features/objects/ui/BannersFilter";
|
||||
import ObjectTable from "@/features/objects/ui/BannersTable";
|
||||
import DeleteObject from "@/features/objects/ui/DeleteBanners";
|
||||
import Pagination from "@/shared/ui/pagination";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function BannersList() {
|
||||
const [editingPlan, setEditingPlan] = useState<BannerListItem | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const limit = 20;
|
||||
|
||||
const [disricDelete, setDiscritDelete] = useState<BannerListItem | null>(
|
||||
null,
|
||||
);
|
||||
const [opneDelete, setOpenDelete] = useState<boolean>(false);
|
||||
|
||||
const handleDelete = (user: BannerListItem) => {
|
||||
setDiscritDelete(user);
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
const {
|
||||
data: object,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["banner_list", currentPage, limit],
|
||||
queryFn: () =>
|
||||
banner_api.list({
|
||||
page: currentPage,
|
||||
page_size: limit,
|
||||
}),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
||||
<h1 className="text-2xl font-bold">Bannerlar</h1>
|
||||
<ObjectFilter
|
||||
dialogOpen={dialogOpen}
|
||||
editingPlan={editingPlan}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingPlan={setEditingPlan}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ObjectTable
|
||||
filteredData={object ? object.results : []}
|
||||
handleDelete={handleDelete}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingPlan={setEditingPlan}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
totalPages={object ? object.total_pages : 1}
|
||||
/>
|
||||
|
||||
<DeleteObject
|
||||
discrit={disricDelete}
|
||||
opneDelete={opneDelete}
|
||||
setDiscritDelete={setDiscritDelete}
|
||||
setOpenDelete={setOpenDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/features/objects/ui/BannersTable.tsx
Normal file
95
src/features/objects/ui/BannersTable.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { BannerListItem } from "@/features/objects/lib/data";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
filteredData: BannerListItem[] | [];
|
||||
setEditingPlan: Dispatch<SetStateAction<BannerListItem | null>>;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
handleDelete: (object: BannerListItem) => void;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
const ObjectTable = ({
|
||||
filteredData,
|
||||
handleDelete,
|
||||
isError,
|
||||
isLoading,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<div className="h-full flex items-center justify-center bg-white/70 z-10">
|
||||
<span className="text-lg font-medium">
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="h-full flex items-center justify-center z-10">
|
||||
<span className="text-lg font-medium text-red-600">
|
||||
Ma'lumotlarni olishda xatolik yuz berdi.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && !isLoading && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Banner</TableHead>
|
||||
<TableHead className="text-right">Amallar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredData && filteredData.length > 0 ? (
|
||||
filteredData.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<img
|
||||
src={API_URLS.BASE_URL + item.banner}
|
||||
alt={item.id}
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right space-x-2 gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-4 text-lg">
|
||||
Obyekt topilmadi.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectTable;
|
||||
80
src/features/objects/ui/DeleteBanners.tsx
Normal file
80
src/features/objects/ui/DeleteBanners.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { banner_api } from "@/features/objects/lib/api";
|
||||
import type { BannerListItem } from "@/features/objects/lib/data";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash, X } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
opneDelete: boolean;
|
||||
setOpenDelete: Dispatch<SetStateAction<boolean>>;
|
||||
setDiscritDelete: Dispatch<SetStateAction<BannerListItem | null>>;
|
||||
discrit: BannerListItem | null;
|
||||
}
|
||||
|
||||
const DeleteObject = ({
|
||||
opneDelete,
|
||||
setOpenDelete,
|
||||
setDiscritDelete,
|
||||
discrit,
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (id: string) => banner_api.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["banner_list"] });
|
||||
setOpenDelete(false);
|
||||
setDiscritDelete(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Bannerni o'chirishda xatolik yuz berdi.");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bannerni o'chirish</DialogTitle>
|
||||
<DialogDescription className="text-md font-semibold">
|
||||
Siz rostan ham bannerni o'chirmoqchimisiz?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
|
||||
onClick={() => setOpenDelete(false)}
|
||||
>
|
||||
<X />
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => discrit && mutate(discrit.id)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Trash />
|
||||
O'chirish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteObject;
|
||||
50
src/features/plans/lib/api.ts
Normal file
50
src/features/plans/lib/api.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
Category,
|
||||
ProductsList,
|
||||
UnityList,
|
||||
} from "@/features/plans/lib/data";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const plans_api = {
|
||||
async list(params: {
|
||||
search: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<ProductsList>> {
|
||||
const res = await httpClient.get(`${API_URLS.ProductsList}`, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async create(body: FormData) {
|
||||
const res = await httpClient.post(`${API_URLS.CreateProduct}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async update({ body, id }: { id: string; body: FormData }) {
|
||||
const res = await httpClient.patch(`${API_URLS.UpdateProduct(id)}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const res = await httpClient.delete(`${API_URLS.DeleteProdut(id)}`);
|
||||
return res;
|
||||
},
|
||||
|
||||
async categories(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<Category>> {
|
||||
const res = await httpClient.get(`${API_URLS.CategoryList}`, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async unity(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<UnityList>> {
|
||||
const res = await httpClient.get(`${API_URLS.UnityList}`, { params });
|
||||
return res;
|
||||
},
|
||||
};
|
||||
70
src/features/plans/lib/data.ts
Normal file
70
src/features/plans/lib/data.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export interface ProductsList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
results: Product[];
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
is_active: boolean;
|
||||
image: string;
|
||||
category: string;
|
||||
price: number;
|
||||
description_uz: string;
|
||||
description_ru: string;
|
||||
unity: string;
|
||||
tg_id: string;
|
||||
code: string;
|
||||
article: string;
|
||||
quantity_left: number;
|
||||
min_quantity: number;
|
||||
brand: null | string;
|
||||
return_date: null | string;
|
||||
expires_date: null | string;
|
||||
manufacturer: null | string;
|
||||
volume: string;
|
||||
images: {
|
||||
id: string;
|
||||
image: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
results: CategoryItem[];
|
||||
}
|
||||
|
||||
export interface CategoryItem {
|
||||
id: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
image: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface UnityList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
results: UnityItem[];
|
||||
}
|
||||
|
||||
export interface UnityItem {
|
||||
id: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
}
|
||||
24
src/features/plans/lib/form.ts
Normal file
24
src/features/plans/lib/form.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import z from "zod";
|
||||
|
||||
export const createPlanFormData = z.object({
|
||||
name_uz: z.string().min(1, "Majburiy maydon"),
|
||||
name_ru: z.string().min(1, "Majburiy maydon"),
|
||||
description_uz: z.string().min(1, "Majburiy maydon"),
|
||||
description_ru: z.string().min(1, "Majburiy maydon"),
|
||||
category_id: z.string().uuid("Kategoriya noto‘g‘ri"),
|
||||
unity_id: z.string().uuid("Birlik noto‘g‘ri"),
|
||||
price: z.number().positive("Narx noto‘g‘ri"),
|
||||
quantity_left: z.number().min(0),
|
||||
min_quantity: z.number().min(0),
|
||||
is_active: z.boolean(),
|
||||
image: z.instanceof(File).optional(),
|
||||
|
||||
images: z.array(z.instanceof(File)).optional(),
|
||||
|
||||
tg_id: z.string().optional(),
|
||||
code: z.string(),
|
||||
article: z.string(),
|
||||
brand: z.string().min(1, "Majburiy maydon"),
|
||||
manufacturer: z.string().min(1, "Majburiy maydon"),
|
||||
volume: z.string().min(1, "Majburiy maydon"),
|
||||
});
|
||||
767
src/features/plans/ui/AddedPlan.tsx
Normal file
767
src/features/plans/ui/AddedPlan.tsx
Normal file
@@ -0,0 +1,767 @@
|
||||
"use client";
|
||||
|
||||
import { categories_api } from "@/features/districts/lib/api";
|
||||
import { plans_api } from "@/features/plans/lib/api";
|
||||
import type { Product } from "@/features/plans/lib/data";
|
||||
import { createPlanFormData } from "@/features/plans/lib/form";
|
||||
import { unity_api } from "@/features/units/lib/api";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Popover } from "@radix-ui/react-popover";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Check, ChevronsUpDown, Loader2, Upload, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
interface Props {
|
||||
initialValues?: Product | null;
|
||||
setDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const AddedPlan = ({ initialValues, setDialogOpen }: Props) => {
|
||||
const form = useForm<z.infer<typeof createPlanFormData>>({
|
||||
resolver: zodResolver(createPlanFormData),
|
||||
defaultValues: {
|
||||
name_uz: initialValues?.name_uz || "",
|
||||
name_ru: initialValues?.name_ru || "",
|
||||
description_uz: initialValues?.description_uz || "",
|
||||
description_ru: initialValues?.description_ru || "",
|
||||
category_id: initialValues?.category || "",
|
||||
unity_id: initialValues?.unity || "",
|
||||
price: initialValues?.price || 0,
|
||||
quantity_left: initialValues?.quantity_left || 0,
|
||||
min_quantity: initialValues?.min_quantity || 0,
|
||||
is_active: initialValues?.is_active || false,
|
||||
images: [],
|
||||
article: initialValues?.article || "",
|
||||
brand: initialValues?.brand || "",
|
||||
code: initialValues?.code || "",
|
||||
manufacturer: initialValues?.manufacturer || "",
|
||||
volume: initialValues?.volume || "",
|
||||
},
|
||||
});
|
||||
const [openUser, setOpenUser] = useState<boolean>(false);
|
||||
const [openUnity, setOpenUnity] = useState<boolean>(false);
|
||||
const [initialImages, setInitialImages] = useState(
|
||||
initialValues?.images || [],
|
||||
);
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: FormData) => plans_api.create(body),
|
||||
onSuccess() {
|
||||
toast.success("Mahsulot qo'shildi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
toast.error((err.response?.data as string) || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
const { mutate: updated, isPending: updatePending } = useMutation({
|
||||
mutationFn: ({ body, id }: { id: string; body: FormData }) =>
|
||||
plans_api.update({ body, id }),
|
||||
onSuccess() {
|
||||
toast.success("Mahsulot qo'shildi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
toast.error((err.response?.data as string) || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: () =>
|
||||
categories_api.list({
|
||||
page: 1,
|
||||
page_size: 999,
|
||||
}),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: unity } = useQuery({
|
||||
queryKey: ["unity_list"],
|
||||
queryFn: () =>
|
||||
unity_api.list({
|
||||
page: 1,
|
||||
page_size: 999,
|
||||
}),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
const [removedImageIds, setRemovedImageIds] = useState<string[]>([]);
|
||||
|
||||
const onSubmit = (data: z.infer<typeof createPlanFormData>) => {
|
||||
const formData = new FormData();
|
||||
formData.append("name_uz", data.name_uz);
|
||||
formData.append("name_ru", data.name_ru);
|
||||
formData.append("is_active", data.is_active ? "true" : "false");
|
||||
formData.append("category_id", data.category_id);
|
||||
formData.append("price", data.price.toString());
|
||||
formData.append("description_uz", data.description_uz);
|
||||
formData.append("description_ru", data.description_ru);
|
||||
formData.append("unity_id", data.unity_id);
|
||||
formData.append("code", data.code.toString());
|
||||
formData.append("article", data.article);
|
||||
formData.append("quantity_left", data.quantity_left.toString());
|
||||
formData.append("min_quantity", data.min_quantity.toString());
|
||||
formData.append("brand", data.brand);
|
||||
formData.append("manufacturer", data.manufacturer);
|
||||
formData.append("volume", data.volume);
|
||||
if (data.image) {
|
||||
formData.append("image", data.image);
|
||||
}
|
||||
if (data.images?.length) {
|
||||
data.images.forEach((file) => formData.append("images", file));
|
||||
}
|
||||
|
||||
if (initialValues) {
|
||||
removedImageIds.map((e) => formData.append("delete_images", e));
|
||||
updated({ body: formData, id: initialValues.id });
|
||||
} else {
|
||||
mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name_uz"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nomi (UZ)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="h-12" placeholder="Nomi (uz)" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* NAME RU */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nomi (RU)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="h-12" placeholder="Nomi (ru)" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description_uz"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tavsif (UZ)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-32 max-h-44"
|
||||
placeholder="Tavsif (uz)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tavsif (RU)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-32 max-h-44"
|
||||
placeholder="Tavsif (ru)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="category_id"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const selectedUser = data?.results.find(
|
||||
(u) => String(u.id) === field.value,
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem className="flex flex-col">
|
||||
<Label className="text-md">Kategoriyalar</Label>
|
||||
|
||||
<Popover open={openUser} onOpenChange={setOpenUser}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openUser}
|
||||
className={cn(
|
||||
"w-full h-12 justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selectedUser &&
|
||||
typeof selectedUser.name_uz === "string"
|
||||
? selectedUser.name_uz
|
||||
: "Kategoriyani tanlang"}
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-sm">
|
||||
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : data && data.results.length > 0 ? (
|
||||
<CommandGroup>
|
||||
{data.results.map((u) => (
|
||||
<CommandItem
|
||||
key={u.id}
|
||||
value={`${u.id}`}
|
||||
onSelect={() => {
|
||||
field.onChange(String(u.id));
|
||||
setOpenUser(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === String(u.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{u.name_uz}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<CommandEmpty>Kategoriya topilmadi</CommandEmpty>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="unity_id"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const selectedUser = unity?.results.find(
|
||||
(u) => String(u.id) === field.value,
|
||||
);
|
||||
return (
|
||||
<FormItem className="flex flex-col">
|
||||
<Label className="text-md">Birliklar</Label>
|
||||
<Popover open={openUnity} onOpenChange={setOpenUnity}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openUser}
|
||||
className={cn(
|
||||
"w-full h-12 justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selectedUser &&
|
||||
typeof selectedUser.name_uz === "string"
|
||||
? selectedUser.name_uz
|
||||
: "Birlikni tanlang"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-sm">
|
||||
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : unity && unity.results.length > 0 ? (
|
||||
<CommandGroup>
|
||||
{unity.results.map((u) => (
|
||||
<CommandItem
|
||||
key={u.id}
|
||||
value={`${u.id}`}
|
||||
onSelect={() => {
|
||||
field.onChange(String(u.id));
|
||||
setOpenUser(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === String(u.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{u.name_uz}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<CommandEmpty>Birlik topilmadi</CommandEmpty>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* PRICE */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Narx</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Narxi"
|
||||
className="h-12"
|
||||
value={field.value ? formatPrice(field.value) : ""}
|
||||
onChange={(e) => {
|
||||
// faqat raqamlarni qoldiramiz
|
||||
const rawValue = e.target.value.replace(/\D/g, "");
|
||||
field.onChange(rawValue ? Number(rawValue) : 0);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* QUANTITY */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quantity_left"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mavjud miqdor</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_quantity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Minimal miqdor</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brand"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Brand</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-12"
|
||||
placeholder="Brand nomi"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ishlab chiqaruvchi</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-12"
|
||||
placeholder="Ishlab chiqaruvchi"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volume"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Hajmi</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-12"
|
||||
placeholder="Hajmi"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="article"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ichki identifikator</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-12"
|
||||
placeholder="Ichki identifikator"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>kodi</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-12"
|
||||
placeholder="Kodi"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ACTIVE */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-3">
|
||||
<FormLabel>Faol</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Asosiy rasm</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{/* Agar initialValues'dan rasm bo'lsa ko'rsatish */}
|
||||
{initialValues?.image && !field.value && (
|
||||
<div className="relative w-full h-48 border rounded-xl overflow-hidden">
|
||||
<img
|
||||
src={API_URLS.BASE_URL + initialValues.image}
|
||||
alt="Mavjud rasm"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-blue-500 text-white text-xs px-2 py-1 rounded">
|
||||
Mavjud rasm
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="file"
|
||||
id="main-image"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormLabel
|
||||
htmlFor="main-image"
|
||||
className="w-full flex flex-col items-center justify-center h-36 border border-dashed rounded-2xl cursor-pointer hover:bg-gray-50 transition"
|
||||
>
|
||||
<Upload className="size-10 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{field.value || initialValues?.image
|
||||
? "Yangi rasm tanlash"
|
||||
: "Rasm tanlang"}
|
||||
</p>
|
||||
|
||||
{field.value && field.value instanceof File && (
|
||||
<div className="mt-3 text-center">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
Yangi tanlangan: {field.value.name}
|
||||
</p>
|
||||
<img
|
||||
src={URL.createObjectURL(field.value)}
|
||||
alt="preview"
|
||||
className="mt-2 mx-auto h-20 w-20 object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* QO'SHIMCHA RASMLAR */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="images"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Qo'shimcha rasmlar</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{initialImages.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Mavjud rasmlar:
|
||||
</p>
|
||||
<div className="grid grid-cols-7 gap-2 mb-3">
|
||||
{initialImages.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative border rounded-xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={API_URLS.BASE_URL + img.image}
|
||||
alt={`existing-${idx}`}
|
||||
className="h-14 w-14 object-cover"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const removed = initialImages[idx].id;
|
||||
setRemovedImageIds([
|
||||
...removedImageIds,
|
||||
removed,
|
||||
]);
|
||||
const updated = initialImages.filter(
|
||||
(_, i) => i !== idx,
|
||||
);
|
||||
setInitialImages(updated);
|
||||
}}
|
||||
className="absolute z-[99999] top-1 right-1 bg-red-500 hover:bg-red-600 text-white rounded-full cursor-pointer flex items-center justify-center w-4 h-4"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</Button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-blue-500/80 text-white text-[10px] text-center py-0.5">
|
||||
Mavjud
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FILE INPUT */}
|
||||
<Input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
id="main-images"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
? Array.from(e.target.files)
|
||||
: [];
|
||||
field.onChange([...(field.value || []), ...files]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormLabel
|
||||
htmlFor="main-images"
|
||||
className="w-full flex flex-col items-center justify-center h-36 border border-dashed rounded-2xl cursor-pointer hover:bg-gray-50 transition"
|
||||
>
|
||||
<Upload className="size-10 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Yangi rasmlar qo'shish
|
||||
</p>
|
||||
</FormLabel>
|
||||
|
||||
{/* Yangi tanlangan rasmlar */}
|
||||
{Array.isArray(field.value) && field.value.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Yangi tanlangan rasmlar:
|
||||
</p>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{field.value.map((file: File, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative group border rounded-xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt="preview"
|
||||
className="h-14 w-14 object-cover"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = field.value?.filter(
|
||||
(_: File, i: number) => i !== index,
|
||||
);
|
||||
field.onChange(updated);
|
||||
}}
|
||||
className="absolute top-1 right-1 bg-red-500/90 hover:bg-red-600 text-white rounded-full p-1 cursor-pointer flex items-center justify-center opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<XIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* SUBMIT */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || updatePending}
|
||||
>
|
||||
{isPending || updatePending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Saqlash"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddedPlan;
|
||||
88
src/features/plans/ui/DeletePlan.tsx
Normal file
88
src/features/plans/ui/DeletePlan.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { plans_api } from "@/features/plans/lib/api";
|
||||
import type { Product } from "@/features/plans/lib/data";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2, Trash, X } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
opneDelete: boolean;
|
||||
setOpenDelete: Dispatch<SetStateAction<boolean>>;
|
||||
setPlanDelete: Dispatch<SetStateAction<Product | null>>;
|
||||
planDelete: Product | null;
|
||||
}
|
||||
|
||||
const DeletePlan = ({
|
||||
opneDelete,
|
||||
setOpenDelete,
|
||||
planDelete,
|
||||
setPlanDelete,
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: deleteUser, isPending } = useMutation({
|
||||
mutationFn: (id: string) => plans_api.delete(id),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["product_list"] });
|
||||
toast.success(`Mahsulot o'chirildi`);
|
||||
setOpenDelete(false);
|
||||
setPlanDelete(null);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = err.response?.data as { message: string };
|
||||
const messageText = errMessage.message;
|
||||
toast.error(messageText || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mahsulotni o'chirish</DialogTitle>
|
||||
<DialogDescription className="text-md font-semibold">
|
||||
Siz rostan ham mahsulotni o'chirmoqchimisiz?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
|
||||
onClick={() => setOpenDelete(false)}
|
||||
>
|
||||
<X />
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => planDelete && deleteUser(planDelete.id)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Trash />
|
||||
O'chirish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeletePlan;
|
||||
68
src/features/plans/ui/FilterPlans.tsx
Normal file
68
src/features/plans/ui/FilterPlans.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Product } from "@/features/plans/lib/data";
|
||||
import AddedPlan from "@/features/plans/ui/AddedPlan";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
searchUser: string;
|
||||
setSearchUser: Dispatch<SetStateAction<string>>;
|
||||
dialogOpen: boolean;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
editingPlan: Product | null;
|
||||
setEditingPlan: Dispatch<SetStateAction<Product | null>>;
|
||||
}
|
||||
|
||||
const FilterPlans = ({
|
||||
searchUser,
|
||||
setSearchUser,
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
setEditingPlan,
|
||||
editingPlan,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Foydalanuvchi ismi"
|
||||
className="h-12"
|
||||
value={searchUser}
|
||||
onChange={(e) => setSearchUser(e.target.value)}
|
||||
/>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-blue-500 cursor-pointer hover:bg-blue-500 h-12"
|
||||
onClick={() => setEditingPlan(null)}
|
||||
>
|
||||
<Plus className="!h-5 !w-5" /> Qo'shish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg h-[80vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{editingPlan ? "Rejani tahrirlash" : "Yangi reja qo'shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<AddedPlan
|
||||
initialValues={editingPlan}
|
||||
setDialogOpen={setDialogOpen}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPlans;
|
||||
192
src/features/plans/ui/PalanTable.tsx
Normal file
192
src/features/plans/ui/PalanTable.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { plans_api } from "@/features/plans/lib/api";
|
||||
import type { Product } from "@/features/plans/lib/data";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import clsx from "clsx";
|
||||
import { Edit, Eye, Loader2, Trash } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
products: Product[] | [];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
setEditingProduct: Dispatch<SetStateAction<Product | null>>;
|
||||
setDetailOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
handleDelete: (product: Product) => void;
|
||||
}
|
||||
|
||||
const ProductTable = ({
|
||||
products,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
setEditingProduct,
|
||||
setDetailOpen,
|
||||
setDialogOpen,
|
||||
handleDelete,
|
||||
}: Props) => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: () => {
|
||||
return plans_api.categories({ page: 1, page_size: 1000 });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.results;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: unityData } = useQuery({
|
||||
queryKey: ["unity"],
|
||||
queryFn: () => {
|
||||
return plans_api.unity({ page: 1, page_size: 1000 });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.results;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || isFetching) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-red-600">
|
||||
Maʼlumotlarni yuklashda xatolik yuz berdi
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Rasmi</TableHead>
|
||||
<TableHead>Nomi (UZ)</TableHead>
|
||||
<TableHead>Nomi (RU)</TableHead>
|
||||
<TableHead>Tavsif (UZ)</TableHead>
|
||||
<TableHead>Tavsif (RU)</TableHead>
|
||||
<TableHead>Kategoriya</TableHead>
|
||||
<TableHead>Birligi</TableHead>
|
||||
<TableHead>Brendi</TableHead>
|
||||
<TableHead>Narx</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Harakatlar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{products.map((product, index) => {
|
||||
const cat = data?.find((c) => c.id === product.category);
|
||||
const unity = unityData?.find((u) => u.id === product.unity);
|
||||
|
||||
return (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<img
|
||||
src={API_URLS.BASE_URL + product.image}
|
||||
alt={product.name_uz}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{product.name_uz}</TableCell>
|
||||
<TableCell>{product.name_ru}</TableCell>
|
||||
<TableCell>{product.description_uz.slice(0, 15)}...</TableCell>
|
||||
<TableCell>{product.description_ru.slice(0, 15)}...</TableCell>
|
||||
<TableCell>{cat?.name_uz}</TableCell>
|
||||
<TableCell>{unity?.name_uz}</TableCell>
|
||||
<TableCell>{product.brand}</TableCell>
|
||||
<TableCell>{formatPrice(product.price, true)}</TableCell>
|
||||
<TableCell
|
||||
className={clsx(
|
||||
product.is_active ? "text-green-600" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
<Select value={product.is_active ? "true" : "false"}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Holati" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Faol</SelectItem>
|
||||
<SelectItem value="false">Nofaol</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="space-x-2 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingProduct(product);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-500 text-white hover:bg-blue-600"
|
||||
onClick={() => {
|
||||
setEditingProduct(product);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(product)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{products.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-gray-500">
|
||||
Mahsulotlar topilmadi
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductTable;
|
||||
218
src/features/plans/ui/PlanDetail.tsx
Normal file
218
src/features/plans/ui/PlanDetail.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { categories_api } from "@/features/districts/lib/api";
|
||||
import type { Product } from "@/features/plans/lib/data";
|
||||
import { unity_api } from "@/features/units/lib/api";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
setDetail: Dispatch<SetStateAction<boolean>>;
|
||||
detail: boolean;
|
||||
plan: Product | null;
|
||||
}
|
||||
|
||||
const PlanDetail = ({ detail, setDetail, plan }: Props) => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: () =>
|
||||
categories_api.list({
|
||||
page: 1,
|
||||
page_size: 999,
|
||||
}),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: unity } = useQuery({
|
||||
queryKey: ["unity_list"],
|
||||
queryFn: () =>
|
||||
unity_api.list({
|
||||
page: 1,
|
||||
page_size: 999,
|
||||
}),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={detail} onOpenChange={setDetail}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{plan.name_uz}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* Product Images */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{plan.image && (
|
||||
<img
|
||||
src={API_URLS.BASE_URL + plan.image}
|
||||
alt={plan.name_uz}
|
||||
className="w-full h-48 object-cover rounded-lg border"
|
||||
/>
|
||||
)}
|
||||
{plan.images.map((img) => (
|
||||
<img
|
||||
key={img.id}
|
||||
src={API_URLS.BASE_URL + img.image}
|
||||
alt={plan.name_uz}
|
||||
className="w-full h-48 object-cover rounded-lg border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Product Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
className={
|
||||
plan.is_active
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}
|
||||
>
|
||||
{plan.is_active ? "Faol" : "Nofaol"}
|
||||
</Badge>
|
||||
{plan.quantity_left <= plan.min_quantity && (
|
||||
<Badge className="bg-orange-100 text-orange-700">Kam qoldi</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Nomi (O'zbekcha):</p>
|
||||
<p className="text-gray-700">{plan.name_uz}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Nomi (Ruscha):</p>
|
||||
<p className="text-gray-700">{plan.name_ru}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descriptions */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
Tavsifi (O'zbekcha):
|
||||
</p>
|
||||
<p className="text-gray-700">{plan.description_uz}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Tavsifi (Ruscha):</p>
|
||||
<p className="text-gray-700">{plan.description_ru}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price and Category */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Narxi:</p>
|
||||
<p className="text-xl font-bold text-blue-600">
|
||||
{plan.price.toLocaleString()} so'm
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Kategoriya:</p>
|
||||
<p className="text-gray-700">
|
||||
{data?.results.find((e) => e.id === plan.category)?.name_uz}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">O'lchov birligi:</p>
|
||||
<p className="text-gray-700">
|
||||
{unity?.results.find((e) => e.id === plan.unity)?.name_uz}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Kod:</p>
|
||||
<p className="text-gray-700">{plan.code}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Artikul:</p>
|
||||
<p className="text-gray-700">{plan.article}</p>
|
||||
</div>
|
||||
{plan.brand && (
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Brand:</p>
|
||||
<p className="text-gray-700">{plan.brand}</p>
|
||||
</div>
|
||||
)}
|
||||
{plan.manufacturer && (
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
Ishlab chiqaruvchi:
|
||||
</p>
|
||||
<p className="text-gray-700">{plan.manufacturer}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Hajm:</p>
|
||||
<p className="text-gray-700">{plan.volume}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-blue-50 p-4 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Qolgan miqdor:</p>
|
||||
<p className="text-lg font-semibold text-gray-700">
|
||||
{plan.quantity_left}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Minimal miqdor:</p>
|
||||
<p className="text-lg font-semibold text-gray-700">
|
||||
{plan.min_quantity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
{(plan.return_date || plan.expires_date) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{plan.return_date && (
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
Qaytarish sanasi:
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
{new Date(plan.return_date).toLocaleDateString("uz-UZ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{plan.expires_date && (
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
Amal qilish muddati:
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
{new Date(plan.expires_date).toLocaleDateString("uz-UZ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanDetail;
|
||||
84
src/features/plans/ui/ProductList.tsx
Normal file
84
src/features/plans/ui/ProductList.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { plans_api } from "@/features/plans/lib/api";
|
||||
import type { Product } from "@/features/plans/lib/data";
|
||||
import DeletePlan from "@/features/plans/ui/DeletePlan";
|
||||
import FilterPlans from "@/features/plans/ui/FilterPlans";
|
||||
import PalanTable from "@/features/plans/ui/PalanTable";
|
||||
import PlanDetail from "@/features/plans/ui/PlanDetail";
|
||||
import Pagination from "@/shared/ui/pagination";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
const ProductList = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchUser, setSearchUser] = useState<string>("");
|
||||
const limit = 20;
|
||||
const { data, isLoading, isError, isFetching } = useQuery({
|
||||
queryKey: ["product_list", searchUser, currentPage],
|
||||
queryFn: () => {
|
||||
return plans_api.list({
|
||||
page: currentPage,
|
||||
page_size: limit,
|
||||
search: searchUser,
|
||||
});
|
||||
},
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const [editingPlan, setEditingPlan] = useState<Product | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<boolean>(false);
|
||||
|
||||
const [openDelete, setOpenDelete] = useState<boolean>(false);
|
||||
const [planDelete, setPlanDelete] = useState<Product | null>(null);
|
||||
|
||||
const handleDelete = (id: Product) => {
|
||||
setOpenDelete(true);
|
||||
setPlanDelete(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
||||
<h1 className="text-2xl font-bold">Mahsulotlar</h1>
|
||||
<FilterPlans
|
||||
dialogOpen={dialogOpen}
|
||||
editingPlan={editingPlan}
|
||||
searchUser={searchUser}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingPlan={setEditingPlan}
|
||||
setSearchUser={setSearchUser}
|
||||
/>
|
||||
|
||||
<PlanDetail detail={detail} setDetail={setDetail} plan={editingPlan} />
|
||||
</div>
|
||||
|
||||
<PalanTable
|
||||
products={data?.results || []}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
setDetailOpen={setDetail}
|
||||
setEditingProduct={setEditingPlan}
|
||||
setDialogOpen={setDialogOpen}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
totalPages={data?.total_pages || 1}
|
||||
/>
|
||||
|
||||
<DeletePlan
|
||||
opneDelete={openDelete}
|
||||
planDelete={planDelete}
|
||||
setOpenDelete={setOpenDelete}
|
||||
setPlanDelete={setPlanDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductList;
|
||||
14
src/features/reports/lib/api.ts
Normal file
14
src/features/reports/lib/api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ResportListRes } from "@/features/reports/lib/data";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const report_api = {
|
||||
async list(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<AxiosResponse<ResportListRes>> {
|
||||
const res = await httpClient.get(`${API_URLS.REPORT}list/`, { params });
|
||||
return res;
|
||||
},
|
||||
};
|
||||
92
src/features/reports/lib/data.ts
Normal file
92
src/features/reports/lib/data.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export interface ReportsTypeList {
|
||||
id: number;
|
||||
pharm_name: string;
|
||||
amount: string;
|
||||
month: Date;
|
||||
}
|
||||
|
||||
export const ReportsData: ReportsTypeList[] = [
|
||||
{
|
||||
id: 1,
|
||||
pharm_name: "City Pharmacy",
|
||||
amount: "500000",
|
||||
month: new Date(2025, 0, 1),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pharm_name: "Central Pharmacy",
|
||||
amount: "750000",
|
||||
month: new Date(2025, 0, 1),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pharm_name: "Green Pharmacy",
|
||||
amount: "620000",
|
||||
month: new Date(2025, 1, 1),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pharm_name: "HealthPlus Pharmacy",
|
||||
amount: "810000",
|
||||
month: new Date(2025, 1, 1),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pharm_name: "Optima Pharmacy",
|
||||
amount: "430000",
|
||||
month: new Date(2025, 2, 1),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
pharm_name: "City Pharmacy",
|
||||
amount: "540000",
|
||||
month: new Date(2025, 2, 1),
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
pharm_name: "Central Pharmacy",
|
||||
amount: "770000",
|
||||
month: new Date(2025, 3, 1),
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
pharm_name: "Green Pharmacy",
|
||||
amount: "650000",
|
||||
month: new Date(2025, 3, 1),
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
pharm_name: "HealthPlus Pharmacy",
|
||||
amount: "820000",
|
||||
month: new Date(2025, 4, 1),
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
pharm_name: "Optima Pharmacy",
|
||||
amount: "460000",
|
||||
month: new Date(2025, 4, 1),
|
||||
},
|
||||
];
|
||||
|
||||
export interface ResportListRes {
|
||||
status_code: number;
|
||||
status: string;
|
||||
message: string;
|
||||
data: {
|
||||
count: number;
|
||||
next: null | string;
|
||||
previous: null | string;
|
||||
results: ResportListResData[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResportListResData {
|
||||
id: number;
|
||||
employee_name: string;
|
||||
factory: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
price: string;
|
||||
created_at: string;
|
||||
}
|
||||
6
src/features/reports/lib/form.ts
Normal file
6
src/features/reports/lib/form.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import z from "zod";
|
||||
|
||||
export const reportsForm = z.object({
|
||||
pharm_name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
amount: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
});
|
||||
126
src/features/reports/ui/AddedReport.tsx
Normal file
126
src/features/reports/ui/AddedReport.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { ReportsTypeList } from "@/features/reports/lib/data";
|
||||
import { reportsForm } from "@/features/reports/lib/form";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
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";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
|
||||
interface Props {
|
||||
initialValues: ReportsTypeList | null;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setPlans: Dispatch<SetStateAction<ReportsTypeList[]>>;
|
||||
}
|
||||
|
||||
const AddedReport = ({ initialValues, setDialogOpen, setPlans }: Props) => {
|
||||
const [load, setLoad] = useState<boolean>(false);
|
||||
const [displayPrice, setDisplayPrice] = useState<string>("");
|
||||
const form = useForm<z.infer<typeof reportsForm>>({
|
||||
resolver: zodResolver(reportsForm),
|
||||
defaultValues: {
|
||||
amount: initialValues?.amount || "",
|
||||
pharm_name: initialValues?.pharm_name || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
setDisplayPrice(formatPrice(initialValues.amount));
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
function onSubmit(values: z.infer<typeof reportsForm>) {
|
||||
setLoad(true);
|
||||
const newReport: ReportsTypeList = {
|
||||
id: initialValues ? initialValues.id : Date.now(),
|
||||
amount: values.amount,
|
||||
pharm_name: values.pharm_name,
|
||||
month: new Date(),
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
setPlans((prev) => {
|
||||
if (initialValues) {
|
||||
return prev.map((item) =>
|
||||
item.id === initialValues.id ? newReport : item,
|
||||
);
|
||||
} else {
|
||||
return [...prev, newReport];
|
||||
}
|
||||
});
|
||||
setLoad(false);
|
||||
setDialogOpen(false);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pharm_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Dorixona nomi</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Nomi" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label>Berilgan summa</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="1 500 000"
|
||||
value={displayPrice}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/\D/g, "");
|
||||
const num = Number(raw);
|
||||
if (!isNaN(num)) {
|
||||
form.setValue("amount", String(num));
|
||||
setDisplayPrice(raw ? formatPrice(num) : "");
|
||||
}
|
||||
}}
|
||||
className="h-12 !text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className="w-full h-12 bg-blue-500 cursor-pointer hover:bg-blue-500">
|
||||
{load ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : initialValues ? (
|
||||
"Tahrirlash"
|
||||
) : (
|
||||
"Qo'shish"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddedReport;
|
||||
97
src/features/reports/ui/ReportsTable.tsx
Normal file
97
src/features/reports/ui/ReportsTable.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { ResportListResData } from "@/features/reports/lib/data";
|
||||
import formatDate from "@/shared/lib/formatDate";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const ReportsTable = ({
|
||||
plans,
|
||||
// setEditingPlan,
|
||||
// setDialogOpen,
|
||||
// handleDelete,
|
||||
isError,
|
||||
isLoading,
|
||||
}: {
|
||||
plans: ResportListResData[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
// setEditingPlan: Dispatch<SetStateAction<ResportListResData | null>>;
|
||||
// setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
// handleDelete: (id: number) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<div className="h-full flex items-center justify-center bg-white/70 z-10">
|
||||
<span className="text-lg font-medium">
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="h-full flex items-center justify-center z-10">
|
||||
<span className="text-lg font-medium text-red-600">
|
||||
Ma'lumotlarni olishda xatolik yuz berdi.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && !isLoading && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-center">
|
||||
<TableHead className="text-start">ID</TableHead>
|
||||
<TableHead className="text-start">Dorixoan nomi</TableHead>
|
||||
<TableHead className="text-start">To'langan summa</TableHead>
|
||||
<TableHead className="text-start">To'langan sanasi</TableHead>
|
||||
{/* <TableHead className="text-right">Harakatlar</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plans.map((plan) => (
|
||||
<TableRow key={plan.id} className="text-start">
|
||||
<TableCell>{plan.id}</TableCell>
|
||||
<TableCell>{plan.employee_name}</TableCell>
|
||||
<TableCell>{formatPrice(plan.price, true)}</TableCell>
|
||||
<TableCell>
|
||||
{formatDate.format(plan.created_at, "DD-MM-YYYY")}
|
||||
</TableCell>
|
||||
|
||||
{/* <TableCell className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-500 text-white hover:bg-blue-500 hover:text-white cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditingPlan(plan);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleDelete(plan.id)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell> */}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsTable;
|
||||
77
src/features/reports/ui/UsersList.tsx
Normal file
77
src/features/reports/ui/UsersList.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { report_api } from "@/features/reports/lib/api";
|
||||
import ReportsTable from "@/features/reports/ui/ReportsTable";
|
||||
import Pagination from "@/shared/ui/pagination";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
const UsersList = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const limit = 20;
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["report_list", currentPage],
|
||||
queryFn: () =>
|
||||
report_api.list({ limit, offset: (currentPage - 1) * limit }),
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
},
|
||||
});
|
||||
const totalPages = data ? Math.ceil(data.count / limit) : 1;
|
||||
// const [plans, setPlans] = useState<ReportsTypeList[]>(ReportsData);
|
||||
|
||||
// const [editingPlan, setEditingPlan] = useState<ReportsTypeList | null>(null);
|
||||
// const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// const handleDelete = (id: number) => {
|
||||
// setPlans(plans.filter((p) => p.id !== id));
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
||||
<h1 className="text-2xl font-bold">To'lovlar</h1>
|
||||
|
||||
{/* <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-blue-500 cursor-pointer hover:bg-blue-500"
|
||||
onClick={() => setEditingPlan(null)}
|
||||
>
|
||||
<Plus className="!h-5 !w-5" /> Qo'shish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{editingPlan ? "Rejani tahrirlash" : "Yangi reja qo'shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<AddedReport
|
||||
initialValues={editingPlan}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setPlans={setPlans}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog> */}
|
||||
</div>
|
||||
|
||||
<ReportsTable
|
||||
// handleDelete={handleDelete}
|
||||
plans={data ? data.results : []}
|
||||
// setDialogOpen={setDialogOpen}
|
||||
// setEditingPlan={setEditingPlan}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersList;
|
||||
35
src/features/units/lib/api.ts
Normal file
35
src/features/units/lib/api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { UnityListRes } from "@/features/units/lib/data";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const unity_api = {
|
||||
async list(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<UnityListRes>> {
|
||||
const res = await httpClient.get(`${API_URLS.UnityList}`, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async create(body: { name_uz: string; name_ru: string }) {
|
||||
const res = await httpClient.post(`${API_URLS.UnitsCreate}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async update({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: string;
|
||||
body: { name_uz: string; name_ru: string };
|
||||
}) {
|
||||
const res = await httpClient.patch(`${API_URLS.UnitsUpdate(id)}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const res = await httpClient.delete(`${API_URLS.UnitsDelete(id)}`);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
15
src/features/units/lib/data.ts
Normal file
15
src/features/units/lib/data.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface UnityListRes {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
results: UnityListResData[];
|
||||
}
|
||||
|
||||
export interface UnityListResData {
|
||||
id: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
}
|
||||
6
src/features/units/lib/form.ts
Normal file
6
src/features/units/lib/form.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import z from "zod";
|
||||
|
||||
export const DoctorForm = z.object({
|
||||
name_uz: z.string().min(1, "Majburiy maydon"),
|
||||
name_ru: z.string().min(1, "Majburiy maydon"),
|
||||
});
|
||||
134
src/features/units/ui/AddedUnits.tsx
Normal file
134
src/features/units/ui/AddedUnits.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { unity_api } from "@/features/units/lib/api";
|
||||
import type { UnityListResData } from "@/features/units/lib/data";
|
||||
import { DoctorForm } from "@/features/units/lib/form";
|
||||
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";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type z from "zod";
|
||||
|
||||
interface Props {
|
||||
initialValues: UnityListResData | null;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const AddedUnits = ({ initialValues, setDialogOpen }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<z.infer<typeof DoctorForm>>({
|
||||
resolver: zodResolver(DoctorForm),
|
||||
defaultValues: {
|
||||
name_ru: initialValues ? initialValues.name_ru : "",
|
||||
name_uz: initialValues ? initialValues.name_uz : "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: async (body: { name_uz: string; name_ru: string }) => {
|
||||
unity_api.create(body);
|
||||
},
|
||||
onSuccess() {
|
||||
setDialogOpen(false);
|
||||
queryClient.refetchQueries({ queryKey: ["unity_list"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Xatolik yuz berdi. Iltimos, qaytadan urinib ko'ring.");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: update, isPending: isUpdatePending } = useMutation({
|
||||
mutationFn: async ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
body: { name_uz: string; name_ru: string };
|
||||
id: string;
|
||||
}) => {
|
||||
unity_api.update({ body, id });
|
||||
},
|
||||
onSuccess() {
|
||||
setDialogOpen(false);
|
||||
queryClient.refetchQueries({ queryKey: ["unity_list"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Xatolik yuz berdi. Iltimos, qaytadan urinib ko'ring.");
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof DoctorForm>) {
|
||||
if (initialValues) {
|
||||
return update({
|
||||
id: initialValues.id,
|
||||
body: {
|
||||
name_uz: values.name_uz,
|
||||
name_ru: values.name_ru,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
mutate({
|
||||
name_uz: values.name_uz,
|
||||
name_ru: values.name_ru,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name_uz"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Nomi (uz)</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Nomi (uz)" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>Nomi (ru)</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Nomi (ru)" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer"
|
||||
type="submit"
|
||||
>
|
||||
{isPending || isUpdatePending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : initialValues ? (
|
||||
"Tahrirlash"
|
||||
) : (
|
||||
"Qo'shish"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddedUnits;
|
||||
88
src/features/units/ui/DeleteUnits.tsx
Normal file
88
src/features/units/ui/DeleteUnits.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { unity_api } from "@/features/units/lib/api";
|
||||
import type { UnityListResData } from "@/features/units/lib/data";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2, Trash, X } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
opneDelete: boolean;
|
||||
setOpenDelete: Dispatch<SetStateAction<boolean>>;
|
||||
setDiscritDelete: Dispatch<SetStateAction<UnityListResData | null>>;
|
||||
discrit: UnityListResData | null;
|
||||
}
|
||||
|
||||
const DeleteUnits = ({
|
||||
opneDelete,
|
||||
setOpenDelete,
|
||||
setDiscritDelete,
|
||||
discrit,
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: deleteDiscrict, isPending } = useMutation({
|
||||
mutationFn: (id: string) => unity_api.delete(id),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["unity_list"] });
|
||||
toast.success(`Birlik o'chirildi`);
|
||||
setOpenDelete(false);
|
||||
setDiscritDelete(null);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = err.response?.data as { message: string };
|
||||
const messageText = errMessage.message;
|
||||
toast.error(messageText || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Birlikni o'chirish</DialogTitle>
|
||||
<DialogDescription className="text-md font-semibold">
|
||||
Siz rostan ham birlikni o'chirmoqchimisiz
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
|
||||
onClick={() => setOpenDelete(false)}
|
||||
>
|
||||
<X />
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => discrit && deleteDiscrict(discrit.id)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Trash />
|
||||
O'chirish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteUnits;
|
||||
55
src/features/units/ui/FilterUnits.tsx
Normal file
55
src/features/units/ui/FilterUnits.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { UnityListResData } from "@/features/units/lib/data";
|
||||
import AddedUnits from "@/features/units/ui/AddedUnits";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
dialogOpen: boolean;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setEditingPlan: Dispatch<SetStateAction<UnityListResData | null>>;
|
||||
editingPlan: UnityListResData | null;
|
||||
}
|
||||
|
||||
const FilterUnits = ({
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
setEditingPlan,
|
||||
editingPlan,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 w-full">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-blue-500 cursor-pointer hover:bg-blue-500"
|
||||
onClick={() => setEditingPlan(null)}
|
||||
>
|
||||
<Plus className="!h-5 !w-5" /> Qo'shish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-x-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{editingPlan ? "Birlik tahrirlash" : "Yangi birlik qo'shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddedUnits
|
||||
initialValues={editingPlan}
|
||||
setDialogOpen={setDialogOpen}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterUnits;
|
||||
105
src/features/units/ui/TableUnits.tsx
Normal file
105
src/features/units/ui/TableUnits.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { UnityListResData } from "@/features/units/lib/data";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/ui/table";
|
||||
import { Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
setEditingPlan: Dispatch<SetStateAction<UnityListResData | null>>;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
doctor: UnityListResData[] | [];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
handleDelete: (user: UnityListResData) => void;
|
||||
}
|
||||
|
||||
const TableUnits = ({
|
||||
doctor,
|
||||
isError,
|
||||
setEditingPlan,
|
||||
isLoading,
|
||||
setDialogOpen,
|
||||
handleDelete,
|
||||
isFetching,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{(isLoading || isFetching) && (
|
||||
<div className="h-full flex items-center justify-center bg-white/70 z-10">
|
||||
<span className="text-lg font-medium">
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="h-full flex items-center justify-center z-10">
|
||||
<span className="text-lg font-medium text-red-600">
|
||||
Ma'lumotlarni olishda xatolik yuz berdi.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Nomi (uz)</TableHead>
|
||||
<TableHead>Nomi (ru)</TableHead>
|
||||
<TableHead className="text-right">Amallar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{doctor.length > 0 ? (
|
||||
doctor.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell className="font-medium">{item.name_uz}</TableCell>
|
||||
<TableCell className="font-medium">{item.name_ru}</TableCell>
|
||||
<TableCell className="text-right flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-blue-600 text-white cursor-pointer hover:bg-blue-600 hover:text-white"
|
||||
onClick={() => {
|
||||
setEditingPlan(item);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-4 text-lg">
|
||||
Birlik topilmadi.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableUnits;
|
||||
86
src/features/units/ui/UnitsList.tsx
Normal file
86
src/features/units/ui/UnitsList.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { unity_api } from "@/features/units/lib/api";
|
||||
import type { UnityListResData } from "@/features/units/lib/data";
|
||||
import DeleteUnits from "@/features/units/ui/DeleteUnits";
|
||||
import FilterUnits from "@/features/units/ui/FilterUnits";
|
||||
import TableUnits from "@/features/units/ui/TableUnits";
|
||||
import Pagination from "@/shared/ui/pagination";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
const UnitsList = () => {
|
||||
const [editingPlan, setEditingPlan] = useState<UnityListResData | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [disricDelete, setDiscritDelete] = useState<UnityListResData | null>(
|
||||
null,
|
||||
);
|
||||
const [opneDelete, setOpenDelete] = useState<boolean>(false);
|
||||
|
||||
const limit = 20;
|
||||
|
||||
const {
|
||||
data: unity,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["unity_list"],
|
||||
queryFn: () =>
|
||||
unity_api.list({
|
||||
page: currentPage,
|
||||
page_size: limit,
|
||||
}),
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (user: UnityListResData) => {
|
||||
setDiscritDelete(user);
|
||||
setOpenDelete(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<h1 className="text-2xl font-bold">Birlikni boshqarish</h1>
|
||||
|
||||
<FilterUnits
|
||||
dialogOpen={dialogOpen}
|
||||
editingPlan={editingPlan}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingPlan={setEditingPlan}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableUnits
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
doctor={unity ? unity.results : []}
|
||||
handleDelete={handleDelete}
|
||||
isFetching={isFetching}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingPlan={setEditingPlan}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
totalPages={unity ? unity.total_pages : 1}
|
||||
/>
|
||||
|
||||
<DeleteUnits
|
||||
discrit={disricDelete}
|
||||
opneDelete={opneDelete}
|
||||
setDiscritDelete={setDiscritDelete}
|
||||
setOpenDelete={setOpenDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnitsList;
|
||||
29
src/features/users/lib/api.ts
Normal file
29
src/features/users/lib/api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { UserCreateReq, UserListRes } from "@/features/users/lib/data";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { API_URLS } from "@/shared/config/api/URLs";
|
||||
import { type AxiosResponse } from "axios";
|
||||
|
||||
export const user_api = {
|
||||
async list(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<AxiosResponse<UserListRes>> {
|
||||
const res = await httpClient.get(`${API_URLS.UsesList}`, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async update({ body, id }: { id: string; body: UserCreateReq }) {
|
||||
const res = await httpClient.patch(`${API_URLS.UserUpdate(id)}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async create(body: UserCreateReq) {
|
||||
const res = await httpClient.post(`${API_URLS.UserCreate}`, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async delete({ id }: { id: string }) {
|
||||
const res = await httpClient.delete(`${API_URLS.UserDelete(id)}`);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
24
src/features/users/lib/data.ts
Normal file
24
src/features/users/lib/data.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface UserData {
|
||||
id: string;
|
||||
username: string;
|
||||
last_login: null | string;
|
||||
date_joined: string;
|
||||
is_superuser: boolean;
|
||||
role: "admin" | "user" | "moderator";
|
||||
}
|
||||
|
||||
export interface UserListRes {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: 20;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_previous: boolean;
|
||||
results: UserData[];
|
||||
}
|
||||
|
||||
export interface UserCreateReq {
|
||||
username: string;
|
||||
password: string;
|
||||
is_superuser: boolean;
|
||||
}
|
||||
11
src/features/users/lib/form.ts
Normal file
11
src/features/users/lib/form.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import z from "zod";
|
||||
|
||||
export const AddedUser = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Foydalanuvchi nomi kamida 3 ta belgidan iborat bo'lishi kerak"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Parol kamida 8 ta belgidan iborat bo'lishi kerak"),
|
||||
is_superuser: z.string().min(1, "Iltimos, foydalanuvchi rolini tanlang"),
|
||||
});
|
||||
180
src/features/users/ui/AddUsers.tsx
Normal file
180
src/features/users/ui/AddUsers.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { user_api } from "@/features/users/lib/api";
|
||||
import type { UserCreateReq, UserData } from "@/features/users/lib/data";
|
||||
import { AddedUser } from "@/features/users/lib/form";
|
||||
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";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type z from "zod";
|
||||
|
||||
interface UserFormProps {
|
||||
initialData: UserData | null;
|
||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const AddUsers = ({ initialData, setDialogOpen }: UserFormProps) => {
|
||||
const form = useForm<z.infer<typeof AddedUser>>({
|
||||
resolver: zodResolver(AddedUser),
|
||||
defaultValues: {
|
||||
is_superuser: initialData?.is_superuser ? "true" : "false",
|
||||
password: "",
|
||||
username: initialData?.username || "",
|
||||
},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: update } = useMutation({
|
||||
mutationFn: ({ body, id }: { id: string; body: UserCreateReq }) =>
|
||||
user_api.update({ body, id }),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["user_list"] });
|
||||
toast.success(`Foydalanuvchi tahrirlandi`);
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = err.response?.data as { message: string };
|
||||
const messageText = errMessage.message;
|
||||
toast.error(messageText || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: create, isPending: createPending } = useMutation({
|
||||
mutationFn: (body: UserCreateReq) => user_api.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["user_list"] });
|
||||
toast.success(`Foydalanuvchi qo'shildi`);
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = err.response?.data as { message: string };
|
||||
const messageText = errMessage.message;
|
||||
toast.error(messageText || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof AddedUser>) {
|
||||
if (initialData) {
|
||||
update({
|
||||
body: {
|
||||
is_superuser: values.is_superuser === "true" ? true : false,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
},
|
||||
id: initialData.id,
|
||||
});
|
||||
} else if (initialData === null) {
|
||||
create({
|
||||
is_superuser: values.is_superuser === "true" ? true : false,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Username</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
{...field}
|
||||
className="!h-12 !text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Parol</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Parol"
|
||||
{...field}
|
||||
className="!h-12 !text-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_superuser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md mr-4">Foydalanuvchi roli</Label>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="w-full !h-12">
|
||||
<SelectValue placeholder="Foydalanuvchi roli" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Admin</SelectItem>
|
||||
<SelectItem value="false">Foydalanuvchi</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer"
|
||||
>
|
||||
{createPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : initialData ? (
|
||||
"Saqlash"
|
||||
) : (
|
||||
"Qo'shish"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddUsers;
|
||||
89
src/features/users/ui/DeleteUser.tsx
Normal file
89
src/features/users/ui/DeleteUser.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { user_api } from "@/features/users/lib/api";
|
||||
import type { UserData } from "@/features/users/lib/data";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Loader2, Trash, X } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
opneDelete: boolean;
|
||||
setOpenDelete: Dispatch<SetStateAction<boolean>>;
|
||||
setUserDelete: Dispatch<SetStateAction<UserData | null>>;
|
||||
userDelete: UserData | null;
|
||||
}
|
||||
|
||||
const DeleteUser = ({
|
||||
opneDelete,
|
||||
setOpenDelete,
|
||||
userDelete,
|
||||
setUserDelete,
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: deleteUser, isPending } = useMutation({
|
||||
mutationFn: ({ id }: { id: string }) => user_api.delete({ id }),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["user_list"] });
|
||||
toast.success(`Foydalanuvchi o'chirildi`);
|
||||
setOpenDelete(false);
|
||||
setUserDelete(null);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = err.response?.data as { message: string };
|
||||
const messageText = errMessage.message;
|
||||
toast.error(messageText || "Xatolik yuz berdi", {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Foydalanuvchini o'chrish</DialogTitle>
|
||||
<DialogDescription className="text-md font-semibold">
|
||||
Siz rostan ham {userDelete?.username}
|
||||
nomli foydalanuvchini o'chimoqchimiszi
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
|
||||
onClick={() => setOpenDelete(false)}
|
||||
>
|
||||
<X />
|
||||
Bekor qilish
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => userDelete && deleteUser({ id: userDelete.id })}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Trash />
|
||||
O'chirish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteUser;
|
||||
57
src/features/users/ui/Filter.tsx
Normal file
57
src/features/users/ui/Filter.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { RegionListResData } from "@/features/region/lib/data";
|
||||
import type { UserData } from "@/features/users/lib/data";
|
||||
import AddUsers from "@/features/users/ui/AddUsers";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Plus } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
setRegionValue: Dispatch<SetStateAction<RegionListResData | null>>;
|
||||
dialogOpen: boolean;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
editingUser: UserData | null;
|
||||
setEditingUser: Dispatch<SetStateAction<UserData | null>>;
|
||||
}
|
||||
|
||||
const Filter = ({
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
editingUser,
|
||||
setEditingUser,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-blue-500 cursor-pointer hover:bg-blue-500"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
<Plus className="!h-5 !w-5" /> Qo'shish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingUser
|
||||
? "Foydalanuvchini tahrirlash"
|
||||
: "Foydalanuvchi qo'shish"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<AddUsers initialData={editingUser} setDialogOpen={setDialogOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filter;
|
||||
112
src/features/users/ui/UserCard.tsx
Normal file
112
src/features/users/ui/UserCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import type { UserData } from "@/features/users/lib/data";
|
||||
import { Avatar, AvatarFallback } from "@/shared/ui/avatar";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import { Clock, Edit, Trash2 } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
export function UserCard({
|
||||
user,
|
||||
setEditingUser,
|
||||
setDialogOpen,
|
||||
setOpenDelete,
|
||||
setUserDelete,
|
||||
}: {
|
||||
user: UserData;
|
||||
setEditingUser: (user: UserData) => void;
|
||||
setDialogOpen: (open: boolean) => void;
|
||||
setOpenDelete: Dispatch<SetStateAction<boolean>>;
|
||||
setUserDelete: Dispatch<SetStateAction<UserData | null>>;
|
||||
}) {
|
||||
const getRoleColor = (role: UserData["role"]) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "moderator":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "user":
|
||||
return "bg-green-100 text-green-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-green-100 text-green-700">
|
||||
{user.username
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="font-semibold">{user.username}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={getRoleColor(user.role)}>
|
||||
{user.role === "admin"
|
||||
? "Admin"
|
||||
: user.role === "user"
|
||||
? "Foydalanuvchi"
|
||||
: user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
{"Kirgan vaqt"} {new Date(user.date_joined).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{user.last_login && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{"Ohirgi marta kirgan"}:{" "}
|
||||
{new Date(user.last_login).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingUser(user);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Tahrirlash
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOpenDelete(true);
|
||||
if (setUserDelete) {
|
||||
setUserDelete(user);
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/features/users/ui/UserTable.tsx
Normal file
61
src/features/users/ui/UserTable.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { UserData } from "@/features/users/lib/data";
|
||||
import { UserCard } from "@/features/users/ui/UserCard";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
interface Props {
|
||||
data: UserData[] | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
setDialogOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setEditingUser: Dispatch<SetStateAction<UserData | null>>;
|
||||
setOpenDelete: Dispatch<SetStateAction<boolean>>;
|
||||
setUserDelete: Dispatch<SetStateAction<UserData | null>>;
|
||||
}
|
||||
|
||||
const UserTable = ({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
setDialogOpen,
|
||||
setEditingUser,
|
||||
setOpenDelete,
|
||||
setUserDelete,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<div className="h-full flex items-center justify-center bg-white/70 z-10">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className="h-full flex items-center justify-center z-10">
|
||||
<span className="text-lg font-medium text-red-600">
|
||||
Ma'lumotlarni olishda xatolik yuz berdi.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{/* Users Grid */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.map((user) => (
|
||||
<UserCard
|
||||
setOpenDelete={setOpenDelete}
|
||||
setUserDelete={setUserDelete}
|
||||
key={user.id}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setEditingUser={setEditingUser}
|
||||
user={{ ...user, role: user.is_superuser ? "admin" : "user" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserTable;
|
||||
77
src/features/users/ui/UsersList.tsx
Normal file
77
src/features/users/ui/UsersList.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { RegionListResData } from "@/features/region/lib/data";
|
||||
import { user_api } from "@/features/users/lib/api";
|
||||
import type { UserData } from "@/features/users/lib/data";
|
||||
import DeleteUser from "@/features/users/ui/DeleteUser";
|
||||
import Filter from "@/features/users/ui/Filter";
|
||||
import UserTable from "@/features/users/ui/UserTable";
|
||||
import Pagination from "@/shared/ui/pagination";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
const UsersList = () => {
|
||||
const [editingUser, setEditingUser] = useState<UserData | null>(null);
|
||||
const [opneDelete, setOpenDelete] = useState(false);
|
||||
const [userDelete, setUserDelete] = useState<UserData | null>(null);
|
||||
|
||||
const [regionValue, setRegionValue] = useState<RegionListResData | null>(
|
||||
null,
|
||||
);
|
||||
const limit = 20;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["user_list", currentPage, regionValue],
|
||||
queryFn: () => {
|
||||
return user_api.list({
|
||||
page: currentPage,
|
||||
page_size: limit,
|
||||
});
|
||||
},
|
||||
select(data) {
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-10 w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
|
||||
<h1 className="text-2xl font-bold">Foydalanuvchilar ro'yxati</h1>
|
||||
|
||||
<Filter
|
||||
dialogOpen={dialogOpen}
|
||||
setDialogOpen={setDialogOpen}
|
||||
editingUser={editingUser}
|
||||
setEditingUser={setEditingUser}
|
||||
setRegionValue={setRegionValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UserTable
|
||||
data={data?.results}
|
||||
isLoading={isLoading}
|
||||
setEditingUser={setEditingUser}
|
||||
isError={isError}
|
||||
setDialogOpen={setDialogOpen}
|
||||
setOpenDelete={setOpenDelete}
|
||||
setUserDelete={setUserDelete}
|
||||
/>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
totalPages={data?.total_pages || 1}
|
||||
/>
|
||||
|
||||
<DeleteUser
|
||||
opneDelete={opneDelete}
|
||||
setOpenDelete={setOpenDelete}
|
||||
userDelete={userDelete}
|
||||
setUserDelete={setUserDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersList;
|
||||
Reference in New Issue
Block a user