doctor and pharmacies crud

This commit is contained in:
Samandar Turgunboyev
2025-11-29 19:19:40 +05:00
parent bcf9d7cd2b
commit 9bc4c3df1f
45 changed files with 3610 additions and 1469 deletions

View File

@@ -8,6 +8,7 @@ export const discrit_api = {
limit?: number; limit?: number;
offset?: number; offset?: number;
name?: string; name?: string;
user?: number;
}): Promise<AxiosResponse<DistrictListRes>> { }): Promise<AxiosResponse<DistrictListRes>> {
const res = await httpClient.get(`${DISTRICT}list/`, { params }); const res = await httpClient.get(`${DISTRICT}list/`, { params });
return res; return res;

View File

@@ -2,8 +2,8 @@ import { discrit_api } from "@/features/districts/lib/api";
import { type DistrictListData } from "@/features/districts/lib/data"; import { type DistrictListData } from "@/features/districts/lib/data";
import DeleteDiscrit from "@/features/districts/ui/DeleteDiscrit"; import DeleteDiscrit from "@/features/districts/ui/DeleteDiscrit";
import Filter from "@/features/districts/ui/Filter"; import Filter from "@/features/districts/ui/Filter";
import PaginationDistrict from "@/features/districts/ui/PaginationDistrict";
import TableDistrict from "@/features/districts/ui/TableDistrict"; import TableDistrict from "@/features/districts/ui/TableDistrict";
import Pagination from "@/shared/ui/pagination";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
@@ -66,7 +66,7 @@ const DistrictsList = () => {
currentPage={currentPage} currentPage={currentPage}
/> />
<PaginationDistrict <Pagination
currentPage={currentPage} currentPage={currentPage}
setCurrentPage={setCurrentPage} setCurrentPage={setCurrentPage}
totalPages={totalPages} totalPages={totalPages}

View File

@@ -1,57 +0,0 @@
import { Button } from "@/shared/ui/button";
import clsx from "clsx";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
interface Props {
currentPage: number;
setCurrentPage: Dispatch<SetStateAction<number>>;
totalPages: number;
}
const PaginationDistrict = ({
currentPage,
setCurrentPage,
totalPages,
}: Props) => {
return (
<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>
);
};
export default PaginationDistrict;

View File

@@ -1,4 +1,8 @@
import type { DoctorListRes } from "@/features/doctors/lib/data"; import type {
CreateDoctorReq,
DoctorListRes,
UpdateDoctorReq,
} from "@/features/doctors/lib/data";
import httpClient from "@/shared/config/api/httpClient"; import httpClient from "@/shared/config/api/httpClient";
import { DOCTOR } from "@/shared/config/api/URLs"; import { DOCTOR } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
@@ -17,4 +21,19 @@ export const doctor_api = {
const res = await httpClient.get(`${DOCTOR}list/`, { params }); const res = await httpClient.get(`${DOCTOR}list/`, { params });
return res; return res;
}, },
async create(body: CreateDoctorReq) {
const res = await httpClient.post(`${DOCTOR}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: UpdateDoctorReq }) {
const res = await httpClient.patch(`${DOCTOR}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${DOCTOR}${id}/delete/`);
return res;
},
}; };

View File

@@ -92,3 +92,32 @@ export interface DoctorListResData {
}; };
created_at: string; 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,58 +29,266 @@ 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>>;
} }
interface CoordsData {
lat: number;
lon: number;
polygon: [number, number][][];
}
const AddedDoctor = ({ initialValues, setDialogOpen }: Props) => { const AddedDoctor = ({ initialValues, setDialogOpen }: Props) => {
const [load, setLoad] = useState<boolean>(false); 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 { data: object, isLoading: isObjectLoading } = useQuery({
queryKey: ["object_list", searchUser, selectDiscrit],
queryFn: () => {
const params: {
name?: string;
district?: string;
} = {
name: searchUser,
district: selectDiscrit,
};
return object_api.list(params);
},
select(data) {
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 { 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]);
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) {
console.log(values); edit({
setDialogOpen(false); id: initialValues.id,
body: {
description: values.desc,
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),
sphere: values.spec,
work_place: values.work,
},
});
} else {
mutate({
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 (
@@ -164,98 +387,349 @@ const AddedDoctor = ({ initialValues, 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(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Foydalanuvchi</Label>
<Popover open={openUser} onOpenChange={setOpenUser}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl> <FormControl>
<Select onValueChange={field.onChange} value={field.value}> <Button
<SelectTrigger className="w-full !h-12"> type="button"
<SelectValue placeholder="Foydalanuvchilar" /> variant="outline"
</SelectTrigger> role="combobox"
<SelectContent> aria-expanded={openUser}
{FakeUserList.map((e) => ( className={cn(
<SelectItem value={String(e.id)}> "w-full h-12 justify-between",
{e.firstName} {e.lastName} !field.value && "text-muted-foreground",
</SelectItem> )}
))} >
</SelectContent> {selectedUser
</Select> ? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi tanlang"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl> </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 /> <FormMessage />
</FormItem> </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)", }}
/>
<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, strokeWidth: 2,
interactivityModel: "default#transparent", 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>
@@ -263,7 +737,7 @@ const AddedDoctor = ({ initialValues, 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

@@ -37,7 +37,9 @@ const DoctorDetailDialog = ({ detail, setDetail, object }: Props) => {
const [polygonCoords, setPolygonCoords] = useState<[number, number][][]>([]); const [polygonCoords, setPolygonCoords] = useState<[number, number][][]>([]);
const [circleCoords] = useState<[number, number] | null>(null); const [circleCoords, setCircleCoords] = useState<[number, number] | null>(
null,
);
const getCoords = async (name: string): Promise<CoordsData | null> => { const getCoords = async (name: string): Promise<CoordsData | null> => {
try { try {
@@ -84,12 +86,11 @@ const DoctorDetailDialog = ({ detail, setDetail, object }: Props) => {
} }
setCoords([object.latitude, object.longitude]); setCoords([object.latitude, object.longitude]);
// setCircleCoords([object.latitude, object.longitude]); setCircleCoords([object.latitude, object.longitude]);
}; };
load(); load();
}, [object]); }, [object]);
if (!object) return null; if (!object) return null;
return ( return (
@@ -154,10 +155,9 @@ const DoctorDetailDialog = ({ detail, setDetail, object }: Props) => {
/> />
)} )}
{/* Radius circle (ish joyi atrofida) */}
{circleCoords && ( {circleCoords && (
<Circle <Circle
geometry={[circleCoords, 500]} geometry={[circleCoords, 300]}
options={{ options={{
fillColor: "rgba(255, 100, 0, 0.3)", fillColor: "rgba(255, 100, 0, 0.3)",
strokeColor: "rgba(255, 100, 0, 0.8)", strokeColor: "rgba(255, 100, 0, 0.8)",

View File

@@ -1,19 +1,19 @@
import { doctor_api } from "@/features/doctors/lib/api"; import { doctor_api } from "@/features/doctors/lib/api";
import { import { type DoctorListResData } from "@/features/doctors/lib/data";
type DoctorListResData, import DeleteDoctor from "@/features/doctors/ui/DeleteDoctor";
type DoctorListType,
} from "@/features/doctors/lib/data";
import DoctorDetailDialog from "@/features/doctors/ui/DoctorDetailDialog"; import DoctorDetailDialog from "@/features/doctors/ui/DoctorDetailDialog";
import FilterDoctor from "@/features/doctors/ui/FilterDoctor"; import FilterDoctor from "@/features/doctors/ui/FilterDoctor";
import PaginationDoctor from "@/features/doctors/ui/PaginationDoctor";
import TableDoctor from "@/features/doctors/ui/TableDoctor"; import TableDoctor from "@/features/doctors/ui/TableDoctor";
import Pagination from "@/shared/ui/pagination";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
const DoctorsList = () => { const DoctorsList = () => {
const [detail, setDetail] = useState<DoctorListResData | null>(null); const [detail, setDetail] = useState<DoctorListResData | 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);
@@ -24,6 +24,11 @@ const DoctorsList = () => {
const [searchSpec, setSearchSpec] = useState(""); const [searchSpec, setSearchSpec] = useState("");
const [searchUser, setSearchUser] = useState(""); const [searchUser, setSearchUser] = useState("");
const [disricDelete, setDiscritDelete] = useState<DoctorListResData | null>(
null,
);
const [opneDelete, setOpenDelete] = useState<boolean>(false);
const limit = 20; const limit = 20;
const { const {
@@ -58,8 +63,10 @@ const DoctorsList = () => {
}, },
}); });
// const handleDelete = (id: number) => { const handleDelete = (user: DoctorListResData) => {
// }; setDiscritDelete(user);
setOpenDelete(true);
};
const totalPages = doctor ? Math.ceil(doctor.count / limit) : 1; const totalPages = doctor ? Math.ceil(doctor.count / limit) : 1;
@@ -79,7 +86,6 @@ const DoctorsList = () => {
searchSpec={searchSpec} searchSpec={searchSpec}
searchUser={searchUser} searchUser={searchUser}
searchWork={searchWork} searchWork={searchWork}
// setData={setData}
setDialogOpen={setDialogOpen} setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan} setEditingPlan={setEditingPlan}
setSearchDistrict={setSearchDistrict} setSearchDistrict={setSearchDistrict}
@@ -103,15 +109,25 @@ const DoctorsList = () => {
isLoading={isLoading} isLoading={isLoading}
doctor={doctor ? doctor.results : []} doctor={doctor ? doctor.results : []}
setDetail={setDetail} setDetail={setDetail}
handleDelete={handleDelete}
isFetching={isFetching} isFetching={isFetching}
setDetailDialog={setDetailDialog} setDetailDialog={setDetailDialog}
setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
/> />
<PaginationDoctor <Pagination
currentPage={currentPage} currentPage={currentPage}
setCurrentPage={setCurrentPage} setCurrentPage={setCurrentPage}
totalPages={totalPages} totalPages={totalPages}
/> />
<DeleteDoctor
discrit={disricDelete}
opneDelete={opneDelete}
setDiscritDelete={setDiscritDelete}
setOpenDelete={setOpenDelete}
/>
</div> </div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import type { DoctorListType } from "@/features/doctors/lib/data"; import type { DoctorListResData } from "@/features/doctors/lib/data";
import AddedDoctor from "@/features/doctors/ui/AddedDoctor"; import AddedDoctor from "@/features/doctors/ui/AddedDoctor";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
@@ -27,8 +27,8 @@ interface Props {
setDialogOpen: Dispatch<SetStateAction<boolean>>; setDialogOpen: Dispatch<SetStateAction<boolean>>;
searchObject: string; searchObject: string;
setSearchObject: Dispatch<SetStateAction<string>>; setSearchObject: Dispatch<SetStateAction<string>>;
setEditingPlan: Dispatch<SetStateAction<DoctorListType | null>>; setEditingPlan: Dispatch<SetStateAction<DoctorListResData | null>>;
editingPlan: DoctorListType | null; editingPlan: DoctorListResData | null;
} }
const FilterDoctor = ({ const FilterDoctor = ({

View File

@@ -1,57 +0,0 @@
import { Button } from "@/shared/ui/button";
import clsx from "clsx";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
currentPage: number;
setCurrentPage: Dispatch<SetStateAction<number>>;
totalPages: number;
}
const PaginationDoctor = ({
currentPage,
setCurrentPage,
totalPages,
}: Props) => {
return (
<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>
);
};
export default PaginationDoctor;

View File

@@ -15,11 +15,14 @@ import type { Dispatch, SetStateAction } from "react";
interface Props { interface Props {
setDetail: Dispatch<SetStateAction<DoctorListResData | null>>; setDetail: Dispatch<SetStateAction<DoctorListResData | null>>;
setEditingPlan: Dispatch<SetStateAction<DoctorListResData | null>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>; setDetailDialog: Dispatch<SetStateAction<boolean>>;
doctor: DoctorListResData[] | []; doctor: DoctorListResData[] | [];
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
handleDelete: (user: DoctorListResData) => void;
} }
const TableDoctor = ({ const TableDoctor = ({
@@ -27,7 +30,10 @@ const TableDoctor = ({
setDetail, setDetail,
setDetailDialog, setDetailDialog,
isError, isError,
setEditingPlan,
isLoading, isLoading,
setDialogOpen,
handleDelete,
isFetching, isFetching,
}: Props) => { }: Props) => {
return ( return (
@@ -100,8 +106,8 @@ const TableDoctor = ({
size="icon" size="icon"
className="bg-blue-600 text-white cursor-pointer hover:bg-blue-600 hover:text-white" className="bg-blue-600 text-white cursor-pointer hover:bg-blue-600 hover:text-white"
onClick={() => { onClick={() => {
// setEditingPlan(item); setEditingPlan(item);
// setDialogOpen(true); setDialogOpen(true);
}} }}
> >
<Pencil size={18} /> <Pencil size={18} />
@@ -110,7 +116,7 @@ const TableDoctor = ({
variant="destructive" variant="destructive"
size="icon" size="icon"
className="cursor-pointer" className="cursor-pointer"
// onClick={() => handleDelete(item.id)} onClick={() => handleDelete(item)}
> >
<Trash2 size={18} /> <Trash2 size={18} />
</Button> </Button>

View File

@@ -0,0 +1,74 @@
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 { 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>>;
}
const LocationFilter = ({
open,
setOpen,
dateFilter,
setDateFilter,
searchUser,
setSearchUser,
}: Props) => {
return (
<div className="flex gap-2 w-full md:w-auto">
<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

@@ -3,27 +3,9 @@ import {
type LocationListType, type LocationListType,
} from "@/features/location/lib/data"; } from "@/features/location/lib/data";
import LocationDetailDialog from "@/features/location/ui/LocationDetailDialog"; import LocationDetailDialog from "@/features/location/ui/LocationDetailDialog";
import formatDate from "@/shared/lib/formatDate"; import LocationFilter from "@/features/location/ui/LocationFilter";
import { Button } from "@/shared/ui/button"; import LocationTable from "@/features/location/ui/LocationTable";
import { Calendar } from "@/shared/ui/calendar"; import Pagination from "@/shared/ui/pagination";
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,
Eye,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
const LocationList = () => { const LocationList = () => {
@@ -62,54 +44,14 @@ const LocationList = () => {
<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}
>
{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>
<LocationDetailDialog <LocationDetailDialog
detail={detailDialog} detail={detailDialog}
@@ -118,105 +60,18 @@ const LocationList = () => {
/> />
</div> </div>
<div className="flex-1 overflow-auto"> <LocationTable
<Table> filtered={filtered}
<TableHeader> handleDelete={handleDelete}
<TableRow> setDetail={setDetail}
<TableHead>#</TableHead> setDetailDialog={setDetailDialog}
<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> <Pagination
{item.district currentPage={currentPage}
? "Tuman" setCurrentPage={setCurrentPage}
: item.object totalPages={totalPages}
? "Obyekt" />
: item.doctor
? "Shifokor"
: item.pharmcies
? "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.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">
<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,92 @@
import type { LocationListType } 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: LocationListType[];
setDetail: Dispatch<SetStateAction<LocationListType | null>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: number) => 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.firstName} {item.user.lastName}
</TableCell>
<TableCell>
{formatDate.format(item.createdAt, "DD-MM-YYYY")}
</TableCell>
<TableCell>
{item.district
? "Tuman"
: item.object
? "Obyekt"
: item.doctor
? "Shifokor"
: item.pharmcies
? "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.id)}
>
<Trash2 size={18} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default LocationTable;

View File

@@ -0,0 +1,36 @@
import type {
ObjectCreate,
ObjectListRes,
ObjectUpdate,
} from "@/features/objects/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { OBJECT } 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;
}): Promise<AxiosResponse<ObjectListRes>> {
const res = await httpClient.get(`${OBJECT}list/`, { params });
return res;
},
async create(body: ObjectCreate) {
const res = await httpClient.post(`${OBJECT}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: ObjectUpdate }) {
const res = await httpClient.patch(`${OBJECT}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${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 handleMapClick = (e: { get: (key: string) => number[] }) => { const params: {
const coords = e.get("coords"); limit?: number;
form.setValue("lat", coords[0].toString()); offset?: number;
form.setValue("long", coords[1].toString()); 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: 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;
};
useEffect(() => {
if (initialValues) {
(async () => {
const result = await getCoords(initialValues.district.name);
if (result) {
setCoords({
latitude: initialValues.latitude,
longitude: initialValues.longitude,
});
setPolygonCoords(result.polygon);
form.setValue("lat", String(result.lat));
form.setValue("long", String(result.lon));
setCircleCoords([initialValues.latitude, initialValues.longitude]);
}
})();
}
}, [initialValues]);
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);
},
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>) { function onSubmit(values: z.infer<typeof ObjectForm>) {
setLoad(true);
const newObject: ObjectListType = {
id: initialValues ? initialValues.id : Date.now(),
name: values.name,
lat: values.lat,
long: values.long,
moreLong: [values.long, values.lat],
user: FakeUserList.find((u) => u.id === Number(values.user))!,
district: fakeDistrict.find((d) => d.id === Number(values.district))!,
};
setTimeout(() => {
setData((prev) => {
if (initialValues) { if (initialValues) {
return prev.map((item) => edit({
item.id === initialValues.id ? newObject : item, body: {
); extra_location: {
} else { latitude: Number(values.lat),
return [...prev, newObject]; longitude: Number(values.long),
} },
latitude: Number(values.lat),
longitude: Number(values.long),
name: values.name,
},
id: initialValues.id,
}); });
setLoad(false); } else {
setDialogOpen(false); mutate({
}, 2000); 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,69 +265,217 @@ 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> );
return (
<FormItem className="flex flex-col">
<Label className="text-md">Foydalanuvchi</Label>
<Popover open={openUser} onOpenChange={setOpenUser}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl> <FormControl>
<Select onValueChange={field.onChange} value={field.value}> <Button
<SelectTrigger className="w-full !h-12"> type="button"
<SelectValue placeholder="Tumanlar" /> variant="outline"
</SelectTrigger> role="combobox"
<SelectContent> aria-expanded={openUser}
{fakeDistrict.map((e) => ( className={cn(
<SelectItem key={e.id} value={String(e.id)}> "w-full h-12 justify-between",
{e.name} !field.value && "text-muted-foreground",
</SelectItem> )}
))} >
</SelectContent> {selectedUser
</Select> ? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi tanlang"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl> </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 /> <FormMessage />
</FormItem> </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> );
return (
<FormItem className="flex flex-col">
<Label className="text-md">Tumanlar</Label>
<Popover open={openDiscrit} onOpenChange={setOpenDiscrit}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl> <FormControl>
<Select onValueChange={field.onChange} value={field.value}> <Button
<SelectTrigger className="w-full !h-12"> type="button"
<SelectValue placeholder="Foydalanuvchilar" /> variant="outline"
</SelectTrigger> role="combobox"
<SelectContent> aria-expanded={openDiscrit}
{FakeUserList.map((e) => ( className={cn(
<SelectItem value={String(e.id)}> "w-full h-12 justify-between",
{e.firstName} {e.lastName} !field.value && "text-muted-foreground",
</SelectItem> )}
))} >
</SelectContent> {selectedDiscrit
</Select> ? `${selectedDiscrit.name}`
: "Tuman tanlang"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl> </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 /> <FormMessage />
</FormItem> </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 options={{
geometry={[[Number(lat), Number(long)], 100]} position: { right: "10px", bottom: "70px" },
}}
/>
<Placemark geometry={[coords.latitude, coords.longitude]} />
{polygonCoords && (
<Polygon
geometry={polygonCoords}
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)",
@@ -173,6 +483,18 @@ export default function AddedObject({
interactivityModel: "default#transparent", 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 options={{
geometry={[[Number(object.lat), Number(object.long)], 100]} position: { right: "10px", bottom: "70px" },
}}
/>
{/* Ish joyining markazi */}
<Placemark geometry={coords} />
{/* Tuman polygon */}
{polygonCoords.length > 0 && (
<Polygon
geometry={polygonCoords}
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)",
strokeWidth: 2, 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: () =>
return nameMatch && districtMatch && userMatch; object_api.list({
district: searchDistrict,
limit,
offset: (currentPage - 1) * limit,
name: searchName,
user: searchUser,
}),
select(data) {
return data.data.data;
},
}); });
}, [data, searchName, searchDistrict, searchUser]);
const totalPages = object ? Math.ceil(object.count / 20) : 1;
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"
/>
<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} setDialogOpen={setDialogOpen}
setData={setData} setEditingPlan={setEditingPlan}
setSearchDistrict={setSearchDistrict}
setSearchName={setSearchName}
setSearchUser={setSearchUser}
/> />
</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,37 @@
import type {
CreatePharmaciesReq,
PharmaciesListRes,
UpdatePharmaciesReq,
} from "@/features/pharmacies/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { PHARMACIES } 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;
}): Promise<AxiosResponse<PharmaciesListRes>> {
const res = await httpClient.get(`${PHARMACIES}list/`, { params });
return res;
},
async create(body: CreatePharmaciesReq) {
const res = await httpClient.post(`${PHARMACIES}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: UpdatePharmaciesReq }) {
const res = await httpClient.patch(`${PHARMACIES}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${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,258 @@ 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 handleMapClick = (e: { get: (key: string) => number[] }) => { const params: {
const coords = e.get("coords"); limit?: number;
form.setValue("lat", coords[0].toString()); offset?: number;
form.setValue("long", coords[1].toString()); 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 { data: object, isLoading: isObjectLoading } = useQuery({
queryKey: ["object_list", searchUser, selectDiscrit],
queryFn: () => {
const params: {
name?: string;
district?: string;
} = {
name: searchUser,
district: selectDiscrit,
};
return object_api.list(params);
},
select(data) {
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;
};
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]);
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);
},
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>) { function onSubmit(values: z.infer<typeof PharmForm>) {
setLoad(true);
const newObject: PharmciesType = {
id: initialValues ? initialValues.id : Date.now(),
name: values.name,
lat: values.lat,
long: values.long,
user: FakeUserList.find((u) => u.id === Number(values.user))!,
district: fakeDistrict.find((d) => d.id === Number(values.district))!,
additional_phone: onlyNumber(values.additional_phone),
inn: values.inn,
object: ObjectListData.find((o) => o.id === Number(values.object))!,
phone_number: onlyNumber(values.phone_number),
};
setTimeout(() => {
setData((prev) => {
if (initialValues) { if (initialValues) {
return prev.map((item) => edit({
item.id === initialValues.id ? newObject : item, id: initialValues.id,
); body: {
} else { extra_location: {
return [...prev, newObject]; 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),
},
}); });
setLoad(false); } else {
setDialogOpen(false); mutate({
}, 2000); 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,94 +351,330 @@ 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(
(u) => String(u.id) === field.value,
);
return (
<FormItem className="flex flex-col">
<Label className="text-md">Foydalanuvchi</Label>
<Popover open={openUser} onOpenChange={setOpenUser}>
<PopoverTrigger asChild disabled={initialValues !== null}>
<FormControl> <FormControl>
<Select onValueChange={field.onChange} value={field.value}> <Button
<SelectTrigger className="w-full !h-12"> type="button"
<SelectValue placeholder="Foydalanuvchilar" /> variant="outline"
</SelectTrigger> role="combobox"
<SelectContent> aria-expanded={openUser}
{FakeUserList.map((e) => ( className={cn(
<SelectItem value={String(e.id)}> "w-full h-12 justify-between",
{e.firstName} {e.lastName} !field.value && "text-muted-foreground",
</SelectItem> )}
))} >
</SelectContent> {selectedUser
</Select> ? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi tanlang"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl> </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 /> <FormMessage />
</FormItem> </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 options={{
geometry={[[Number(lat), Number(long)], 100]} position: { right: "10px", bottom: "70px" },
}}
/>
<Placemark geometry={[coords.latitude, coords.longitude]} />
{polygonCoords && (
<Polygon
geometry={polygonCoords}
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)",
@@ -254,6 +682,18 @@ const AddedPharmacies = ({ initialValues, setData, setDialogOpen }: Props) => {
interactivityModel: "default#transparent", 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 +701,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)]} options={{
position: { right: "10px", bottom: "70px" },
}}
/> />
<Circle
geometry={[[Number(object.lat), Number(object.long)], 100]} {/* Ish joyining markazi */}
<Placemark geometry={coords} />
{/* Tuman polygon */}
{polygonCoords.length > 0 && (
<Polygon
geometry={polygonCoords}
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)",
strokeWidth: 2, 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,
const filteredData = useMemo(() => { searchDistrict,
return data.filter((item) => { searchName,
const nameMatch = `${item.name}` searchObject,
.toLowerCase() searchUser,
.includes(searchName.toLowerCase()); ],
const districtMatch = item.district.name queryFn: () =>
.toLowerCase() pharmacies_api.list({
.includes(searchDistrict.toLowerCase()); district: searchDistrict,
const objectMatch = item.object.name offset: (currentPage - 1) * limit,
.toLowerCase() limit: limit,
.includes(searchObject.toLowerCase()); name: searchName,
const userMatch = `${item.user.firstName} ${item.user.lastName}` place: searchObject,
.toLowerCase() user: searchUser,
.includes(searchUser.toLowerCase()); }),
select(data) {
return nameMatch && districtMatch && objectMatch && userMatch; return data.data.data;
},
}); });
}, [data, searchName, searchDistrict, searchObject, searchUser]);
const totalPages = pharmacies ? Math.ceil(pharmacies.count / limit) : 1;
const handleDelete = (user: PharmaciesListData) => {
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">
<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
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} setDialogOpen={setDialogOpen}
setData={setData} setEditingPlan={setEditingPlan}
setSearchDistrict={setSearchDistrict}
setSearchName={setSearchName}
setSearchObject={setSearchObject}
setSearchUser={setSearchUser}
/> />
</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,139 @@
import type { Plan } 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: Plan | null;
setEditingPlan: Dispatch<SetStateAction<Plan | null>>;
setPlans: Dispatch<SetStateAction<Plan[]>>;
}
const FilterPlans = ({
setStatusFilter,
statusFilter,
open,
setOpen,
dateFilter,
setDateFilter,
searchUser,
setSearchUser,
dialogOpen,
setDialogOpen,
setEditingPlan,
editingPlan,
setPlans,
}: 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="Bajarildi">Bajarildi</SelectItem>
<SelectItem value="Bajarilmagan">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}
setPlans={setPlans}
/>
</DialogContent>
</Dialog>
</div>
);
};
export default FilterPlans;

View File

@@ -0,0 +1,101 @@
import type { Plan } 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, Trash } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filteredPlans: Plan[];
setEditingPlan: Dispatch<SetStateAction<Plan | null>>;
setDetail: Dispatch<SetStateAction<boolean>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: number) => void;
}
const PalanTable = ({
filteredPlans,
setEditingPlan,
setDetail,
setDialogOpen,
handleDelete,
}: Props) => {
return (
<div className="flex-1 overflow-auto">
<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">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>
);
};
export default PalanTable;

View File

@@ -1,45 +1,9 @@
"use client";
import type { Plan } from "@/features/plans/lib/data"; import type { Plan } from "@/features/plans/lib/data";
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 { FakeUserList } from "@/features/users/lib/data";
import { Button } from "@/shared/ui/button"; import Pagination from "@/shared/ui/pagination";
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 {
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"; import { useMemo, useState } from "react";
const PlansList = () => { const PlansList = () => {
@@ -79,16 +43,13 @@ const PlansList = () => {
const filteredPlans = useMemo(() => { const filteredPlans = useMemo(() => {
return plans.filter((item) => { return plans.filter((item) => {
// 1) Status (agar all bo'lsa filtrlanmaydi)
const statusMatch = const statusMatch =
statusFilter === "all" || item.status === statusFilter; statusFilter === "all" || item.status === statusFilter;
// 2) Sana filtri: createdAt === tanlangan sana
const dateMatch = dateFilter const dateMatch = dateFilter
? item.createdAt.toDateString() === dateFilter.toDateString() ? item.createdAt.toDateString() === dateFilter.toDateString()
: true; : true;
// 3) User ism familiya bo'yicha qidiruv
const userMatch = `${item.user.firstName} ${item.user.lastName}` const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase() .toLowerCase()
.includes(searchUser.toLowerCase()); .includes(searchUser.toLowerCase());
@@ -99,208 +60,40 @@ const PlansList = () => {
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
<div className="flex gap-2 mb-4"> dateFilter={dateFilter}
{/* Status filter */} dialogOpen={dialogOpen}
<Select value={statusFilter} onValueChange={setStatusFilter}> editingPlan={editingPlan}
<SelectTrigger className="w-full !h-12"> open={open}
<SelectValue placeholder="foydalanuvchi" /> searchUser={searchUser}
</SelectTrigger> setDateFilter={setDateFilter}
<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} setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
setOpen={setOpen}
setPlans={setPlans} setPlans={setPlans}
setSearchUser={setSearchUser}
setStatusFilter={setStatusFilter}
statusFilter={statusFilter}
/> />
</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={filteredPlans}
<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> />
<TableHead className="text-start">Tavsifi</TableHead>
<TableHead className="text-start">Kimga tegishli</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.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))}
>
<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

@@ -1,7 +1,6 @@
import { ReportsData, type ReportsTypeList } from "@/features/reports/lib/data"; import { ReportsData, type ReportsTypeList } from "@/features/reports/lib/data";
import AddedReport from "@/features/reports/ui/AddedReport"; import AddedReport from "@/features/reports/ui/AddedReport";
import formatDate from "@/shared/lib/formatDate"; import ReportsTable from "@/features/reports/ui/ReportsTable";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
Dialog, Dialog,
@@ -10,16 +9,8 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { import Pagination from "@/shared/ui/pagination";
Table, import { Plus } from "lucide-react";
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 = () => {
@@ -37,7 +28,7 @@ const ReportsList = () => {
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>
@@ -65,92 +56,18 @@ const ReportsList = () => {
</Dialog> </Dialog>
</div> </div>
<div className="flex-1 overflow-auto"> <ReportsTable
<Table> handleDelete={handleDelete}
<TableHeader> plans={plans}
<TableRow className="text-center"> setDialogOpen={setDialogOpen}
<TableHead className="text-start">ID</TableHead> setEditingPlan={setEditingPlan}
<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.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,78 @@
import type { ReportsTypeList } from "@/features/reports/lib/data";
import formatDate from "@/shared/lib/formatDate";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Edit, Trash } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
const ReportsTable = ({
plans,
setEditingPlan,
setDialogOpen,
handleDelete,
}: {
plans: ReportsTypeList[];
setEditingPlan: Dispatch<SetStateAction<ReportsTypeList | null>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: number) => void;
}) => {
return (
<div className="flex-1 overflow-auto">
<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.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">
<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

@@ -1,11 +1,12 @@
import type { import type {
BotUsers,
UserCreateReq, UserCreateReq,
UserListRes, UserListRes,
UserUpdateReq, UserUpdateReq,
} from "@/features/users/lib/data"; } from "@/features/users/lib/data";
import httpClient from "@/shared/config/api/httpClient"; import httpClient from "@/shared/config/api/httpClient";
import { USER } from "@/shared/config/api/URLs"; import { USER } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios"; import axios, { type AxiosResponse } from "axios";
export const user_api = { export const user_api = {
async list(params: { async list(params: {
@@ -38,4 +39,11 @@ export const user_api = {
const res = await httpClient.delete(`${USER}${id}/delete/`); const res = await httpClient.delete(`${USER}${id}/delete/`);
return res; return res;
}, },
async bot_start(): Promise<AxiosResponse<BotUsers>> {
const res = await axios.get(
"https://api.telegram.org/bot8137312508:AAF37FpdHaWIUPQqkai9IqW3ob6Z500KnC0/getUpdates",
);
return res;
},
}; };

View File

@@ -72,6 +72,7 @@ export interface UserListData {
name: string; name: string;
}; };
is_active: boolean; is_active: boolean;
telegram_id: string;
created_at: string; created_at: string;
} }
@@ -87,4 +88,39 @@ export interface UserCreateReq {
last_name: string; last_name: string;
region_id: number; region_id: number;
is_active: boolean; is_active: boolean;
telegram_id: string;
}
export interface BotUsers {
ok: boolean;
result: BotUsersData[];
}
export interface BotUsersData {
update_id: number;
message: {
message_id: number;
from: {
id: number;
is_bot: boolean;
first_name: string;
language_code: string;
username?: string;
};
chat: {
id: number;
first_name: string;
type: string;
username?: string;
};
date: number;
text: string;
entities: [
{
offset: number;
length: number;
type: string;
},
];
};
} }

View File

@@ -5,4 +5,5 @@ export const AddedUser = z.object({
lastName: z.string().min(1, { message: "Majburiy maydon" }), lastName: z.string().min(1, { message: "Majburiy maydon" }),
region: z.string().min(1, { message: "Majburiy maydon" }), region: z.string().min(1, { message: "Majburiy maydon" }),
isActive: z.string().min(1, { message: "Majburiy maydon" }), isActive: z.string().min(1, { message: "Majburiy maydon" }),
telegram_id: z.string().min(1, { message: "Foydalnuvchini tanlang" }),
}); });

View File

@@ -26,7 +26,8 @@ import {
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Loader2 } from "lucide-react"; import { Loader2, Search } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import type z from "zod"; import type z from "zod";
@@ -42,11 +43,13 @@ const AddUsers = ({ initialData, setDialogOpen }: UserFormProps) => {
defaultValues: { defaultValues: {
firstName: initialData?.first_name || "", firstName: initialData?.first_name || "",
lastName: initialData?.last_name || "", lastName: initialData?.last_name || "",
region: initialData?.region.name || "", region: initialData ? String(initialData.region.id) : "",
isActive: initialData ? String(initialData.is_active) : "true", isActive: initialData ? String(initialData.is_active) : "true",
telegram_id: initialData ? initialData.telegram_id : "",
}, },
}); });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [bot, setBot] = useState<"select" | "input">("select");
const { mutate: update, isPending } = useMutation({ const { mutate: update, isPending } = useMutation({
mutationFn: ({ body, id }: { id: number; body: UserUpdateReq }) => mutationFn: ({ body, id }: { id: number; body: UserUpdateReq }) =>
@@ -94,12 +97,13 @@ const AddUsers = ({ initialData, setDialogOpen }: UserFormProps) => {
}, },
id: initialData.id, id: initialData.id,
}); });
} else if (initialData === null) { } else if (initialData === null && values.telegram_id) {
create({ create({
first_name: values.firstName, first_name: values.firstName,
is_active: values.isActive === "true" ? true : false, is_active: values.isActive === "true" ? true : false,
last_name: values.lastName, last_name: values.lastName,
region_id: Number(values.region), region_id: Number(values.region),
telegram_id: values.telegram_id,
}); });
} }
} }
@@ -110,9 +114,64 @@ const AddUsers = ({ initialData, setDialogOpen }: UserFormProps) => {
select: (res) => res.data.data, select: (res) => res.data.data,
}); });
const { data: user } = useQuery({
queryKey: ["bot_users"],
queryFn: () => user_api.bot_start(),
select: (res) => res.data.result,
});
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
control={form.control}
name="telegram_id"
render={({ field }) => (
<FormItem>
<Label className="text-md mr-4">Botga start bosganlar</Label>
<FormControl>
<div className="flex gap-1">
{bot === "select" ? (
<Select
value={String(field.value)}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="Botga start bosganlar" />
</SelectTrigger>
<SelectContent>
{user?.map((e) => (
<SelectItem value={String(e.message.chat.id)}>
{e.message.chat.username
? e.message.chat.username
: e.message.chat.first_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-12"
placeholder="Telgram idni yozing"
{...field}
/>
)}
<Button
type="button"
className="h-12 !bg-blue-700 w-12 cursor-pointer"
onClick={() =>
bot === "select" ? setBot("input") : setBot("select")
}
>
<Search />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="firstName" name="firstName"

View File

@@ -40,7 +40,6 @@ const DeleteUser = ({
setUserDelete(null); setUserDelete(null);
}, },
onError: (err: AxiosError) => { onError: (err: AxiosError) => {
console.log(err);
const errMessage = err.response?.data as { message: string }; const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message; const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", { toast.error(messageText || "Xatolik yuz berdi", {

View File

@@ -4,8 +4,8 @@ import { user_api } from "@/features/users/lib/api";
import type { UserListData } from "@/features/users/lib/data"; import type { UserListData } from "@/features/users/lib/data";
import DeleteUser from "@/features/users/ui/DeleteUser"; import DeleteUser from "@/features/users/ui/DeleteUser";
import Filter from "@/features/users/ui/Filter"; import Filter from "@/features/users/ui/Filter";
import Pagination from "@/features/users/ui/Pagination";
import UserTable from "@/features/users/ui/UserTable"; import UserTable from "@/features/users/ui/UserTable";
import Pagination from "@/shared/ui/pagination";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";

View File

@@ -7,5 +7,17 @@ const REGION = "/api/v1/admin/district/";
const REGIONS = "/api/v1/admin/region/"; const REGIONS = "/api/v1/admin/region/";
const DISTRICT = "/api/v1/admin/district/"; const DISTRICT = "/api/v1/admin/district/";
const DOCTOR = "/api/v1/admin/doctor/"; const DOCTOR = "/api/v1/admin/doctor/";
const OBJECT = "/api/v1/admin/place/";
const PHARMACIES = "/api/v1/admin/pharmacy/";
export { BASE_URL, DISTRICT, DOCTOR, LOGIN, REGION, REGIONS, USER }; export {
BASE_URL,
DISTRICT,
DOCTOR,
LOGIN,
OBJECT,
PHARMACIES,
REGION,
REGIONS,
USER,
};

View File

@@ -10,8 +10,6 @@ const httpClient = axios.create({
httpClient.interceptors.request.use( httpClient.interceptors.request.use(
async (config) => { async (config) => {
console.log(`API REQUEST to ${config.url}`, config);
// Language configs // Language configs
const language = i18n.language; const language = i18n.language;
config.headers["Accept-Language"] = language; config.headers["Accept-Language"] = language;

View File

@@ -15,8 +15,8 @@ const Pagination = ({ currentPage, setCurrentPage, totalPages }: Props) => {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="cursor-pointer"
disabled={currentPage === 1} disabled={currentPage === 1}
className="cursor-pointer"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
> >
<ChevronLeft /> <ChevronLeft />
@@ -40,9 +40,9 @@ const Pagination = ({ currentPage, setCurrentPage, totalPages }: Props) => {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="cursor-pointer"
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))} onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
className="cursor-pointer"
> >
<ChevronRight /> <ChevronRight />
</Button> </Button>