Compare commits

...

10 Commits

Author SHA1 Message Date
Samandar Turgunboyev
e656ad2e61 added pil name 2025-12-08 22:40:08 +05:00
Samandar Turgunboyev
17b833dd88 update api request and response 2025-12-05 17:49:55 +05:00
Samandar Turgunboyev
f7dbb665a0 apilar ulandi 2025-12-02 19:31:37 +05:00
Samandar Turgunboyev
40036322cb eslint bug fixed 2025-12-01 09:20:18 +05:00
Samandar Turgunboyev
9bc4c3df1f doctor and pharmacies crud 2025-11-29 19:19:40 +05:00
Samandar Turgunboyev
bcf9d7cd2b doctor list 2025-11-29 11:49:26 +05:00
Samandar Turgunboyev
83efa1f24a api ulandi 2025-11-28 19:41:12 +05:00
Samandar Turgunboyev
c8f96f46ad bug ficx vercel 2025-11-26 10:49:38 +05:00
Samandar Turgunboyev
427a0324fd bug ficx vercel 2025-11-26 10:41:24 +05:00
Samandar Turgunboyev
0ca088dfbe bug ficx vercel 2025-11-26 10:39:40 +05:00
126 changed files with 10613 additions and 3582 deletions

3
.env
View File

@@ -1 +1,2 @@
VITE_API_URL=https://jsonplaceholder.typicode.com VITE_API_URL=https://api.meridynpharma.com
VITE_SOCKET_URL=wss://api.meridynpharma.com

View File

@@ -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.svg" />
<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>MERIDYN PHARM</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

175
package-lock.json generated
View File

@@ -24,10 +24,12 @@
"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", "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", "next-themes": "^0.4.6",
"react": "^19.1.1", "react": "^19.1.1",
@@ -37,6 +39,7 @@
"react-hook-form": "^7.66.1", "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", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
@@ -44,6 +47,7 @@
}, },
"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",
@@ -2332,6 +2336,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": { "node_modules/@standard-schema/utils": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -2656,6 +2666,13 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true "dev": true
}, },
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3259,6 +3276,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3434,6 +3467,45 @@
"integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
"dev": true "dev": true
}, },
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -4224,6 +4296,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4725,8 +4806,7 @@
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"dev": true
}, },
"node_modules/nano-spawn": { "node_modules/nano-spawn": {
"version": "1.0.3", "version": "1.0.3",
@@ -5379,6 +5459,68 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -5947,6 +6089,35 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -28,10 +28,12 @@
"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", "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", "next-themes": "^0.4.6",
"react": "^19.1.1", "react": "^19.1.1",
@@ -41,6 +43,7 @@
"react-hook-form": "^7.66.1", "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", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
@@ -48,6 +51,7 @@
}, },
"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",

9
public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -7,7 +7,7 @@ const App = () => {
return ( return (
<MainProvider> <MainProvider>
<AppRouter /> <AppRouter />
<Toaster /> <Toaster richColors={true} position="top-center" />
</MainProvider> </MainProvider>
); );
}; };

20
src/LoginLayout.tsx Normal file
View File

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

View File

@@ -0,0 +1,22 @@
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: {
token: string;
};
}
export const auth_pai = {
async login(body: {
username: string;
password: string;
}): Promise<AxiosResponse<LoginRes>> {
const res = await httpClient.post(API_URLS.LOGIN, body);
return res;
},
};

View File

@@ -0,0 +1,159 @@
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",
});
saveToken(res.data.data.token);
navigate("dashboard");
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
const FormSchema = z.object({
email: z.string().min(1, "Login kiriting"),
password: z.string().min(1, "Parolni kiriting"),
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: "",
password: "",
},
});
const handleSubmit = async (values: z.infer<typeof FormSchema>) => {
mutate({
password: values.password,
username: values.email,
});
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-6">
<Card className="w-full max-w-md">
<CardHeader className="gap-1">
<CardTitle className="text-xl font-semibold">
Admin panelga kirish
</CardTitle>
<CardDescription className="text-md">
Login va parolingizni kiriting.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<Label>Login</Label>
<FormControl>
<Input
placeholder="admin"
{...field}
className="!h-12 !text-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<Label>Parol</Label>
<FormControl>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="12345"
{...field}
className="!h-12 !text-md"
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-black"
>
{showPassword ? (
<EyeOff size={20} />
) : (
<Eye size={20} />
)}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full h-12 text-lg"
disabled={isPending}
>
{isPending ? <Loader2 className="animate-spin" /> : "Kirish"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
};
export default AuthLogin;

View File

@@ -0,0 +1,17 @@
import type { DistributedList } from "@/features/distributed/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const distributed_api = {
async list(params: {
limit?: number;
offset?: number;
product?: string;
user?: string;
date?: string;
}): Promise<AxiosResponse<DistributedList>> {
const res = await httpClient.get(API_URLS.DISTRIBUTED, { params });
return res;
},
};

View File

@@ -0,0 +1,29 @@
export interface DistributedList {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: DistributedListData[];
};
}
export interface DistributedListData {
id: number;
product: {
id: number;
name: string;
price: number;
};
quantity: number;
employee_name: string;
user: {
id: number;
first_name: string;
last_name: string;
};
created_at: string;
date: string;
}

View File

@@ -0,0 +1,199 @@
import { distributed_api } from "@/features/distributed/lib/api";
import type { DistributedListData } from "@/features/distributed/lib/data";
import { DistributedDetail } from "@/features/distributed/ui/SpecificationDetail ";
import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import { Input } from "@/shared/ui/input";
import Pagination from "@/shared/ui/pagination";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { useQuery } from "@tanstack/react-query";
import { ChevronDownIcon, Eye, Loader2 } from "lucide-react";
import { useState } from "react";
const DistributedList = () => {
const [currentPage, setCurrentPage] = useState(1);
const [nameFilter, setNameFilter] = useState<string>("");
const limit = 20;
const [disctritFilter, setDisctritFilter] = useState<string>("");
const [openDate, setOpenDate] = useState<boolean>(false);
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
const [open, setOpen] = useState<boolean>(false);
const [supportDetail, setSupportDetail] =
useState<DistributedListData | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: [
"distributed_list",
currentPage,
nameFilter,
disctritFilter,
dateFilter,
],
queryFn: () =>
distributed_api.list({
limit,
offset: (currentPage - 1) * limit,
product: nameFilter,
user: disctritFilter,
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
}),
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data?.count / limit) : 1;
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">Yordam so'rovlari ro'yxati</h1>
<div className="flex gap-2 mb-4">
<Input
type="text"
placeholder="Foydalanuvchi nomi"
className="h-12"
value={disctritFilter}
onChange={(e) => setDisctritFilter(e.target.value)}
/>
<Input
type="text"
placeholder="Mahsulot nomi"
className="h-12"
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
/>
<Popover open={openDate} onOpenChange={setOpenDate}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-48 justify-between font-normal h-12"
>
{dateFilter ? dateFilter.toDateString() : "Sana"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpenDate(false);
}}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpenDate(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<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>
)}
{!isLoading && !isError && (
<Table>
<TableHeader>
<TableRow className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Kim qo'shgan</TableHead>
<TableHead className="text-start">Xaridorning ismi</TableHead>
<TableHead className="text-start">Mahsulot nomi</TableHead>
<TableHead className="text-start">Nechta berilgan</TableHead>
<TableHead className="text-start">Topshirilgan sana</TableHead>
<TableHead className="text-start">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data && data.results.length > 0 ? (
data?.results.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>
{plan.user.first_name} {plan.user.last_name}
</TableCell>
<TableCell>{plan.employee_name}</TableCell>
<TableCell>{plan.product.name}</TableCell>
<TableCell>{plan.quantity}</TableCell>
<TableCell>
{formatDate.format(plan.date, "YYYY-MM-DD")}
</TableCell>
<TableCell>
<Button
className="bg-blue-500 hover:bg-blue-500 cursor-pointer"
onClick={() => {
setOpen(true);
setSupportDetail(plan);
}}
>
<Eye />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-4 text-lg">
Farmasevtika topilmadi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
<Pagination
currentPage={currentPage}
setCurrentPage={setCurrentPage}
totalPages={totalPages}
/>
<DistributedDetail
open={open}
setOpen={setOpen}
specification={supportDetail}
/>
</div>
);
};
export default DistributedList;

View File

@@ -0,0 +1,92 @@
"use client";
import type { DistributedListData } from "@/features/distributed/lib/data";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import type { Dispatch, SetStateAction } from "react";
interface Props {
specification: DistributedListData | null;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export const DistributedDetail = ({ specification, open, setOpen }: Props) => {
if (!specification) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="border-b pb-4">
<DialogTitle className="text-2xl font-bold text-gray-800">
Tafsilot
</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-6">
{/* Asosiy ma'lumotlar - Grid */}
<div className="grid grid-cols-1">
{/* Xaridor */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
<p className="text-sm text-blue-600 font-medium mb-1">
Xaridorning ismi
</p>
<p className="text-lg font-semibold text-gray-800">
{specification.employee_name}
</p>
</div>
{/* Foydalanuvchi */}
<div className="bg-gradient-to-br mt-5 from-purple-50 to-purple-100 rounded-lg p-4 border border-purple-200 md:col-span-2">
<p className="text-sm text-purple-600 font-medium mb-1">
Mas'ul xodim
</p>
<p className="text-lg font-semibold text-gray-800">
{specification.user.first_name} {specification.user.last_name}
</p>
</div>
<div className="bg-gradient-to-br mt-5 from-green-50 to-green-100 rounded-lg p-4 border border-green-200 md:col-span-2">
<p className="text-sm text-green-600 font-medium mb-1">
Topshirilgan sanasi
</p>
<p className="text-lg font-semibold text-gray-800">
{specification.date}
</p>
</div>
</div>
{/* Dorilar ro'yxati */}
<div className="bg-gray-50 rounded-lg p-5 border border-gray-200">
<h3 className="text-lg font-bold text-gray-800 mb-4 flex items-center">
Topshirilgan dori
</h3>
<div className="space-y-3">
<div className="bg-white rounded-lg p-4 border border-gray-200 hover:border-indigo-300 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold text-gray-800">
{specification.product.name}
</p>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>
Miqdor: <strong>{specification.quantity} ta</strong>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,46 @@
import type { DistrictListRes } from "@/features/districts/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const discrit_api = {
async list(params: {
limit?: number;
offset?: number;
name?: string;
user?: number;
}): Promise<AxiosResponse<DistrictListRes>> {
const res = await httpClient.get(`${API_URLS.DISTRICT}list/`, { params });
return res;
},
async create(body: {
name: string;
user_id: number;
}): Promise<AxiosResponse<DistrictListRes>> {
const res = await httpClient.post(`${API_URLS.DISTRICT}create/`, body);
return res;
},
async update({
body,
id,
}: {
id: number;
body: {
name: string;
user: number;
};
}): Promise<AxiosResponse<DistrictListRes>> {
const res = await httpClient.patch(
`${API_URLS.DISTRICT}${id}/update/`,
body,
);
return res;
},
async delete(id: number): Promise<AxiosResponse<DistrictListRes>> {
const res = await httpClient.delete(`${API_URLS.DISTRICT}${id}/delete/`);
return res;
},
};

View File

@@ -23,3 +23,26 @@ export const fakeDistrict: District[] = [
user: FakeUserList[2], user: FakeUserList[2],
}, },
]; ];
export interface DistrictListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: string | null;
previous: string | null;
results: DistrictListData[];
};
}
export interface DistrictListData {
id: number;
name: string;
user: {
id: number;
first_name: string;
last_name: string;
};
created_at: string;
}

View File

@@ -1,7 +1,17 @@
import type { District } from "@/features/districts/lib/data"; import { discrit_api } from "@/features/districts/lib/api";
import type { DistrictListData } from "@/features/districts/lib/data";
import { addDistrict } from "@/features/districts/lib/form"; import { addDistrict } from "@/features/districts/lib/form";
import { FakeUserList } from "@/features/users/lib/data"; import { user_api } from "@/features/users/lib/api";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -11,33 +21,77 @@ import {
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react"; import { useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod"; import z from "zod";
type FormValues = z.infer<typeof addDistrict>; type FormValues = z.infer<typeof addDistrict>;
interface Props { interface Props {
initialValues: District | null; initialValues: DistrictListData | null;
setDistricts: Dispatch<SetStateAction<District[]>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
} }
export default function AddDistrict({ export default function AddDistrict({ initialValues, setDialogOpen }: Props) {
initialValues, const [openUser, setOpenUser] = useState<boolean>(false);
setDistricts, const [userSearch, setUserSearch] = useState<string>("");
setDialogOpen, const queryClient = useQueryClient();
}: Props) {
const [load, setLoad] = useState<boolean>(false); const { data: user, isLoading: isUserLoading } = useQuery({
queryKey: ["user_list", userSearch],
queryFn: () => user_api.list({ search: userSearch }),
select(data) {
return data.data.data.results;
},
});
const { mutate, isPending } = useMutation({
mutationFn: (body: { name: string; user_id: number }) =>
discrit_api.create(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["discrit_list"] });
toast.success(`Tuman 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",
});
},
});
const { mutate: update, isPending: updatePending } = useMutation({
mutationFn: ({
body,
id,
}: {
id: number;
body: { name: string; user: number };
}) => discrit_api.update({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["discrit_list"] });
toast.success(`Tuman 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",
});
},
});
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(addDistrict), resolver: zodResolver(addDistrict),
defaultValues: { defaultValues: {
@@ -47,41 +101,16 @@ export default function AddDistrict({
}); });
function onSubmit(values: FormValues) { function onSubmit(values: FormValues) {
const selectedUser = FakeUserList.find(
(u) => u.id === Number(values.userId),
);
if (!selectedUser) return;
setLoad(true);
if (initialValues) { if (initialValues) {
setTimeout(() => { update({
setDistricts((prev) => id: initialValues.id,
prev.map((d) => body: {
d.id === initialValues.id name: values.name,
? { user: Number(values.userId),
...d, },
name: values.name, });
user: selectedUser,
}
: d,
),
);
setDialogOpen(false);
setLoad(false);
}, 2000);
} else { } else {
setTimeout(() => { mutate({ name: values.name, user_id: Number(values.userId) });
setDistricts((prev) => [
...prev,
{
id: prev.length ? prev[prev.length - 1].id + 1 : 1,
name: values.name,
user: selectedUser,
},
]);
setDialogOpen(false);
setLoad(false);
}, 2000);
} }
} }
@@ -109,36 +138,100 @@ export default function AddDistrict({
<FormField <FormField
name="userId" name="userId"
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => {
<FormItem> const selectedUser = user?.find(
<Label className="text-md">Kim qoshgan</Label> (u) => String(u.id) === field.value,
<FormControl> );
<Select value={field.value} onValueChange={field.onChange}> return (
<SelectTrigger className="w-full !h-12"> <FormItem className="flex flex-col">
<SelectValue placeholder="Foydalanuvchi tanlang" /> <Label className="text-md">Foydalanuvchi</Label>
</SelectTrigger>
<SelectContent> <Popover open={openUser} onOpenChange={setOpenUser}>
{FakeUserList.map((u) => ( <PopoverTrigger asChild>
<SelectItem key={u.id} value={String(u.id)}> <FormControl>
{u.firstName} {u.lastName} <Button
</SelectItem> type="button"
))} variant="outline"
</SelectContent> role="combobox"
</Select> aria-expanded={openUser}
</FormControl> className={cn(
<FormMessage /> "w-full h-12 justify-between",
</FormItem> !field.value && "text-muted-foreground",
)} )}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={userSearch}
onValueChange={setUserSearch}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.length > 0 ? (
<CommandGroup>
{user.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.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
{/* SUBMIT */} <Button
<Button className="w-full h-12 bg-blue-700 hover:bg-blue-700 cursor-pointer"> type="submit"
{load ? ( className="w-full h-12 bg-blue-700 hover:bg-blue-800"
<Loader2 className="animate-spin" /> disabled={isPending || updatePending}
>
{isPending || updatePending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"
) : ( ) : (
"Qoshish" "Qo'shish"
)} )}
</Button> </Button>
</form> </form>

View File

@@ -0,0 +1,89 @@
import { discrit_api } from "@/features/districts/lib/api";
import type { DistrictListData } from "@/features/districts/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<DistrictListData | null>>;
discrit: DistrictListData | null;
}
const DeleteDiscrit = ({
opneDelete,
setOpenDelete,
setDiscritDelete,
discrit,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteDiscrict, isPending } = useMutation({
mutationFn: (id: number) => discrit_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["discrit_list"] });
toast.success(`Tuman 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>Tumanni o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {discrit?.user.first_name} {discrit?.user.last_name}{" "}
ga tegishli {discrit?.name} tumanini o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() => discrit && deleteDiscrict(discrit.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteDiscrit;

View File

@@ -1,194 +1,83 @@
import { fakeDistrict, type District } from "@/features/districts/lib/data"; import { discrit_api } from "@/features/districts/lib/api";
import AddDistrict from "@/features/districts/ui/AddDistrict"; import { type DistrictListData } from "@/features/districts/lib/data";
import { Button } from "@/shared/ui/button"; import DeleteDiscrit from "@/features/districts/ui/DeleteDiscrit";
import { import Filter from "@/features/districts/ui/Filter";
Dialog, import TableDistrict from "@/features/districts/ui/TableDistrict";
DialogContent, import Pagination from "@/shared/ui/pagination";
DialogHeader, import { useQuery } from "@tanstack/react-query";
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import { ChevronLeft, ChevronRight, Edit, Plus, Trash } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const DistrictsList = () => { const DistrictsList = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
const [districts, setDistricts] = useState<District[]>(fakeDistrict);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [editingDistrict, setEditingDistrict] =
useState<DistrictListData | null>(null);
const [userSearch, setUserSearch] = useState(""); const { data, isLoading, isError } = useQuery({
queryKey: ["discrit_list", currentPage, search],
queryFn: () =>
discrit_api.list({
limit: 20,
offset: (currentPage - 1) * 20,
name: search,
}),
select(data) {
return data.data.data;
},
});
const [editing, setEditing] = useState<District | null>(null); const totalPages = data ? Math.ceil(data.count / 20) : 1;
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const filtered = districts.filter((d) => { const [disricDelete, setDiscritDelete] = useState<DistrictListData | null>(
return ( null,
d.name.toLowerCase().includes(search.toLowerCase()) && );
`${d.user.firstName} ${d.user.lastName}` const [opneDelete, setOpenDelete] = useState<boolean>(false);
.toLowerCase()
.includes(userSearch.toLowerCase())
);
});
function deleteDistrict(id: number) { const handleDelete = (user: DistrictListData) => {
setDistricts((prev) => prev.filter((d) => d.id !== id)); setDiscritDelete(user);
} setOpenDelete(true);
};
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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"> <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">Tumanlar royxati</h1> <h1 className="text-2xl font-bold">Tumanlar royxati</h1>
<div className="flex gap-4 justify-end"> <Filter
<Input search={search}
placeholder="Tuman nomi boyicha qidirish..." setSearch={setSearch}
value={search} dialogOpen={dialogOpen}
onChange={(e) => setSearch(e.target.value)} editing={editingDistrict}
className="max-w-sm h-12" setDialogOpen={setDialogOpen}
/> setEditing={setEditingDistrict}
/>
<Input
placeholder="Foydalanuvchi boyicha qidirish..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="max-w-sm h-12"
/>
<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" /> Tuman qoshish
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl">
{editing ? "Tumanni tahrirlash" : "Yangi tuman qoshish"}
</DialogTitle>
</DialogHeader>
<AddDistrict
initialValues={editing}
setDistricts={setDistricts}
setDialogOpen={setDialogOpen}
/>
</DialogContent>
</Dialog>
</div>
</div> </div>
<div className="flex-1 overflow-auto"> <TableDistrict
<Table> data={data ? data.results : []}
<TableHeader> isError={isError}
<TableRow> isLoading={isLoading}
<TableHead>ID</TableHead> setDialogOpen={setDialogOpen}
<TableHead>Tuman nomi</TableHead> setEditingDistrict={setEditingDistrict}
<TableHead>Kim qoshgan</TableHead> handleDelete={handleDelete}
<TableHead className="text-right">Harakatlar</TableHead> currentPage={currentPage}
</TableRow> />
</TableHeader>
<TableBody> <Pagination
{filtered.map((d) => ( currentPage={currentPage}
<TableRow key={d.id}> setCurrentPage={setCurrentPage}
<TableCell>{d.id}</TableCell> totalPages={totalPages}
<TableCell>{d.name}</TableCell> />
<TableCell>
{d.user.firstName} {d.user.lastName}
</TableCell>
<TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(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 <DeleteDiscrit
variant="destructive" discrit={disricDelete}
size="sm" setDiscritDelete={setDiscritDelete}
onClick={() => deleteDistrict(d.id)} opneDelete={opneDelete}
className="cursor-pointer" setOpenDelete={setOpenDelete}
> />
<Trash className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-6">
Hech qanday tuman topilmadi
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t">
<Button
variant="outline"
size="icon"
disabled={currentPage === 1}
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
>
<ChevronLeft />
</Button>
{Array.from({ length: totalPages }, (_, i) => (
<Button
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,65 @@
import type { DistrictListData } from "@/features/districts/lib/data";
import AddDistrict from "@/features/districts/ui/AddDistrict";
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, type SetStateAction } from "react";
interface Props {
search: string;
setSearch: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
editing: DistrictListData | null;
setEditing: Dispatch<SetStateAction<DistrictListData | null>>;
}
const Filter = ({
search,
setSearch,
dialogOpen,
setDialogOpen,
setEditing,
editing,
}: Props) => {
return (
<div className="flex gap-4 justify-end">
<Input
placeholder="Tuman nomi boyicha qidirish..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm h-12"
/>
<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" /> Tuman qoshish
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl">
{editing ? "Tumanni tahrirlash" : "Yangi tuman qoshish"}
</DialogTitle>
</DialogHeader>
<AddDistrict initialValues={editing} setDialogOpen={setDialogOpen} />
</DialogContent>
</Dialog>
</div>
);
};
export default Filter;

View File

@@ -0,0 +1,109 @@
import type { DistrictListData } from "@/features/districts/lib/data";
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: DistrictListData[] | [];
isLoading: boolean;
isError: boolean;
handleDelete: (user: DistrictListData) => void;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setEditingDistrict: Dispatch<SetStateAction<DistrictListData | null>>;
currentPage: number;
}
const TableDistrict = ({
data,
isError,
isLoading,
handleDelete,
setDialogOpen,
setEditingDistrict,
currentPage,
}: 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>Tuman nomi</TableHead>
<TableHead>Kim qoshgan</TableHead>
<TableHead className="text-right">Harakatlar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((d, index) => (
<TableRow key={d.id}>
<TableCell>{index + 1 + (currentPage - 1) * 20}</TableCell>
<TableCell>{d.name}</TableCell>
<TableCell>
{d.user.first_name} {d.user.last_name}
</TableCell>
<TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingDistrict(d);
setDialogOpen(true);
}}
className="bg-blue-500 text-white hover:text-white hover:bg-blue-500 cursor-pointer"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(d)}
className="cursor-pointer"
>
<Trash className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{data.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-6">
Hech qanday tuman topilmadi
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
);
};
export default TableDistrict;

View File

@@ -0,0 +1,40 @@
import type {
CreateDoctorReq,
DoctorListRes,
UpdateDoctorReq,
} from "@/features/doctors/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const doctor_api = {
async list(params: {
limit?: number;
offset?: number;
full_name?: string;
district_name?: string;
place_name?: string;
work_place?: string;
sphere?: string;
user?: string;
user_id?: number;
}): Promise<AxiosResponse<DoctorListRes>> {
const res = await httpClient.get(`${API_URLS.DOCTOR}list/`, { params });
return res;
},
async create(body: CreateDoctorReq) {
const res = await httpClient.post(`${API_URLS.DOCTOR}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: UpdateDoctorReq }) {
const res = await httpClient.patch(`${API_URLS.DOCTOR}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.DOCTOR}${id}/delete/`);
return res;
},
};

View File

@@ -50,3 +50,74 @@ export const doctorListData: DoctorListType[] = [
long: ObjectListData[1].long, long: ObjectListData[1].long,
}, },
]; ];
export interface DoctorListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: string;
previous: string;
results: DoctorListResData[];
};
}
export interface DoctorListResData {
id: number;
first_name: string;
last_name: string;
phone_number: string;
work_place: string;
sphere: string;
description: string;
district: {
id: number;
name: string;
};
place: {
id: number;
name: string;
};
user: {
id: number;
first_name: string;
last_name: string;
};
longitude: number;
latitude: number;
extra_location: {
latitude: number;
longitude: number;
};
created_at: string;
}
export interface CreateDoctorReq {
first_name: string;
last_name: string;
phone_number: string;
work_place: string;
sphere: string;
description: string;
district_id: number;
place_id: number;
user_id: number;
longitude: number;
latitude: number;
extra_location: {
longitude: number;
latitude: number;
};
}
export interface UpdateDoctorReq {
first_name: string;
last_name: string;
phone_number: string;
work_place: string;
sphere: string;
description: string;
longitude: number;
latitude: number;
extra_location: { longitude: number; latitude: number };
}

View File

@@ -1,10 +1,25 @@
import { fakeDistrict } from "@/features/districts/lib/data"; import { discrit_api } from "@/features/districts/lib/api";
import type { DoctorListType } from "@/features/doctors/lib/data"; import { doctor_api } from "@/features/doctors/lib/api";
import type {
CreateDoctorReq,
DoctorListResData,
UpdateDoctorReq,
} from "@/features/doctors/lib/data";
import { DoctorForm } from "@/features/doctors/lib/form"; import { DoctorForm } from "@/features/doctors/lib/form";
import { ObjectListData } from "@/features/objects/lib/data"; import { object_api } from "@/features/objects/lib/api";
import { FakeUserList } from "@/features/users/lib/data"; import { user_api } from "@/features/users/lib/api";
import formatPhone from "@/shared/lib/formatPhone"; import formatPhone from "@/shared/lib/formatPhone";
import onlyNumber from "@/shared/lib/onlyNumber";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -14,85 +29,267 @@ import {
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { Textarea } from "@/shared/ui/textarea"; import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import {
import { Loader2 } from "lucide-react"; Circle,
import { useState, type Dispatch, type SetStateAction } from "react"; Map,
Placemark,
Polygon,
YMaps,
ZoomControl,
} from "@pbe/react-yandex-maps";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
interface Props { interface Props {
initialValues: DoctorListType | null; initialValues: DoctorListResData | null;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
setData: Dispatch<SetStateAction<DoctorListType[]>>;
} }
const AddedDoctor = ({ initialValues, setData, setDialogOpen }: Props) => { interface CoordsData {
const [load, setLoad] = useState<boolean>(false); lat: number;
lon: number;
polygon: [number, number][][];
}
const AddedDoctor = ({ initialValues, setDialogOpen }: Props) => {
const queryClient = useQueryClient();
const [searchUser, setSearchUser] = useState<string>("");
const [searchObject, setSearchObject] = useState<string>("");
const [selectDiscrit, setSelectedDiscrit] = useState<string>("");
const [searchDiscrit, setSearchDiscrit] = useState<string>("");
const [openUser, setOpenUser] = useState<boolean>(false);
const [openDiscrit, setOpenDiscrit] = useState<boolean>(false);
const [openObject, setOpenObject] = useState<boolean>(false);
const form = useForm<z.infer<typeof DoctorForm>>({ const form = useForm<z.infer<typeof DoctorForm>>({
resolver: zodResolver(DoctorForm), resolver: zodResolver(DoctorForm),
defaultValues: { defaultValues: {
desc: initialValues?.desc || "", desc: initialValues?.description || "",
district: initialValues?.district.id.toString() || "", district: initialValues?.district.id.toString() || "",
first_name: initialValues?.first_name || "", first_name: initialValues?.first_name || "",
last_name: initialValues?.last_name || "", last_name: initialValues?.last_name || "",
lat: initialValues?.lat || "41.2949", lat: String(initialValues?.latitude) || "41.2949",
long: initialValues?.long || "69.2361", long: String(initialValues?.longitude) || "69.2361",
object: initialValues?.object.id.toString() || "", object: initialValues?.place.id.toString() || "",
phone_number: initialValues?.phone_number || "+998", phone_number: initialValues?.phone_number || "+998",
spec: initialValues?.spec || "", spec: initialValues?.sphere || "",
work: initialValues?.work || "", work: initialValues?.work_place || "",
user: initialValues?.user.id.toString() || "", user: initialValues?.user.id.toString() || "",
}, },
}); });
const lat = form.watch("lat"); const { data: user, isLoading: isUserLoading } = useQuery({
const long = form.watch("long"); queryKey: ["user_list", searchUser],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
search?: string;
is_active?: boolean | string;
region_id?: number;
} = {
limit: 8,
search: searchUser,
};
const handleMapClick = (e: { get: (key: string) => number[] }) => { return user_api.list(params);
const coords = e.get("coords"); },
form.setValue("lat", coords[0].toString()); select(data) {
form.setValue("long", coords[1].toString()); return data.data.data;
},
});
const user_id = form.watch("user");
const { data: object, isLoading: isObjectLoading } = useQuery({
queryKey: ["object_list", searchUser, selectDiscrit, user_id],
queryFn: () => {
const params: {
name?: string;
district?: string;
user_id?: number;
} = {
name: searchUser,
district: selectDiscrit,
user_id: Number(user_id),
};
return object_api.list(params);
},
select(data) {
return data.data.data;
},
});
const { data: discrit, isLoading: discritLoading } = useQuery({
queryKey: ["discrit_list", searchDiscrit, user_id],
queryFn: () => {
const params: {
name?: string;
user?: number;
} = {
name: searchDiscrit,
};
if (user_id !== "") {
params.user = Number(user_id);
}
return discrit_api.list(params);
},
select(data) {
return data.data.data;
},
});
const { mutate, isPending } = useMutation({
mutationFn: (body: CreateDoctorReq) => doctor_api.create(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["doctor_list"] });
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: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { body: UpdateDoctorReq; id: number }) =>
doctor_api.update({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["doctor_list"] });
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 [coords, setCoords] = useState({
latitude: 41.311081,
longitude: 69.240562,
});
const [polygonCoords, setPolygonCoords] = useState<
[number, number][][] | null
>(null);
const [circleCoords, setCircleCoords] = useState<[number, number] | null>(
null,
);
const getCoords = async (name: string): Promise<CoordsData | null> => {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(name)}&format=json&polygon_geojson=1&limit=1`,
);
const data = await res.json();
if (data.length > 0 && data[0].geojson) {
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
let polygon: [number, number][][] = [];
if (data[0].geojson.type === "Polygon") {
polygon = data[0].geojson.coordinates.map((ring: [number, number][]) =>
ring.map((coord: [number, number]) => [coord[1], coord[0]]),
);
} else if (data[0].geojson.type === "MultiPolygon") {
polygon = data[0].geojson.coordinates.map(
(poly: [number, number][][]) =>
poly[0].map((coord: [number, number]) => [coord[1], coord[0]]),
);
}
return { lat, lon, polygon };
}
return null;
};
useEffect(() => {
if (initialValues) {
(async () => {
const result = await getCoords(initialValues.district.name);
if (result) {
setCoords({
latitude: Number(initialValues.latitude),
longitude: Number(initialValues.longitude),
});
setPolygonCoords(result.polygon);
form.setValue("lat", String(result.lat));
form.setValue("long", String(result.lon));
setCircleCoords([
Number(initialValues.latitude),
Number(initialValues.longitude),
]);
}
})();
}
}, [initialValues, form]);
const handleMapClick = (
e: ymaps.IEvent<MouseEvent, { coords: [number, number] }>,
) => {
const [lat, lon] = e.get("coords");
setCoords({ latitude: lat, longitude: lon });
form.setValue("lat", String(lat));
form.setValue("long", String(lon));
}; };
function onSubmit(values: z.infer<typeof DoctorForm>) { function onSubmit(values: z.infer<typeof DoctorForm>) {
setLoad(true); if (initialValues) {
const newObject: DoctorListType = { edit({
id: initialValues ? initialValues.id : Date.now(), id: initialValues.id,
user: FakeUserList.find((u) => u.id === Number(values.user))!, body: {
district: fakeDistrict.find((d) => d.id === Number(values.district))!, description: values.desc,
desc: values.desc, extra_location: {
first_name: values.first_name, latitude: Number(values.lat),
last_name: values.last_name, longitude: Number(values.long),
lat: values.lat, },
long: values.long, first_name: values.first_name,
object: ObjectListData.find((d) => d.id === Number(values.object))!, last_name: values.last_name,
phone_number: values.phone_number, latitude: Number(values.lat),
spec: values.spec, longitude: Number(values.long),
work: values.work, phone_number: onlyNumber(values.phone_number),
}; sphere: values.spec,
work_place: values.work,
setTimeout(() => { },
setData((prev) => {
if (initialValues) {
return prev.map((item) =>
item.id === initialValues.id ? newObject : item,
);
} else {
return [...prev, newObject];
}
}); });
setLoad(false); } else {
setDialogOpen(false); mutate({
}, 2000); description: values.desc,
district_id: Number(values.district),
extra_location: {
latitude: Number(values.lat),
longitude: Number(values.long),
},
first_name: values.first_name,
last_name: values.last_name,
latitude: Number(values.lat),
longitude: Number(values.long),
phone_number: onlyNumber(values.phone_number),
place_id: Number(values.object),
sphere: values.spec,
user_id: Number(values.user),
work_place: values.work,
});
}
} }
return ( return (
@@ -191,98 +388,349 @@ const AddedDoctor = ({ initialValues, setData, setDialogOpen }: Props) => {
/> />
<FormField <FormField
control={form.control}
name="district"
render={({ field }) => (
<FormItem>
<Label>Tuman</Label>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="Tumanlar" />
</SelectTrigger>
<SelectContent>
{fakeDistrict.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="object"
render={({ field }) => (
<FormItem>
<Label>Obyekt</Label>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="Obyekt" />
</SelectTrigger>
<SelectContent>
{ObjectListData.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user" name="user"
render={({ field }) => ( control={form.control}
<FormItem> render={({ field }) => {
<Label>Foydalanuvchi</Label> const selectedUser = user?.results.find(
<FormControl> (u) => String(u.id) === field.value,
<Select onValueChange={field.onChange} value={field.value}> );
<SelectTrigger className="w-full !h-12"> return (
<SelectValue placeholder="Foydalanuvchilar" /> <FormItem className="flex flex-col">
</SelectTrigger> <Label className="text-md">Foydalanuvchi</Label>
<SelectContent>
{FakeUserList.map((e) => ( <Popover open={openUser} onOpenChange={setOpenUser}>
<SelectItem value={String(e.id)}> <PopoverTrigger asChild disabled={initialValues !== null}>
{e.firstName} {e.lastName} <FormControl>
</SelectItem> <Button
))} type="button"
</SelectContent> variant="outline"
</Select> role="combobox"
</FormControl> aria-expanded={openUser}
<FormMessage /> className={cn(
</FormItem> "w-full h-12 justify-between",
)} !field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchUser}
onValueChange={setSearchUser}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.results.length > 0 ? (
<CommandGroup>
{user.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.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name="district"
control={form.control}
render={({ field }) => {
const selectedDiscrit = discrit?.results.find(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Tumanlar</Label>
<Popover open={openDiscrit} onOpenChange={setOpenDiscrit}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={openDiscrit}
className={cn(
"w-full h-12 justify-between",
!field.value && "text-muted-foreground",
)}
>
{selectedDiscrit
? `${selectedDiscrit.name}`
: "Tuman 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchDiscrit}
onValueChange={setSearchDiscrit}
/>
<CommandList>
{discritLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : discrit && discrit.results.length > 0 ? (
<CommandGroup>
{discrit.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={async () => {
field.onChange(String(u.id));
const selectedDistrict =
discrit.results?.find(
(d) => d.id === Number(u.id),
);
setOpenUser(false);
if (!selectedDistrict) return;
setSelectedDiscrit(selectedDistrict.name);
const coordsData = await getCoords(
selectedDistrict?.name,
);
if (!coordsData) return;
setCoords({
latitude: coordsData.lat,
longitude: coordsData.lon,
});
setPolygonCoords(coordsData.polygon);
form.setValue("lat", String(coordsData.lat));
form.setValue("long", String(coordsData.lon));
setOpenDiscrit(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Tuman topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name="object"
control={form.control}
render={({ field }) => {
const selectedObject = object?.results.find(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Obyektlar</Label>
<Popover open={openObject} onOpenChange={setOpenObject}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={openDiscrit}
className={cn(
"w-full h-12 justify-between",
!field.value && "text-muted-foreground",
)}
>
{selectedObject
? `${selectedObject.name}`
: "Obyekt 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchObject}
onValueChange={setSearchObject}
/>
<CommandList>
{isObjectLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : object && object.results.length > 0 ? (
<CommandGroup>
{object.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={async () => {
field.onChange(String(u.id));
const selectedObject = object.results?.find(
(d) => d.id === Number(u.id),
);
setOpenUser(false);
if (!selectedObject) return;
setCircleCoords([
selectedObject.latitude,
selectedObject.longitude,
]);
setCoords({
latitude: selectedObject.latitude,
longitude: selectedObject.longitude,
});
form.setValue(
"lat",
String(selectedObject.latitude),
);
form.setValue(
"long",
String(selectedObject.longitude),
);
setOpenObject(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Obyekt topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<div className="h-[300px] w-full border rounded-lg overflow-hidden"> <div className="h-[300px] w-full border rounded-lg overflow-hidden">
<YMaps> <YMaps query={{ lang: "en_RU" }}>
<Map <Map
defaultState={{ center: [Number(lat), Number(long)], zoom: 16 }} defaultState={{
center: [coords.latitude, coords.longitude],
zoom: 12,
}}
width="100%" width="100%"
height="300px" height="100%"
onClick={handleMapClick} onClick={handleMapClick}
> >
<Placemark geometry={[Number(lat), Number(long)]} /> <ZoomControl
<Circle
geometry={[[Number(lat), Number(long)], 100]}
options={{ options={{
fillColor: "rgba(0,150,255,0.2)", position: { right: "10px", bottom: "70px" },
strokeColor: "rgba(0,150,255,0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}} }}
/> />
<Placemark geometry={[coords.latitude, coords.longitude]} />
{polygonCoords && (
<Polygon
geometry={polygonCoords}
options={{
fillColor: "rgba(0, 150, 255, 0.2)",
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}}
/>
)}
{circleCoords && (
<Circle
geometry={[circleCoords, 300]}
options={{
fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}}
/>
)}
</Map> </Map>
</YMaps> </YMaps>
</div> </div>
@@ -290,7 +738,7 @@ const AddedDoctor = ({ initialValues, setData, setDialogOpen }: Props) => {
className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer" className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer"
type="submit" type="submit"
> >
{load ? ( {isPending || editPending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,90 @@
import { doctor_api } from "@/features/doctors/lib/api";
import type { DoctorListResData } from "@/features/doctors/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<DoctorListResData | null>>;
discrit: DoctorListResData | null;
}
const DeleteDoctor = ({
opneDelete,
setOpenDelete,
setDiscritDelete,
discrit,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteDiscrict, isPending } = useMutation({
mutationFn: (id: number) => doctor_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["doctor_list"] });
toast.success(`Shifokor 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>Tumanni o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {discrit?.user.first_name} {discrit?.user.last_name}{" "}
ga tegishli {discrit?.first_name} {discrit?.last_name} shifokorni
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 DeleteDoctor;

View File

@@ -1,4 +1,4 @@
import type { DoctorListType } from "@/features/doctors/lib/data"; import type { DoctorListResData } from "@/features/doctors/lib/data";
import formatPhone from "@/shared/lib/formatPhone"; import formatPhone from "@/shared/lib/formatPhone";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
@@ -8,15 +8,89 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import {
Circle,
Map,
Placemark,
Polygon,
YMaps,
ZoomControl,
} from "@pbe/react-yandex-maps";
import { useEffect, useState } from "react";
interface CoordsData {
lat: number;
lon: number;
polygon: [number, number][][];
}
interface Props { interface Props {
detail: boolean; detail: boolean;
setDetail: (open: boolean) => void; setDetail: (open: boolean) => void;
object: DoctorListType | null; object: DoctorListResData | null;
} }
const DoctorDetailDialog = ({ detail, setDetail, object }: Props) => { const DoctorDetailDialog = ({ detail, setDetail, object }: Props) => {
const [coords, setCoords] = useState<[number, number]>([
41.311081, 69.240562,
]);
const [polygonCoords, setPolygonCoords] = useState<[number, number][][]>([]);
const [circleCoords, setCircleCoords] = useState<[number, number] | null>(
null,
);
const getCoords = async (name: string): Promise<CoordsData | null> => {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(
name,
)}&format=json&polygon_geojson=1&limit=1`,
);
const data = await res.json();
if (!data.length || !data[0].geojson) return null;
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
let polygon: [number, number][][] = [];
if (data[0].geojson.type === "Polygon") {
polygon = data[0].geojson.coordinates.map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
if (data[0].geojson.type === "MultiPolygon") {
polygon = data[0].geojson.coordinates[0].map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
return { lat, lon, polygon };
} catch {
return null;
}
};
useEffect(() => {
if (!object) return;
const load = async () => {
const district = await getCoords(object.district.name);
if (district) {
setPolygonCoords(district.polygon);
}
setCoords([object.latitude, object.longitude]);
setCircleCoords([object.latitude, object.longitude]);
};
load();
}, [object]);
if (!object) return null; if (!object) return null;
return ( return (
@@ -28,61 +102,76 @@ const DoctorDetailDialog = ({ detail, setDetail, object }: Props) => {
<div className="space-y-3 mt-2"> <div className="space-y-3 mt-2">
<p> <p>
<span className="font-semibold">Ism Familiya:</span>{" "} <span className="font-semibold">Ism:</span> {object.first_name}{" "}
{object.first_name} {object.last_name} {object.last_name}
</p> </p>
<p> <p>
<span className="font-semibold">Telefon:</span>{" "} <span className="font-semibold">Telefon:</span>{" "}
{formatPhone(object.phone_number)} {formatPhone(object.phone_number)}
</p> </p>
<p> <p>
<span className="font-semibold">Ish joyi:</span> {object.work} <span className="font-semibold">Ish joyi:</span> {object.work_place}
</p> </p>
<p> <p>
<span className="font-semibold">Mutaxassislik:</span> {object.spec} <span className="font-semibold">Mutaxassislik:</span>{" "}
</p> {object.sphere}
<p>
<span className="font-semibold">Tavsif:</span> {object.desc}
</p> </p>
<p> <p>
<span className="font-semibold">Tuman:</span> {object.district.name} <span className="font-semibold">Tuman:</span> {object.district.name}
</p> </p>
<p> <p>
<span className="font-semibold">Foydalanuvchi:</span>{" "} <span className="font-semibold">Manzili:</span>
{object.user.firstName} {object.user.lastName}
</p> </p>
<p>
<span className="font-semibold">Obyekt:</span> {object.object.name} {/* 🗺 MAP */}
</p>
<span className="font-semibold">Manzili:</span>
<div className="h-[300px] w-full border rounded-lg overflow-hidden"> <div className="h-[300px] w-full border rounded-lg overflow-hidden">
<YMaps> <YMaps query={{ lang: "en_RU" }}>
<Map <Map
defaultState={{ state={{
center: [Number(object.lat), Number(object.long)], center: coords,
zoom: 16, zoom: 12,
}} }}
width="100%" width="100%"
height="300px" height="100%"
> >
<Placemark <ZoomControl
geometry={[Number(object.lat), Number(object.long)]}
/>
<Circle
geometry={[[Number(object.lat), Number(object.long)], 100]}
options={{ options={{
fillColor: "rgba(0, 150, 255, 0.2)", position: { right: "10px", bottom: "70px" },
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
}} }}
/> />
{/* Ish joyining markazi */}
<Placemark geometry={coords} />
{/* Tuman polygon */}
{polygonCoords.length > 0 && (
<Polygon
geometry={polygonCoords}
options={{
fillColor: "rgba(0, 150, 255, 0.2)",
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
}}
/>
)}
{circleCoords && (
<Circle
geometry={[circleCoords, 300]}
options={{
fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)",
strokeWidth: 2,
}}
/>
)}
</Map> </Map>
</YMaps> </YMaps>
</div> </div>
</div> </div>
<DialogClose asChild> <DialogClose asChild>
<Button className="mt-4 w-full bg-blue-600 cursor-pointer hover:bg-blue-600"> <Button className="mt-4 w-full bg-blue-600 hover:bg-blue-600">
Yopish Yopish
</Button> </Button>
</DialogClose> </DialogClose>

View File

@@ -1,49 +1,22 @@
import { import { doctor_api } from "@/features/doctors/lib/api";
doctorListData, import { type DoctorListResData } from "@/features/doctors/lib/data";
type DoctorListType, import DeleteDoctor from "@/features/doctors/ui/DeleteDoctor";
} from "@/features/doctors/lib/data";
import AddedDoctor from "@/features/doctors/ui/AddedDoctor";
import DoctorDetailDialog from "@/features/doctors/ui/DoctorDetailDialog"; import DoctorDetailDialog from "@/features/doctors/ui/DoctorDetailDialog";
import formatPhone from "@/shared/lib/formatPhone"; import FilterDoctor from "@/features/doctors/ui/FilterDoctor";
import { Badge } from "@/shared/ui/badge"; import TableDoctor from "@/features/doctors/ui/TableDoctor";
import { Button } from "@/shared/ui/button"; import Pagination from "@/shared/ui/pagination";
import { import { useQuery } from "@tanstack/react-query";
Dialog, import { useState } from "react";
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import {
ChevronLeft,
ChevronRight,
Eye,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
const DoctorsList = () => { const DoctorsList = () => {
const [data, setData] = useState<DoctorListType[]>(doctorListData); const [detail, setDetail] = useState<DoctorListResData | null>(null);
const [detail, setDetail] = useState<DoctorListType | null>(null);
const [detailDialog, setDetailDialog] = useState<boolean>(false); const [detailDialog, setDetailDialog] = useState<boolean>(false);
const [editingPlan, setEditingPlan] = useState<DoctorListType | null>(null); const [editingPlan, setEditingPlan] = useState<DoctorListResData | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
// Filter states
const [searchName, setSearchName] = useState(""); const [searchName, setSearchName] = useState("");
const [searchDistrict, setSearchDistrict] = useState(""); const [searchDistrict, setSearchDistrict] = useState("");
const [searchObject, setSearchObject] = useState(""); const [searchObject, setSearchObject] = useState("");
@@ -51,50 +24,51 @@ const DoctorsList = () => {
const [searchSpec, setSearchSpec] = useState(""); const [searchSpec, setSearchSpec] = useState("");
const [searchUser, setSearchUser] = useState(""); const [searchUser, setSearchUser] = useState("");
const handleDelete = (id: number) => { const [disricDelete, setDiscritDelete] = useState<DoctorListResData | null>(
setData((prev) => prev.filter((e) => e.id !== id)); null,
);
const [opneDelete, setOpenDelete] = useState<boolean>(false);
const limit = 20;
const {
data: doctor,
isError,
isLoading,
isFetching,
} = useQuery({
queryKey: [
"doctor_list",
currentPage,
searchDistrict,
searchName,
searchObject,
searchWork,
searchSpec,
searchUser,
],
queryFn: () =>
doctor_api.list({
limit,
offset: (currentPage - 1) * limit,
district_name: searchDistrict,
full_name: searchName,
place_name: searchObject,
work_place: searchWork,
sphere: searchSpec,
user: searchUser,
}),
select(data) {
return data.data.data;
},
});
const handleDelete = (user: DoctorListResData) => {
setDiscritDelete(user);
setOpenDelete(true);
}; };
// Filtered data const totalPages = doctor ? Math.ceil(doctor.count / limit) : 1;
const filteredData = useMemo(() => {
return data.filter((item) => {
const nameMatch = `${item.first_name} ${item.last_name}`
.toLowerCase()
.includes(searchName.toLowerCase());
const districtMatch = item.district.name
.toLowerCase()
.includes(searchDistrict.toLowerCase());
const objectMatch = item.object.name
.toLowerCase()
.includes(searchObject.toLowerCase());
const workMatch = item.work
.toLowerCase()
.includes(searchWork.toLowerCase());
const specMatch = item.spec
.toLowerCase()
.includes(searchSpec.toLowerCase());
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return (
nameMatch &&
districtMatch &&
objectMatch &&
workMatch &&
specMatch &&
userMatch
);
});
}, [
data,
searchName,
searchDistrict,
searchObject,
searchWork,
searchSpec,
searchUser,
]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <div className="flex flex-col h-full p-10 w-full">
@@ -103,69 +77,24 @@ const DoctorsList = () => {
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<h1 className="text-2xl font-bold">Shifokorlarni boshqarish</h1> <h1 className="text-2xl font-bold">Shifokorlarni boshqarish</h1>
<div className="flex justify-end gap-2 w-full"> <FilterDoctor
<Input dialogOpen={dialogOpen}
placeholder="Shifokor Ism Familiyasi" editingPlan={editingPlan}
value={searchName} searchDistrict={searchDistrict}
onChange={(e) => setSearchName(e.target.value)} searchName={searchName}
className="w-full md:w-48" searchObject={searchObject}
/> searchSpec={searchSpec}
<Input searchUser={searchUser}
placeholder="Tuman" searchWork={searchWork}
value={searchDistrict} setDialogOpen={setDialogOpen}
onChange={(e) => setSearchDistrict(e.target.value)} setEditingPlan={setEditingPlan}
className="w-full md:w-48" setSearchDistrict={setSearchDistrict}
/> setSearchName={setSearchName}
<Input setSearchObject={setSearchObject}
placeholder="Obyekt" setSearchSpec={setSearchSpec}
value={searchObject} setSearchUser={setSearchUser}
onChange={(e) => setSearchObject(e.target.value)} setSearchWork={setSearchWork}
className="w-full md:w-48" />
/>
<Input
placeholder="Ish joyi"
value={searchWork}
onChange={(e) => setSearchWork(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Sohasi"
value={searchSpec}
onChange={(e) => setSearchSpec(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Kim qo'shgan"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
className="w-full md:w-48"
/>
<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
? "Shifokor tahrirlash"
: "Yangi shifokor qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedDoctor
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
setData={setData}
/>
</DialogContent>
</Dialog>
</div>
</div> </div>
<DoctorDetailDialog <DoctorDetailDialog
@@ -175,116 +104,30 @@ const DoctorsList = () => {
/> />
</div> </div>
<div className="flex-1 overflow-auto"> <TableDoctor
<Table> isError={isError}
<TableHeader> isLoading={isLoading}
<TableRow> doctor={doctor ? doctor.results : []}
<TableHead>#</TableHead> setDetail={setDetail}
<TableHead>Shifokor Ism Familiyasi</TableHead> handleDelete={handleDelete}
<TableHead>Telefon raqami</TableHead> isFetching={isFetching}
<TableHead>Tuman</TableHead> setDetailDialog={setDetailDialog}
<TableHead>Obyekt</TableHead> setDialogOpen={setDialogOpen}
<TableHead>Ish joyi</TableHead> setEditingPlan={setEditingPlan}
<TableHead>Sohasi</TableHead> />
<TableHead>Kim qo'shgan</TableHead>
<TableHead className="text-right">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell className="font-medium">
{item.first_name} {item.last_name}
</TableCell>
<TableCell className="font-medium">
{formatPhone(item.phone_number)}
</TableCell>
<TableCell>
<Badge variant="outline">{item.district.name}</Badge>
</TableCell>
<TableCell>{item.object.name}</TableCell>
<TableCell>{item.work}</TableCell>
<TableCell>{item.spec}</TableCell>
<TableCell>
{item.user.firstName} {item.user.lastName}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<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.id)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
disabled={currentPage === 1} />
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} <DeleteDoctor
> discrit={disricDelete}
<ChevronLeft /> opneDelete={opneDelete}
</Button> setDiscritDelete={setDiscritDelete}
{Array.from({ length: totalPages }, (_, i) => ( setOpenDelete={setOpenDelete}
<Button />
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,116 @@
import type { DoctorListResData } from "@/features/doctors/lib/data";
import AddedDoctor from "@/features/doctors/ui/AddedDoctor";
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 {
searchName: string;
setSearchName: Dispatch<SetStateAction<string>>;
searchDistrict: string;
setSearchDistrict: Dispatch<SetStateAction<string>>;
searchWork: string;
setSearchWork: Dispatch<SetStateAction<string>>;
searchSpec: string;
setSearchSpec: Dispatch<SetStateAction<string>>;
searchUser: string;
setSearchUser: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
searchObject: string;
setSearchObject: Dispatch<SetStateAction<string>>;
setEditingPlan: Dispatch<SetStateAction<DoctorListResData | null>>;
editingPlan: DoctorListResData | null;
}
const FilterDoctor = ({
searchName,
setSearchName,
searchDistrict,
setSearchDistrict,
searchObject,
setSearchObject,
searchWork,
setSearchWork,
searchSpec,
setSearchSpec,
searchUser,
setSearchUser,
dialogOpen,
setDialogOpen,
setEditingPlan,
editingPlan,
}: Props) => {
return (
<div className="flex justify-end gap-2 w-full">
<Input
placeholder="Shifokor Ism Familiyasi"
value={searchName}
onChange={(e) => setSearchName(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Tuman"
value={searchDistrict}
onChange={(e) => setSearchDistrict(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Obyekt"
value={searchObject}
onChange={(e) => setSearchObject(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Ish joyi"
value={searchWork}
onChange={(e) => setSearchWork(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Sohasi"
value={searchSpec}
onChange={(e) => setSearchSpec(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Kim qo'shgan"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
className="w-full md:w-48"
/>
<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 ? "Shifokor tahrirlash" : "Yangi shifokor qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedDoctor
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
/>
</DialogContent>
</Dialog>
</div>
);
};
export default FilterDoctor;

View File

@@ -0,0 +1,140 @@
import type { DoctorListResData } from "@/features/doctors/lib/data";
import formatPhone from "@/shared/lib/formatPhone";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Eye, Loader2, Pencil, Trash2 } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
setDetail: Dispatch<SetStateAction<DoctorListResData | null>>;
setEditingPlan: Dispatch<SetStateAction<DoctorListResData | null>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>;
doctor: DoctorListResData[] | [];
isLoading: boolean;
isError: boolean;
isFetching: boolean;
handleDelete: (user: DoctorListResData) => void;
}
const TableDoctor = ({
doctor,
setDetail,
setDetailDialog,
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>Shifokor Ism Familiyasi</TableHead>
<TableHead>Telefon raqami</TableHead>
<TableHead>Tuman</TableHead>
<TableHead>Obyekt</TableHead>
<TableHead>Ish joyi</TableHead>
<TableHead>Sohasi</TableHead>
<TableHead>Kim qo'shgan</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.first_name} {item.last_name}
</TableCell>
<TableCell className="font-medium">
{formatPhone(item.phone_number)}
</TableCell>
<TableCell>
<Badge variant="outline">{item.district.name}</Badge>
</TableCell>
<TableCell>{item.place.name}</TableCell>
<TableCell>{item.work_place}</TableCell>
<TableCell>{item.sphere}</TableCell>
<TableCell>
{item.user.first_name} {item.user.last_name}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<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">
Shifokor topilmadi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
);
};
export default TableDoctor;

View File

@@ -0,0 +1,40 @@
import type { LocationListRes } from "@/features/location/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const location_api = {
async list(params: {
limit?: number;
offset?: number;
date?: string;
user?: string;
}): Promise<AxiosResponse<LocationListRes>> {
const res = await httpClient.get(`${API_URLS.LOCATION}list/`, { params });
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.LOCATION}${id}/delete/`);
return res;
},
async list_user_location(params: {
limit?: number;
offset?: number;
date?: string;
user?: string;
}): Promise<AxiosResponse<LocationListRes>> {
const res = await httpClient.get(`${API_URLS.USER_LOCATION}list/`, {
params,
});
return res;
},
async list_user_location_delete(id: number) {
const res = await httpClient.delete(
`${API_URLS.USER_LOCATION}${id}/delete/`,
);
return res;
},
};

View File

@@ -75,3 +75,51 @@ export const LocationFakeData: LocationListType[] = [
createdAt: new Date("2025-02-01T10:15:00"), createdAt: new Date("2025-02-01T10:15:00"),
}, },
]; ];
export interface LocationListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: LocationListDataRes[];
};
}
export interface LocationListDataRes {
id: number;
longitude: number;
latitude: number;
created_at: string;
district: {
id: number;
name: string;
};
place: {
id: number;
name: string;
longitude: number;
latitude: number;
};
doctor: {
id: number;
first_name: string;
last_name: string;
longitude: number;
latitude: number;
};
pharmacy: {
id: number;
name: string;
longitude: number;
latitude: number;
};
user: {
id: number;
first_name: string;
last_name: string;
};
updated_at: string;
}

View File

@@ -0,0 +1,114 @@
import { location_api } from "@/features/location/lib/api";
import type { LocationListDataRes } from "@/features/location/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>>;
setLocationDelete: Dispatch<SetStateAction<LocationListDataRes | null>>;
locationDelete: LocationListDataRes | null;
viewLocation: "user_send" | "user_send_object";
}
const DeleteLocation = ({
opneDelete,
viewLocation,
locationDelete,
setOpenDelete,
setLocationDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => location_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["location_list"] });
toast.success(`Jo'natilgan lokatsiya o'chirildi`);
setOpenDelete(false);
setLocationDelete(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",
});
},
});
const { mutate: deleteUserLocation, isPending: deleteUserLocationPen } =
useMutation({
mutationFn: (id: number) => location_api.list_user_location_delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["user_location_list"] });
toast.success(`Jo'natilgan lokatsiya o'chirildi`);
setOpenDelete(false);
setLocationDelete(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>Dorini o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham jo'natilgan lokatsiyani 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={() =>
locationDelete && viewLocation === "user_send_object"
? deleteUser(locationDelete.id)
: locationDelete &&
viewLocation === "user_send" &&
deleteUserLocation(locationDelete.id)
}
>
{isPending || deleteUserLocationPen ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteLocation;

View File

@@ -1,4 +1,4 @@
import type { LocationListType } from "@/features/location/lib/data"; import type { LocationListDataRes } from "@/features/location/lib/data";
import formatDate from "@/shared/lib/formatDate"; import formatDate from "@/shared/lib/formatDate";
import { import {
Dialog, Dialog,
@@ -7,26 +7,100 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import { Circle, Map, Placemark, Polygon, YMaps } from "@pbe/react-yandex-maps";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
interface Props { interface Props {
detail: boolean; detail: boolean;
setDetail: Dispatch<SetStateAction<boolean>>; setDetail: Dispatch<SetStateAction<boolean>>;
object: LocationListType | null; object: LocationListDataRes | null;
}
interface CoordsData {
lat: number;
lon: number;
polygon: [number, number][][];
} }
const LocationDetailDialog = ({ detail, object, setDetail }: Props) => { const LocationDetailDialog = ({ detail, object, setDetail }: Props) => {
const [circle, setCircle] = useState<string[] | undefined>([""]); const [circle, setCircle] = useState<string[] | undefined>([""]);
const [coords, setCoords] = useState<[number, number]>([
41.311081, 69.240562,
]);
const [polygonCoords, setPolygonCoords] = useState<[number, number][][]>([]);
const getCoords = async (name: string): Promise<CoordsData | null> => {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(
name,
)}&format=json&polygon_geojson=1&limit=1`,
);
const data = await res.json();
if (!data.length || !data[0].geojson) return null;
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
let polygon: [number, number][][] = [];
if (data[0].geojson.type === "Polygon") {
polygon = data[0].geojson.coordinates.map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
if (data[0].geojson.type === "MultiPolygon") {
polygon = data[0].geojson.coordinates[0].map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
return { lat, lon, polygon };
} catch {
return null;
}
};
useEffect(() => { useEffect(() => {
if (object && object.object) { if (!object) return;
setCircle([object.object.lat, object.object.long]);
} else if (object && object.pharmcies) { const load = async () => {
setCircle([object.pharmcies.lat, object.pharmcies.long]); if (object.district) {
const district = await getCoords(object.district.name);
if (district) {
setPolygonCoords(district.polygon);
}
} else {
setPolygonCoords([]);
}
setCoords([Number(object.latitude), Number(object.longitude)]);
};
load();
}, [object]);
useEffect(() => {
if (object && object.place) {
setCircle([
object.place.latitude.toString(),
object.place.longitude.toString(),
]);
} else if (object && object.pharmacy) {
setCircle([
object.pharmacy.latitude.toString(),
object.pharmacy.longitude.toString(),
]);
} else if (object && object.doctor) { } else if (object && object.doctor) {
setCircle([object.doctor.lat, object.doctor.long]); setCircle([
object.doctor.latitude.toString(),
object.doctor.longitude.toString(),
]);
} else { } else {
setCircle(undefined); setCircle(undefined);
} }
@@ -49,13 +123,13 @@ const LocationDetailDialog = ({ detail, object, setDetail }: Props) => {
Jo'natgan foydalanvchi: Jo'natgan foydalanvchi:
</p> </p>
<p className="text-black"> <p className="text-black">
{object.user.firstName} {object.user.lastName} {object.user.first_name} {object.user.last_name}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<p className="font-semibold text-gray-900">Jo'natgan vaqti:</p> <p className="font-semibold text-gray-900">Jo'natgan vaqti:</p>
<p className="text-black"> <p className="text-black">
{formatDate.format(object.createdAt, "DD-MM-YYYY")} {formatDate.format(object.created_at, "DD-MM-YYYY")}
</p> </p>
</div> </div>
{object.district && ( {object.district && (
@@ -69,15 +143,16 @@ const LocationDetailDialog = ({ detail, object, setDetail }: Props) => {
<YMaps> <YMaps>
<Map <Map
defaultState={{ defaultState={{
center: [Number(object.lat), Number(object.long)], center: coords,
zoom: 16, zoom: 12,
}} }}
width="100%" width="100%"
height="300px" height="300px"
> >
<Placemark {/* Marking user location */}
geometry={[Number(object.lat), Number(object.long)]} <Placemark geometry={coords} />
/>
{/* Circle around user */}
{circle && ( {circle && (
<Circle <Circle
geometry={[[Number(circle[0]), Number(circle[1])], 100]} geometry={[[Number(circle[0]), Number(circle[1])], 100]}
@@ -88,6 +163,18 @@ const LocationDetailDialog = ({ detail, object, setDetail }: Props) => {
}} }}
/> />
)} )}
{/* District polygon */}
{polygonCoords.length > 0 && (
<Polygon
geometry={polygonCoords}
options={{
fillColor: "rgba(0, 150, 255, 0.15)",
strokeColor: "rgba(0, 150, 255, 1)",
strokeWidth: 2,
}}
/>
)}
</Map> </Map>
</YMaps> </YMaps>
</div> </div>

View File

@@ -0,0 +1,102 @@
import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { ChevronDownIcon } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
dateFilter: Date | undefined;
setDateFilter: Dispatch<SetStateAction<Date | undefined>>;
searchUser: string;
setSearchUser: Dispatch<SetStateAction<string>>;
viewLocation: "user_send" | "user_send_object";
setViewLocation: Dispatch<SetStateAction<"user_send" | "user_send_object">>;
}
const LocationFilter = ({
open,
setOpen,
dateFilter,
setDateFilter,
searchUser,
setSearchUser,
viewLocation,
setViewLocation,
}: Props) => {
return (
<div className="flex gap-2 w-full md:w-auto">
<Select
value={viewLocation}
onValueChange={(v) =>
setViewLocation(v as "user_send" | "user_send_object")
}
>
<SelectTrigger className="w-[240px] !h-12">
<SelectValue placeholder="Tanlang" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user_send_object">
Obyektdan jo'natilgan
</SelectItem>
<SelectItem value="user_send">Turgan joyidan jo'natilgan</SelectItem>
</SelectContent>
</Select>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-48 justify-between font-normal h-12"
>
{dateFilter ? dateFilter.toDateString() : "Sana"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpen(false);
}}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpen(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
<Input
type="text"
placeholder="Foydalanuvchi ismi"
className="h-12"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
</div>
);
};
export default LocationFilter;

View File

@@ -1,115 +1,81 @@
import { import { location_api } from "@/features/location/lib/api";
LocationFakeData, import { type LocationListDataRes } from "@/features/location/lib/data";
type LocationListType, import DeleteLocation from "@/features/location/ui/DeleteLocation";
} from "@/features/location/lib/data";
import LocationDetailDialog from "@/features/location/ui/LocationDetailDialog"; import LocationDetailDialog from "@/features/location/ui/LocationDetailDialog";
import LocationFilter from "@/features/location/ui/LocationFilter";
import LocationTable from "@/features/location/ui/LocationTable";
import UserLocationTable from "@/features/location/ui/UserLocationTable";
import formatDate from "@/shared/lib/formatDate"; import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button"; import Pagination from "@/shared/ui/pagination";
import { Calendar } from "@/shared/ui/calendar"; import { useQuery } from "@tanstack/react-query";
import { Input } from "@/shared/ui/input"; import { useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import {
ChevronDownIcon,
ChevronLeft,
ChevronRight,
Eye,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
const LocationList = () => { const LocationList = () => {
const [data, setData] = useState<LocationListType[]>(LocationFakeData); const [detail, setDetail] = useState<LocationListDataRes | null>(null);
const [detail, setDetail] = useState<LocationListType | null>(null);
const [detailDialog, setDetailDialog] = useState<boolean>(false); const [detailDialog, setDetailDialog] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const limit = 20;
// Filter state
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined); const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
const [searchUser, setSearchUser] = useState<string>(""); const [searchUser, setSearchUser] = useState<string>("");
const [viewLocation, setViewLocation] = useState<
"user_send" | "user_send_object"
>("user_send_object");
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const handleDelete = (id: number) => { const { data: location } = useQuery({
setData((prev) => prev.filter((e) => e.id !== id)); queryKey: ["location_list", currentPage, searchUser, dateFilter],
queryFn: () =>
location_api.list({
limit,
offset: (currentPage - 1) * limit,
user: searchUser,
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
}),
select(data) {
return data.data.data;
},
});
const { data: user_location } = useQuery({
queryKey: ["user_location_list", currentPage, searchUser, dateFilter],
queryFn: () =>
location_api.list_user_location({
limit,
offset: (currentPage - 1) * limit,
user: searchUser,
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
}),
select(data) {
return data.data.data;
},
});
const totalPages = location ? Math.ceil(location.count / limit) : 1;
const [openDelete, setOpenDelete] = useState<boolean>(false);
const [locationDelete, setLocationDelete] =
useState<LocationListDataRes | null>(null);
const handleDelete = (id: LocationListDataRes) => {
setOpenDelete(true);
setLocationDelete(id);
}; };
// Filtered data
const filtered = useMemo(() => {
return data.filter((item) => {
const dateMatch = dateFilter
? item.createdAt.toDateString() === dateFilter.toDateString()
: true;
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return dateMatch && userMatch;
});
}, [data, dateFilter, searchUser]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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"> <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">Jo'natilgan lokatsiyalar</h1> <h1 className="text-2xl font-bold">Jo'natilgan lokatsiyalar</h1>
<div className="flex gap-2 w-full md:w-auto"> <LocationFilter
<Popover open={open} onOpenChange={setOpen}> dateFilter={dateFilter}
<PopoverTrigger asChild> open={open}
<Button searchUser={searchUser}
variant="outline" setDateFilter={setDateFilter}
id="date" setOpen={setOpen}
className="w-48 justify-between font-normal h-12" setSearchUser={setSearchUser}
> setViewLocation={setViewLocation}
{dateFilter ? dateFilter.toDateString() : "Sana"} viewLocation={viewLocation}
<ChevronDownIcon /> />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpen(false);
}}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpen(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
<Input
type="text"
placeholder="Foydalanuvchi ismi"
className="h-12"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
</div>
<LocationDetailDialog <LocationDetailDialog
detail={detailDialog} detail={detailDialog}
@@ -117,106 +83,47 @@ const LocationList = () => {
object={detail} object={detail}
/> />
</div> </div>
{viewLocation === "user_send_object" && (
<>
<LocationTable
filtered={location ? location.results : []}
handleDelete={handleDelete}
setDetail={setDetail}
setDetailDialog={setDetailDialog}
/>
<div className="flex-1 overflow-auto"> <Pagination
<Table> currentPage={currentPage}
<TableHeader> setCurrentPage={setCurrentPage}
<TableRow> totalPages={totalPages}
<TableHead>#</TableHead> />
<TableHead>Jo'natgan foydalanuvchi</TableHead> </>
<TableHead>Jo'natgan vaqti</TableHead> )}
<TableHead>Qayerdan jo'natdi</TableHead>
<TableHead className="text-right">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
{item.user.firstName} {item.user.lastName}
</TableCell>
<TableCell>
{formatDate.format(item.createdAt, "DD-MM-YYYY")}
</TableCell>
<TableCell> {viewLocation === "user_send" && (
{item.district <>
? "Tuman" <UserLocationTable
: item.object filtered={user_location ? user_location.results : []}
? "Obyekt" handleDelete={handleDelete}
: item.doctor setDetail={setDetail}
? "Shifokor" setDetailDialog={setDetailDialog}
: item.pharmcies />
? "Dorixona"
: "Turgan joyidan"}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
onClick={() => { />
setDetail(item); </>
setDetailDialog(true); )}
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<Button
variant="destructive"
size="icon"
className="cursor-pointer"
onClick={() => handleDelete(item.id)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <DeleteLocation
<Button locationDelete={locationDelete}
variant="outline" opneDelete={openDelete}
size="icon" setOpenDelete={setOpenDelete}
disabled={currentPage === 1} setLocationDelete={setLocationDelete}
className="cursor-pointer" viewLocation={viewLocation}
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} />
>
<ChevronLeft />
</Button>
{Array.from({ length: totalPages }, (_, i) => (
<Button
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,92 @@
import type { LocationListDataRes } from "@/features/location/lib/data";
import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Eye, Trash2 } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filtered: LocationListDataRes[] | [];
setDetail: Dispatch<SetStateAction<LocationListDataRes | null>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: LocationListDataRes) => void;
}
const LocationTable = ({
filtered,
setDetail,
setDetailDialog,
handleDelete,
}: Props) => {
return (
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Jo'natgan foydalanuvchi</TableHead>
<TableHead>Jo'natgan vaqti</TableHead>
<TableHead>Qayerdan jo'natdi</TableHead>
<TableHead className="text-right">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
{item.user.first_name} {item.user.last_name}
</TableCell>
<TableCell>
{formatDate.format(item.created_at, "DD-MM-YYYY")}
</TableCell>
<TableCell>
{item.district
? "Tuman"
: item.place
? "Obyekt"
: item.doctor
? "Shifokor"
: item.pharmacy
? "Dorixona"
: "Turgan joyidan"}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<Button
variant="destructive"
size="icon"
className="cursor-pointer"
onClick={() => handleDelete(item)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default LocationTable;

View File

@@ -0,0 +1,92 @@
import type { LocationListDataRes } from "@/features/location/lib/data";
import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Eye, Trash2 } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filtered: LocationListDataRes[] | [];
setDetail: Dispatch<SetStateAction<LocationListDataRes | null>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: LocationListDataRes) => void;
}
const UserLocationTable = ({
filtered,
setDetail,
setDetailDialog,
handleDelete,
}: Props) => {
return (
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Jo'natgan foydalanuvchi</TableHead>
<TableHead>Jo'natgan vaqti</TableHead>
<TableHead>Qayerdan jo'natdi</TableHead>
<TableHead className="text-right">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
{item.user.first_name} {item.user.last_name}
</TableCell>
<TableCell>
{formatDate.format(item.created_at, "DD-MM-YYYY")}
</TableCell>
<TableCell>
{item.district
? "Tuman"
: item.place
? "Obyekt"
: item.doctor
? "Shifokor"
: item.pharmacy
? "Dorixona"
: "Turgan joyidan"}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<Button
variant="destructive"
size="icon"
className="cursor-pointer"
onClick={() => handleDelete(item)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default UserLocationTable;

View File

@@ -0,0 +1,37 @@
import type {
ObjectCreate,
ObjectListRes,
ObjectUpdate,
} 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 object_api = {
async list(params: {
limit?: number;
offset?: number;
name?: string;
district?: string;
user?: string;
user_id?: number;
}): Promise<AxiosResponse<ObjectListRes>> {
const res = await httpClient.get(`${API_URLS.OBJECT}list/`, { params });
return res;
},
async create(body: ObjectCreate) {
const res = await httpClient.post(`${API_URLS.OBJECT}create/`, 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: number) {
const res = await httpClient.delete(`${API_URLS.OBJECT}${id}/delete/`);
return res;
},
};

View File

@@ -31,3 +31,52 @@ export const ObjectListData: ObjectListType[] = [
moreLong: ["41.2851", "69.2043"], moreLong: ["41.2851", "69.2043"],
}, },
]; ];
export interface ObjectListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: string | null;
previous: string | null;
results: ObjectListData[];
};
}
export interface ObjectListData {
id: number;
name: string;
district: {
id: number;
name: string;
};
user: {
id: string;
first_name: string;
last_name: string;
};
longitude: number;
latitude: number;
extra_location: {
latitude: number;
longitude: number;
};
created_at: string;
}
export interface ObjectCreate {
district_id: number;
user_id: number;
name: string;
longitude: number;
latitude: number;
extra_location: { longitude: number; latitude: number };
}
export interface ObjectUpdate {
name: string;
longitude: number;
latitude: number;
extra_location: { longitude: number; latitude: number };
}

View File

@@ -1,8 +1,22 @@
import { fakeDistrict } from "@/features/districts/lib/data"; import { discrit_api } from "@/features/districts/lib/api";
import type { ObjectListType } from "@/features/objects/lib/data"; import { object_api } from "@/features/objects/lib/api";
import type {
ObjectCreate,
ObjectListData,
ObjectUpdate,
} from "@/features/objects/lib/data";
import { ObjectForm } from "@/features/objects/lib/form"; import { ObjectForm } from "@/features/objects/lib/form";
import { FakeUserList } from "@/features/users/lib/data"; import { user_api } from "@/features/users/lib/api";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -12,77 +26,225 @@ import {
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import {
import { Loader2 } from "lucide-react"; Circle,
import { useState, type Dispatch, type SetStateAction } from "react"; Map,
Placemark,
Polygon,
YMaps,
ZoomControl,
} from "@pbe/react-yandex-maps";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
interface Props { interface Props {
initialValues: ObjectListType | null; initialValues: ObjectListData | null;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
setData: Dispatch<SetStateAction<ObjectListType[]>>;
} }
export default function AddedObject({ interface CoordsData {
initialValues, lat: number;
setDialogOpen, lon: number;
setData, polygon: [number, number][][];
}: Props) { }
const [load, setLoad] = useState<boolean>(false);
export default function AddedObject({ initialValues, setDialogOpen }: Props) {
const [searchUser, setSearchUser] = useState<string>("");
const [searchDiscrit, setSearchDiscrit] = useState<string>("");
const queryClient = useQueryClient();
const [openUser, setOpenUser] = useState<boolean>(false);
const [openDiscrit, setOpenDiscrit] = useState<boolean>(false);
const form = useForm<z.infer<typeof ObjectForm>>({ const form = useForm<z.infer<typeof ObjectForm>>({
resolver: zodResolver(ObjectForm), resolver: zodResolver(ObjectForm),
defaultValues: { defaultValues: {
lat: initialValues?.lat || "41.2949", lat: initialValues ? String(initialValues?.latitude) : "41.2949",
long: initialValues?.long || "69.2361", long: initialValues ? String(initialValues?.longitude) : "69.2361",
name: initialValues?.name || "", name: initialValues?.name || "",
user: initialValues ? String(initialValues.user.id) : "", user: initialValues ? String(initialValues.user.id) : "",
district: initialValues ? String(initialValues.district.id) : "", district: initialValues ? String(initialValues.district.id) : "",
}, },
}); });
const lat = form.watch("lat"); const { data: user, isLoading: isUserLoading } = useQuery({
const long = form.watch("long"); queryKey: ["user_list", searchUser],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
search?: string;
is_active?: boolean | string;
region_id?: number;
} = {
limit: 8,
search: searchUser,
};
const handleMapClick = (e: { get: (key: string) => number[] }) => { return user_api.list(params);
const coords = e.get("coords"); },
form.setValue("lat", coords[0].toString()); select(data) {
form.setValue("long", coords[1].toString()); return data.data.data;
},
});
const user_id = form.watch("user");
const { data: discrit, isLoading: discritLoading } = useQuery({
queryKey: ["discrit_list", searchDiscrit, user_id],
queryFn: () => {
const params: {
name?: string;
user?: number;
} = {
name: searchDiscrit,
};
if (user_id !== "") {
params.user = Number(user_id);
}
return discrit_api.list(params);
},
select(data) {
return data.data.data;
},
});
const [coords, setCoords] = useState({
latitude: 41.311081,
longitude: 69.240562,
});
const [polygonCoords, setPolygonCoords] = useState<
[number, number][][] | null
>(null);
const [circleCoords, setCircleCoords] = useState<[number, number] | null>(
null,
);
const getCoords = async (name: string): Promise<CoordsData | null> => {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(name)}&format=json&polygon_geojson=1&limit=1`,
);
const data = await res.json();
if (data.length > 0 && data[0].geojson) {
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
let polygon: [number, number][][] = [];
if (data[0].geojson.type === "Polygon") {
polygon = data[0].geojson.coordinates.map((ring: [number, number][]) =>
ring.map((coord: [number, number]) => [coord[1], coord[0]]),
);
} else if (data[0].geojson.type === "MultiPolygon") {
polygon = data[0].geojson.coordinates.map(
(poly: [number, number][][]) =>
poly[0].map((coord: [number, number]) => [coord[1], coord[0]]),
);
}
return { lat, lon, polygon };
}
return null;
}; };
function onSubmit(values: z.infer<typeof ObjectForm>) { useEffect(() => {
setLoad(true); if (initialValues) {
const newObject: ObjectListType = { (async () => {
id: initialValues ? initialValues.id : Date.now(), const result = await getCoords(initialValues.district.name);
name: values.name, if (result) {
lat: values.lat, setCoords({
long: values.long, latitude: initialValues.latitude,
moreLong: [values.long, values.lat], longitude: initialValues.longitude,
user: FakeUserList.find((u) => u.id === Number(values.user))!, });
district: fakeDistrict.find((d) => d.id === Number(values.district))!, setPolygonCoords(result.polygon);
}; form.setValue("lat", String(result.lat));
form.setValue("long", String(result.lon));
setTimeout(() => { setCircleCoords([initialValues.latitude, initialValues.longitude]);
setData((prev) => {
if (initialValues) {
return prev.map((item) =>
item.id === initialValues.id ? newObject : item,
);
} else {
return [...prev, newObject];
} }
}); })();
setLoad(false); }
}, [initialValues, form]);
const handleMapClick = (
e: ymaps.IEvent<MouseEvent, { coords: [number, number] }>,
) => {
const [lat, lon] = e.get("coords");
setCoords({ latitude: lat, longitude: lon });
form.setValue("lat", String(lat));
form.setValue("long", String(lon));
};
const { mutate, isPending } = useMutation({
mutationFn: (body: ObjectCreate) => object_api.create(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["object_list"] });
toast.success(`Obyekt qo'shildi`);
setDialogOpen(false); setDialogOpen(false);
}, 2000); },
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: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { id: number; body: ObjectUpdate }) =>
object_api.update({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["object_list"] });
toast.success(`Obyekt 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 ObjectForm>) {
if (initialValues) {
edit({
body: {
extra_location: {
latitude: Number(values.lat),
longitude: Number(values.long),
},
latitude: Number(values.lat),
longitude: Number(values.long),
name: values.name,
},
id: initialValues.id,
});
} else {
mutate({
district_id: Number(values.district),
extra_location: {
latitude: Number(values.lat),
longitude: Number(values.long),
},
latitude: Number(values.lat),
longitude: Number(values.long),
name: values.name,
user_id: Number(values.user),
});
}
} }
return ( return (
@@ -103,76 +265,236 @@ export default function AddedObject({
/> />
<FormField <FormField
name="user"
control={form.control} control={form.control}
name="district" render={({ field }) => {
render={({ field }) => ( const selectedUser = user?.results.find(
<FormItem> (u) => String(u.id) === field.value,
<Label>Tuman</Label> );
<FormControl> return (
<Select onValueChange={field.onChange} value={field.value}> <FormItem className="flex flex-col">
<SelectTrigger className="w-full !h-12"> <Label className="text-md">Foydalanuvchi</Label>
<SelectValue placeholder="Tumanlar" />
</SelectTrigger> <Popover open={openUser} onOpenChange={setOpenUser}>
<SelectContent> <PopoverTrigger asChild disabled={initialValues !== null}>
{fakeDistrict.map((e) => ( <FormControl>
<SelectItem key={e.id} value={String(e.id)}> <Button
{e.name} type="button"
</SelectItem> variant="outline"
))} role="combobox"
</SelectContent> aria-expanded={openUser}
</Select> className={cn(
</FormControl> "w-full h-12 justify-between",
<FormMessage /> !field.value && "text-muted-foreground",
</FormItem> )}
)} >
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchUser}
onValueChange={setSearchUser}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.results.length > 0 ? (
<CommandGroup>
{user.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.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<FormField <FormField
name="district"
control={form.control} control={form.control}
name="user" render={({ field }) => {
render={({ field }) => ( const selectedDiscrit = discrit?.results.find(
<FormItem> (u) => String(u.id) === field.value,
<Label>Foydalanuvchi</Label> );
<FormControl> return (
<Select onValueChange={field.onChange} value={field.value}> <FormItem className="flex flex-col">
<SelectTrigger className="w-full !h-12"> <Label className="text-md">Tumanlar</Label>
<SelectValue placeholder="Foydalanuvchilar" />
</SelectTrigger> <Popover open={openDiscrit} onOpenChange={setOpenDiscrit}>
<SelectContent> <PopoverTrigger asChild disabled={initialValues !== null}>
{FakeUserList.map((e) => ( <FormControl>
<SelectItem value={String(e.id)}> <Button
{e.firstName} {e.lastName} type="button"
</SelectItem> variant="outline"
))} role="combobox"
</SelectContent> aria-expanded={openDiscrit}
</Select> className={cn(
</FormControl> "w-full h-12 justify-between",
<FormMessage /> !field.value && "text-muted-foreground",
</FormItem> )}
)} >
{selectedDiscrit
? `${selectedDiscrit.name}`
: "Tuman 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchDiscrit}
onValueChange={setSearchDiscrit}
/>
<CommandList>
{discritLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : discrit && discrit.results.length > 0 ? (
<CommandGroup>
{discrit.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={async () => {
field.onChange(String(u.id));
const selectedDistrict =
discrit.results?.find(
(d) => d.id === Number(u.id),
);
setOpenUser(false);
if (!selectedDistrict) return;
const coordsData = await getCoords(
selectedDistrict?.name,
);
if (!coordsData) return;
setCoords({
latitude: coordsData.lat,
longitude: coordsData.lon,
});
setPolygonCoords(coordsData.polygon);
form.setValue("lat", String(coordsData.lat));
form.setValue("long", String(coordsData.lon));
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Tuman topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<div className="h-[300px] w-full border rounded-lg overflow-hidden"> <div className="h-[300px] w-full border rounded-lg overflow-hidden">
<YMaps> <YMaps query={{ lang: "en_RU" }}>
<Map <Map
defaultState={{ defaultState={{
center: [Number(lat), Number(long)], center: [coords.latitude, coords.longitude],
zoom: 16, zoom: 12,
}} }}
width="100%" width="100%"
height="300px" height="100%"
onClick={handleMapClick} onClick={handleMapClick}
> >
<Placemark geometry={[Number(lat), Number(long)]} /> <ZoomControl
<Circle
geometry={[[Number(lat), Number(long)], 100]}
options={{ options={{
fillColor: "rgba(0, 150, 255, 0.2)", position: { right: "10px", bottom: "70px" },
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}} }}
/> />
<Placemark geometry={[coords.latitude, coords.longitude]} />
{polygonCoords && (
<Polygon
geometry={polygonCoords}
options={{
fillColor: "rgba(0, 150, 255, 0.2)",
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}}
/>
)}
{circleCoords && (
<Circle
geometry={[circleCoords, 300]}
options={{
fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}}
/>
)}
</Map> </Map>
</YMaps> </YMaps>
</div> </div>
@@ -180,7 +502,7 @@ export default function AddedObject({
className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer" className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer"
type="submit" type="submit"
> >
{load ? ( {isPending || editPending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,89 @@
import { object_api } from "@/features/objects/lib/api";
import type { ObjectListData } 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 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<ObjectListData | null>>;
discrit: ObjectListData | null;
}
const DeleteObject = ({
opneDelete,
setOpenDelete,
setDiscritDelete,
discrit,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteDiscrict, isPending } = useMutation({
mutationFn: (id: number) => object_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["object_list"] });
toast.success(`Tuman 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>Tumanni o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {discrit?.user.first_name} {discrit?.user.last_name}{" "}
ga tegishli {discrit?.name} obyektni 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 DeleteObject;

View File

@@ -1,4 +1,4 @@
import type { ObjectListType } from "@/features/objects/lib/data"; import type { ObjectListData } from "@/features/objects/lib/data";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent } from "@/shared/ui/card"; import { Card, CardContent } from "@/shared/ui/card";
import { import {
@@ -8,16 +8,89 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import {
import { type Dispatch, type SetStateAction } from "react"; Circle,
Map,
Placemark,
Polygon,
YMaps,
ZoomControl,
} from "@pbe/react-yandex-maps";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
interface Props { interface Props {
object: ObjectListType | null; object: ObjectListData | null;
setDetail: Dispatch<SetStateAction<boolean>>; setDetail: Dispatch<SetStateAction<boolean>>;
detail: boolean; detail: boolean;
} }
interface CoordsData {
lat: number;
lon: number;
polygon: [number, number][][];
}
const ObjectDetailDialog = ({ object, detail, setDetail }: Props) => { const ObjectDetailDialog = ({ object, detail, setDetail }: Props) => {
const [coords, setCoords] = useState<[number, number]>([
41.311081, 69.240562,
]);
const [polygonCoords, setPolygonCoords] = useState<[number, number][][]>([]);
const [circleCoords, setCircleCoords] = useState<[number, number] | null>(
null,
);
const getCoords = async (name: string): Promise<CoordsData | null> => {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(
name,
)}&format=json&polygon_geojson=1&limit=1`,
);
const data = await res.json();
if (!data.length || !data[0].geojson) return null;
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
let polygon: [number, number][][] = [];
if (data[0].geojson.type === "Polygon") {
polygon = data[0].geojson.coordinates.map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
if (data[0].geojson.type === "MultiPolygon") {
polygon = data[0].geojson.coordinates[0].map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
return { lat, lon, polygon };
} catch {
return null;
}
};
useEffect(() => {
if (!object) return;
const load = async () => {
const district = await getCoords(object.district.name);
if (district) {
setPolygonCoords(district.polygon);
}
setCoords([object.latitude, object.longitude]);
setCircleCoords([object.latitude, object.longitude]);
};
load();
}, [object]);
if (!object) return null; if (!object) return null;
return ( return (
@@ -35,30 +108,52 @@ const ObjectDetailDialog = ({ object, detail, setDetail }: Props) => {
</div> </div>
<div> <div>
<span className="font-semibold">Foydalanuvchi:</span>{" "} <span className="font-semibold">Foydalanuvchi:</span>{" "}
{object.user.firstName} {object.user.lastName} {object.user.first_name} {object.user.last_name}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="h-[300px] w-full border rounded-lg overflow-hidden"> <div className="h-[300px] w-full border rounded-lg overflow-hidden">
<YMaps> <YMaps query={{ lang: "en_RU" }}>
<Map <Map
defaultState={{ state={{
center: [Number(object.lat), Number(object.long)], center: coords,
zoom: 16, zoom: 12,
}} }}
width="100%" width="100%"
height="300px" height="100%"
> >
<Placemark geometry={[Number(object.lat), Number(object.long)]} /> <ZoomControl
<Circle
geometry={[[Number(object.lat), Number(object.long)], 100]}
options={{ options={{
fillColor: "rgba(0, 150, 255, 0.2)", position: { right: "10px", bottom: "70px" },
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
}} }}
/> />
{/* Ish joyining markazi */}
<Placemark geometry={coords} />
{/* Tuman polygon */}
{polygonCoords.length > 0 && (
<Polygon
geometry={polygonCoords}
options={{
fillColor: "rgba(0, 150, 255, 0.2)",
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
}}
/>
)}
{circleCoords && (
<Circle
geometry={[circleCoords, 300]}
options={{
fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)",
strokeWidth: 2,
}}
/>
)}
</Map> </Map>
</YMaps> </YMaps>
</div> </div>

View File

@@ -0,0 +1,88 @@
import type { ObjectListData } from "@/features/objects/lib/data";
import AddedObject from "@/features/objects/ui/AddedObject";
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 {
searchName: string;
setSearchName: Dispatch<SetStateAction<string>>;
searchDistrict: string;
setSearchDistrict: Dispatch<SetStateAction<string>>;
searchUser: string;
setSearchUser: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setEditingPlan: Dispatch<SetStateAction<ObjectListData | null>>;
editingPlan: ObjectListData | null;
}
const ObjectFilter = ({
searchName,
setSearchName,
searchDistrict,
setSearchDistrict,
searchUser,
setSearchUser,
dialogOpen,
setDialogOpen,
editingPlan,
setEditingPlan,
}: Props) => {
return (
<div className="flex gap-2 flex-wrap w-full md:w-auto">
<Input
placeholder="Obyekt nomi"
value={searchName}
onChange={(e) => setSearchName(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Tuman"
value={searchDistrict}
onChange={(e) => setSearchDistrict(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Foydalanuvchi"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
className="w-full md:w-48"
/>
<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 obyekt qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedObject
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
/>
</DialogContent>
</Dialog>
</div>
);
};
export default ObjectFilter;

View File

@@ -1,126 +1,78 @@
import { import { object_api } from "@/features/objects/lib/api";
ObjectListData, import { ObjectListData } from "@/features/objects/lib/data";
type ObjectListType, import DeleteObject from "@/features/objects/ui/DeleteObject";
} from "@/features/objects/lib/data";
import AddedObject from "@/features/objects/ui/AddedObject";
import ObjectDetailDialog from "@/features/objects/ui/ObjectDetail"; import ObjectDetailDialog from "@/features/objects/ui/ObjectDetail";
import { Badge } from "@/shared/ui/badge"; import ObjectFilter from "@/features/objects/ui/ObjectFilter";
import { Button } from "@/shared/ui/button"; import ObjectTable from "@/features/objects/ui/ObjectTable";
import { import Pagination from "@/shared/ui/pagination";
Dialog, import { useQuery } from "@tanstack/react-query";
DialogContent, import { useState } from "react";
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import {
ChevronLeft,
ChevronRight,
Eye,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
export default function ObjectList() { export default function ObjectList() {
const [data, setData] = useState<ObjectListType[]>(ObjectListData); const [detail, setDetail] = useState<ObjectListData | null>(null);
const [detail, setDetail] = useState<ObjectListType | null>(null);
const [detailDialog, setDetailDialog] = useState<boolean>(false); const [detailDialog, setDetailDialog] = useState<boolean>(false);
const [editingPlan, setEditingPlan] = useState<ObjectListType | null>(null); const [editingPlan, setEditingPlan] = useState<ObjectListData | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const limit = 20;
// Filter state
const [searchName, setSearchName] = useState(""); const [searchName, setSearchName] = useState("");
const [searchDistrict, setSearchDistrict] = useState(""); const [searchDistrict, setSearchDistrict] = useState("");
const [searchUser, setSearchUser] = useState(""); const [searchUser, setSearchUser] = useState("");
const handleDelete = (id: number) => { const [disricDelete, setDiscritDelete] = useState<ObjectListData | null>(
setData((prev) => prev.filter((e) => e.id !== id)); null,
);
const [opneDelete, setOpenDelete] = useState<boolean>(false);
const handleDelete = (user: ObjectListData) => {
setDiscritDelete(user);
setOpenDelete(true);
}; };
// Filtered data const {
const filteredData = useMemo(() => { data: object,
return data.filter((item) => { isLoading,
const nameMatch = item.name isError,
.toLowerCase() } = useQuery({
.includes(searchName.toLowerCase()); queryKey: [
const districtMatch = item.district.name "object_list",
.toLowerCase() searchDistrict,
.includes(searchDistrict.toLowerCase()); currentPage,
const userMatch = `${item.user.firstName} ${item.user.lastName}` searchName,
.toLowerCase() searchUser,
.includes(searchUser.toLowerCase()); ],
queryFn: () =>
object_api.list({
district: searchDistrict,
limit,
offset: (currentPage - 1) * limit,
name: searchName,
user: searchUser,
}),
select(data) {
return data.data.data;
},
});
return nameMatch && districtMatch && userMatch; const totalPages = object ? Math.ceil(object.count / 20) : 1;
});
}, [data, searchName, searchDistrict, searchUser]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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"> <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">Obyektlarni boshqarish</h1> <h1 className="text-2xl font-bold">Obyektlarni boshqarish</h1>
<ObjectFilter
<div className="flex gap-2 flex-wrap w-full md:w-auto"> dialogOpen={dialogOpen}
<Input editingPlan={editingPlan}
placeholder="Obyekt nomi" searchDistrict={searchDistrict}
value={searchName} searchName={searchName}
onChange={(e) => setSearchName(e.target.value)} searchUser={searchUser}
className="w-full md:w-48" setDialogOpen={setDialogOpen}
/> setEditingPlan={setEditingPlan}
<Input setSearchDistrict={setSearchDistrict}
placeholder="Tuman" setSearchName={setSearchName}
value={searchDistrict} setSearchUser={setSearchUser}
onChange={(e) => setSearchDistrict(e.target.value)} />
className="w-full md:w-48"
/>
<Input
placeholder="Foydalanuvchi"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
className="w-full md:w-48"
/>
<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 obyekt qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedObject
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
setData={setData}
/>
</DialogContent>
</Dialog>
</div>
<ObjectDetailDialog <ObjectDetailDialog
detail={detailDialog} detail={detailDialog}
setDetail={setDetailDialog} setDetail={setDetailDialog}
@@ -128,104 +80,29 @@ export default function ObjectList() {
/> />
</div> </div>
<div className="flex-1 overflow-auto"> <ObjectTable
<Table> filteredData={object ? object.results : []}
<TableHeader> handleDelete={handleDelete}
<TableRow> isError={isError}
<TableHead>#</TableHead> isLoading={isLoading}
<TableHead>Obyekt nomi</TableHead> setDetail={setDetail}
<TableHead>Tuman</TableHead> setDetailDialog={setDetailDialog}
<TableHead>Foydalanuvchi</TableHead> setDialogOpen={setDialogOpen}
<TableHead className="text-right">Amallar</TableHead> setEditingPlan={setEditingPlan}
</TableRow> />
</TableHeader>
<TableBody>
{filteredData.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>
<Badge variant="outline">{item.district.name}</Badge>
</TableCell>
<TableCell>
{item.user.firstName} {item.user.lastName}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<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.id)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
disabled={currentPage === 1} />
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} <DeleteObject
> discrit={disricDelete}
<ChevronLeft /> opneDelete={opneDelete}
</Button> setDiscritDelete={setDiscritDelete}
{Array.from({ length: totalPages }, (_, i) => ( setOpenDelete={setOpenDelete}
<Button />
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,124 @@
import type { ObjectListData } from "@/features/objects/lib/data";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Eye, Loader2, Pencil, Trash2 } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filteredData: ObjectListData[] | [];
setDetail: Dispatch<SetStateAction<ObjectListData | null>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>;
setEditingPlan: Dispatch<SetStateAction<ObjectListData | null>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (object: ObjectListData) => void;
isLoading: boolean;
isError: boolean;
}
const ObjectTable = ({
filteredData,
setDetail,
setDetailDialog,
setEditingPlan,
handleDelete,
setDialogOpen,
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>Obyekt nomi</TableHead>
<TableHead>Tuman</TableHead>
<TableHead>Foydalanuvchi</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">{item.name}</TableCell>
<TableCell>
<Badge variant="outline">{item.district.name}</Badge>
</TableCell>
<TableCell>
{item.user.first_name} {item.user.last_name}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<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={5} className="text-center py-4 text-lg">
Obyekt topilmadi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
);
};
export default ObjectTable;

View File

@@ -0,0 +1,33 @@
import type { FactoryCreate, FactoryListRes } from "@/features/pharm/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const factory_api = {
async list(params: {
limit?: number;
offset?: number;
name?: string;
}): Promise<AxiosResponse<FactoryListRes>> {
const res = await httpClient.get(`${API_URLS.FACTORY}list/`, { params });
return res;
},
async create(body: FactoryCreate) {
const res = await httpClient.post(`${API_URLS.FACTORY}create/`, body);
return res;
},
async update({ id, body }: { id: number; body: FactoryCreate }) {
const res = await httpClient.patch(
`${API_URLS.FACTORY}${id}/update/`,
body,
);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.FACTORY}${id}/delete/`);
return res;
},
};

View File

@@ -9,3 +9,25 @@ export const pharmData: PharmType[] = [
name: "Meridyn", name: "Meridyn",
}, },
]; ];
export interface FactoryListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: FactoryListDataRes[];
};
}
export interface FactoryListDataRes {
id: number;
name: string;
created_at: string;
}
export interface FactoryCreate {
name: string;
}

View File

@@ -1,4 +1,5 @@
import type { PharmType } from "@/features/pharm/lib/data"; import { factory_api } from "@/features/pharm/lib/api";
import type { FactoryCreate, PharmType } from "@/features/pharm/lib/data";
import { pharmForm } from "@/features/pharm/lib/form"; import { pharmForm } from "@/features/pharm/lib/form";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
@@ -11,19 +12,21 @@ import {
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
interface Props { interface Props {
initialValues: PharmType | null; initialValues: PharmType | null;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
setPlans: Dispatch<SetStateAction<PharmType[]>>;
} }
const AddedPharm = ({ initialValues, setDialogOpen, setPlans }: Props) => { const AddedPharm = ({ initialValues, setDialogOpen }: Props) => {
const [load, setLoad] = useState(false); const queryClient = useQueryClient();
const form = useForm<z.infer<typeof pharmForm>>({ const form = useForm<z.infer<typeof pharmForm>>({
resolver: zodResolver(pharmForm), resolver: zodResolver(pharmForm),
defaultValues: { defaultValues: {
@@ -31,35 +34,51 @@ const AddedPharm = ({ initialValues, setDialogOpen, setPlans }: Props) => {
}, },
}); });
const { mutate, isPending } = useMutation({
mutationFn: (body: FactoryCreate) => factory_api.create(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["factory_list"] });
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: update, isPending: updatePending } = useMutation({
mutationFn: ({ id, body }: { id: number; body: FactoryCreate }) =>
factory_api.update({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["factory_list"] });
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(data: z.infer<typeof pharmForm>) { function onSubmit(data: z.infer<typeof pharmForm>) {
setLoad(true); if (!initialValues) {
if (initialValues) { mutate({
setTimeout(() => { name: data.name,
setPlans((prev) => });
prev.map((plan) =>
plan.id === initialValues.id
? {
...plan,
...data,
}
: plan,
),
);
setLoad(false);
setDialogOpen(false);
}, 2000);
} else { } else {
setTimeout(() => { update({
setPlans((prev) => [ body: {
...prev, name: data.name,
{ },
id: prev.length ? prev[prev.length - 1].id + 1 : 1, id: initialValues.id,
name: data.name, });
},
]);
setLoad(false);
setDialogOpen(false);
}, 2000);
} }
} }
@@ -87,9 +106,9 @@ const AddedPharm = ({ initialValues, setDialogOpen, setPlans }: Props) => {
<Button <Button
className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer" className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer"
disabled={load} disabled={isPending || updatePending}
> >
{load ? ( {isPending || updatePending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,89 @@
import { factory_api } from "@/features/pharm/lib/api";
import type { FactoryListDataRes } from "@/features/pharm/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>>;
setPillDelete: Dispatch<SetStateAction<FactoryListDataRes | null>>;
pillDelete: FactoryListDataRes | null;
}
const DeletePharm = ({
opneDelete,
setOpenDelete,
pillDelete,
setPillDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => factory_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["factory_list"] });
toast.success(`Farmasevtika o'chirildi`);
setOpenDelete(false);
setPillDelete(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>Farmasevtikani o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {pillDelete?.name} nomli farmasevtikani
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={() => pillDelete && deleteUser(pillDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeletePharm;

View File

@@ -1,5 +1,10 @@
import { pharmData, type PharmType } from "@/features/pharm/lib/data"; import { factory_api } from "@/features/pharm/lib/api";
import {
type FactoryListDataRes,
type PharmType,
} from "@/features/pharm/lib/data";
import AddedPharm from "@/features/pharm/ui/AddedPharm"; import AddedPharm from "@/features/pharm/ui/AddedPharm";
import DeletePharm from "@/features/pharm/ui/DeletePharm";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
Dialog, Dialog,
@@ -9,6 +14,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import Pagination from "@/shared/ui/pagination";
import { import {
Table, Table,
TableBody, TableBody,
@@ -17,34 +23,39 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/shared/ui/table"; } from "@/shared/ui/table";
import clsx from "clsx"; import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Edit, Plus, Trash } from "lucide-react"; import { Edit, Loader2, Plus, Trash } from "lucide-react";
import { useMemo, useState } from "react"; import { useState } from "react";
const PharmList = () => { const PharmList = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const [nameFilter, setNameFilter] = useState<string>("");
const [plans, setPlans] = useState<PharmType[]>(pharmData); const limit = 20;
const { data, isLoading, isError } = useQuery({
queryKey: ["factory_list", currentPage, nameFilter],
queryFn: () =>
factory_api.list({
limit,
offset: (currentPage - 1) * limit,
name: nameFilter,
}),
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data?.count / limit) : 1;
const [editingPlan, setEditingPlan] = useState<PharmType | null>(null); const [editingPlan, setEditingPlan] = useState<PharmType | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [nameFilter, setNameFilter] = useState<string>(""); const [openDelete, setOpenDelete] = useState<boolean>(false);
const [pillDelete, setPillDelete] = useState<FactoryListDataRes | null>(null);
const handleDelete = (id: number) => { const handleDelete = (id: FactoryListDataRes) => {
setPlans(plans.filter((p) => p.id !== id)); setOpenDelete(true);
setPillDelete(id);
}; };
const filteredPlans = useMemo(() => {
return plans.filter((item) => {
const statusMatch = item.name
.toLowerCase()
.includes(nameFilter.toLowerCase());
return statusMatch;
});
}, [plans, nameFilter]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
@@ -80,7 +91,6 @@ const PharmList = () => {
<AddedPharm <AddedPharm
initialValues={editingPlan} initialValues={editingPlan}
setDialogOpen={setDialogOpen} setDialogOpen={setDialogOpen}
setPlans={setPlans}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -88,84 +98,83 @@ const PharmList = () => {
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<Table> {isLoading && (
<TableHeader> <div className="h-full flex items-center justify-center bg-white/70 z-10">
<TableRow className="text-center"> <span className="text-lg font-medium">
<TableHead className="text-start">ID</TableHead> <Loader2 className="animate-spin" />
<TableHead className="text-start">Nomi</TableHead> </span>
<TableHead className="text-end">Amallar</TableHead> </div>
</TableRow> )}
</TableHeader>
<TableBody> {isError && (
{filteredPlans.map((plan) => ( <div className="h-full flex items-center justify-center z-10">
<TableRow key={plan.id} className="text-start"> <span className="text-lg font-medium text-red-600">
<TableCell>{plan.id}</TableCell> Ma'lumotlarni olishda xatolik yuz berdi.
<TableCell>{plan.name}</TableCell> </span>
<TableCell className="flex gap-2 justify-end"> </div>
<Button )}
variant="outline" {!isLoading && !isError && (
size="sm" <Table>
className="bg-blue-500 text-white hover:bg-blue-500 hover:text-white cursor-pointer" <TableHeader>
onClick={() => { <TableRow className="text-center">
setEditingPlan(plan); <TableHead className="text-start">ID</TableHead>
setDialogOpen(true); <TableHead className="text-start">Nomi</TableHead>
}} <TableHead className="text-end">Amallar</TableHead>
>
<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> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {data && data.results.length > 0 ? (
data?.results.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>{plan.name}</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)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="text-center py-4 text-lg">
Farmasevtika topilmadi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div> </div>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
disabled={currentPage === 1} />
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} <DeletePharm
> opneDelete={openDelete}
<ChevronLeft /> pillDelete={pillDelete}
</Button> setOpenDelete={setOpenDelete}
{Array.from({ length: totalPages }, (_, i) => ( setPillDelete={setPillDelete}
<Button />
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,41 @@
import type {
CreatePharmaciesReq,
PharmaciesListRes,
UpdatePharmaciesReq,
} from "@/features/pharmacies/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const pharmacies_api = {
async list(params: {
limit?: number;
offset?: number;
name?: string;
place?: string;
district?: string;
user?: string;
user_id?: number;
}): Promise<AxiosResponse<PharmaciesListRes>> {
const res = await httpClient.get(`${API_URLS.PHARMACIES}list/`, { params });
return res;
},
async create(body: CreatePharmaciesReq) {
const res = await httpClient.post(`${API_URLS.PHARMACIES}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: UpdatePharmaciesReq }) {
const res = await httpClient.patch(
`${API_URLS.PHARMACIES}${id}/update/`,
body,
);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.PHARMACIES}${id}/delete/`);
return res;
},
};

View File

@@ -80,3 +80,66 @@ export const PharmciesData: PharmciesType[] = [
lat: "41.3", lat: "41.3",
}, },
]; ];
export interface PharmaciesListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: PharmaciesListData[];
};
}
export interface PharmaciesListData {
id: number;
name: string;
inn: string;
owner_phone: string;
responsible_phone: string;
district: {
id: number;
name: string;
};
place: {
id: number;
name: string;
};
user: {
id: number;
first_name: string;
last_name: string;
};
longitude: number;
latitude: number;
extra_location: {
latitude: number;
longitude: number;
};
created_at: string;
}
export interface CreatePharmaciesReq {
name: string;
inn: string;
owner_phone: string;
responsible_phone: string;
district_id: number;
place_id: number;
user_id: number;
longitude: number;
latitude: number;
extra_location: { longitude: number; latitude: number };
}
export interface UpdatePharmaciesReq {
name: string;
inn: string;
owner_phone: string;
responsible_phone: string;
longitude: number;
latitude: number;
extra_location: { longitude: number; latitude: number };
}

View File

@@ -1,11 +1,25 @@
import { fakeDistrict } from "@/features/districts/lib/data"; import { discrit_api } from "@/features/districts/lib/api";
import { ObjectListData } from "@/features/objects/lib/data"; import { object_api } from "@/features/objects/lib/api";
import type { PharmciesType } from "@/features/pharmacies/lib/data"; import { pharmacies_api } from "@/features/pharmacies/lib/api";
import type {
CreatePharmaciesReq,
PharmaciesListData,
UpdatePharmaciesReq,
} from "@/features/pharmacies/lib/data";
import { PharmForm } from "@/features/pharmacies/lib/form"; import { PharmForm } from "@/features/pharmacies/lib/form";
import { FakeUserList } from "@/features/users/lib/data"; import { user_api } from "@/features/users/lib/api";
import formatPhone from "@/shared/lib/formatPhone"; import formatPhone from "@/shared/lib/formatPhone";
import onlyNumber from "@/shared/lib/onlyNumber"; import onlyNumber from "@/shared/lib/onlyNumber";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -15,80 +29,259 @@ import {
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import {
import { Loader2 } from "lucide-react"; Circle,
import { useState, type Dispatch, type SetStateAction } from "react"; Map,
Placemark,
Polygon,
YMaps,
ZoomControl,
} from "@pbe/react-yandex-maps";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
interface Props { interface Props {
initialValues: PharmciesType | null; initialValues: PharmaciesListData | null;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
setData: Dispatch<SetStateAction<PharmciesType[]>>;
} }
const AddedPharmacies = ({ initialValues, setData, setDialogOpen }: Props) => { interface CoordsData {
const [load, setLoad] = useState<boolean>(false); lat: number;
lon: number;
polygon: [number, number][][];
}
const AddedPharmacies = ({ initialValues, setDialogOpen }: Props) => {
const queryClient = useQueryClient();
const [searchUser, setSearchUser] = useState<string>("");
const [searchObject, setSearchObject] = useState<string>("");
const [selectDiscrit, setSelectedDiscrit] = useState<string>("");
const [searchDiscrit, setSearchDiscrit] = useState<string>("");
const [openUser, setOpenUser] = useState<boolean>(false);
const [openDiscrit, setOpenDiscrit] = useState<boolean>(false);
const [openObject, setOpenObject] = useState<boolean>(false);
const form = useForm<z.infer<typeof PharmForm>>({ const form = useForm<z.infer<typeof PharmForm>>({
resolver: zodResolver(PharmForm), resolver: zodResolver(PharmForm),
defaultValues: { defaultValues: {
additional_phone: initialValues?.additional_phone || "+998", additional_phone: initialValues?.responsible_phone || "+998",
district: initialValues?.district.id.toString() || "", district: initialValues?.district.id.toString() || "",
inn: initialValues?.inn || "", inn: initialValues?.inn || "",
lat: initialValues?.lat || "41.2949", lat: String(initialValues?.latitude) || "41.2949",
long: initialValues?.long || "69.2361", long: String(initialValues?.longitude) || "69.2361",
name: initialValues?.name || "", name: initialValues?.name || "",
object: initialValues?.object.id.toString() || "", object: initialValues?.place.id.toString() || "",
phone_number: initialValues?.phone_number || "+998", phone_number: initialValues?.owner_phone || "+998",
user: initialValues?.user.id.toString() || "", user: initialValues?.user.id.toString() || "",
}, },
}); });
const lat = form.watch("lat"); const { data: user, isLoading: isUserLoading } = useQuery({
const long = form.watch("long"); queryKey: ["user_list", searchUser],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
search?: string;
is_active?: boolean | string;
region_id?: number;
} = {
limit: 8,
search: searchUser,
};
const handleMapClick = (e: { get: (key: string) => number[] }) => { return user_api.list(params);
const coords = e.get("coords"); },
form.setValue("lat", coords[0].toString()); select(data) {
form.setValue("long", coords[1].toString()); return data.data.data;
},
});
const user_id = form.watch("user");
const { data: object, isLoading: isObjectLoading } = useQuery({
queryKey: ["object_list", searchUser, selectDiscrit, user_id],
queryFn: () => {
const params: {
name?: string;
district?: string;
user_id?: number;
} = {
name: searchUser,
district: selectDiscrit,
user_id: Number(user_id),
};
return object_api.list(params);
},
select(data) {
return data.data.data;
},
});
const { data: discrit, isLoading: discritLoading } = useQuery({
queryKey: ["discrit_list", searchDiscrit, user_id],
queryFn: () => {
const params: {
name?: string;
user?: number;
} = {
name: searchDiscrit,
};
if (user_id !== "") {
params.user = Number(user_id);
}
return discrit_api.list(params);
},
select(data) {
return data.data.data;
},
});
const [coords, setCoords] = useState({
latitude: 41.311081,
longitude: 69.240562,
});
const [polygonCoords, setPolygonCoords] = useState<
[number, number][][] | null
>(null);
const [circleCoords, setCircleCoords] = useState<[number, number] | null>(
null,
);
const getCoords = async (name: string): Promise<CoordsData | null> => {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(name)}&format=json&polygon_geojson=1&limit=1`,
);
const data = await res.json();
if (data.length > 0 && data[0].geojson) {
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
let polygon: [number, number][][] = [];
if (data[0].geojson.type === "Polygon") {
polygon = data[0].geojson.coordinates.map((ring: [number, number][]) =>
ring.map((coord: [number, number]) => [coord[1], coord[0]]),
);
} else if (data[0].geojson.type === "MultiPolygon") {
polygon = data[0].geojson.coordinates.map(
(poly: [number, number][][]) =>
poly[0].map((coord: [number, number]) => [coord[1], coord[0]]),
);
}
return { lat, lon, polygon };
}
return null;
}; };
function onSubmit(values: z.infer<typeof PharmForm>) { useEffect(() => {
setLoad(true); if (initialValues) {
const newObject: PharmciesType = { (async () => {
id: initialValues ? initialValues.id : Date.now(), const result = await getCoords(initialValues.district.name);
name: values.name, if (result) {
lat: values.lat, setCoords({
long: values.long, latitude: Number(initialValues.latitude),
user: FakeUserList.find((u) => u.id === Number(values.user))!, longitude: Number(initialValues.longitude),
district: fakeDistrict.find((d) => d.id === Number(values.district))!, });
additional_phone: onlyNumber(values.additional_phone), setPolygonCoords(result.polygon);
inn: values.inn, form.setValue("lat", String(result.lat));
object: ObjectListData.find((o) => o.id === Number(values.object))!, form.setValue("long", String(result.lon));
phone_number: onlyNumber(values.phone_number), setCircleCoords([
}; Number(initialValues.latitude),
Number(initialValues.longitude),
setTimeout(() => { ]);
setData((prev) => {
if (initialValues) {
return prev.map((item) =>
item.id === initialValues.id ? newObject : item,
);
} else {
return [...prev, newObject];
} }
}); })();
setLoad(false); }
}, [initialValues, form]);
const handleMapClick = (
e: ymaps.IEvent<MouseEvent, { coords: [number, number] }>,
) => {
const [lat, lon] = e.get("coords");
setCoords({ latitude: lat, longitude: lon });
form.setValue("lat", String(lat));
form.setValue("long", String(lon));
};
const { mutate, isPending } = useMutation({
mutationFn: (body: CreatePharmaciesReq) => pharmacies_api.create(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["pharmacies_list"] });
setDialogOpen(false); setDialogOpen(false);
}, 2000); },
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: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { id: number; body: UpdatePharmaciesReq }) =>
pharmacies_api.update({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["pharmacies_list"] });
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 PharmForm>) {
if (initialValues) {
edit({
id: initialValues.id,
body: {
extra_location: {
latitude: Number(values.lat),
longitude: Number(values.long),
},
latitude: Number(values.lat),
longitude: Number(values.long),
inn: values.inn,
name: values.name,
owner_phone: onlyNumber(values.phone_number),
responsible_phone: onlyNumber(values.additional_phone),
},
});
} else {
mutate({
district_id: Number(values.district),
extra_location: {
latitude: Number(values.lat),
longitude: Number(values.long),
},
latitude: Number(values.lat),
longitude: Number(values.long),
inn: values.inn,
name: values.name,
owner_phone: onlyNumber(values.phone_number),
place_id: Number(values.object),
responsible_phone: onlyNumber(values.additional_phone),
user_id: Number(values.user),
});
}
} }
return ( return (
@@ -159,101 +352,349 @@ const AddedPharmacies = ({ initialValues, setData, setDialogOpen }: Props) => {
/> />
<FormField <FormField
control={form.control}
name="district"
render={({ field }) => (
<FormItem>
<Label>Tuman</Label>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="Tumanlar" />
</SelectTrigger>
<SelectContent>
{fakeDistrict.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="object"
render={({ field }) => (
<FormItem>
<Label>Obyekt</Label>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="Obyektlar" />
</SelectTrigger>
<SelectContent>
{ObjectListData.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user" name="user"
render={({ field }) => ( control={form.control}
<FormItem> render={({ field }) => {
<Label>Foydalanuvchi</Label> const selectedUser = user?.results.find(
<FormControl> (u) => String(u.id) === field.value,
<Select onValueChange={field.onChange} value={field.value}> );
<SelectTrigger className="w-full !h-12"> return (
<SelectValue placeholder="Foydalanuvchilar" /> <FormItem className="flex flex-col">
</SelectTrigger> <Label className="text-md">Foydalanuvchi</Label>
<SelectContent>
{FakeUserList.map((e) => ( <Popover open={openUser} onOpenChange={setOpenUser}>
<SelectItem value={String(e.id)}> <PopoverTrigger asChild disabled={initialValues !== null}>
{e.firstName} {e.lastName} <FormControl>
</SelectItem> <Button
))} type="button"
</SelectContent> variant="outline"
</Select> role="combobox"
</FormControl> aria-expanded={openUser}
<FormMessage /> className={cn(
</FormItem> "w-full h-12 justify-between",
)} !field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchUser}
onValueChange={setSearchUser}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.results.length > 0 ? (
<CommandGroup>
{user.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.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name="district"
control={form.control}
render={({ field }) => {
const selectedDiscrit = discrit?.results.find(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Tumanlar</Label>
<Popover open={openDiscrit} onOpenChange={setOpenDiscrit}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={openDiscrit}
className={cn(
"w-full h-12 justify-between",
!field.value && "text-muted-foreground",
)}
>
{selectedDiscrit
? `${selectedDiscrit.name}`
: "Tuman 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchDiscrit}
onValueChange={setSearchDiscrit}
/>
<CommandList>
{discritLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : discrit && discrit.results.length > 0 ? (
<CommandGroup>
{discrit.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={async () => {
field.onChange(String(u.id));
const selectedDistrict =
discrit.results?.find(
(d) => d.id === Number(u.id),
);
setOpenUser(false);
if (!selectedDistrict) return;
setSelectedDiscrit(selectedDistrict.name);
const coordsData = await getCoords(
selectedDistrict?.name,
);
if (!coordsData) return;
setCoords({
latitude: coordsData.lat,
longitude: coordsData.lon,
});
setPolygonCoords(coordsData.polygon);
form.setValue("lat", String(coordsData.lat));
form.setValue("long", String(coordsData.lon));
setOpenDiscrit(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Tuman topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name="object"
control={form.control}
render={({ field }) => {
const selectedObject = object?.results.find(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Obyektlar</Label>
<Popover open={openObject} onOpenChange={setOpenObject}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={openDiscrit}
className={cn(
"w-full h-12 justify-between",
!field.value && "text-muted-foreground",
)}
>
{selectedObject
? `${selectedObject.name}`
: "Obyekt 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchObject}
onValueChange={setSearchObject}
/>
<CommandList>
{isObjectLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : object && object.results.length > 0 ? (
<CommandGroup>
{object.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={async () => {
field.onChange(String(u.id));
const selectedObject = object.results?.find(
(d) => d.id === Number(u.id),
);
setOpenUser(false);
if (!selectedObject) return;
setCircleCoords([
selectedObject.latitude,
selectedObject.longitude,
]);
setCoords({
latitude: selectedObject.latitude,
longitude: selectedObject.longitude,
});
form.setValue(
"lat",
String(selectedObject.latitude),
);
form.setValue(
"long",
String(selectedObject.longitude),
);
setOpenObject(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Obyekt topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<div className="h-[300px] w-full border rounded-lg overflow-hidden"> <div className="h-[300px] w-full border rounded-lg overflow-hidden">
<YMaps> <YMaps query={{ lang: "en_RU" }}>
<Map <Map
defaultState={{ defaultState={{
center: [Number(lat), Number(long)], center: [coords.latitude, coords.longitude],
zoom: 16, zoom: 12,
}} }}
width="100%" width="100%"
height="300px" height="100%"
onClick={handleMapClick} onClick={handleMapClick}
> >
<Placemark geometry={[Number(lat), Number(long)]} /> <ZoomControl
<Circle
geometry={[[Number(lat), Number(long)], 100]}
options={{ options={{
fillColor: "rgba(0, 150, 255, 0.2)", position: { right: "10px", bottom: "70px" },
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}} }}
/> />
<Placemark geometry={[coords.latitude, coords.longitude]} />
{polygonCoords && (
<Polygon
geometry={polygonCoords}
options={{
fillColor: "rgba(0, 150, 255, 0.2)",
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}}
/>
)}
{circleCoords && (
<Circle
geometry={[circleCoords, 300]}
options={{
fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)",
strokeWidth: 2,
interactivityModel: "default#transparent",
}}
/>
)}
</Map> </Map>
</YMaps> </YMaps>
</div> </div>
@@ -261,7 +702,7 @@ const AddedPharmacies = ({ initialValues, setData, setDialogOpen }: Props) => {
className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer" className="w-full h-12 bg-blue-500 hover:bg-blue-500 cursor-pointer"
type="submit" type="submit"
> >
{load ? ( {isPending || editPending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,89 @@
import { pharmacies_api } from "@/features/pharmacies/lib/api";
import type { PharmaciesListData } from "@/features/pharmacies/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<PharmaciesListData | null>>;
discrit: PharmaciesListData | null;
}
const DeletePharmacies = ({
opneDelete,
setOpenDelete,
setDiscritDelete,
discrit,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteDiscrict, isPending } = useMutation({
mutationFn: (id: number) => pharmacies_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["pharmacies_list"] });
toast.success(`Dorixona 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>Tumanni o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {discrit?.user.first_name} {discrit?.user.last_name}{" "}
ga tegishli {discrit?.name} dorixonani 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 DeletePharmacies;

View File

@@ -1,4 +1,4 @@
import type { PharmciesType } from "@/features/pharmacies/lib/data"; import type { PharmaciesListData } from "@/features/pharmacies/lib/data";
import formatPhone from "@/shared/lib/formatPhone"; import formatPhone from "@/shared/lib/formatPhone";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
@@ -8,30 +8,93 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import {
Circle,
Map,
Placemark,
Polygon,
YMaps,
ZoomControl,
} from "@pbe/react-yandex-maps";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface Props { interface Props {
detail: boolean; detail: boolean;
setDetail: (value: boolean) => void; setDetail: (value: boolean) => void;
object: PharmciesType | null; object: PharmaciesListData | null;
}
interface CoordsData {
lat: number;
lon: number;
polygon: [number, number][][];
} }
const PharmDetailDialog = ({ detail, setDetail, object }: Props) => { const PharmDetailDialog = ({ detail, setDetail, object }: Props) => {
const [open, setOpen] = useState(detail); const [coords, setCoords] = useState<[number, number]>([
41.311081, 69.240562,
]);
const [polygonCoords, setPolygonCoords] = useState<[number, number][][]>([]);
const [circleCoords, setCircleCoords] = useState<[number, number] | null>(
null,
);
const getCoords = async (name: string): Promise<CoordsData | null> => {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(
name,
)}&format=json&polygon_geojson=1&limit=1`,
);
const data = await res.json();
if (!data.length || !data[0].geojson) return null;
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
let polygon: [number, number][][] = [];
if (data[0].geojson.type === "Polygon") {
polygon = data[0].geojson.coordinates.map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
if (data[0].geojson.type === "MultiPolygon") {
polygon = data[0].geojson.coordinates[0].map((ring: []) =>
ring.map((c) => [c[1], c[0]]),
);
}
return { lat, lon, polygon };
} catch {
return null;
}
};
useEffect(() => { useEffect(() => {
setOpen(detail); if (!object) return;
}, [detail]);
const load = async () => {
const district = await getCoords(object.district.name);
if (district) {
setPolygonCoords(district.polygon);
}
setCoords([object.latitude, object.longitude]);
setCircleCoords([object.latitude, object.longitude]);
};
load();
}, [object]);
if (!object) return null;
return ( return (
<Dialog <Dialog open={detail} onOpenChange={setDetail}>
open={open}
onOpenChange={(val) => {
setOpen(val);
setDetail(val);
}}
>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Farmatsiya tafsilotlari</DialogTitle> <DialogTitle>Farmatsiya tafsilotlari</DialogTitle>
@@ -46,44 +109,64 @@ const PharmDetailDialog = ({ detail, setDetail, object }: Props) => {
<strong>INN:</strong> {object.inn} <strong>INN:</strong> {object.inn}
</div> </div>
<div> <div>
<strong>Telefon:</strong> {formatPhone(object.phone_number)} <strong>Telefon:</strong> {formatPhone(object.owner_phone)}
</div> </div>
<div> <div>
<strong>Qoshimcha telefon:</strong>{" "} <strong>Qoshimcha telefon:</strong>{" "}
{formatPhone(object.additional_phone)} {formatPhone(object.responsible_phone)}
</div> </div>
<div> <div>
<strong>Tuman:</strong> {object.district.name} <strong>Tuman:</strong> {object.district.name}
</div> </div>
<div> <div>
<strong>Obyekt:</strong> {object.object.name} <strong>Obyekt:</strong> {object.place.name}
</div> </div>
<div> <div>
<strong>Kimga tegishli:</strong> {object.user.firstName}{" "} <strong>Kimga tegishli:</strong> {object.user.first_name}{" "}
{object.user.lastName} {object.user.last_name}
</div> </div>
<div className="h-[300px] w-full border rounded-lg overflow-hidden"> <div className="h-[300px] w-full border rounded-lg overflow-hidden">
<YMaps> <YMaps query={{ lang: "en_RU" }}>
<Map <Map
defaultState={{ state={{
center: [Number(object.lat), Number(object.long)], center: coords,
zoom: 16, zoom: 12,
}} }}
width="100%" width="100%"
height="300px" height="100%"
> >
<Placemark <ZoomControl
geometry={[Number(object.lat), Number(object.long)]}
/>
<Circle
geometry={[[Number(object.lat), Number(object.long)], 100]}
options={{ options={{
fillColor: "rgba(0, 150, 255, 0.2)", position: { right: "10px", bottom: "70px" },
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
}} }}
/> />
{/* Ish joyining markazi */}
<Placemark geometry={coords} />
{/* Tuman polygon */}
{polygonCoords.length > 0 && (
<Polygon
geometry={polygonCoords}
options={{
fillColor: "rgba(0, 150, 255, 0.2)",
strokeColor: "rgba(0, 150, 255, 0.8)",
strokeWidth: 2,
}}
/>
)}
{circleCoords && (
<Circle
geometry={[circleCoords, 300]}
options={{
fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)",
strokeWidth: 2,
}}
/>
)}
</Map> </Map>
</YMaps> </YMaps>
</div> </div>

View File

@@ -0,0 +1,98 @@
import type { PharmaciesListData } from "@/features/pharmacies/lib/data";
import AddedPharmacies from "@/features/pharmacies/ui/AddedPharmacies";
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 {
searchName: string;
setSearchName: Dispatch<SetStateAction<string>>;
searchDistrict: string;
setSearchDistrict: Dispatch<SetStateAction<string>>;
searchObject: string;
setSearchObject: Dispatch<SetStateAction<string>>;
searchUser: string;
setSearchUser: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setEditingPlan: Dispatch<SetStateAction<PharmaciesListData | null>>;
editingPlan: PharmaciesListData | null;
}
const PharmaciesFilter = ({
searchName,
setSearchName,
searchDistrict,
setSearchDistrict,
searchObject,
searchUser,
setSearchUser,
setSearchObject,
dialogOpen,
setDialogOpen,
setEditingPlan,
editingPlan,
}: Props) => {
return (
<div className="flex justify-end gap-2 w-full">
<Input
placeholder="Dorixona nomi"
value={searchName}
onChange={(e) => setSearchName(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Tuman"
value={searchDistrict}
onChange={(e) => setSearchDistrict(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Obyekt"
value={searchObject}
onChange={(e) => setSearchObject(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Kim qo'shgan"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
className="w-full md:w-48"
/>
<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
? "Dorixonani tahrirlash"
: "Yangi dorixona qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedPharmacies
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
/>
</DialogContent>
</Dialog>
</div>
);
};
export default PharmaciesFilter;

View File

@@ -1,132 +1,82 @@
import { import { pharmacies_api } from "@/features/pharmacies/lib/api";
PharmciesData, import { type PharmaciesListData } from "@/features/pharmacies/lib/data";
type PharmciesType, import DeletePharmacies from "@/features/pharmacies/ui/DeletePharmacies";
} from "@/features/pharmacies/lib/data"; import PharmaciesFilter from "@/features/pharmacies/ui/PharmaciesFilter";
import AddedPharmacies from "@/features/pharmacies/ui/AddedPharmacies"; import PharmaciesTable from "@/features/pharmacies/ui/PharmaciesTable";
import PharmDetailDialog from "@/features/pharmacies/ui/PharmDetailDialog"; import PharmDetailDialog from "@/features/pharmacies/ui/PharmDetailDialog";
import formatPhone from "@/shared/lib/formatPhone"; import Pagination from "@/shared/ui/pagination";
import { Button } from "@/shared/ui/button"; import { useQuery } from "@tanstack/react-query";
import { import { useState } from "react";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import {
ChevronLeft,
ChevronRight,
Eye,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
const PharmaciesList = () => { const PharmaciesList = () => {
const [data, setData] = useState<PharmciesType[]>(PharmciesData); const [detail, setDetail] = useState<PharmaciesListData | null>(null);
const [detail, setDetail] = useState<PharmciesType | null>(null);
const [detailDialog, setDetailDialog] = useState<boolean>(false); const [detailDialog, setDetailDialog] = useState<boolean>(false);
const [editingPlan, setEditingPlan] = useState<PharmciesType | null>(null); const [editingPlan, setEditingPlan] = useState<PharmaciesListData | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const [disricDelete, setDiscritDelete] = useState<PharmaciesListData | null>(
null,
);
const [opneDelete, setOpenDelete] = useState<boolean>(false);
const limit = 20;
const [searchName, setSearchName] = useState(""); const [searchName, setSearchName] = useState("");
const [searchDistrict, setSearchDistrict] = useState(""); const [searchDistrict, setSearchDistrict] = useState("");
const [searchObject, setSearchObject] = useState(""); const [searchObject, setSearchObject] = useState("");
const [searchUser, setSearchUser] = useState(""); const [searchUser, setSearchUser] = useState("");
const handleDelete = (id: number) => { const { data: pharmacies } = useQuery({
setData((prev) => prev.filter((e) => e.id !== id)); queryKey: [
"pharmacies_list",
currentPage,
searchDistrict,
searchName,
searchObject,
searchUser,
],
queryFn: () =>
pharmacies_api.list({
district: searchDistrict,
offset: (currentPage - 1) * limit,
limit: limit,
name: searchName,
place: searchObject,
user: searchUser,
}),
select(data) {
return data.data.data;
},
});
const totalPages = pharmacies ? Math.ceil(pharmacies.count / limit) : 1;
const handleDelete = (user: PharmaciesListData) => {
setDiscritDelete(user);
setOpenDelete(true);
}; };
const filteredData = useMemo(() => {
return data.filter((item) => {
const nameMatch = `${item.name}`
.toLowerCase()
.includes(searchName.toLowerCase());
const districtMatch = item.district.name
.toLowerCase()
.includes(searchDistrict.toLowerCase());
const objectMatch = item.object.name
.toLowerCase()
.includes(searchObject.toLowerCase());
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return nameMatch && districtMatch && objectMatch && userMatch;
});
}, [data, searchName, searchDistrict, searchObject, searchUser]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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"> <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"> <div className="flex flex-col gap-4 w-full">
<h1 className="text-2xl font-bold">Dorixonalrni boshqarish</h1> <h1 className="text-2xl font-bold">Dorixonalarni boshqarish</h1>
<div className="flex justify-end gap-2 w-full"> <PharmaciesFilter
<Input dialogOpen={dialogOpen}
placeholder="Dorixona nomi" editingPlan={editingPlan}
value={searchName} searchDistrict={searchDistrict}
onChange={(e) => setSearchName(e.target.value)} searchName={searchName}
className="w-full md:w-48" searchObject={searchObject}
/> searchUser={searchUser}
<Input setDialogOpen={setDialogOpen}
placeholder="Tuman" setEditingPlan={setEditingPlan}
value={searchDistrict} setSearchDistrict={setSearchDistrict}
onChange={(e) => setSearchDistrict(e.target.value)} setSearchName={setSearchName}
className="w-full md:w-48" setSearchObject={setSearchObject}
/> setSearchUser={setSearchUser}
<Input />
placeholder="Obyekt"
value={searchObject}
onChange={(e) => setSearchObject(e.target.value)}
className="w-full md:w-48"
/>
<Input
placeholder="Kim qo'shgan"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
className="w-full md:w-48"
/>
<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
? "Dorixonani tahrirlash"
: "Yangi dorixona qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedPharmacies
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
setData={setData}
/>
</DialogContent>
</Dialog>
</div>
</div> </div>
<PharmDetailDialog <PharmDetailDialog
@@ -136,111 +86,27 @@ const PharmaciesList = () => {
/> />
</div> </div>
<div className="flex-1 overflow-auto"> <PharmaciesTable
<Table> filteredData={pharmacies ? pharmacies.results : []}
<TableHeader> handleDelete={handleDelete}
<TableRow> setDetail={setDetail}
<TableHead>#</TableHead> setDetailDialog={setDetailDialog}
<TableHead>Dorixona nomi</TableHead> setDialogOpen={setDialogOpen}
<TableHead>Inn</TableHead> setEditingPlan={setEditingPlan}
<TableHead>Egasining nomeri</TableHead> />
<TableHead>Ma'sul shaxsning nomeri</TableHead>
<TableHead>Tuman</TableHead>
<TableHead>Obyekt</TableHead>
<TableHead>Kim qo'shgan</TableHead>
<TableHead className="text-right">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.inn}</TableCell>
<TableCell>{formatPhone(item.phone_number)}</TableCell>
<TableCell>{formatPhone(item.additional_phone)}</TableCell>
<TableCell>{item.district.name}</TableCell>
<TableCell>{item.object.name}</TableCell>
<TableCell>
{item.user.firstName} {item.user.lastName}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
onClick={() => { />
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<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.id)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <DeletePharmacies
<Button discrit={disricDelete}
variant="outline" opneDelete={opneDelete}
size="icon" setDiscritDelete={setDiscritDelete}
disabled={currentPage === 1} setOpenDelete={setOpenDelete}
className="cursor-pointer" />
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
>
<ChevronLeft />
</Button>
{Array.from({ length: totalPages }, (_, i) => (
<Button
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,102 @@
import type { PharmaciesListData } from "@/features/pharmacies/lib/data";
import formatPhone from "@/shared/lib/formatPhone";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Eye, Pencil, Trash2 } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filteredData: PharmaciesListData[];
setDetail: Dispatch<SetStateAction<PharmaciesListData | null>>;
setEditingPlan: Dispatch<SetStateAction<PharmaciesListData | null>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (pharmacies: PharmaciesListData) => void;
}
const PharmaciesTable = ({
filteredData,
setDetail,
setEditingPlan,
setDetailDialog,
setDialogOpen,
handleDelete,
}: Props) => {
return (
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Dorixona nomi</TableHead>
<TableHead>Inn</TableHead>
<TableHead>Egasining nomeri</TableHead>
<TableHead>Ma'sul shaxsning nomeri</TableHead>
<TableHead>Tuman</TableHead>
<TableHead>Obyekt</TableHead>
<TableHead>Kim qo'shgan</TableHead>
<TableHead className="text-right">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.inn}</TableCell>
<TableCell>{formatPhone(item.owner_phone)}</TableCell>
<TableCell>{formatPhone(item.responsible_phone)}</TableCell>
<TableCell>{item.district.name}</TableCell>
<TableCell>{item.place.name}</TableCell>
<TableCell>
{item.user.first_name} {item.user.last_name}
</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(item);
setDetailDialog(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<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>
))}
</TableBody>
</Table>
</div>
);
};
export default PharmaciesTable;

View File

@@ -0,0 +1,30 @@
import type { PillCreateReq, PillListRes } from "@/features/pill/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const pill_api = {
async list(params: {
limit?: number;
offset?: number;
name?: string;
}): Promise<AxiosResponse<PillListRes>> {
const res = await httpClient.get(`${API_URLS.PILL}list/`, { params });
return res;
},
async added(body: PillCreateReq) {
const res = httpClient.post(`${API_URLS.PILL}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: PillCreateReq }) {
const res = httpClient.patch(`${API_URLS.PILL}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = httpClient.delete(`${API_URLS.PILL}${id}/delete/`);
return res;
},
};

View File

@@ -16,3 +16,27 @@ export const FakePills: PillType[] = [
{ id: 9, name: "Amoxicillin 500mg", price: "28000" }, { id: 9, name: "Amoxicillin 500mg", price: "28000" },
{ id: 10, name: "Immuno Plus", price: "30000" }, { id: 10, name: "Immuno Plus", price: "30000" },
]; ];
export interface PillListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: PillListData[];
};
}
export interface PillListData {
id: number;
name: string;
price: string;
created_at: string;
}
export interface PillCreateReq {
name: string;
price: string;
}

View File

@@ -1,4 +1,5 @@
import type { PillType } from "@/features/pill/lib/data"; import { pill_api } from "@/features/pill/lib/api";
import type { PillCreateReq, PillType } from "@/features/pill/lib/data";
import { createPillFormData } from "@/features/pill/lib/form"; import { createPillFormData } from "@/features/pill/lib/form";
import formatPrice from "@/shared/lib/formatPrice"; import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
@@ -12,20 +13,22 @@ import {
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
interface Props { interface Props {
initialValues: PillType | null; initialValues: PillType | null;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
setPlans: Dispatch<SetStateAction<PillType[]>>;
} }
const AddedPill = ({ initialValues, setDialogOpen, setPlans }: Props) => { const AddedPill = ({ initialValues, setDialogOpen }: Props) => {
const [load, setLoad] = useState(false);
const [displayPrice, setDisplayPrice] = useState<string>(""); const [displayPrice, setDisplayPrice] = useState<string>("");
const queryClient = useQueryClient();
const form = useForm<z.infer<typeof createPillFormData>>({ const form = useForm<z.infer<typeof createPillFormData>>({
resolver: zodResolver(createPillFormData), resolver: zodResolver(createPillFormData),
defaultValues: { defaultValues: {
@@ -34,6 +37,44 @@ const AddedPill = ({ initialValues, setDialogOpen, setPlans }: Props) => {
}, },
}); });
const { mutate: added, isPending: addedPending } = useMutation({
mutationFn: (body: PillCreateReq) => {
return pill_api.added(body);
},
onSuccess: () => {
toast.success("Dori qo'shildi");
setDialogOpen(false);
queryClient.resetQueries({ queryKey: ["pill_list"] });
},
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: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { id: number; body: PillCreateReq }) => {
return pill_api.update({ body, id });
},
onSuccess: () => {
toast.success("Dori yangilandi");
queryClient.resetQueries({ queryKey: ["pill_list"] });
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",
});
},
});
useEffect(() => { useEffect(() => {
if (initialValues) { if (initialValues) {
setDisplayPrice(formatPrice(initialValues.price)); setDisplayPrice(formatPrice(initialValues.price));
@@ -41,35 +82,19 @@ const AddedPill = ({ initialValues, setDialogOpen, setPlans }: Props) => {
}, [initialValues]); }, [initialValues]);
function onSubmit(data: z.infer<typeof createPillFormData>) { function onSubmit(data: z.infer<typeof createPillFormData>) {
setLoad(true);
if (initialValues) { if (initialValues) {
setTimeout(() => { edit({
setPlans((prev) => id: initialValues.id,
prev.map((plan) => body: {
plan.id === initialValues.id name: data.name,
? { price: data.price,
...plan, },
...data, });
}
: plan,
),
);
setLoad(false);
setDialogOpen(false);
}, 2000);
} else { } else {
setTimeout(() => { added({
setPlans((prev) => [ name: data.name,
...prev, price: data.price,
{ });
id: prev.length ? prev[prev.length - 1].id + 1 : 1,
name: data.name,
price: data.price,
},
]);
setLoad(false);
setDialogOpen(false);
}, 2000);
} }
} }
@@ -125,9 +150,9 @@ const AddedPill = ({ initialValues, setDialogOpen, setPlans }: Props) => {
<Button <Button
className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer" className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer"
disabled={load} disabled={addedPending || editPending}
> >
{load ? ( {addedPending || editPending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,88 @@
import { pill_api } from "@/features/pill/lib/api";
import type { PillListData } from "@/features/pill/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>>;
setPillDelete: Dispatch<SetStateAction<PillListData | null>>;
pillDelete: PillListData | null;
}
const DeletePill = ({
opneDelete,
setOpenDelete,
pillDelete,
setPillDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => pill_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["pill_list"] });
toast.success(`Foydalanuvchi o'chirildi`);
setOpenDelete(false);
setPillDelete(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>Dorini o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {pillDelete?.name} nomli dorini 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={() => pillDelete && deleteUser(pillDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeletePill;

View File

@@ -1,5 +1,7 @@
import { FakePills, type PillType } from "@/features/pill/lib/data"; import { pill_api } from "@/features/pill/lib/api";
import { type PillListData, type PillType } from "@/features/pill/lib/data";
import AddedPill from "@/features/pill/ui/AddedPill"; import AddedPill from "@/features/pill/ui/AddedPill";
import DeletePill from "@/features/pill/ui/DeletePill";
import formatPrice from "@/shared/lib/formatPrice"; import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
@@ -10,6 +12,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import Pagination from "@/shared/ui/pagination";
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,34 +21,41 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/shared/ui/table"; } from "@/shared/ui/table";
import clsx from "clsx"; import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Edit, Plus, Trash } from "lucide-react"; import { Edit, Plus, Trash } from "lucide-react";
import { useMemo, useState } from "react"; import { useState } from "react";
const PillList = () => { const PillList = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const limit = 20;
const [plans, setPlans] = useState<PillType[]>(FakePills); const [nameFilter, setNameFilter] = useState<string>("");
const { data } = useQuery({
queryKey: ["pill_list", nameFilter, currentPage],
queryFn: () =>
pill_api.list({
limit,
offset: (currentPage - 1) * limit,
name: nameFilter,
}),
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data.count / limit) : 1;
const [editingPlan, setEditingPlan] = useState<PillType | null>(null); const [editingPlan, setEditingPlan] = useState<PillType | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [nameFilter, setNameFilter] = useState<string>(""); const [openDelete, setOpenDelete] = useState<boolean>(false);
const [pillDelete, setPillDelete] = useState<PillListData | null>(null);
const handleDelete = (id: number) => { const handleDelete = (id: PillListData) => {
setPlans(plans.filter((p) => p.id !== id)); setOpenDelete(true);
setPillDelete(id);
}; };
const filteredPlans = useMemo(() => {
return plans.filter((item) => {
const statusMatch = item.name
.toLowerCase()
.includes(nameFilter.toLowerCase());
return statusMatch;
});
}, [plans, nameFilter]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
@@ -79,7 +89,6 @@ const PillList = () => {
<AddedPill <AddedPill
initialValues={editingPlan} initialValues={editingPlan}
setDialogOpen={setDialogOpen} setDialogOpen={setDialogOpen}
setPlans={setPlans}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -97,7 +106,7 @@ const PillList = () => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredPlans.map((plan) => ( {data?.results.map((plan) => (
<TableRow key={plan.id} className="text-start"> <TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell> <TableCell>{plan.id}</TableCell>
<TableCell>{plan.name}</TableCell> <TableCell>{plan.name}</TableCell>
@@ -118,7 +127,7 @@ const PillList = () => {
variant="destructive" variant="destructive"
size="sm" size="sm"
className="cursor-pointer" className="cursor-pointer"
onClick={() => handleDelete(plan.id)} onClick={() => handleDelete(plan)}
> >
<Trash className="h-4 w-4" /> <Trash className="h-4 w-4" />
</Button> </Button>
@@ -129,44 +138,18 @@ const PillList = () => {
</Table> </Table>
</div> </div>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
disabled={currentPage === 1} />
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} <DeletePill
> opneDelete={openDelete}
<ChevronLeft /> setOpenDelete={setOpenDelete}
</Button> pillDelete={pillDelete}
{Array.from({ length: totalPages }, (_, i) => ( setPillDelete={setPillDelete}
<Button />
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,36 @@
import type {
PlanCreateReq,
PlanListRes,
PlanUpdateReq,
} 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: {
limit?: number;
offset?: number;
status?: boolean;
date?: string;
user?: string;
}): Promise<AxiosResponse<PlanListRes>> {
const res = await httpClient.get(`${API_URLS.PLANS}list/`, { params });
return res;
},
async create(body: PlanCreateReq) {
const res = await httpClient.post(`${API_URLS.PLANS}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: PlanUpdateReq }) {
const res = await httpClient.patch(`${API_URLS.PLANS}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.PLANS}${id}/delete/`);
return res;
},
};

View File

@@ -8,3 +8,65 @@ export interface Plan {
status: "Bajarildi" | "Bajarilmagan"; status: "Bajarildi" | "Bajarilmagan";
createdAt: Date; createdAt: Date;
} }
export interface PlanListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: PlanListData[];
};
}
export interface PlanListData {
id: number;
title: string;
description: string;
date: string;
comment: null | string;
doctor: {
id: number;
first_name: string;
last_name: string;
} | null;
pharmacy: {
id: number;
name: string;
} | null;
user: {
id: number;
first_name: string;
last_name: string;
};
longitude: number;
latitude: number;
extra_location: {
latitude: number;
longitude: number;
};
created_at: string;
}
export interface PlanCreateReq {
title: string;
description: string;
date: string;
user_id: number;
doctor_id: number | null;
pharmacy_id: number | null;
longitude: number;
latitude: number;
extra_location: { longitude: number; latitude: number };
}
export interface PlanUpdateReq {
title: string;
description: string;
date: string;
longitude: number;
latitude: number;
extra_location: { longitude: number; latitude: number };
}

View File

@@ -4,4 +4,11 @@ export const createPlanFormData = z.object({
name: z.string().min(1, { message: "Majburiy maydon" }), name: z.string().min(1, { message: "Majburiy maydon" }),
description: z.string().min(1, { message: "Majburiy maydon" }), description: z.string().min(1, { message: "Majburiy maydon" }),
user: z.string().min(1, { message: "Majburiy maydon" }), user: z.string().min(1, { message: "Majburiy maydon" }),
date: z.string().min(1, { message: "Majburiy maydon" }),
doctor_id: z.string().optional(),
pharmacy_id: z.string().optional(),
}); });
// longitude: number;
// latitude: number;
// extra_location: { longitude: number; latitude: number };

View File

@@ -1,7 +1,25 @@
import type { Plan } from "@/features/plans/lib/data"; import { doctor_api } from "@/features/doctors/lib/api";
import { pharmacies_api } from "@/features/pharmacies/lib/api";
import { plans_api } from "@/features/plans/lib/api";
import type {
PlanCreateReq,
PlanListData,
PlanUpdateReq,
} from "@/features/plans/lib/data";
import { createPlanFormData } from "@/features/plans/lib/form"; import { createPlanFormData } from "@/features/plans/lib/form";
import { FakeUserList } from "@/features/users/lib/data"; import { user_api } from "@/features/users/lib/api";
import formatDate from "@/shared/lib/formatDate";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -11,71 +29,176 @@ import {
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { Textarea } from "@/shared/ui/textarea"; import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react"; import type { AxiosError } from "axios";
import { Check, ChevronDownIcon, ChevronsUpDown, Loader2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod"; import z from "zod";
interface Props { interface Props {
initialValues?: Plan | null; initialValues?: PlanListData | null;
setDialogOpen: (open: boolean) => void; setDialogOpen: (open: boolean) => void;
setPlans: React.Dispatch<React.SetStateAction<Plan[]>>;
} }
const AddedPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => { const AddedPlan = ({ initialValues, setDialogOpen }: Props) => {
const [load, setLoad] = useState(false);
const form = useForm<z.infer<typeof createPlanFormData>>({ const form = useForm<z.infer<typeof createPlanFormData>>({
resolver: zodResolver(createPlanFormData), resolver: zodResolver(createPlanFormData),
defaultValues: { defaultValues: {
name: initialValues?.name || "", name: initialValues?.title || "",
description: initialValues?.description || "", description: initialValues?.description || "",
user: initialValues ? String(initialValues.user.id) : "", user: initialValues ? String(initialValues.user.id) : "",
date: initialValues ? initialValues?.date : "",
},
});
const [type, setType] = useState<"DOCTOR" | "PHARM">("DOCTOR");
const [long, setLong] = useState<number>(41.233);
const [lat, setLat] = useState<number>(63.233);
const [searchUser, setSearchUser] = useState<string>("");
const [openUser, setOpenUser] = useState<boolean>(false);
const [searchDoctor, setSearchDoctor] = useState<string>("");
const [openDoctor, setOpenDoctor] = useState<boolean>(false);
const [searchPharm, setSearchPharm] = useState<string>("");
const [openPharm, setOpenPharm] = useState<boolean>(false);
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const { data: user, isLoading: isUserLoading } = useQuery({
queryKey: ["user_list", searchUser],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
search?: string;
is_active?: boolean | string;
region_id?: number;
} = {
limit: 8,
search: searchUser,
};
return user_api.list(params);
},
select(data) {
return data.data.data;
},
});
const user_id = form.watch("user");
const { data: doctor, isLoading: isDoctorLoading } = useQuery({
queryKey: ["doctor_list", searchDoctor, user_id],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
user_id?: number;
full_name?: string;
} = {
limit: 8,
full_name: searchDoctor,
user_id: Number(user_id),
};
return doctor_api.list(params);
},
select(data) {
return data.data.data;
},
});
const { data: pharm, isLoading: isPharmLoading } = useQuery({
queryKey: ["pharm_list", searchPharm, user_id],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
user_id?: number;
name?: string;
} = {
limit: 8,
name: searchPharm,
user_id: Number(user_id),
};
return pharmacies_api.list(params);
},
select(data) {
return data.data.data;
},
});
const { mutate, isPending } = useMutation({
mutationFn: (body: PlanCreateReq) => plans_api.create(body),
onSuccess: () => {
setDialogOpen(false);
toast.success("Reja qo'shildi");
queryClient.refetchQueries({ queryKey: ["plan_list"] });
},
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: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { body: PlanUpdateReq; id: number }) =>
plans_api.update({ body, id }),
onSuccess: () => {
setDialogOpen(false);
toast.success("Reja tahrirlandi");
queryClient.refetchQueries({ queryKey: ["plan_list"] });
},
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(data: z.infer<typeof createPlanFormData>) { function onSubmit(data: z.infer<typeof createPlanFormData>) {
setLoad(true);
if (initialValues) { if (initialValues) {
setTimeout(() => { edit({
setPlans((prev) => body: {
prev.map((plan) => date: formatDate.format(data.date, "YYYY-MM-DD"),
plan.id === initialValues.id description: data.description,
? { extra_location: {
...plan, latitude: initialValues.latitude,
...data, longitude: initialValues.longitude,
user: FakeUserList.find((u) => u.id === Number(data.user))!, // user obyekt
}
: plan,
),
);
setLoad(false);
setDialogOpen(false);
}, 2000);
} else {
setTimeout(() => {
setPlans((prev) => [
...prev,
{
id: prev.length ? prev[prev.length - 1].id + 1 : 1,
name: data.name,
description: data.description,
user: FakeUserList.find((u) => u.id === Number(data.user))!, // user obyekt
status: "Bajarilmagan",
createdAt: new Date(),
}, },
]); latitude: initialValues.latitude,
setLoad(false); longitude: initialValues.longitude,
setDialogOpen(false); title: data.name,
}, 2000); },
id: initialValues.id,
});
} else {
mutate({
date: formatDate.format(data.date, "YYYY-MM-DD"),
description: data.description,
extra_location: {
latitude: lat,
longitude: long,
},
latitude: lat,
longitude: long,
title: data.name,
doctor_id: data.doctor_id ? Number(data.doctor_id) : null,
pharmacy_id: data.pharmacy_id ? Number(data.pharmacy_id) : null,
user_id: Number(data.user),
});
} }
} }
@@ -86,27 +209,295 @@ const AddedPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => {
<FormField <FormField
name="user" name="user"
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => {
<FormItem> const selectedUser = user?.results.find(
<Label className="text-md">Kimga tegishli</Label> (u) => String(u.id) === field.value,
<FormControl> );
<Select value={field.value} onValueChange={field.onChange}> return (
<SelectTrigger className="w-full !h-12"> <FormItem className="flex flex-col">
<SelectValue placeholder="foydalanuvchi" /> <Label className="text-md">Foydalanuvchi</Label>
</SelectTrigger>
<SelectContent> <Popover open={openUser} onOpenChange={setOpenUser}>
{FakeUserList.map((e) => ( <PopoverTrigger asChild disabled={initialValues !== null}>
<SelectItem value={String(e.id)}> <FormControl>
{e.firstName} {e.lastName} <Button
</SelectItem> type="button"
))} variant="outline"
</SelectContent> role="combobox"
</Select> aria-expanded={openUser}
</FormControl> className={cn(
<FormMessage /> "w-full h-12 justify-between",
</FormItem> !field.value && "text-muted-foreground",
)} )}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchUser}
onValueChange={setSearchUser}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.results.length > 0 ? (
<CommandGroup>
{user.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.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<div className="flex gap-2">
<Button
type="button"
onClick={() => setType("DOCTOR")}
className={cn(
"cursor-pointer h-10 text-sm",
type === "DOCTOR"
? "bg-blue-700 hover:bg-blue-700 text-white"
: "bg-gray-300 hover:bg-gray-300 text-black/80",
)}
>
Shifokorga birlashtirish
</Button>
<Button
onClick={() => setType("PHARM")}
type="button"
className={cn(
"cursor-pointer h-10 text-sm",
type === "PHARM"
? "bg-blue-700 hover:bg-blue-700 text-white"
: "bg-gray-300 hover:bg-gray-300 text-black/80",
)}
>
Dorixonaga birlashtirish
</Button>
</div>
{type === "DOCTOR" && (
<FormField
name="doctor_id"
control={form.control}
render={({ field }) => {
const selectedUser = doctor?.results.find(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Shifokorlar</Label>
<Popover open={openDoctor} onOpenChange={setOpenDoctor}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={openDoctor}
className={cn(
"w-full h-12 justify-between",
!field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Shifokorni 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchDoctor}
onValueChange={setSearchDoctor}
/>
<CommandList>
{isDoctorLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : doctor && doctor.results.length > 0 ? (
<CommandGroup>
{doctor.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={() => {
field.onChange(String(u.id));
setOpenDoctor(false);
setLat(u.latitude);
setLong(u.longitude);
form.setValue("pharmacy_id", undefined);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.first_name} {u.last_name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Shifokor topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
)}
{type === "PHARM" && (
<FormField
name="pharmacy_id"
control={form.control}
render={({ field }) => {
const selectedUser = pharm?.results.find(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Dorixonalar</Label>
<Popover open={openPharm} onOpenChange={setOpenPharm}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={openPharm}
className={cn(
"w-full h-12 justify-between",
!field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.name}`
: "Dorixonani 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchPharm}
onValueChange={setSearchPharm}
/>
<CommandList>
{isPharmLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : pharm && pharm.results.length > 0 ? (
<CommandGroup>
{pharm.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={() => {
field.onChange(String(u.id));
setOpenPharm(false);
setLat(u.latitude);
setLong(u.longitude);
form.setValue("doctor_id", undefined);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Dorixona topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
@@ -144,11 +535,58 @@ const AddedPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => {
)} )}
/> />
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem className="flex flex-col">
<Label className="text-md">Rejani bajarish kuni</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-full h-12 justify-between font-normal"
>
{field.value
? new Date(field.value).toLocaleDateString()
: "Sanani tanlang"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
mode="single"
toYear={new Date().getFullYear() + 50}
selected={field.value ? new Date(field.value) : undefined}
captionLayout="dropdown"
onSelect={(value) => {
if (value) {
field.onChange(value.toISOString()); // ⬅️ forma ichiga yozamiz
}
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<Button <Button
className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer" className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer"
disabled={load} disabled={
isPending || editPending || initialValues?.comment ? true : false
}
> >
{load ? ( {isPending || editPending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,89 @@
import { plans_api } from "@/features/plans/lib/api";
import type { PlanListData } 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<PlanListData | null>>;
planDelete: PlanListData | null;
}
const DeletePlan = ({
opneDelete,
setOpenDelete,
planDelete,
setPlanDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => plans_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["plan_list"] });
toast.success(`Foydalanuvchi 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>Rejani o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {planDelete?.user.first_name}{" "}
{planDelete?.user.last_name} ha tegishli rejani o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() => planDelete && deleteUser(planDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeletePlan;

View File

@@ -0,0 +1,136 @@
import type { PlanListData } from "@/features/plans/lib/data";
import AddedPlan from "@/features/plans/ui/AddedPlan";
import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { ChevronDownIcon, Plus } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
statusFilter: string;
setStatusFilter: Dispatch<SetStateAction<string>>;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
dateFilter: Date | undefined;
setDateFilter: Dispatch<SetStateAction<Date | undefined>>;
searchUser: string;
setSearchUser: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
editingPlan: PlanListData | null;
setEditingPlan: Dispatch<SetStateAction<PlanListData | null>>;
}
const FilterPlans = ({
setStatusFilter,
statusFilter,
open,
setOpen,
dateFilter,
setDateFilter,
searchUser,
setSearchUser,
dialogOpen,
setDialogOpen,
setEditingPlan,
editingPlan,
}: Props) => {
return (
<div className="flex gap-2 mb-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="foydalanuvchi" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Barchasi</SelectItem>
<SelectItem value="true">Bajarildi</SelectItem>
<SelectItem value="false">Bajarilmagan</SelectItem>
</SelectContent>
</Select>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-48 justify-between font-normal h-12"
>
{dateFilter ? dateFilter.toDateString() : "Sana"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpen(false);
}}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpen(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
<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">
<DialogHeader>
<DialogTitle className="text-xl">
{editingPlan ? "Rejani tahrirlash" : "Yangi reja qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedPlan
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
/>
</DialogContent>
</Dialog>
</div>
);
};
export default FilterPlans;

View File

@@ -0,0 +1,137 @@
import type { PlanListData } from "@/features/plans/lib/data";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import { Edit, Eye, Loader2, Trash } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filteredPlans: PlanListData[] | [];
setEditingPlan: Dispatch<SetStateAction<PlanListData | null>>;
setDetail: Dispatch<SetStateAction<boolean>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: PlanListData) => void;
isLoading: boolean;
isError: boolean;
isFetching: boolean;
}
const PalanTable = ({
filteredPlans,
setEditingPlan,
setDetail,
isError,
isFetching,
isLoading,
setDialogOpen,
handleDelete,
}: 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 className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Reja nomi</TableHead>
<TableHead className="text-start">Tavsifi</TableHead>
<TableHead className="text-start">Kimga tegishli</TableHead>
<TableHead className="text-start">Shifokor biriktirgan</TableHead>
<TableHead className="text-start">
Dorixonaga biriktirgan
</TableHead>
<TableHead className="text-start">Status</TableHead>
<TableHead className="text-right">Harakatlar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlans.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>{plan.title}</TableCell>
<TableCell>{plan.description}</TableCell>
<TableCell>
{plan.user.first_name + " " + plan.user.last_name}
</TableCell>
<TableCell>
{plan.doctor
? plan.doctor.first_name + " " + plan.doctor.last_name
: "-"}
</TableCell>
<TableCell>
{plan.pharmacy ? plan.pharmacy.name : "-"}
</TableCell>
<TableCell
className={clsx(
plan.comment ? "text-green-500" : "text-red-500",
)}
>
{plan.comment ? "Bajarilgan" : "Bajarilmagan"}
</TableCell>
<TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
className="bg-green-500 text-white cursor-pointer hover:bg-green-500 hover:text-white"
onClick={() => {
setEditingPlan(plan);
setDetail(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={plan.comment ? true : false}
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"
disabled={plan.comment ? true : false}
onClick={() => handleDelete(plan)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
};
export default PalanTable;

View File

@@ -1,4 +1,4 @@
import type { Plan } from "@/features/plans/lib/data"; import type { PlanListData } from "@/features/plans/lib/data";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,13 +8,20 @@ import {
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import {
Circle,
Map,
Placemark,
YMaps,
ZoomControl,
} from "@pbe/react-yandex-maps";
import clsx from "clsx"; import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
interface Props { interface Props {
setDetail: Dispatch<SetStateAction<boolean>>; setDetail: Dispatch<SetStateAction<boolean>>;
detail: boolean; detail: boolean;
plan: Plan | null; plan: PlanListData | null;
} }
const PlanDetail = ({ detail, setDetail, plan }: Props) => { const PlanDetail = ({ detail, setDetail, plan }: Props) => {
@@ -22,7 +29,7 @@ const PlanDetail = ({ detail, setDetail, plan }: Props) => {
return ( return (
<Dialog open={detail} onOpenChange={setDetail}> <Dialog open={detail} onOpenChange={setDetail}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl font-semibold"> <DialogTitle className="text-xl font-semibold">
Reja haqida batafsil Reja haqida batafsil
@@ -30,41 +37,104 @@ const PlanDetail = ({ detail, setDetail, plan }: Props) => {
</DialogHeader> </DialogHeader>
<DialogDescription className="space-y-4 mt-2 text-md"> <DialogDescription className="space-y-4 mt-2 text-md">
{/* Reja nomi */}
<div> <div>
<p className="font-semibold text-gray-900">Reja nomi:</p> <p className="font-semibold text-gray-900">Reja nomi:</p>
<p>{plan.name}</p> <p>{plan.title}</p>
</div> </div>
{/* Reja tavsifi */}
<div> <div>
<p className="font-semibold text-gray-900">Tavsifi:</p> <p className="font-semibold text-gray-900">Tavsifi:</p>
<p>{plan.description}</p> <p>{plan.description}</p>
</div> </div>
{/* Kimga tegishli */} {plan.comment && (
<div>
<p className="font-semibold text-gray-900">Qanday bajarildi:</p>
<p>{plan.comment}</p>
</div>
)}
{plan.doctor && (
<div>
<p className="font-semibold text-gray-900">
Shikorga biriktirgan
</p>
<p>
<span>Shifokot ismi: </span>
{plan.doctor.first_name} {plan.doctor.last_name}
</p>
</div>
)}
{plan.pharmacy && (
<div>
<p className="font-semibold text-gray-900">
Dorixonaga biriktirgan
</p>
<p>
<span>Dorixona nomi: </span>
{plan.pharmacy.name}
</p>
</div>
)}
<div> <div>
<p className="font-semibold text-gray-900">Kimga tegishli:</p> <p className="font-semibold text-gray-900">Kimga tegishli:</p>
<p> <p>
{plan.user.firstName} {plan.user.lastName} {plan.user.first_name} {plan.user.last_name}
</p> </p>
</div> </div>
{/* Reja statusi */}
<div> <div>
<p className="font-semibold text-gray-900">Reja statusi:</p> <p className="font-semibold text-gray-900">Reja statusi:</p>
<Badge <Badge
className={clsx( className={clsx(
plan.status === "Bajarildi" plan.comment
? "bg-green-100 text-green-700" ? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700", : "bg-yellow-100 text-yellow-700",
"text-sm px-4 py-2 mt-2", "text-sm px-4 py-2 mt-2",
)} )}
> >
{plan.status} {plan.comment ? "Bajarilgan" : "Bajarilmagan"}
</Badge> </Badge>
</div> </div>
<div>
{plan.doctor
? "Shifokor manzili:"
: plan.pharmacy && "Dorixona manzili:"}
</div>
<YMaps query={{ lang: "en_RU" }}>
<div className="h-[300px] w-full rounded-md overflow-hidden">
<Map
state={{
center: [plan.latitude, plan.longitude],
zoom: 12,
}}
width="100%"
height="100%"
>
<ZoomControl
options={{
position: { right: "10px", bottom: "70px" },
}}
/>
<Placemark geometry={[plan.latitude, plan.longitude]} />
<Circle
geometry={[[plan.latitude, plan.longitude], 300]}
options={{
fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)",
strokeWidth: 2,
}}
/>
</Map>
</div>
</YMaps>
</DialogDescription> </DialogDescription>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,306 +1,106 @@
"use client"; import { plans_api } from "@/features/plans/lib/api";
import type { PlanListData } from "@/features/plans/lib/data";
import type { Plan } from "@/features/plans/lib/data"; import DeletePlan from "@/features/plans/ui/DeletePlan";
import AddedPlan from "@/features/plans/ui/AddedPlan"; import FilterPlans from "@/features/plans/ui/FilterPlans";
import PalanTable from "@/features/plans/ui/PalanTable";
import PlanDetail from "@/features/plans/ui/PlanDetail"; import PlanDetail from "@/features/plans/ui/PlanDetail";
import { FakeUserList } from "@/features/users/lib/data"; import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button"; import Pagination from "@/shared/ui/pagination";
import { Calendar } from "@/shared/ui/calendar"; import { useQuery } from "@tanstack/react-query";
import { import { useState } from "react";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import {
ChevronDownIcon,
ChevronLeft,
ChevronRight,
Edit,
Eye,
Plus,
Trash,
} from "lucide-react";
import { useMemo, useState } from "react";
const PlansList = () => { const PlansList = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
const [plans, setPlans] = useState<Plan[]>([
{
id: 1,
name: "Tumanga borish",
description: "Tumanga borish rejasi",
user: FakeUserList[0],
status: "Bajarildi",
createdAt: new Date("2025-02-03"),
},
{
id: 2,
name: "Yangi reja",
description: "Yangi reja tavsifi",
user: FakeUserList[1],
status: "Bajarilmagan",
createdAt: new Date("2025-01-12"),
},
]);
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<boolean>(false);
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined); const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const [searchUser, setSearchUser] = useState<string>(""); const [searchUser, setSearchUser] = useState<string>("");
const limit = 20;
const { data, isLoading, isError, isFetching } = useQuery({
queryKey: ["plan_list", dateFilter, searchUser, statusFilter, currentPage],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
status?: boolean;
date?: string;
user?: string;
} = {
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
user: searchUser,
limit,
offset: (currentPage - 1) * limit,
};
const handleDelete = (id: number) => { if (statusFilter !== "all") {
setPlans(plans.filter((p) => p.id !== id)); params.status = statusFilter === "true" ? true : false;
}
return plans_api.list(params);
},
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data.count / limit) : 1;
const [editingPlan, setEditingPlan] = useState<PlanListData | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<boolean>(false);
const [openDelete, setOpenDelete] = useState<boolean>(false);
const [planDelete, setPlanDelete] = useState<PlanListData | null>(null);
const handleDelete = (id: PlanListData) => {
setOpenDelete(true);
setPlanDelete(id);
}; };
const filteredPlans = useMemo(() => {
return plans.filter((item) => {
// 1) Status (agar all bo'lsa filtrlanmaydi)
const statusMatch =
statusFilter === "all" || item.status === statusFilter;
// 2) Sana filtri: createdAt === tanlangan sana
const dateMatch = dateFilter
? item.createdAt.toDateString() === dateFilter.toDateString()
: true;
// 3) User ism familiya bo'yicha qidiruv
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return statusMatch && dateMatch && userMatch;
});
}, [plans, statusFilter, dateFilter, searchUser]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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 md:flex-row justify-between items-start md:items-center mb-4 gap-4">
<h1 className="text-2xl font-bold">Rejalarni boshqarish</h1> <h1 className="text-2xl font-bold">Rejalarni boshqarish</h1>
<FilterPlans
dateFilter={dateFilter}
dialogOpen={dialogOpen}
editingPlan={editingPlan}
open={open}
searchUser={searchUser}
setDateFilter={setDateFilter}
setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
setOpen={setOpen}
setSearchUser={setSearchUser}
setStatusFilter={setStatusFilter}
statusFilter={statusFilter}
/>
<div className="flex gap-2 mb-4">
{/* Status filter */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="foydalanuvchi" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Barchasi</SelectItem>
<SelectItem value="Bajarildi">Bajarildi</SelectItem>
<SelectItem value="Bajarilmagan">Bajarilmagan</SelectItem>
</SelectContent>
</Select>
{/* Sana filter */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-48 justify-between font-normal h-12"
>
{dateFilter ? dateFilter.toDateString() : "Sana"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpen(false);
}}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpen(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
<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">
<DialogHeader>
<DialogTitle className="text-xl">
{editingPlan ? "Rejani tahrirlash" : "Yangi reja qo'shish"}
</DialogTitle>
</DialogHeader>
{/* Form */}
<AddedPlan
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
setPlans={setPlans}
/>
</DialogContent>
</Dialog>
</div>
{/* Deail plan */}
<PlanDetail detail={detail} setDetail={setDetail} plan={editingPlan} /> <PlanDetail detail={detail} setDetail={setDetail} plan={editingPlan} />
</div> </div>
{/* Table */} <PalanTable
<div className="flex-1 overflow-auto"> filteredPlans={data ? data.results : []}
<Table> handleDelete={handleDelete}
<TableHeader> setDetail={setDetail}
<TableRow className="text-center"> setDialogOpen={setDialogOpen}
<TableHead className="text-start">ID</TableHead> setEditingPlan={setEditingPlan}
<TableHead className="text-start">Reja nomi</TableHead> isError={isError}
<TableHead className="text-start">Tavsifi</TableHead> isFetching={isFetching}
<TableHead className="text-start">Kimga tegishli</TableHead> isLoading={isLoading}
<TableHead className="text-start">Status</TableHead> />
<TableHead className="text-right">Harakatlar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlans.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>{plan.name}</TableCell>
<TableCell>{plan.description}</TableCell>
<TableCell>
{plan.user.firstName + " " + plan.user.lastName}
</TableCell>
<TableCell
className={clsx(
plan.status === "Bajarildi"
? "text-green-500"
: "text-red-500",
)}
>
{plan.status}
</TableCell>
<TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
className="bg-green-500 text-white cursor-pointer hover:bg-green-500 hover:text-white"
onClick={() => {
setEditingPlan(plan);
setDetail(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
<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>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
disabled={currentPage === 1} />
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} <DeletePlan
> opneDelete={openDelete}
<ChevronLeft /> planDelete={planDelete}
</Button> setOpenDelete={setOpenDelete}
{Array.from({ length: totalPages }, (_, i) => ( setPlanDelete={setPlanDelete}
<Button />
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,32 @@
import type { RegionListRes } from "@/features/region/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const region_api = {
async list(params: {
limit?: number;
offset?: number;
name?: string;
}): Promise<AxiosResponse<RegionListRes>> {
return await httpClient.get(`${API_URLS.REGIONS}list/`, { params });
},
async create(body: { name: string }) {
const res = await httpClient.post(`${API_URLS.REGIONS}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: { name: string } }) {
const res = await httpClient.patch(
`${API_URLS.REGIONS}${id}/update/`,
body,
);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.REGIONS}${id}/delete/`);
return res;
},
};

View File

@@ -17,3 +17,16 @@ export const fakeRegionList: RegionType[] = [
name: "Andijon", name: "Andijon",
}, },
]; ];
export interface RegionListRes {
status_code: number;
status: string;
message: string;
data: RegionListResData[];
}
export interface RegionListResData {
id: number;
name: string;
created_at: string;
}

View File

@@ -1,3 +1,4 @@
import { region_api } from "@/features/region/lib/api";
import type { RegionType } from "@/features/region/lib/data"; import type { RegionType } from "@/features/region/lib/data";
import { regionForm } from "@/features/region/lib/form"; import { regionForm } from "@/features/region/lib/form";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
@@ -11,42 +12,69 @@ import {
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
interface Props { interface Props {
initialValues: RegionType | null; initialValues: RegionType | null;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
setPlans: Dispatch<SetStateAction<RegionType[]>>;
} }
const AddedRegion = ({ initialValues, setDialogOpen, setPlans }: Props) => { const AddedRegion = ({ initialValues, setDialogOpen }: Props) => {
const [load, setLoad] = useState<boolean>(false);
const form = useForm<z.infer<typeof regionForm>>({ const form = useForm<z.infer<typeof regionForm>>({
resolver: zodResolver(regionForm), resolver: zodResolver(regionForm),
defaultValues: { name: initialValues?.name || "" }, defaultValues: { name: initialValues?.name || "" },
}); });
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: (body: { name: string }) => region_api.create(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["region_list"] });
toast.success(`Yangi hudud 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",
});
},
});
const { mutate: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { id: number; body: { name: string } }) =>
region_api.update({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["region_list"] });
toast.success(`Yangi hudud 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(value: z.infer<typeof regionForm>) { function onSubmit(value: z.infer<typeof regionForm>) {
setLoad(true); if (initialValues) {
edit({ id: initialValues.id, body: { name: value.name } });
setTimeout(() => { } else {
setPlans((prev) => { mutate({
if (initialValues) { name: value.name,
return prev.map((item) =>
item.id === initialValues.id ? { ...item, ...value } : item,
);
}
return [
...prev,
{ id: prev.length ? prev[prev.length - 1].id + 1 : 1, ...value },
];
}); });
setLoad(false); }
setDialogOpen(false);
}, 2000);
} }
return ( return (
@@ -66,8 +94,11 @@ const AddedRegion = ({ initialValues, setDialogOpen, setPlans }: Props) => {
)} )}
/> />
<Button className="w-full bg-blue-500 cursor-pointer hover:bg-blue-500"> <Button
{load ? ( type="submit"
className="w-full bg-blue-500 cursor-pointer hover:bg-blue-500"
>
{isPending || editPending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,88 @@
import { region_api } from "@/features/region/lib/api";
import type { RegionListResData } from "@/features/region/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>>;
setRegionDelete: Dispatch<SetStateAction<RegionListResData | null>>;
regionDelete: RegionListResData | null;
}
const DeleteRegion = ({
opneDelete,
setOpenDelete,
setRegionDelete,
regionDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteRegion, isPending } = useMutation({
mutationFn: (id: number) => region_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["region_list"] });
toast.success(`Foydalanuvchi o'chirildi`);
setOpenDelete(false);
setRegionDelete(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 {regionDelete?.name} hududini 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={() => regionDelete && deleteRegion(regionDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteRegion;

View File

@@ -1,5 +1,8 @@
import { fakeRegionList, type RegionType } from "@/features/region/lib/data"; import { region_api } from "@/features/region/lib/api";
import { type RegionListResData } from "@/features/region/lib/data";
import AddedRegion from "@/features/region/ui/AddedRegion"; import AddedRegion from "@/features/region/ui/AddedRegion";
import DeleteRegion from "@/features/region/ui/DeleteRegion";
import RegionTable from "@/features/region/ui/RegionTable";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
Dialog, Dialog,
@@ -8,28 +11,33 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { import { useQuery } from "@tanstack/react-query";
Table,
TableBody, import { Plus } from "lucide-react";
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import { ChevronLeft, ChevronRight, Edit, Plus, Trash } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const RegionList = () => { const RegionList = () => {
const [currentPage, setCurrentPage] = useState(1); const { data, isLoading, isError } = useQuery({
const totalPages = 5; queryKey: ["region_list"],
const [plans, setPlans] = useState<RegionType[]>(fakeRegionList); queryFn: () => region_api.list({}),
select(data) {
return data.data.data;
},
});
const [editingPlan, setEditingPlan] = useState<RegionType | null>(null); const [regionDelete, setRegionDelete] = useState<RegionListResData | null>(
null,
);
const [opneDelete, setOpenDelete] = useState<boolean>(false);
const [editingPlan, setEditingPlan] = useState<RegionListResData | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const handleDelete = (id: number) => { const handleDelete = (user: RegionListResData) => {
setPlans(plans.filter((p) => p.id !== id)); setRegionDelete(user);
setOpenDelete(true);
}; };
return ( return (
@@ -58,91 +66,27 @@ const RegionList = () => {
<AddedRegion <AddedRegion
initialValues={editingPlan} initialValues={editingPlan}
setDialogOpen={setDialogOpen} setDialogOpen={setDialogOpen}
setPlans={setPlans}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</div> </div>
{data && (
<div className="flex-1 overflow-auto"> <RegionTable
<Table> region={data!}
<TableHeader> handleDelete={handleDelete}
<TableRow className="text-center"> isError={isError}
<TableHead className="text-start">ID</TableHead> setDialogOpen={setDialogOpen}
<TableHead className="text-start">Nomi</TableHead> isLoading={isLoading}
</TableRow> setEditingRegion={setEditingPlan}
</TableHeader> />
<TableBody> )}
{plans.map((plan) => ( <DeleteRegion
<TableRow key={plan.id} className="text-start"> opneDelete={opneDelete}
<TableCell>{plan.id}</TableCell> regionDelete={regionDelete}
<TableCell>{plan.name}</TableCell> setOpenDelete={setOpenDelete}
<TableCell className="flex gap-2 justify-end"> setRegionDelete={setRegionDelete}
<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>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t">
<Button
variant="outline"
size="icon"
disabled={currentPage === 1}
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
>
<ChevronLeft />
</Button>
{Array.from({ length: totalPages }, (_, i) => (
<Button
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,89 @@
import type { RegionListResData } from "@/features/region/lib/data";
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";
const RegionTable = ({
region,
handleDelete,
isLoading,
isError,
setEditingRegion,
setDialogOpen,
}: {
region: RegionListResData[];
isLoading: boolean;
isError: boolean;
setEditingRegion: Dispatch<SetStateAction<RegionListResData | null>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (user: RegionListResData) => 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>
)}
{!isLoading && !isError && (
<Table>
<TableHeader>
<TableRow className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Nomi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{region.map((plan, index) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{index + 1}</TableCell>
<TableCell>{plan.name}</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={() => {
setEditingRegion(plan);
setDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
className="cursor-pointer"
onClick={() => handleDelete(plan)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
};
export default RegionTable;

View File

@@ -0,0 +1,14 @@
import type { ResportListRes } from "@/features/reports/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const report_api = {
async list(params: {
limit: number;
offset: number;
}): Promise<AxiosResponse<ResportListRes>> {
const res = await httpClient.get(`${API_URLS.REPORT}list/`, { params });
return res;
},
};

View File

@@ -67,3 +67,26 @@ export const ReportsData: ReportsTypeList[] = [
month: new Date(2025, 4, 1), month: new Date(2025, 4, 1),
}, },
]; ];
export interface ResportListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: ResportListResData[];
};
}
export interface ResportListResData {
id: number;
employee_name: string;
factory: {
id: number;
name: string;
};
price: string;
created_at: string;
}

View File

@@ -1,45 +1,36 @@
import { ReportsData, type ReportsTypeList } from "@/features/reports/lib/data"; import { report_api } from "@/features/reports/lib/api";
import AddedReport from "@/features/reports/ui/AddedReport"; import ReportsTable from "@/features/reports/ui/ReportsTable";
import formatDate from "@/shared/lib/formatDate"; import Pagination from "@/shared/ui/pagination";
import formatPrice from "@/shared/lib/formatPrice"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import { ChevronLeft, ChevronRight, Edit, Plus, Trash } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const ReportsList = () => { const ReportsList = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const limit = 20;
const [plans, setPlans] = useState<ReportsTypeList[]>(ReportsData); 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 [editingPlan, setEditingPlan] = useState<ReportsTypeList | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); // const [dialogOpen, setDialogOpen] = useState(false);
const handleDelete = (id: number) => { // const handleDelete = (id: number) => {
setPlans(plans.filter((p) => p.id !== id)); // setPlans(plans.filter((p) => p.id !== id));
}; // };
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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"> <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">Rejalarni boshqarish</h1> <h1 className="text-2xl font-bold">To'lovlar</h1>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> {/* <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
variant="default" variant="default"
@@ -62,95 +53,23 @@ const ReportsList = () => {
setPlans={setPlans} setPlans={setPlans}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog> */}
</div> </div>
<div className="flex-1 overflow-auto"> <ReportsTable
<Table> // handleDelete={handleDelete}
<TableHeader> plans={data ? data.results : []}
<TableRow className="text-center"> // setDialogOpen={setDialogOpen}
<TableHead className="text-start">ID</TableHead> // setEditingPlan={setEditingPlan}
<TableHead className="text-start">Dorixoan nomi</TableHead> isLoading={isLoading}
<TableHead className="text-start">To'langan summa</TableHead> isError={isError}
<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.pharm_name}</TableCell>
<TableCell>{formatPrice(plan.amount, true)}</TableCell>
<TableCell>
{formatDate.format(plan.month, "DD-MM-YYYY")}
</TableCell>
<TableCell className="flex gap-2 justify-end"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="sm" totalPages={totalPages}
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>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t">
<Button
variant="outline"
size="icon"
disabled={currentPage === 1}
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
>
<ChevronLeft />
</Button>
{Array.from({ length: totalPages }, (_, i) => (
<Button
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,97 @@
import type { ResportListResData } from "@/features/reports/lib/data";
import formatDate from "@/shared/lib/formatDate";
import formatPrice from "@/shared/lib/formatPrice";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Loader2 } from "lucide-react";
const ReportsTable = ({
plans,
// setEditingPlan,
// setDialogOpen,
// handleDelete,
isError,
isLoading,
}: {
plans: ResportListResData[];
isLoading: boolean;
isError: boolean;
// setEditingPlan: Dispatch<SetStateAction<ResportListResData | null>>;
// setDialogOpen: Dispatch<SetStateAction<boolean>>;
// handleDelete: (id: number) => void;
}) => {
return (
<div className="flex-1 overflow-auto">
{isLoading && (
<div className="h-full flex items-center justify-center bg-white/70 z-10">
<span className="text-lg font-medium">
<Loader2 className="animate-spin" />
</span>
</div>
)}
{isError && (
<div className="h-full flex items-center justify-center z-10">
<span className="text-lg font-medium text-red-600">
Ma'lumotlarni olishda xatolik yuz berdi.
</span>
</div>
)}
{!isError && !isLoading && (
<Table>
<TableHeader>
<TableRow className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Dorixoan nomi</TableHead>
<TableHead className="text-start">To'langan summa</TableHead>
<TableHead className="text-start">To'langan sanasi</TableHead>
{/* <TableHead className="text-right">Harakatlar</TableHead> */}
</TableRow>
</TableHeader>
<TableBody>
{plans.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>{plan.employee_name}</TableCell>
<TableCell>{formatPrice(plan.price, true)}</TableCell>
<TableCell>
{formatDate.format(plan.created_at, "DD-MM-YYYY")}
</TableCell>
{/* <TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
className="bg-blue-500 text-white hover:bg-blue-500 hover:text-white cursor-pointer"
onClick={() => {
setEditingPlan(plan);
setDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
className="cursor-pointer"
onClick={() => handleDelete(plan.id)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell> */}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
};
export default ReportsTable;

View File

@@ -0,0 +1,33 @@
import type {
OrderCreateReq,
OrderListRes,
OrderUpdateReq,
} from "@/features/specifications/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const order_api = {
async list(params: {
limit: number;
offset: number;
}): Promise<AxiosResponse<OrderListRes>> {
const res = await httpClient.get(`${API_URLS.ORDER}list/`, { params });
return res;
},
async create(body: OrderCreateReq) {
const res = await httpClient.post(`${API_URLS.ORDER}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: OrderUpdateReq }) {
const res = await httpClient.patch(`${API_URLS.ORDER}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.ORDER}${id}/delete/`);
return res;
},
};

View File

@@ -79,3 +79,71 @@ export const FakeSpecifications: SpecificationsType[] = [
paidPrice: 22400, paidPrice: 22400,
}, },
]; ];
export interface OrderListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: OrderListDataRes[];
};
}
export interface OrderListDataRes {
id: number;
factory: {
id: number;
name: string;
};
total_price: string;
paid_price: string;
advance: number;
employee_name: string;
overdue_price: string;
order_items: [
{
id: number;
product: number;
quantity: number;
total_price: string;
},
];
file: string;
user: {
id: number;
first_name: string;
last_name: string;
};
}
export interface OrderCreateReq {
factory_id: number;
paid_price: string;
total_price: string;
advance: number;
employee_name: string;
user_id: number;
items: {
product: number;
quantity: number;
total_price: string;
}[];
}
export interface OrderUpdateReq {
total_price: string;
user_id: number;
factory_id: number;
paid_price: string;
advance: number;
employee_name: string;
overdue_price?: string;
items: {
product_id: number;
quantity: number;
total_price: string;
}[];
}

View File

@@ -1,17 +1,29 @@
"use client"; "use client";
import { pharmData } from "@/features/pharm/lib/data"; import { factory_api } from "@/features/pharm/lib/api";
import { pill_api } from "@/features/pill/lib/api";
import { order_api } from "@/features/specifications/lib/api";
import { import {
SpecificationsFakePills, type OrderCreateReq,
type SpecificationsType, type OrderListDataRes,
type OrderUpdateReq,
} from "@/features/specifications/lib/data"; } from "@/features/specifications/lib/data";
import { import {
SpecificationsForm, SpecificationsForm,
type SpecificationsFormType, type SpecificationsFormType,
} from "@/features/specifications/lib/form"; } from "@/features/specifications/lib/form";
import { FakeUserList } from "@/features/users/lib/data"; import { user_api } from "@/features/users/lib/api";
import formatPrice from "@/shared/lib/formatPrice"; import formatPrice from "@/shared/lib/formatPrice";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -21,65 +33,161 @@ import {
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
interface Props { interface Props {
initialValues: SpecificationsType | null; initialValues: OrderListDataRes | null;
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
setData: React.Dispatch<React.SetStateAction<SpecificationsType[]>>;
} }
export const AddedSpecification = ({ export const AddedSpecification = ({ initialValues, setDialogOpen }: Props) => {
setData, const queryClient = useQueryClient();
initialValues, const { data: pill } = useQuery({
setDialogOpen, queryKey: ["pill_list", initialValues],
}: Props) => { queryFn: () =>
pill_api.list({
limit: 999,
}),
select(data) {
return data.data.data;
},
});
const [userSearch, setUserSearch] = useState<string>("");
const [openUser, setOpenUser] = useState<boolean>(false);
const { data: user, isLoading: isUserLoading } = useQuery({
queryKey: ["user_list", userSearch, initialValues],
queryFn: () => user_api.list({ search: userSearch }),
select(data) {
return data.data.data.results;
},
});
const [factorySearch, setFactorySearch] = useState<string>("");
const [openFactory, setOpenFactory] = useState<boolean>(false);
const { data: pharm, isLoading: isPharmLoading } = useQuery({
queryKey: ["factory_list", userSearch, initialValues],
queryFn: () => factory_api.list({ name: factorySearch }),
select(data) {
return data.data.data.results;
},
});
const { mutate: create, isPending: createPending } = useMutation({
mutationFn: (body: OrderCreateReq) => order_api.create(body),
onSuccess: () => {
setDialogOpen(false);
queryClient.resetQueries({ queryKey: ["order_list"] });
},
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: update, isPending: updatePending } = useMutation({
mutationFn: ({ body, id }: { id: number; body: OrderUpdateReq }) =>
order_api.update({ body, id }),
onSuccess: () => {
setDialogOpen(false);
queryClient.resetQueries({ queryKey: ["order_list"] });
},
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 form = useForm<SpecificationsFormType>({ const form = useForm<SpecificationsFormType>({
resolver: zodResolver(SpecificationsForm), resolver: zodResolver(SpecificationsForm),
defaultValues: initialValues defaultValues: {
? { client: "",
client: initialValues.client, pharm: "",
pharm: String(initialValues.pharm.id), user: "",
percentage: initialValues.percentage, percentage: 0,
totalPrice: initialValues.totalPrice, totalPrice: 0,
paidPrice: initialValues.paidPrice, paidPrice: 0,
user: String(initialValues.user.id), medicines: [],
medicines: [ },
...initialValues.medicines, });
...SpecificationsFakePills.filter(
(p) => !initialValues.medicines.some((m) => m.id === p.id), useEffect(() => {
).map((p) => ({ if (!pill) return;
id: p.id,
name: p.name, if (initialValues) {
count: 0, const mergedMedicines = [
price: p.price, ...initialValues.order_items.map((item) => {
})), const pillItem = pill.results.find((p) => p.id === item.product);
],
} return {
: { id: item.product,
client: "", name: pillItem ? pillItem.name : "Unknown",
pharm: "", price: pillItem ? Number(pillItem.price) : 0,
user: "", count: Number(item.quantity),
percentage: 0, };
totalPrice: 0, }),
paidPrice: 0,
medicines: SpecificationsFakePills.map((p) => ({ ...pill.results
.filter(
(p) => !initialValues.order_items.some((m) => m.product === p.id),
)
.map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
price: Number(p.price),
count: 0, count: 0,
price: p.price,
})), })),
}, ];
});
form.reset({
client: initialValues.employee_name,
pharm: String(initialValues.factory.id),
percentage: initialValues.advance,
totalPrice: Number(initialValues.total_price),
paidPrice: Number(initialValues.paid_price),
user: String(initialValues.user.id),
medicines: mergedMedicines,
});
return;
}
const fakeMedicines = pill.results.map((p) => ({
id: p.id,
name: p.name,
price: Number(p.price),
count: 0,
}));
form.reset({
client: "",
pharm: "",
user: "",
percentage: 0,
totalPrice: 0,
paidPrice: 0,
medicines: fakeMedicines,
});
}, [pill, initialValues, form]);
const medicines = form.watch("medicines"); const medicines = form.watch("medicines");
@@ -105,84 +213,229 @@ export const AddedSpecification = ({
}, [form]); }, [form]);
const onSubmit = (values: SpecificationsFormType) => { const onSubmit = (values: SpecificationsFormType) => {
if (initialValues) { if (!initialValues) {
setData((prev) => const items = medicines
prev.map((item) => .filter((med) => med.count > 0)
item.id === initialValues.id .map((med) => ({
? { product: med.id,
...item, quantity: med.count,
...values, total_price: (med.price * med.count).toFixed(2),
pharm: pharmData.find((e) => e.id === Number(values.pharm))!, }));
user: FakeUserList.find((e) => e.id === Number(values.user))!,
} const total_price = items
: item, .reduce((sum, item) => sum + parseFloat(item.total_price), 0)
), .toFixed(2);
);
} else { create({
setData((prev) => [ advance: values.percentage,
...prev, employee_name: values.client,
{ factory_id: Number(values.pharm),
...values, paid_price: String(values.paidPrice),
id: Date.now(), total_price,
pharm: pharmData.find((e) => e.id === Number(values.pharm))!, items,
user: FakeUserList[1], user_id: Number(values.user),
});
} else if (initialValues) {
const items = medicines
.filter((med) => med.count > 0)
.map((med) => ({
product_id: med.id,
quantity: med.count,
total_price: (med.price * med.count).toFixed(2),
}));
const total_price = items
.reduce((sum, item) => sum + parseFloat(item.total_price), 0)
.toFixed(2);
update({
body: {
advance: values.percentage,
employee_name: values.client,
paid_price: String(values.paidPrice),
total_price,
items: items,
factory_id: Number(values.pharm),
user_id: Number(values.user),
}, },
]); id: initialValues.id,
});
} }
setDialogOpen(false);
}; };
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control}
name="pharm" name="pharm"
render={({ field }) => ( control={form.control}
<FormItem> render={({ field }) => {
<Label>Farmasevtika</Label> const selectedUser = pharm?.find(
<FormControl> (u) => String(u.id) === field.value,
<Select onValueChange={field.onChange} value={field.value}> );
<SelectTrigger className="w-full !h-12"> return (
<SelectValue placeholder="Farmasevtikalar" /> <FormItem className="flex flex-col">
</SelectTrigger> <Label className="text-md">Farmasevtika</Label>
<SelectContent>
{pharmData.map((e) => ( <Popover open={openFactory} onOpenChange={setOpenFactory}>
<SelectItem value={String(e.id)} key={e.id}> <PopoverTrigger asChild>
{e.name} <FormControl>
</SelectItem> <Button
))} type="button"
</SelectContent> variant="outline"
</Select> role="combobox"
</FormControl> aria-expanded={openFactory}
<FormMessage /> className={cn(
</FormItem> "w-full h-12 justify-between",
)} !field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={factorySearch}
onValueChange={setFactorySearch}
/>
<CommandList>
{isPharmLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : pharm && pharm.length > 0 ? (
<CommandGroup>
{pharm.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={() => {
field.onChange(String(u.id));
setOpenFactory(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Farmasevtika topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<FormField <FormField
control={form.control}
name="user" name="user"
render={({ field }) => ( control={form.control}
<FormItem> render={({ field }) => {
<Label>Foydalanuvchi</Label> const selectedUser = user?.find(
<FormControl> (u) => String(u.id) === field.value,
<Select onValueChange={field.onChange} value={field.value}> );
<SelectTrigger className="w-full !h-12"> return (
<SelectValue placeholder="Foydalanuvchilar" /> <FormItem className="flex flex-col">
</SelectTrigger> <Label className="text-md">Foydalanuvchi</Label>
<SelectContent>
{FakeUserList.map((e) => ( <Popover open={openUser} onOpenChange={setOpenUser}>
<SelectItem value={String(e.id)} key={e.id}> <PopoverTrigger asChild>
{e.firstName} {e.lastName} <FormControl>
</SelectItem> <Button
))} type="button"
</SelectContent> variant="outline"
</Select> role="combobox"
</FormControl> aria-expanded={openUser}
<FormMessage /> className={cn(
</FormItem> "w-full h-12 justify-between",
)} !field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={userSearch}
onValueChange={setUserSearch}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.length > 0 ? (
<CommandGroup>
{user.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.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<FormField <FormField
@@ -206,7 +459,10 @@ export const AddedSpecification = ({
key={med.id} key={med.id}
className="flex justify-between items-center space-x-2" className="flex justify-between items-center space-x-2"
> >
<p className="w-40">{med.name}</p> <div className="flex flex-col">
<p className="w-40">{med.name}</p>
<p>Narxi:{formatPrice(med.price)}</p>
</div>
<div className="flex gap-1"> <div className="flex gap-1">
<FormControl> <FormControl>
@@ -293,9 +549,10 @@ export const AddedSpecification = ({
<Button <Button
type="submit" type="submit"
disabled={createPending || updatePending}
className="w-full bg-blue-500 hover:bg-blue-500 cursor-pointer h-12" className="w-full bg-blue-500 hover:bg-blue-500 cursor-pointer h-12"
> >
Saqlash {createPending || updatePending ? <Loader2 /> : "Saqlash"}
</Button> </Button>
</form> </form>
</Form> </Form>

View File

@@ -0,0 +1,88 @@
import { order_api } from "@/features/specifications/lib/api";
import type { OrderListDataRes } from "@/features/specifications/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>>;
setPillDelete: Dispatch<SetStateAction<OrderListDataRes | null>>;
pillDelete: OrderListDataRes | null;
}
const DeleteOrder = ({
opneDelete,
setOpenDelete,
pillDelete,
setPillDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => order_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["order_list"] });
toast.success(`Foydalanuvchi o'chirildi`);
setOpenDelete(false);
setPillDelete(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>Dorini o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham bu zakazni 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={() => pillDelete && deleteUser(pillDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteOrder;

View File

@@ -1,17 +1,21 @@
"use client"; "use client";
import type { SpecificationsType } from "@/features/specifications/lib/data"; import { pill_api } from "@/features/pill/lib/api";
import type { OrderListDataRes } from "@/features/specifications/lib/data";
import formatPrice from "@/shared/lib/formatPrice"; import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { useQuery } from "@tanstack/react-query";
import { HardDriveDownloadIcon } from "lucide-react";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
interface Props { interface Props {
specification: SpecificationsType | null; specification: OrderListDataRes | null;
open: boolean; open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>; setOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -21,6 +25,44 @@ export const SpecificationDetail = ({
open, open,
setOpen, setOpen,
}: Props) => { }: Props) => {
const { data } = useQuery({
queryKey: ["pill_list"],
queryFn: () => pill_api.list({ limit: 999, offset: 1 }),
select(data) {
return data.data.data;
},
});
const downloadFile = async (fileUrl: string, fileName: string) => {
try {
const response = await fetch(fileUrl, {
method: "GET",
headers: {
// Agar token kerak bo'lsa qo'shing
// Authorization: `Bearer ${yourToken}`,
},
});
if (!response.ok) {
throw new Error("Fayl yuklab olishda xatolik yuz berdi");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || "file.pdf"; // fayl nomi
document.body.appendChild(a);
a.click();
// Tozalash
a.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error(err);
}
};
if (!specification) return null; if (!specification) return null;
return ( return (
@@ -39,7 +81,7 @@ export const SpecificationDetail = ({
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200"> <div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
<p className="text-sm text-blue-600 font-medium mb-1">Xaridor</p> <p className="text-sm text-blue-600 font-medium mb-1">Xaridor</p>
<p className="text-lg font-semibold text-gray-800"> <p className="text-lg font-semibold text-gray-800">
{specification.client} {specification.employee_name}
</p> </p>
</div> </div>
@@ -49,7 +91,7 @@ export const SpecificationDetail = ({
Farmasevtika Farmasevtika
</p> </p>
<p className="text-lg font-semibold text-gray-800"> <p className="text-lg font-semibold text-gray-800">
{specification.pharm.name} {specification.factory.name}
</p> </p>
</div> </div>
@@ -59,7 +101,7 @@ export const SpecificationDetail = ({
Mas'ul xodim Mas'ul xodim
</p> </p>
<p className="text-lg font-semibold text-gray-800"> <p className="text-lg font-semibold text-gray-800">
{specification.user.firstName} {specification.user.lastName} {specification.user.first_name} {specification.user.last_name}
</p> </p>
</div> </div>
</div> </div>
@@ -68,46 +110,56 @@ export const SpecificationDetail = ({
<div className="bg-gray-50 rounded-lg p-5 border border-gray-200"> <div className="bg-gray-50 rounded-lg p-5 border border-gray-200">
<h3 className="text-lg font-bold text-gray-800 mb-4 flex items-center"> <h3 className="text-lg font-bold text-gray-800 mb-4 flex items-center">
<span className="bg-indigo-600 text-white rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3"> <span className="bg-indigo-600 text-white rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">
{specification.medicines.length} {specification.order_items.length}
</span> </span>
Dorilar ro'yxati Dorilar ro'yxati
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{specification.medicines.map((med, index) => ( {specification.order_items.map((med, index) => {
<div const pill_name = data?.results.find(
key={med.id} (e) => e.id === med.product,
className="bg-white rounded-lg p-4 border border-gray-200 hover:border-indigo-300 transition-colors" );
> return (
<div className="flex items-start justify-between"> <div
<div className="flex-1"> key={med.id}
<div className="flex items-center gap-2 mb-2"> className="bg-white rounded-lg p-4 border border-gray-200 hover:border-indigo-300 transition-colors"
<span className="bg-indigo-100 text-indigo-700 text-xs font-semibold px-2 py-1 rounded"> >
#{index + 1} <div className="flex items-start justify-between">
</span> <div className="flex-1">
<p className="font-semibold text-gray-800"> <div className="flex items-center gap-2 mb-2">
{med.name} <span className="bg-indigo-100 text-indigo-700 text-xs font-semibold px-2 py-1 rounded">
#{index + 1}
</span>
<p className="font-semibold text-gray-800">
{pill_name?.name}
</p>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>
Miqdor: <strong>{med.quantity} ta</strong>
</span>
<span>×</span>
<span>
Narx:{" "}
<strong>
{formatPrice(
Number(med.total_price) / med.quantity,
)}
</strong>
</span>
</div>
</div>
<div className="text-right ml-4">
<p className="text-xs text-gray-500 mb-1">Jami</p>
<p className="text-lg font-bold text-indigo-600">
{formatPrice(med.total_price)}
</p> </p>
</div> </div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>
Miqdor: <strong>{med.count} ta</strong>
</span>
<span>×</span>
<span>
Narx: <strong>{formatPrice(med.price)}</strong>
</span>
</div>
</div>
<div className="text-right ml-4">
<p className="text-xs text-gray-500 mb-1">Jami</p>
<p className="text-lg font-bold text-indigo-600">
{formatPrice(med.count * med.price)}
</p>
</div> </div>
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
@@ -121,29 +173,38 @@ export const SpecificationDetail = ({
<div className="flex justify-between items-center pb-3 border-b border-slate-300"> <div className="flex justify-between items-center pb-3 border-b border-slate-300">
<span className="text-gray-600 font-medium">Jami narx:</span> <span className="text-gray-600 font-medium">Jami narx:</span>
<span className="text-xl font-bold text-gray-800"> <span className="text-xl font-bold text-gray-800">
{formatPrice(specification.totalPrice)} {formatPrice(specification.total_price)}
</span> </span>
</div> </div>
<div className="flex justify-between items-center pb-3 border-b border-slate-300"> <div className="flex justify-between items-center pb-3 border-b border-slate-300">
<span className="text-gray-600 font-medium"> <span className="text-gray-600 font-medium">
Chegirma foizi: To'langan foizi:
</span> </span>
<span className="text-lg font-semibold text-orange-600"> <span className="text-lg font-semibold text-orange-600">
{specification.percentage}% {specification.advance}%
</span> </span>
</div> </div>
<div className="flex justify-between items-center pt-2"> <div className="flex justify-between items-center pt-2">
<span className="text-gray-700 font-bold text-lg"> <span className="text-gray-700 font-bold text-lg">
To'lanadi: To'langan:
</span> </span>
<span className="text-2xl font-bold text-green-600"> <span className="text-2xl font-bold text-green-600">
{formatPrice(specification.paidPrice)} {formatPrice(specification.paid_price)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<Button
className="w-full h-12 bg-blue-600 text-md hover:bg-blue-700 cursor-pointer"
onClick={() =>
downloadFile(specification.file, `order-${specification.id}`)
}
>
<HardDriveDownloadIcon className="size-5" />
PDF faylda yuklab olish
</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,10 +1,9 @@
"use client"; "use client";
import { import { order_api } from "@/features/specifications/lib/api";
FakeSpecifications, import { type OrderListDataRes } from "@/features/specifications/lib/data";
type SpecificationsType,
} from "@/features/specifications/lib/data";
import { AddedSpecification } from "@/features/specifications/ui/AddedSpecification"; import { AddedSpecification } from "@/features/specifications/ui/AddedSpecification";
import DeleteOrder from "@/features/specifications/ui/DeleteOrder";
import { SpecificationDetail } from "@/features/specifications/ui/SpecificationDetail "; import { SpecificationDetail } from "@/features/specifications/ui/SpecificationDetail ";
import formatPrice from "@/shared/lib/formatPrice"; import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
@@ -15,6 +14,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import Pagination from "@/shared/ui/pagination";
import { import {
Table, Table,
TableBody, TableBody,
@@ -23,30 +23,38 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/shared/ui/table"; } from "@/shared/ui/table";
import clsx from "clsx"; import { useQuery } from "@tanstack/react-query";
import { import { Eye, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
ChevronLeft,
ChevronRight,
Eye,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const SpecificationsList = () => { const SpecificationsList = () => {
const [data, setData] = useState<SpecificationsType[]>(FakeSpecifications); const [editingPlan, setEditingPlan] = useState<OrderListDataRes | null>(null);
const [editingPlan, setEditingPlan] = useState<SpecificationsType | null>( const [detail, setDetail] = useState<OrderListDataRes | null>(null);
null,
);
const [detail, setDetail] = useState<SpecificationsType | null>(null);
const [detailOpen, setDetailOpen] = useState<boolean>(false); const [detailOpen, setDetailOpen] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const limit = 20;
const handleDelete = (id: number) => const {
setData((prev) => prev.filter((e) => e.id !== id)); data: order,
isLoading,
isError,
} = useQuery({
queryKey: ["order_list", currentPage],
queryFn: () => order_api.list({ limit, offset: (currentPage - 1) * limit }),
select(data) {
return data.data.data;
},
});
const totalPages = order ? Math.ceil(order.count / limit) : 1;
const [openDelete, setOpenDelete] = useState<boolean>(false);
const [pillDelete, setPillDelete] = useState<OrderListDataRes | null>(null);
const handleDelete = (id: OrderListDataRes) => {
setOpenDelete(true);
setPillDelete(id);
};
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <div className="flex flex-col h-full p-10 w-full">
@@ -70,7 +78,6 @@ const SpecificationsList = () => {
<AddedSpecification <AddedSpecification
initialValues={editingPlan} initialValues={editingPlan}
setDialogOpen={setDialogOpen} setDialogOpen={setDialogOpen}
setData={setData}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -83,103 +90,98 @@ const SpecificationsList = () => {
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<Table> {isLoading && (
<TableHeader> <div className="h-full flex items-center justify-center bg-white/70 z-10">
<TableRow> <span className="text-lg font-medium">
<TableHead>#</TableHead> <Loader2 className="animate-spin" />
<TableHead>Foydalanuvchi</TableHead> </span>
<TableHead>Farmasevtika</TableHead> </div>
<TableHead>Zakaz qilgan</TableHead> )}
<TableHead>Jami</TableHead>
<TableHead>% Tolangan</TableHead> {isError && (
<TableHead>Tolangan summa</TableHead> <div className="h-full flex items-center justify-center z-10">
<TableHead className="text-right">Amallar</TableHead> <span className="text-lg font-medium text-red-600">
</TableRow> Ma'lumotlarni olishda xatolik yuz berdi.
</TableHeader> </span>
<TableBody> </div>
{data.map((item, idx) => ( )}
<TableRow key={item.id}> {!isLoading && !isError && (
<TableCell>{idx + 1}</TableCell> <Table>
<TableCell> <TableHeader>
{item.user.firstName} {item.user.lastName} <TableRow>
</TableCell> <TableHead>#</TableHead>
<TableCell>{item.pharm.name}</TableCell> <TableHead>Foydalanuvchi</TableHead>
<TableCell>{item.client}</TableCell> <TableHead>Farmasevtika</TableHead>
<TableCell>{formatPrice(item.totalPrice)}</TableCell> <TableHead>Zakaz qilgan</TableHead>
<TableCell>{item.percentage}%</TableCell> <TableHead>Jami</TableHead>
<TableCell>{formatPrice(item.paidPrice)}</TableCell> <TableHead>% Tolangan</TableHead>
<TableCell className="text-right flex gap-2 justify-end"> <TableHead>Tolangan summa</TableHead>
<Button <TableHead className="text-right">Amallar</TableHead>
size="icon"
variant="outline"
onClick={() => {
setDetail(item);
setDetailOpen(true);
}}
className="bg-green-500 hover:bg-green-500 hover:text-white text-white cursor-pointer"
>
<Eye size={18} />
</Button>
<Button
size="icon"
variant="outline"
className="bg-blue-600 text-white hover:bg-blue-600 hover:text-white cursor-pointer"
onClick={() => {
setEditingPlan(item);
setDialogOpen(true);
}}
>
<Pencil size={18} />
</Button>
<Button
size="icon"
variant="destructive"
className="cursor-pointer"
onClick={() => handleDelete(item.id)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {order?.results.map((item, idx) => (
<TableRow key={item.id}>
<TableCell>{idx + 1}</TableCell>
<TableCell>
{item.user.first_name} {item.user.last_name}
</TableCell>
<TableCell>{item.factory.name}</TableCell>
<TableCell>{item.employee_name}</TableCell>
<TableCell>{formatPrice(item.total_price)}</TableCell>
<TableCell>{item.advance}%</TableCell>
<TableCell>{formatPrice(item.paid_price)}</TableCell>
<TableCell className="text-right flex gap-2 justify-end">
<Button
size="icon"
variant="outline"
onClick={() => {
setDetail(item);
setDetailOpen(true);
}}
className="bg-green-500 hover:bg-green-500 hover:text-white text-white cursor-pointer"
>
<Eye size={18} />
</Button>
<Button
size="icon"
variant="outline"
className="bg-blue-600 text-white hover:bg-blue-600 hover:text-white cursor-pointer"
onClick={() => {
setEditingPlan(item);
setDialogOpen(true);
}}
>
<Pencil size={18} />
</Button>
<Button
size="icon"
variant="destructive"
className="cursor-pointer"
onClick={() => handleDelete(item)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div> </div>
{/* Pagination */} <Pagination
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> currentPage={currentPage}
<Button setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
variant="outline" />
disabled={currentPage === 1}
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} <DeleteOrder
> opneDelete={openDelete}
<ChevronLeft /> pillDelete={pillDelete}
</Button> setOpenDelete={setOpenDelete}
{Array.from({ length: totalPages }, (_, i) => ( setPillDelete={setPillDelete}
<Button />
key={i}
size="icon"
variant={currentPage === i + 1 ? "default" : "outline"}
className={clsx(
currentPage === i + 1 ? "bg-blue-500 hover:bg-blue-600" : "",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
size="icon"
variant="outline"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,18 @@
import type { SupportListRes } from "@/features/support/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const support_api = {
async list(params: {
limit?: number;
offset?: number;
problem?: string;
district?: string;
user?: string;
date?: string;
}): Promise<AxiosResponse<SupportListRes>> {
const res = await httpClient.get(API_URLS.SUPPORT, { params });
return res;
},
};

View File

@@ -0,0 +1,27 @@
export interface SupportListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: SupportListData[];
};
}
export interface SupportListData {
id: number;
problem: string;
date: string;
type: "PROBLEM" | "HELP";
district: {
id: number;
name: string;
} | null;
user: {
id: number;
first_name: string;
last_name: string;
};
created_at: string;
}

View File

@@ -0,0 +1,70 @@
import type { SupportListData } from "@/features/support/lib/data";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import type { Dispatch, SetStateAction } from "react";
const SupportDetail = ({
open,
setOpen,
supportDetail,
}: {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
supportDetail: SupportListData | null;
}) => {
if (supportDetail === null) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Batafsil</DialogTitle>
<DialogDescription className="mt-5 flex flex-col gap-2">
<p className="text-black text-lg font-medium">
<span className="text-foreground">Kim jo'natgan:</span>{" "}
{supportDetail?.user.first_name} {supportDetail?.user.last_name}
</p>
{supportDetail?.district && (
<p className="text-black text-lg font-medium">
<span className="text-foreground">Tuman nomi:</span>{" "}
{supportDetail?.district?.name}
</p>
)}
<p className="text-black text-lg font-medium">
<span className="text-foreground">Jo'natilgan vaqti:</span>{" "}
{supportDetail?.date}
</p>
<p className="text-black text-lg font-medium">
<span className="text-foreground">Xabar turi:</span>{" "}
{supportDetail?.type === "PROBLEM"
? "Muommo hal qilish"
: "Yordam so'rash"}
</p>
<p className="text-black text-lg font-medium">
<span className="text-foreground">Xabar tavsifi:</span>{" "}
{supportDetail?.problem}
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-5">
<Button
className="bg-blue-500 hover:bg-blue-500 cursor-pointer w-32 h-12 text-md"
onClick={() => {
setOpen(false);
}}
>
Yopish
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default SupportDetail;

View File

@@ -0,0 +1,225 @@
import { support_api } from "@/features/support/lib/api";
import type { SupportListData } from "@/features/support/lib/data";
import SupportDetail from "@/features/support/ui/SupportDetail";
import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import { Input } from "@/shared/ui/input";
import Pagination from "@/shared/ui/pagination";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { useQuery } from "@tanstack/react-query";
import { ChevronDownIcon, Eye, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
const SupportList = () => {
const [currentPage, setCurrentPage] = useState(1);
const [nameFilter, setNameFilter] = useState<string>("");
const [disctritFilter, setDisctritFilter] = useState<string | null>(null);
const [open, setOpen] = useState<boolean>(false);
const [openDate, setOpenDate] = useState<boolean>(false);
const [supportDetail, setSupportDetail] = useState<SupportListData | null>(
null,
);
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
useEffect(() => {
if (disctritFilter?.length === 0) {
setDisctritFilter(null);
}
}, [disctritFilter]);
const limit = 20;
const { data, isLoading, isError } = useQuery({
queryKey: [
"factory_list",
currentPage,
nameFilter,
disctritFilter,
dateFilter,
],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
problem?: string;
district?: string;
user?: string;
date?: string;
} = {
limit: limit,
offset: (currentPage - 1) * limit,
user: nameFilter,
};
if (disctritFilter !== null) {
params.district = disctritFilter;
}
if (dateFilter) {
params.date = formatDate.format(dateFilter, "YYYY-MM-DD");
}
return support_api.list(params);
},
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data?.count / limit) : 1;
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">Yordam so'rovlari ro'yxati</h1>
<div className="flex gap-2 mb-4">
<Input
type="text"
placeholder="Foydalanuvchi nomi"
className="h-12"
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
/>
<Input
type="text"
placeholder="Tuman nomi"
className="h-12"
value={disctritFilter ?? ""}
onChange={(e) => setDisctritFilter(e.target.value)}
/>
<Popover open={openDate} onOpenChange={setOpenDate}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-48 justify-between font-normal h-12"
>
{dateFilter ? dateFilter.toDateString() : "Sana"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpenDate(false);
}}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpenDate(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<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>
)}
{!isLoading && !isError && (
<Table>
<TableHeader>
<TableRow className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Kim jo'natgan</TableHead>
<TableHead className="text-start">Habar haqida</TableHead>
<TableHead className="text-start">Habar turi</TableHead>
<TableHead className="text-start">Tuman</TableHead>
<TableHead className="text-start">Jo'natilgan sanasi</TableHead>
<TableHead className="text-start">Amallar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data && data.results.length > 0 ? (
data?.results.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>
{plan.user.first_name} {plan.user.last_name}
</TableCell>
<TableCell>{plan.problem.slice(0, 50)}...</TableCell>
<TableCell>
{plan.type === "PROBLEM"
? "Muommo hal qilish"
: "Yordam so'rash"}
</TableCell>
<TableCell>
{plan.district ? plan.district.name : "-"}
</TableCell>
<TableCell>{plan.date}</TableCell>
<TableCell>
<Button
className="bg-blue-500 hover:bg-blue-500 cursor-pointer"
onClick={() => {
setOpen(true);
setSupportDetail(plan);
}}
>
<Eye />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-4 text-lg">
Farmasevtika topilmadi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
<Pagination
currentPage={currentPage}
setCurrentPage={setCurrentPage}
totalPages={totalPages}
/>
<SupportDetail
open={open}
setOpen={setOpen}
supportDetail={supportDetail}
/>
</div>
);
};
export default SupportList;

View File

@@ -0,0 +1,39 @@
import type {
PlanTourCreate,
PlanTourListRes,
PlanTourUpdate,
} from "@/features/tour-plan/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const tour_plan_api = {
async list(params: {
limit?: number;
offset?: number;
name?: string;
date?: string;
user?: string;
}): Promise<AxiosResponse<PlanTourListRes>> {
const res = await httpClient.get(`${API_URLS.TOUR_PLAN}list/`, { params });
return res;
},
async create(body: PlanTourCreate) {
const res = await httpClient.post(`${API_URLS.TOUR_PLAN}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: PlanTourUpdate }) {
const res = await httpClient.patch(
`${API_URLS.TOUR_PLAN}${id}/update/`,
body,
);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.TOUR_PLAN}${id}/delete/`);
return res;
},
};

View File

@@ -58,3 +58,46 @@ export const fakeTourPlan: TourPlanType[] = [
status: "planned", status: "planned",
}, },
]; ];
export interface PlanTourListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: PlanTourListDataRes[];
};
}
export interface PlanTourListDataRes {
id: number;
place_name: string;
user: {
id: number;
first_name: string;
last_name: string;
};
latitude: null | string;
longitude: null | string;
location_send: boolean;
date: null | string;
created_at: string;
}
export interface PlanTourCreate {
place_name: string;
// latitude: number;
// longitude: number;
user_id: number;
date: string;
}
export interface PlanTourUpdate {
place_name: string;
user: number;
// latitude: number;
// longitude: number;
date: string;
}

View File

@@ -4,6 +4,4 @@ export const tourPlanForm = z.object({
district: z.string().min(1, { message: "Majburiy maydon" }), district: z.string().min(1, { message: "Majburiy maydon" }),
user: z.string().min(1, { message: "Majburiy maydon" }), user: z.string().min(1, { message: "Majburiy maydon" }),
date: z.date().min(1, { message: "Majburiy maydon" }), date: z.date().min(1, { message: "Majburiy maydon" }),
long: z.string().min(1, { message: "Majburiy maydon" }),
lat: z.string().min(1, { message: "Majburiy maydon" }),
}); });

View File

@@ -1,8 +1,23 @@
import type { TourPlanType } from "@/features/tour-plan/lib/data"; import { tour_plan_api } from "@/features/tour-plan/lib/api";
import type {
PlanTourCreate,
PlanTourListDataRes,
PlanTourUpdate,
} from "@/features/tour-plan/lib/data";
import { tourPlanForm } from "@/features/tour-plan/lib/form"; import { tourPlanForm } from "@/features/tour-plan/lib/form";
import { FakeUserList } from "@/features/users/lib/data"; import { user_api } from "@/features/users/lib/api";
import formatDate from "@/shared/lib/formatDate";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar"; import { Calendar } from "@/shared/ui/calendar";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -13,103 +28,196 @@ import {
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDownIcon, Loader2 } from "lucide-react"; import type { AxiosError } from "axios";
import { Check, ChevronDownIcon, ChevronsUpDown, Loader2 } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react"; import { useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
interface Props { interface Props {
initialValues: TourPlanType | null; initialValues: PlanTourListDataRes | null;
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
setPlans: Dispatch<SetStateAction<TourPlanType[]>>;
} }
const AddedTourPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => { const AddedTourPlan = ({ initialValues, setDialogOpen }: Props) => {
const [load, setLoad] = useState<boolean>(false); const queryClient = useQueryClient();
const form = useForm<z.infer<typeof tourPlanForm>>({ const form = useForm<z.infer<typeof tourPlanForm>>({
resolver: zodResolver(tourPlanForm), resolver: zodResolver(tourPlanForm),
defaultValues: { defaultValues: {
date: initialValues?.date || undefined, date: initialValues?.date ? new Date(initialValues?.date) : undefined,
district: initialValues?.district || "", district: initialValues?.place_name || "",
lat: initialValues?.lat || "41.2949",
long: initialValues?.long || "69.2361",
user: initialValues?.user.id.toString() || "", user: initialValues?.user.id.toString() || "",
}, },
}); });
const { mutate, isPending } = useMutation({
mutationFn: (body: PlanTourCreate) => tour_plan_api.create(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["tour_plan_list"] });
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: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { id: number; body: PlanTourUpdate }) =>
tour_plan_api.update({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["tour_plan_list"] });
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 [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchUser, setSearchUser] = useState<string>("");
const lat = form.watch("lat"); const [openUser, setOpenUser] = useState<boolean>(false);
const long = form.watch("long"); const { data: user, isLoading: isUserLoading } = useQuery({
queryKey: ["user_list", searchUser],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
search?: string;
is_active?: boolean | string;
region_id?: number;
} = {
limit: 8,
search: searchUser,
};
const handleMapClick = (e: { get: (key: string) => number[] }) => { return user_api.list(params);
const coords = e.get("coords"); },
form.setValue("lat", coords[0].toString()); select(data) {
form.setValue("long", coords[1].toString()); return data.data.data;
}; },
});
function onSubmit(values: z.infer<typeof tourPlanForm>) { function onSubmit(values: z.infer<typeof tourPlanForm>) {
setLoad(true); if (!initialValues) {
const newObject: TourPlanType = { mutate({
id: initialValues ? initialValues.id : Date.now(), date: formatDate.format(values.date, "YYYY-MM-DD"),
user: FakeUserList.find((u) => u.id === Number(values.user))!, place_name: values.district,
date: values.date, user_id: Number(values.user),
district: values.district,
lat: values.lat,
long: values.long,
status: "planned",
};
setTimeout(() => {
setPlans((prev) => {
if (initialValues) {
return prev.map((item) =>
item.id === initialValues.id ? newObject : item,
);
} else {
return [...prev, newObject];
}
}); });
setLoad(false); } else if (initialValues) {
setDialogOpen(false); edit({
}, 2000); body: {
user: Number(values.user),
date: formatDate.format(values.date, "YYYY-MM-DD"),
place_name: values.district,
},
id: initialValues.id,
});
}
} }
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control}
name="user" name="user"
render={({ field }) => ( control={form.control}
<FormItem> render={({ field }) => {
<Label>Kim uchun</Label> const selectedUser = user?.results.find(
<FormControl> (u) => String(u.id) === field.value,
<Select onValueChange={field.onChange} value={field.value}> );
<SelectTrigger className="w-full"> return (
<SelectValue placeholder="Foydalanuvchilar" /> <FormItem className="flex flex-col">
</SelectTrigger> <Label className="text-md">Foydalanuvchi</Label>
<SelectContent>
{FakeUserList.map((e) => ( <Popover open={openUser} onOpenChange={setOpenUser}>
<SelectItem value={String(e.id)}> <PopoverTrigger asChild>
{e.firstName} {e.lastName} <FormControl>
</SelectItem> <Button
))} type="button"
</SelectContent> variant="outline"
</Select> role="combobox"
</FormControl> aria-expanded={openUser}
<FormMessage /> className={cn(
</FormItem> "w-full h-12 justify-between",
)} !field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi 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}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchUser}
onValueChange={setSearchUser}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.results.length > 0 ? (
<CommandGroup>
{user.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.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/> />
<FormField <FormField
@@ -168,7 +276,7 @@ const AddedTourPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => {
)} )}
/> />
<div className="h-[300px] w-full border rounded-lg overflow-hidden"> {/* <div className="h-[300px] w-full border rounded-lg overflow-hidden">
<YMaps> <YMaps>
<Map <Map
defaultState={{ center: [Number(lat), Number(long)], zoom: 16 }} defaultState={{ center: [Number(lat), Number(long)], zoom: 16 }}
@@ -188,13 +296,13 @@ const AddedTourPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => {
/> />
</Map> </Map>
</YMaps> </YMaps>
</div> </div> */}
<Button <Button
type="submit" type="submit"
className="w-full bg-blue-500 hover:bg-blue-500 cursor-pointer" className="w-full bg-blue-500 hover:bg-blue-500 cursor-pointer"
> >
{load ? ( {isPending || editPending ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : initialValues ? ( ) : initialValues ? (
"Tahrirlash" "Tahrirlash"

View File

@@ -0,0 +1,89 @@
import { tour_plan_api } from "@/features/tour-plan/lib/api";
import type { PlanTourListDataRes } from "@/features/tour-plan/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<PlanTourListDataRes | null>>;
planDelete: PlanTourListDataRes | null;
}
const DeleteTourPlan = ({
opneDelete,
setOpenDelete,
planDelete,
setPlanDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => tour_plan_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["tour_plan_list"] });
toast.success(`Foydalanuvchi 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>Tur Planni o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {planDelete?.user.first_name}{" "}
{planDelete?.user.last_name} ha tegishli rejani 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 DeleteTourPlan;

View File

@@ -0,0 +1,113 @@
import type { PlanTourListDataRes } from "@/features/tour-plan/lib/data";
import AddedTourPlan from "@/features/tour-plan/ui/AddedTourPlan";
import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { ChevronDownIcon, Plus } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react";
interface Props {
dateFilter: Date | undefined;
setDateFilter: Dispatch<SetStateAction<Date | undefined>>;
searchUser: string;
setSearchUser: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setEditingPlan: Dispatch<SetStateAction<PlanTourListDataRes | null>>;
editingPlan: PlanTourListDataRes | null;
}
const FilterTourPlan = ({
dateFilter,
setDateFilter,
searchUser,
setSearchUser,
setDialogOpen,
dialogOpen,
setEditingPlan,
editingPlan,
}: Props) => {
const [open, setOpen] = useState<boolean>(false);
return (
<div className="flex gap-2 mb-4">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-48 justify-between font-normal h-12"
>
{dateFilter ? dateFilter.toDateString() : "Sana"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpen(false);
}}
toYear={new Date().getFullYear() + 50}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpen(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
<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">
<DialogHeader>
<DialogTitle className="text-xl">
{editingPlan ? "Rejani tahrirlash" : "Yangi reja qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedTourPlan
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
/>
</DialogContent>
</Dialog>
</div>
);
};
export default FilterTourPlan;

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import type { TourPlanType } from "@/features/tour-plan/lib/data"; import type { PlanTourListDataRes } from "@/features/tour-plan/lib/data";
import formatDate from "@/shared/lib/formatDate";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { import {
Dialog, Dialog,
@@ -13,7 +14,7 @@ import { Circle, Map, Placemark, YMaps } from "@pbe/react-yandex-maps";
interface Props { interface Props {
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
plan: TourPlanType | null; plan: PlanTourListDataRes | null;
} }
const TourPlanDetailDialog = ({ open, setOpen, plan }: Props) => { const TourPlanDetailDialog = ({ open, setOpen, plan }: Props) => {
@@ -33,55 +34,50 @@ const TourPlanDetailDialog = ({ open, setOpen, plan }: Props) => {
<div> <div>
<p className="font-semibold">Foydalanuvchi:</p> <p className="font-semibold">Foydalanuvchi:</p>
<p> <p>
{plan.user.firstName} {plan.user.lastName} {plan.user.first_name} {plan.user.last_name}
</p> </p>
</div> </div>
{/* District */} {/* District */}
<div> <div>
<p className="font-semibold">Hudud:</p> <p className="font-semibold">Hudud:</p>
<p>{plan.district}</p> <p>{plan.place_name}</p>
</div> </div>
{/* Sana */} {/* Sana */}
<div> <div>
<p className="font-semibold">Sana:</p> <p className="font-semibold">Sana:</p>
<p>{plan.date.toLocaleString()}</p> <p>{plan.date && formatDate.format(plan.date, "YYYY-MM-DD")}</p>
</div> </div>
{/* Status */} {/* Status */}
<div> <div>
<p className="font-semibold">Status:</p> <p className="font-semibold">Status:</p>
<Badge <Badge
className={ className={plan.location_send ? "bg-green-600" : "bg-yellow-500"}
plan.status === "completed" ? "bg-green-600" : "bg-yellow-500"
}
> >
{plan.status === "completed" ? "Bajarilgan" : "Rejalashtirilgan"} {plan.location_send ? "Bajarilgan" : "Rejalashtirilgan"}
</Badge> </Badge>
</div> </div>
{plan.userLocation && ( {plan.location_send && (
<YMaps> <YMaps>
<Map <Map
defaultState={{ defaultState={{
center: [ center: [Number(plan.latitude), Number(plan.longitude)],
Number(plan.userLocation.lat),
Number(plan.userLocation.long),
],
zoom: 16, zoom: 16,
}} }}
width="100%" width="100%"
height="300px" height="300px"
> >
<Placemark <Placemark
geometry={[ geometry={[Number(plan.latitude), Number(plan.longitude)]}
Number(plan.userLocation.lat),
Number(plan.userLocation.long),
]}
/> />
<Circle <Circle
geometry={[[Number(plan.lat), Number(plan.long)], 100]} geometry={[
[Number(plan.latitude), Number(plan.longitude)],
100,
]}
options={{ options={{
fillColor: "rgba(0, 150, 255, 0.2)", fillColor: "rgba(0, 150, 255, 0.2)",
strokeColor: "rgba(0, 150, 255, 0.8)", strokeColor: "rgba(0, 150, 255, 0.8)",

View File

@@ -1,151 +1,67 @@
import { fakeTourPlan, type TourPlanType } from "@/features/tour-plan/lib/data"; import { tour_plan_api } from "@/features/tour-plan/lib/api";
import AddedTourPlan from "@/features/tour-plan/ui/AddedTourPlan"; import { type PlanTourListDataRes } from "@/features/tour-plan/lib/data";
import DeleteTourPlan from "@/features/tour-plan/ui/DeleteTourPlab";
import FilterTourPlan from "@/features/tour-plan/ui/FilterTourPlan";
import TourPlanDetailDialog from "@/features/tour-plan/ui/TourPlanDetailDialog"; import TourPlanDetailDialog from "@/features/tour-plan/ui/TourPlanDetailDialog";
import TourPlanTable from "@/features/tour-plan/ui/TourPlanTable";
import formatDate from "@/shared/lib/formatDate"; import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button"; import Pagination from "@/shared/ui/pagination";
import { Calendar } from "@/shared/ui/calendar"; import { useQuery } from "@tanstack/react-query";
import { import { useState } from "react";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import {
ChevronDownIcon,
ChevronLeft,
ChevronRight,
Edit,
Eye,
Plus,
Trash,
} from "lucide-react";
import { useMemo, useState } from "react";
const TourPlanList = () => { const TourPlanList = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5; const limit = 20;
const [plans, setPlans] = useState<TourPlanType[]>(fakeTourPlan);
const [editingPlan, setEditingPlan] = useState<TourPlanType | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<TourPlanType | null>(null);
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined); const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
const [open, setOpen] = useState<boolean>(false);
const [searchUser, setSearchUser] = useState<string>(""); const [searchUser, setSearchUser] = useState<string>("");
const handleDelete = (id: number) => { const { data, isError, isLoading, isFetching } = useQuery({
setPlans(plans.filter((p) => p.id !== id)); queryKey: ["tour_plan_list", currentPage, dateFilter, searchUser],
queryFn: () =>
tour_plan_api.list({
limit,
offset: (currentPage - 1) * limit,
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
user: searchUser,
}),
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data.count / limit) : 1;
const [editingPlan, setEditingPlan] = useState<PlanTourListDataRes | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<PlanTourListDataRes | null>(null);
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const [openDelete, setOpenDelete] = useState<boolean>(false);
const [planDelete, setPlanDelete] = useState<PlanTourListDataRes | null>(
null,
);
const handleDelete = (id: PlanTourListDataRes) => {
setOpenDelete(true);
setPlanDelete(id);
}; };
const filteredPlans = useMemo(() => {
return plans.filter((item) => {
// 2) Sana filtri: createdAt === tanlangan sana
const dateMatch = dateFilter
? item.date.toDateString() === dateFilter.toDateString()
: true;
// 3) User ism familiya bo'yicha qidiruv
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return dateMatch && userMatch;
});
}, [plans, dateFilter, searchUser]);
return ( return (
<div className="flex flex-col h-full p-10 w-full"> <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 md:flex-row justify-between items-start md:items-center mb-4 gap-4">
<h1 className="text-2xl font-bold">Rejalarni boshqarish</h1> <h1 className="text-2xl font-bold">Rejalarni boshqarish</h1>
<div className="flex gap-2 mb-4"> <FilterTourPlan
{/* Sana filter */} dateFilter={dateFilter}
<Popover open={open} onOpenChange={setOpen}> dialogOpen={dialogOpen}
<PopoverTrigger asChild> editingPlan={editingPlan}
<Button searchUser={searchUser}
variant="outline" setDateFilter={setDateFilter}
id="date" setDialogOpen={setDialogOpen}
className="w-48 justify-between font-normal h-12" setEditingPlan={setEditingPlan}
> setSearchUser={setSearchUser}
{dateFilter ? dateFilter.toDateString() : "Sana"} />
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
mode="single"
selected={dateFilter}
captionLayout="dropdown"
onSelect={(date) => {
setDateFilter(date);
setOpen(false);
}}
/>
<div className="p-2 border-t bg-white">
<Button
variant="outline"
className="w-full"
onClick={() => {
setDateFilter(undefined);
setOpen(false);
}}
>
Tozalash
</Button>
</div>
</PopoverContent>
</Popover>
<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">
<DialogHeader>
<DialogTitle className="text-xl">
{editingPlan ? "Rejani tahrirlash" : "Yangi reja qo'shish"}
</DialogTitle>
</DialogHeader>
<AddedTourPlan
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
setPlans={setPlans}
/>
</DialogContent>
</Dialog>
</div>
<TourPlanDetailDialog <TourPlanDetailDialog
plan={detail} plan={detail}
@@ -154,116 +70,30 @@ const TourPlanList = () => {
/> />
</div> </div>
{/* Table */} <TourPlanTable
<div className="flex-1 overflow-auto"> data={data ? data.results : []}
<Table> handleDelete={handleDelete}
<TableHeader> isError={isError}
<TableRow className="text-center"> isFetching={isFetching}
<TableHead className="text-start">ID</TableHead> isLoading={isLoading}
<TableHead className="text-start">Kimga tegishli</TableHead> setDetail={setDetail}
<TableHead className="text-start">Boriladigan joyi</TableHead> setDetailOpen={setDetailOpen}
<TableHead className="text-start">Sanasi</TableHead> setDialogOpen={setDialogOpen}
<TableHead className="text-start">Statusi</TableHead> setEditingPlan={setEditingPlan}
<TableHead className="text-right">Harakatlar</TableHead> />
</TableRow>
</TableHeader>
<TableBody>
{filteredPlans.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>
{plan.user.firstName + " " + plan.user.lastName}
</TableCell>
<TableCell>{plan.district}</TableCell>
<TableCell>
{formatDate.format(plan.date, "DD-MM-YYYY")}
</TableCell>
<TableCell
className={clsx(
plan.status === "completed"
? "text-green-500"
: "text-red-500",
)}
>
{plan.status === "completed" ? "Borildi" : "Borilmagan"}
</TableCell>
<TableCell className="flex gap-2 justify-end"> <Pagination
<Button currentPage={currentPage}
variant="outline" setCurrentPage={setCurrentPage}
size="icon" totalPages={totalPages}
onClick={() => { />
setDetail(plan);
setDetailOpen(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<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>
<div className="mt-2 sticky bottom-0 bg-white flex justify-end gap-2 z-10 py-2 border-t"> <DeleteTourPlan
<Button opneDelete={openDelete}
variant="outline" planDelete={planDelete}
size="icon" setOpenDelete={setOpenDelete}
disabled={currentPage === 1} setPlanDelete={setPlanDelete}
className="cursor-pointer" />
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
>
<ChevronLeft />
</Button>
{Array.from({ length: totalPages }, (_, i) => (
<Button
key={i}
variant={currentPage === i + 1 ? "default" : "outline"}
size="icon"
className={clsx(
currentPage === i + 1
? "bg-blue-500 hover:bg-blue-500"
: " bg-none hover:bg-blue-200",
"cursor-pointer",
)}
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))}
<Button
variant="outline"
size="icon"
disabled={currentPage === totalPages}
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
className="cursor-pointer"
>
<ChevronRight />
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,135 @@
import type { PlanTourListDataRes } from "@/features/tour-plan/lib/data";
import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import { Edit, Eye, Loader2, Trash } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
data: PlanTourListDataRes[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
setDetail: Dispatch<SetStateAction<PlanTourListDataRes | null>>;
setDetailOpen: Dispatch<SetStateAction<boolean>>;
setEditingPlan: Dispatch<SetStateAction<PlanTourListDataRes | null>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (paln: PlanTourListDataRes) => void;
}
const TourPlanTable = ({
data,
isFetching,
isError,
isLoading,
setDetail,
setEditingPlan,
handleDelete,
setDialogOpen,
setDetailOpen,
}: 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 className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Kimga tegishli</TableHead>
<TableHead className="text-start">Boriladigan joyi</TableHead>
<TableHead className="text-start">Sanasi</TableHead>
<TableHead className="text-start">Statusi</TableHead>
<TableHead className="text-right">Harakatlar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.length > 0 ? (
data.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>
{plan.user.first_name + " " + plan.user.last_name}
</TableCell>
<TableCell>{plan.place_name}</TableCell>
<TableCell>
{plan.date && formatDate.format(plan.date, "DD-MM-YYYY")}
</TableCell>
<TableCell
className={clsx(
plan.location_send ? "text-green-500" : "text-red-500",
)}
>
{plan.location_send ? "Borildi" : "Borilmagan"}
</TableCell>
<TableCell className="flex gap-1 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(plan);
setDetailOpen(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<Button
variant="outline"
size="icon"
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="icon"
className="cursor-pointer"
onClick={() => handleDelete(plan)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center py-4 text-lg">
Tur plan topilmadi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
);
};
export default TourPlanTable;

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