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