api ulangan

This commit is contained in:
Samandar Turgunboyev
2025-12-22 19:03:57 +05:00
parent fd397b670b
commit 7f2fe3868b
121 changed files with 12636 additions and 5528 deletions

2
.env
View File

@@ -1 +1 @@
VITE_API_URL=https://jsonplaceholder.typicode.com
VITE_API_URL=https://api.gastro.felixits.uz

12
.npmrc Normal file
View File

@@ -0,0 +1,12 @@
# pnpm configuration
# auto audit installing
audit=true
# allow running install scripts (needed for husky)
ignore-scripts=false
# turn on npm registry SSL checking
strict-ssl=true
minimum-release-age=262974

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/fias.svg" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FIAS - React Js app</title>
<title>Gastro Adminka</title>
</head>
<body>
<div id="root"></div>

5403
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,28 +11,51 @@
"prettier": "prettier src --write",
"prepare": "husky"
},
"packageManager": "pnpm@9.0.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@pbe/react-yandex-maps": "^1.2.5",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.7",
"@tanstack/react-query": "^5.77.1",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-day-picker": "^9.11.2",
"react-dom": "^19.1.1",
"react-github-btn": "^1.4.0",
"react-hook-form": "^7.66.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.6",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13"
"tailwindcss": "^4.1.13",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.15.21",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
@@ -56,4 +79,4 @@
"eslint src --fix"
]
}
}
}

4407
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,13 @@
import MainProvider from "@/providers/main";
import AppRouter from "@/providers/routing/AppRoutes";
import "@/shared/config/i18n";
import { Toaster } from "@/shared/ui/sonner";
const App = () => {
return (
<MainProvider>
<AppRouter />
<Toaster richColors={true} position="top-center" />
</MainProvider>
);
};

20
src/LoginLayout.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { getToken } from "@/shared/lib/cookie";
import { useEffect, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
const LoginLayout = ({ children }: { children: ReactNode }) => {
const token = getToken();
const navigate = useNavigate();
useEffect(() => {
if (!token) {
navigate("/");
} else if (token) {
navigate("/dashboard");
}
}, [token, navigate]);
return children;
};
export default LoginLayout;

17
src/SidebarLayout.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { SidebarProvider, SidebarTrigger } from "@/shared/ui/sidebar";
import { AppSidebar } from "@/widgets/sidebar-layout";
import React from "react";
const SidebarLayout = ({ children }: { children: React.ReactNode }) => {
return (
<SidebarProvider>
<AppSidebar />
<main className="min-h-screen w-full">
<SidebarTrigger />
{children}
</main>
</SidebarProvider>
);
};
export default SidebarLayout;

View 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;
},
};

View 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;

View 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;
},
};

View File

@@ -0,0 +1,5 @@
export interface CategoryCreate {
name_uz: string;
name_ru: string;
image: File;
}

View 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 bolishi kerak"),
name_ru: z.string().min(2, "Tuman nomi kamida 2 ta harf bolishi kerak"),
image: z
.file()
.refine((file) => file.size <= 5 * 1024 * 1024, {
message: "Fayl hajmi 5MB dan oshmasligi kerak",
})
.optional(),
});

View 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>
);
}

View 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 royxati</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;

View 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;

View 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 qoshish
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl">
{editing ? "Kategoriyani tahrirlash" : "Kategoriya qoshish"}
</DialogTitle>
</DialogHeader>
<AddDistrict initialValues={editing} setDialogOpen={setDialogOpen} />
</DialogContent>
</Dialog>
</div>
);
};
export default FilterCategory;

View 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;

View 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;
},
};

View 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" }),
});

View 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;
}

View 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;

View 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;

View 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;

View 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;
},
};

View 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;
}

View 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>
);
}

View 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;
},
};

View 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;
}

View File

@@ -0,0 +1,5 @@
import z from "zod";
export const BannerForm = z.object({
banner: z.file().min(1, { message: "Majburiy maydon" }),
});

View 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>
);
}

View 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;

View 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>
);
}

View 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;

View 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;

View 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;
},
};

View 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;
}

View 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 notogri"),
unity_id: z.string().uuid("Birlik notogri"),
price: z.number().positive("Narx notogri"),
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"),
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
},
};

View 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;
}

View 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" }),
});

View 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;

View 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;

View 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;

View 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;
},
};

View 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;
}

View 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"),
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
},
};

View 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;
}

View 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"),
});

View 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;

View 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;

View 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;

View 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>
</>
);
}

View 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;

View 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;

View File

@@ -1,5 +1,5 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));

View File

@@ -1,9 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,

12
src/pages/Banners.tsx Normal file
View File

@@ -0,0 +1,12 @@
import BannersList from "@/features/objects/ui/BannersList";
import SidebarLayout from "@/SidebarLayout";
const Banners = () => {
return (
<SidebarLayout>
<BannersList />
</SidebarLayout>
);
};
export default Banners;

12
src/pages/Categories.tsx Normal file
View File

@@ -0,0 +1,12 @@
import CategoriesList from "@/features/districts/ui/CategoriesList";
import SidebarLayout from "@/SidebarLayout";
const Categories = () => {
return (
<SidebarLayout>
<CategoriesList />
</SidebarLayout>
);
};
export default Categories;

12
src/pages/Faq.tsx Normal file
View File

@@ -0,0 +1,12 @@
import FaqList from "@/features/faq/ui/FaqList";
import SidebarLayout from "@/SidebarLayout";
const Faq = () => {
return (
<SidebarLayout>
<FaqList />
</SidebarLayout>
);
};
export default Faq;

View File

@@ -1,5 +1,10 @@
import Welcome from "@/widgets/welcome/ui/welcome";
import DashboardPage from "@/features/home/ui/Home";
import SidebarLayout from "@/SidebarLayout";
export function Home() {
return <Welcome />;
export default function HomePage() {
return (
<SidebarLayout>
<DashboardPage />
</SidebarLayout>
);
}

10
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,10 @@
import AuthLogin from "@/features/auth/ui/AuthLogin";
import LoginLayout from "@/LoginLayout";
export default function AdminLoginPage() {
return (
<LoginLayout>
<AuthLogin />
</LoginLayout>
);
}

5
src/pages/Orders.tsx Normal file
View File

@@ -0,0 +1,5 @@
const Orders = () => {
return <>{/* <OrdersList /> */}</>;
};
export default Orders;

12
src/pages/Product.tsx Normal file
View File

@@ -0,0 +1,12 @@
import ProductList from "@/features/plans/ui/ProductList";
import SidebarLayout from "@/SidebarLayout";
const Product = () => {
return (
<SidebarLayout>
<ProductList />
</SidebarLayout>
);
};
export default Product;

12
src/pages/Units.tsx Normal file
View File

@@ -0,0 +1,12 @@
import UnitsList from "@/features/units/ui/UnitsList";
import SidebarLayout from "@/SidebarLayout";
const Units = () => {
return (
<SidebarLayout>
<UnitsList />
</SidebarLayout>
);
};
export default Units;

12
src/pages/Users.tsx Normal file
View File

@@ -0,0 +1,12 @@
import UsersList from "@/features/users/ui/UsersList";
import SidebarLayout from "@/SidebarLayout";
const Users = () => {
return (
<SidebarLayout>
<UsersList />
</SidebarLayout>
);
};
export default Users;

View File

@@ -1,3 +1,12 @@
import LoginLayout from "@/LoginLayout";
import Banners from "@/pages/Banners";
import Categories from "@/pages/Categories";
import Faq from "@/pages/Faq";
import HomePage from "@/pages/Home";
import Orders from "@/pages/Orders";
import Product from "@/pages/Product";
import Units from "@/pages/Units";
import Users from "@/pages/Users";
import routesConfig from "@/providers/routing/config";
import { Navigate, useRoutes } from "react-router-dom";
@@ -8,6 +17,42 @@ const AppRouter = () => {
path: "*",
element: <Navigate to="/" />,
},
{
path: "/dashboard",
element: (
<LoginLayout>
<HomePage />
</LoginLayout>
),
},
{
path: "/dashboard/products",
element: <Product />,
},
{
path: "/dashboard/categories",
element: <Categories />,
},
{
path: "/dashboard/units",
element: <Units />,
},
{
path: "/dashboard/banners",
element: <Banners />,
},
{
path: "/dashboard/orders",
element: <Orders />,
},
{
path: "/dashboard/users",
element: <Users />,
},
{
path: "/dashboard/faq",
element: <Faq />,
},
]);
return routes;

View File

@@ -1,4 +1,4 @@
import { Home } from "@/pages/Home";
import Login from "@/pages/Login";
import { Outlet, type RouteObject } from "react-router-dom";
const routesConfig: RouteObject = {
@@ -8,7 +8,7 @@ const routesConfig: RouteObject = {
children: [
{
path: "/",
element: <Home />,
element: <Login />,
},
],
},

View File

@@ -1,6 +1,11 @@
import { createContext, useContext, useEffect, useState } from 'react';
"use client";
type Theme = 'dark' | 'light' | 'system';
import {
ThemeProviderContext,
type Theme,
type ThemeProviderState,
} from "@/providers/theme/themeContext";
import { useContext, useEffect, useState } from "react";
type ThemeProviderProps = {
children: React.ReactNode;
@@ -8,23 +13,10 @@ type ThemeProviderProps = {
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
...props
defaultTheme = "system",
storageKey = "vite-ui-theme",
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
@@ -32,15 +24,13 @@ export function ThemeProvider({
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? 'dark'
: 'light';
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
@@ -48,7 +38,7 @@ export function ThemeProvider({
root.classList.add(theme);
}, [theme]);
const value = {
const value: ThemeProviderState = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
@@ -57,17 +47,14 @@ export function ThemeProvider({
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
export function useTheme() {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
if (!context) throw new Error("useTheme must be used within ThemeProvider");
return context;
};
}

View File

@@ -0,0 +1,16 @@
import { createContext } from "react";
export type Theme = "dark" | "light" | "system";
export type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
export const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
export const ThemeProviderContext =
createContext<ThemeProviderState>(initialState);

View File

@@ -1,6 +1,34 @@
const BASE_URL =
import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com';
const API_V = "/api/v1/";
const ENDP_POSTS = '/posts/';
export { BASE_URL, ENDP_POSTS };
export const API_URLS = {
BASE_URL: import.meta.env.VITE_API_URL || "https://api.gastro.felixits.uz/",
LOGIN: `${API_V}admin/user/login/`,
Statistica: `${API_V}admin/dashboard/statistics/`,
ProductsList: `${API_V}admin/product/list/`,
CategoryList: `${API_V}admin/category/list/`,
UnityList: `${API_V}admin/unity/list/`,
CategoryCreate: `${API_V}admin/category/create/`,
CategoryUpdate: (id: string) => `${API_V}admin/category/${id}/update/`,
CategoryDelete: (id: string) => `${API_V}admin/category/${id}/delete/`,
UnitsCreate: `${API_V}admin/unity/create/`,
UnitsUpdate: (id: string) => `${API_V}admin/unity/${id}/update/`,
UnitsDelete: (id: string) => `${API_V}admin/unity/${id}/delete/`,
BannersList: `${API_V}admin/banner/list/`,
BannerCreate: `${API_V}admin/banner/create/`,
BannerDelete: (id: string) => `${API_V}admin/banner/${id}/delete/`,
OrdersList: `${API_V}admin/order/list/`,
OrdersDelete: (id: string | number) => `${API_V}admin/order/${id}/delete/`,
UsesList: `${API_V}admin/user/list/`,
UserCreate: `${API_V}admin/user/create/`,
UserUpdate: (id: string) => `${API_V}admin/user/${id}/update/`,
UserDelete: (id: string) => `${API_V}admin/user/${id}/delete/`,
FaqList: `${API_V}admin/faq/`,
FaqCreate: `${API_V}admin/faq/`,
FaqUpdate: (id: string) => `${API_V}admin/faq/${id}/`,
FaqDelete: (id: string) => `${API_V}admin/faq/${id}/`,
OrderStatus: (id: string | number) =>
`${API_V}admin/order/${id}/status_update/`,
CreateProduct: `${API_V}admin/product/create/`,
UpdateProduct: (id: string) => `${API_V}admin/user/${id}/update/`,
DeleteProdut: (id: string) => `${API_V}admin/product/${id}/delete/`,
};

View File

@@ -1,23 +1,22 @@
import axios from 'axios';
import { BASE_URL } from './URLs';
import i18n from '@/shared/config/i18n';
import i18n from "@/shared/config/i18n";
import { getToken } from "@/shared/lib/cookie";
import axios from "axios";
import { API_URLS } from "./URLs";
const httpClient = axios.create({
baseURL: BASE_URL,
baseURL: API_URLS.BASE_URL,
timeout: 10000,
});
httpClient.interceptors.request.use(
async (config) => {
console.log(`API REQUEST to ${config.url}`, config);
// Language configs
const language = i18n.language;
config.headers['Accept-Language'] = language;
// const accessToken = localStorage.getItem('accessToken');
// if (accessToken) {
// config.headers['Authorization'] = `Bearer ${accessToken}`;
// }
config.headers["Accept-Language"] = language;
const accessToken = getToken();
if (accessToken) {
config.headers["Authorization"] = `Bearer ${accessToken}`;
}
return config;
},
@@ -27,7 +26,7 @@ httpClient.interceptors.request.use(
httpClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API error:', error);
console.error("API error:", error);
return Promise.reject(error);
},
);

View File

@@ -1,14 +0,0 @@
import httpClient from '@/shared/config/api/httpClient';
import type { TestApiType } from '@/shared/config/api/test/test.model';
import type { ReqWithPagination } from '@/shared/config/api/types';
import { ENDP_POSTS } from '@/shared/config/api/URLs';
import type { AxiosResponse } from 'axios';
const getPosts = async (
pagination?: ReqWithPagination,
): Promise<AxiosResponse<TestApiType>> => {
const response = await httpClient.get(ENDP_POSTS, { params: pagination });
return response;
};
export { getPosts };

View File

@@ -0,0 +1,11 @@
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { GetMeRes } from "@/shared/config/api/user/type";
import type { AxiosResponse } from "axios";
export const user_api = {
async getMe(): Promise<AxiosResponse<GetMeRes>> {
const res = httpClient.get(API_URLS.GET_ME);
return res;
},
};

View File

@@ -0,0 +1,16 @@
export interface GetMeRes {
status: string;
status_code: number;
data: GetMeResData;
}
export interface GetMeResData {
created_at: string;
first_name: string;
id: number;
is_active: boolean;
is_superuser: boolean;
last_name: string;
region: null | number;
telegram_id: null | number;
}

View File

@@ -1,4 +1,141 @@
{
"welcome": "Rus. Bizning saytga xush kelibsiz",
"language": "Til"
"menu": "Меню",
"dashboard": "Панель управления",
"products": "Продукты",
"categories": "Категории",
"new": "Новый",
"status": "Статус",
"ready": "Готово",
"Add Unit": "Добавить единицы",
"units": "Единицы",
"Cancele": "Отмена",
"orders": "Заказы",
"Active": "Статус",
"users": "Пользователи",
"Rasm": "Изображения",
"Delete": "Удалить",
"banners": "Баннеры",
"settings": "Настройки",
"orderNumber": "Номер заказа",
"time": "Время",
"profile": "Профиль",
"logout": "Выйти",
"productCode": "Код товара",
"ConDel": "Подтвердите удаление",
"comment": "Коментарий",
"sure": "Вы уверены, что хотите удалить этот товар?",
"Hech kim": "Никто",
"language": "Язык",
"theme": "Тема",
"light": "Светлая",
"dark": "Темная",
"system": "Системная",
"min_quantity": "Мин. кол-во",
"save": "Сохранить",
"cancel": "Отмена",
"edit": "Редактировать",
"delete": "Удалить",
"create": "Создать",
"update": "Обновить",
"welcomeMessage": "Добро пожаловать! Вот что происходит в вашем магазине.",
"totalProducts": "Всего товаров",
"activeProducts": "Активные товары на складе",
"totalUsers": "Всего пользователей",
"registeredUsers": "Зарегистрированные пользователи",
"totalOrders": "Всего заказов",
"ordersThisMonth": "Заказы в этом месяце",
"revenue": "Доход",
"totalRevenue": "Общий доход за месяц",
"conversionRate": "Коэффициент конверсии",
"visitorsToCustomers": "Посетителей в клиентов",
"pageViews": "Просмотры страниц",
"totalPageViews": "Всего просмотров сегодня",
"productCategories": "Категории товаров",
"categoriesTitle": "Категории",
"categoriesSubtitle": "Управляйте категориями товаров",
"addCategory": "Добавить категорию",
"noCategories": "Категории не найдены. Создайте первую категорию, чтобы начать.",
"Name (UZ)": "Название (UZ)",
"Name (RU)": "Название (RU)",
"Price": "Цена",
"Category": "Категория",
"Unit": "Ед.изм",
"Units": "Единицы",
"Telegram ID": "Telegram ID",
"categoriyEdit": "Редактировать",
"Code": "Код",
"min_Quantity": "Сколько",
"Article": "Артикул",
"Description (UZ)": "Описание (UZ)",
"Filter_by_Role": "Фильтр по роли",
"Description (RU)": "Описание (RU)",
"Image": "Изображение",
"Please fill all required fields": "Пожалуйста, заполните все обязательные поля",
"Select category": "Выберите категорию",
"Select unit": "Выберите единицу",
"Select Telegram ID": "Выберите Telegram ID",
"Loading categories...": "Категории загружаются...",
"Loading units...": "Единицы загружаются...",
"Loading suppliers...": "Поставщики загружаются...",
"Error": "Ошибка",
"Failed to fetch suppliers": "Не удалось получить поставщиков",
"Fill in the product information below.": "Заполните информацию о продукте ниже.",
"Update the product information below.": "Обновите информацию о продукте ниже.",
"quantity_Left": "Кол-во товаров",
"Filter by category": "Фильтр по категории",
"All Categories": "Все категории",
"Add Product": "Добавить продукт",
"Manage your product inventory": "Управляйте своим товарным запасом",
"Actions": "Действия",
"Banners": "Баннеры",
"Manage your page banners": "Управляйте баннерами страницы",
"Add Banner": "Добавить баннер",
"Edit Banner": "Редактировать баннер",
"Banner added successfully": "Баннер успешно добавлен",
"Banner updated successfully": "Баннер успешно обновлен",
"Banner deleted": "Баннер удален",
"Failed to load banners": "Ошибка загрузки баннеров",
"Failed to save banner": "Ошибка при сохранении баннера",
"Failed to delete banner": "Ошибка при удалении баннера",
"Click to upload image": "Нажмите, чтобы загрузить изображение",
"Authentication required": "Требуется аутентификация",
"Saving...": "Сохраняется...",
"noName": "Нет имени",
"noEmail": "Нет email",
"allUsers": "Все пользователи",
"exportExcel": "Экспорт в Excel",
"orderId": "ID заказа",
"customerName": "Имя клиента",
"customerEmail": "Email клиента",
"product": "Продукт",
"quantity": "Количество",
"price": "Цена",
"total": "Итого",
"date": "Дата",
"viewDetails": "Посмотреть детали",
"close": "Закрыть",
"orderDetails": "Детали заказа",
"completeOrderInfo": "Полная информация о заказе",
"customerInformation": "Информация о клиенте",
"orderItems": "Элементы заказа",
"name": "Имя",
"email": "Электронная почта",
"items": "Элементы",
"amount": "Сумма",
"actions": "Действия",
"filterByRole": "Фильтр по роли",
"All": "Все",
"Admin": "Админ",
"User": "Пользователь",
"No users found. Add a new user to get started.": "Пользователи не найдены. Добавьте нового пользователя, чтобы начать.",
"Add User": "Добавить пользователя",
"Manage user accounts": "Управление учетными записями пользователей",
"First Name": "Имя",
"Last Name": "Фамилия",
"Username": "Имя пользователя",
"Password": "Пароль",
"Leave empty to keep current password": "Оставьте пустым, чтобы не менять",
"Role": "Роль",
"Joined": "Дата регистрации",
"Last login": "Последний вход"
}

View File

@@ -1,4 +1,141 @@
{
"welcome": "Uzbek. Bizning saytga xush kelibsiz",
"language": "Til"
"menu": "Menyu",
"dashboard": "Boshqaruv paneli",
"products": "Mahsulotlar",
"units": "Birliklar",
"categories": "Kategoriyalar",
"Add Unit": "Birlik qoshish",
"ConDel": "Ochirishni tasdiqlang",
"Delete": "Ochirish",
"sure": "Ochirmoqchi ekanligingizga ishonchingiz komilmi?",
"orders": "Buyurtmalar",
"users": "Foydalanuvchilar",
"Hech kim": "Hech kim",
"Cancele": "Bekor qilish",
"Rasm": "Rasm",
"comment": "Izoh",
"new": "Yangi",
"ready": "Tayyor",
"productCode": "Mahsulot kodi",
"settings": "Sozlamalar",
"min_quantity": "Minimal miqdor",
"time": "Vaqt",
"banners": "Bannerlar",
"profile": "Profil",
"logout": "Chiqish",
"orderNumber": "Buyurtma raqami",
"language": "Til",
"theme": "Mavzu",
"light": "Yorug'",
"dark": "Qorong'u",
"categoriyEdit": "Tahrirlash",
"system": "Tizim",
"Active": "Holati",
"save": "Saqlash",
"cancel": "Bekor qilish",
"edit": "Tahrirlash",
"delete": "O'chirish",
"create": "Yaratish",
"update": "Yangilash",
"welcomeMessage": "Xush kelibsiz! Dokoningizdagi yangiliklar mana bu yerda.",
"totalProducts": "Jami mahsulotlar",
"activeProducts": "Inventarda faol mahsulotlar",
"totalUsers": "Jami foydalanuvchilar",
"registeredUsers": "Royxatdan otgan foydalanuvchilar",
"totalOrders": "Jami buyurtmalar",
"ordersThisMonth": "Bu oygi buyurtmalar",
"revenue": "Daromad",
"totalRevenue": "Bu oygi jami daromad",
"conversionRate": "Konversiya korsatkichi",
"visitorsToCustomers": "Mehmonlardan mijozlarga",
"pageViews": "Sahifa korishlar",
"totalPageViews": "Bugungi jami sahifa korishlar",
"productCategories": "Mahsulot kategoriyalari",
"categoriesTitle": "Kategoriyalar",
"categoriesSubtitle": "Mahsulot kategoriyalarini boshqarish",
"addCategory": "Kategoriya qoshish",
"noCategories": "Hech qanday kategoriya topilmadi. Boshlash uchun birinchi kategoriyani yarating.",
"Name (UZ)": "Nom (UZ)",
"Name (RU)": "Nom (RU)",
"Price": "Narx",
"Category": "Kategoriya",
"Unit": "Olchov birligi",
"Units": "Birliklar",
"Telegram ID": "Telegram ID",
"Code": "Kod",
"Article": "Maqola",
"Description (UZ)": "Tavsif (UZ)",
"Description (RU)": "Tavsif (RU)",
"Image": "Rasm",
"Please fill all required fields": "Iltimos, barcha majburiy maydonlarni toldiring",
"Select category": "Kategoriya tanlang",
"Select unit": "Birlik tanlang",
"Select Telegram ID": "Telegram ID tanlang",
"Loading categories...": "Kategoriyalar yuklanmoqda...",
"Loading units...": "Birliklar yuklanmoqda...",
"Loading suppliers...": "Yetkazib beruvchilar yuklanmoqda...",
"Error": "Xato",
"status": "Holat",
"Failed to fetch suppliers": "Yetkazib beruvchilarni olishda xato yuz berdi",
"Fill in the product information below.": "Quyida mahsulot ma'lumotlarini toldiring.",
"Update the product information below.": "Quyida mahsulot ma'lumotlarini yangilang.",
"quantity_Left": "Tovar soni",
"Filter by category": "Kategoriya boyicha filtrlash",
"Filter_by_Role": "Rol bo'yicha filtrlash",
"All Categories": "Barcha kategoriyalar",
"Add Product": "Mahsulot qoshish",
"Manage your product inventory": "Mahsulot inventarizatsiyasini boshqaring",
"Actions": "Harakatlar",
"min_Quantity": "Nechtadan",
"Banners": "Bannerlar",
"Manage your page banners": "Sahifa bannerlarini boshqaring",
"Add Banner": "Banner qoshish",
"Edit Banner": "Bannerni tahrirlash",
"Banner added successfully": "Banner muvaffaqiyatli qoshildi",
"Banner updated successfully": "Banner muvaffaqiyatli yangilandi",
"Banner deleted": "Banner ochirildi",
"Failed to load banners": "Bannerlarni yuklashda xato",
"Failed to save banner": "Bannerni saqlashda xato",
"Failed to delete banner": "Bannerni ochirishda xato",
"Click to upload image": "Rasm yuklash uchun bosing",
"Authentication required": "Autentifikatsiya talab qilinadi",
"Saving...": "Saqlanmoqda...",
"noName": "Ismi yoq",
"noEmail": "Email yoq",
"allUsers": "Barcha foydalanuvchilar",
"exportExcel": "Excelga eksport qilish",
"orderId": "Buyurtma ID",
"customerName": "Mijozning ismi",
"customerEmail": "Mijozning emaili",
"product": "Mahsulot",
"quantity": "Soni",
"price": "Narxi",
"total": "Jami",
"date": "Sana",
"viewDetails": "Batafsil korish",
"close": "Yopish",
"orderDetails": "Buyurtma tafsilotlari",
"completeOrderInfo": "Ushbu buyurtma haqida toliq malumot",
"customerInformation": "Mijoz haqida malumot",
"orderItems": "Buyurtma elementlari",
"name": "Ism",
"email": "Email",
"items": "Elementlar",
"amount": "Summasi",
"actions": "Harakatlar",
"filterByRole": "Rol boyicha filtrlash",
"All": "Barchasi",
"Admin": "Admin",
"User": "Foydalanuvchi",
"No users found. Add a new user to get started.": "Foydalanuvchi topilmadi. Boshlash uchun yangi foydalanuvchi qoshing.",
"Add User": "Foydalanuvchi qoshish",
"Manage user accounts": "Foydalanuvchi hisoblarini boshqarish",
"First Name": "Ism",
"Last Name": "Familiya",
"Username": "Foydalanuvchi nomi",
"Password": "Parol",
"Leave empty to keep current password": "Ozgartirmaslik uchun bosh qoldiring",
"Role": "Rol",
"Joined": "Qoshilgan sana",
"Last login": "Oxirgi kirish"
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect } from "react";
/**
* Hook for closing some items when they are unnecessary to the user
@@ -27,13 +27,13 @@ const useCloser = (
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('scroll', handleScroll);
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("scroll", handleScroll);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('scroll', handleScroll);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("scroll", handleScroll);
};
}, [ref, closeFunction]);
}, [ref, closeFunction, scrollClose]);
};
export default useCloser;

15
src/shared/hooks/user.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { GetMeResData } from "@/shared/config/api/user/type";
import { create } from "zustand";
type State = {
user: GetMeResData | null;
};
type Actions = {
addUser: (user: GetMeResData) => void;
};
export const userStore = create<State & Actions>((set) => ({
user: null,
addUser: (user: GetMeResData | null) => set(() => ({ user })),
}));

15
src/shared/lib/cookie.ts Normal file
View File

@@ -0,0 +1,15 @@
import cookie from "js-cookie";
const token = "access_meridyn_admin";
export const saveToken = (value: string) => {
cookie.set(token, value);
};
export const getToken = () => {
return cookie.get(token);
};
export const removeToken = () => {
cookie.remove(token);
};

View File

@@ -1,5 +1,5 @@
import i18n from '@/shared/config/i18n';
import { LanguageRoutes } from '@/shared/config/i18n/type';
import i18n from "@/shared/config/i18n";
import { LanguageRoutes } from "@/shared/config/i18n/type";
/**
* Format price. With label.
@@ -10,20 +10,20 @@ import { LanguageRoutes } from '@/shared/config/i18n/type';
const formatPrice = (amount: number | string, withLabel?: boolean) => {
const locale = i18n.language;
const label = withLabel
? locale == LanguageRoutes.RU
? ' сум'
: locale == LanguageRoutes.KI
? ' сўм'
: ' som'
: '';
const parts = String(amount).split('.');
? locale === LanguageRoutes.RU
? " сум"
: locale === LanguageRoutes.KI
? " сўм"
: " som"
: "";
const parts = String(amount).split(".");
const dollars = parts[0];
const cents = parts.length > 1 ? parts[1] : '00';
const cents = parts.length > 1 ? parts[1] : "00";
const formattedDollars = dollars.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
const formattedDollars = dollars.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
if (String(amount).length == 0) {
return formattedDollars + '.' + cents + label;
if (String(amount).length === 0) {
return formattedDollars + "." + cents + label;
} else {
return formattedDollars + label;
}

View File

@@ -0,0 +1,6 @@
const onlyNumber = (digits: string | number) => {
const phone = digits.toString();
return phone.replace(/\D/g, "");
};
export default onlyNumber;

51
src/shared/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,51 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/shared/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

46
src/shared/ui/badge.tsx Normal file
View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

214
src/shared/ui/calendar.tsx Normal file
View File

@@ -0,0 +1,214 @@
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/shared/lib/utils";
import { Button, buttonVariants } from "@/shared/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months,
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next,
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday,
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header,
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number,
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day,
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

92
src/shared/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

Some files were not shown because too many files have changed in this diff Show More