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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>FIAS - React Js app</title>
|
<title>Gastro Adminka</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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",
|
"prettier": "prettier src --write",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@9.0.0",
|
||||||
"dependencies": {
|
"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-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",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"@tanstack/react-query": "^5.77.1",
|
"@tanstack/react-query": "^5.77.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
"react-day-picker": "^9.11.2",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-github-btn": "^1.4.0",
|
"react-github-btn": "^1.4.0",
|
||||||
|
"react-hook-form": "^7.66.1",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13"
|
"tailwindcss": "^4.1.13",
|
||||||
|
"zod": "^4.1.13",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
@@ -56,4 +79,4 @@
|
|||||||
"eslint src --fix"
|
"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 MainProvider from "@/providers/main";
|
||||||
import AppRouter from "@/providers/routing/AppRoutes";
|
import AppRouter from "@/providers/routing/AppRoutes";
|
||||||
import "@/shared/config/i18n";
|
import "@/shared/config/i18n";
|
||||||
|
import { Toaster } from "@/shared/ui/sonner";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<MainProvider>
|
<MainProvider>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
|
<Toaster richColors={true} position="top-center" />
|
||||||
</MainProvider>
|
</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 "tailwindcss";
|
||||||
@import 'tw-animate-css';
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@@ -1,9 +1,9 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css';
|
import App from "./App.tsx";
|
||||||
import App from './App.tsx';
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</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() {
|
export default function HomePage() {
|
||||||
return <Welcome />;
|
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 routesConfig from "@/providers/routing/config";
|
||||||
import { Navigate, useRoutes } from "react-router-dom";
|
import { Navigate, useRoutes } from "react-router-dom";
|
||||||
|
|
||||||
@@ -8,6 +17,42 @@ const AppRouter = () => {
|
|||||||
path: "*",
|
path: "*",
|
||||||
element: <Navigate to="/" />,
|
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;
|
return routes;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Home } from "@/pages/Home";
|
import Login from "@/pages/Login";
|
||||||
import { Outlet, type RouteObject } from "react-router-dom";
|
import { Outlet, type RouteObject } from "react-router-dom";
|
||||||
|
|
||||||
const routesConfig: RouteObject = {
|
const routesConfig: RouteObject = {
|
||||||
@@ -8,7 +8,7 @@ const routesConfig: RouteObject = {
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "/",
|
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 = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -8,23 +13,10 @@ type ThemeProviderProps = {
|
|||||||
storageKey?: string;
|
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({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = 'system',
|
defaultTheme = "system",
|
||||||
storageKey = 'vite-ui-theme',
|
storageKey = "vite-ui-theme",
|
||||||
...props
|
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
const [theme, setTheme] = useState<Theme>(
|
const [theme, setTheme] = useState<Theme>(
|
||||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||||
@@ -32,15 +24,13 @@ export function ThemeProvider({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement;
|
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
|
.matches
|
||||||
? 'dark'
|
? "dark"
|
||||||
: 'light';
|
: "light";
|
||||||
|
|
||||||
root.classList.add(systemTheme);
|
root.classList.add(systemTheme);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -48,7 +38,7 @@ export function ThemeProvider({
|
|||||||
root.classList.add(theme);
|
root.classList.add(theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const value = {
|
const value: ThemeProviderState = {
|
||||||
theme,
|
theme,
|
||||||
setTheme: (theme: Theme) => {
|
setTheme: (theme: Theme) => {
|
||||||
localStorage.setItem(storageKey, theme);
|
localStorage.setItem(storageKey, theme);
|
||||||
@@ -57,17 +47,14 @@ export function ThemeProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProviderContext.Provider {...props} value={value}>
|
<ThemeProviderContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeProviderContext.Provider>
|
</ThemeProviderContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTheme = () => {
|
export function useTheme() {
|
||||||
const context = useContext(ThemeProviderContext);
|
const context = useContext(ThemeProviderContext);
|
||||||
|
if (!context) throw new Error("useTheme must be used within ThemeProvider");
|
||||||
if (context === undefined)
|
|
||||||
throw new Error('useTheme must be used within a ThemeProvider');
|
|
||||||
|
|
||||||
return context;
|
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 =
|
const API_V = "/api/v1/";
|
||||||
import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com';
|
|
||||||
|
|
||||||
const ENDP_POSTS = '/posts/';
|
export const API_URLS = {
|
||||||
|
BASE_URL: import.meta.env.VITE_API_URL || "https://api.gastro.felixits.uz/",
|
||||||
export { BASE_URL, ENDP_POSTS };
|
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 i18n from "@/shared/config/i18n";
|
||||||
import { BASE_URL } from './URLs';
|
import { getToken } from "@/shared/lib/cookie";
|
||||||
import i18n from '@/shared/config/i18n';
|
import axios from "axios";
|
||||||
|
import { API_URLS } from "./URLs";
|
||||||
|
|
||||||
const httpClient = axios.create({
|
const httpClient = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: API_URLS.BASE_URL,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
httpClient.interceptors.request.use(
|
httpClient.interceptors.request.use(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
console.log(`API REQUEST to ${config.url}`, config);
|
|
||||||
|
|
||||||
// Language configs
|
// Language configs
|
||||||
const language = i18n.language;
|
const language = i18n.language;
|
||||||
config.headers['Accept-Language'] = language;
|
config.headers["Accept-Language"] = language;
|
||||||
// const accessToken = localStorage.getItem('accessToken');
|
const accessToken = getToken();
|
||||||
// if (accessToken) {
|
if (accessToken) {
|
||||||
// config.headers['Authorization'] = `Bearer ${accessToken}`;
|
config.headers["Authorization"] = `Bearer ${accessToken}`;
|
||||||
// }
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
@@ -27,7 +26,7 @@ httpClient.interceptors.request.use(
|
|||||||
httpClient.interceptors.response.use(
|
httpClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('API error:', error);
|
console.error("API error:", error);
|
||||||
return Promise.reject(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",
|
"menu": "Меню",
|
||||||
"language": "Til"
|
"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",
|
"menu": "Menyu",
|
||||||
"language": "Til"
|
"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
|
* Hook for closing some items when they are unnecessary to the user
|
||||||
@@ -27,13 +27,13 @@ const useCloser = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
document.addEventListener('scroll', handleScroll);
|
document.addEventListener("scroll", handleScroll);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
document.removeEventListener('scroll', handleScroll);
|
document.removeEventListener("scroll", handleScroll);
|
||||||
};
|
};
|
||||||
}, [ref, closeFunction]);
|
}, [ref, closeFunction, scrollClose]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useCloser;
|
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 i18n from "@/shared/config/i18n";
|
||||||
import { LanguageRoutes } from '@/shared/config/i18n/type';
|
import { LanguageRoutes } from "@/shared/config/i18n/type";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format price. With label.
|
* Format price. With label.
|
||||||
@@ -10,20 +10,20 @@ import { LanguageRoutes } from '@/shared/config/i18n/type';
|
|||||||
const formatPrice = (amount: number | string, withLabel?: boolean) => {
|
const formatPrice = (amount: number | string, withLabel?: boolean) => {
|
||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
const label = withLabel
|
const label = withLabel
|
||||||
? locale == LanguageRoutes.RU
|
? locale === LanguageRoutes.RU
|
||||||
? ' сум'
|
? " сум"
|
||||||
: locale == LanguageRoutes.KI
|
: locale === LanguageRoutes.KI
|
||||||
? ' сўм'
|
? " сўм"
|
||||||
: ' so‘m'
|
: " so‘m"
|
||||||
: '';
|
: "";
|
||||||
const parts = String(amount).split('.');
|
const parts = String(amount).split(".");
|
||||||
const dollars = parts[0];
|
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) {
|
if (String(amount).length === 0) {
|
||||||
return formattedDollars + '.' + cents + label;
|
return formattedDollars + "." + cents + label;
|
||||||
} else {
|
} else {
|
||||||
return formattedDollars + label;
|
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