apilar ulandi

This commit is contained in:
Samandar Turgunboyev
2025-12-02 19:31:37 +05:00
parent 40036322cb
commit f7dbb665a0
56 changed files with 3235 additions and 1189 deletions

View File

@@ -1,5 +1,5 @@
import httpClient from "@/shared/config/api/httpClient";
import { LOGIN } from "@/shared/config/api/URLs";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
interface LoginRes {
@@ -16,7 +16,7 @@ export const auth_pai = {
username: string;
password: string;
}): Promise<AxiosResponse<LoginRes>> {
const res = await httpClient.post(LOGIN, body);
const res = await httpClient.post(API_URLS.LOGIN, body);
return res;
},
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
import { location_api } from "@/features/location/lib/api";
import type { LocationListDataRes } from "@/features/location/lib/data";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Trash, X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
opneDelete: boolean;
setOpenDelete: Dispatch<SetStateAction<boolean>>;
setLocationDelete: Dispatch<SetStateAction<LocationListDataRes | null>>;
locationDelete: LocationListDataRes | null;
viewLocation: "user_send" | "user_send_object";
}
const DeleteLocation = ({
opneDelete,
viewLocation,
locationDelete,
setOpenDelete,
setLocationDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => location_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["location_list"] });
toast.success(`Jo'natilgan lokatsiya o'chirildi`);
setOpenDelete(false);
setLocationDelete(null);
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
const { mutate: deleteUserLocation, isPending: deleteUserLocationPen } =
useMutation({
mutationFn: (id: number) => location_api.list_user_location_delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["user_location_list"] });
toast.success(`Jo'natilgan lokatsiya o'chirildi`);
setOpenDelete(false);
setLocationDelete(null);
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dorini o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham jo'natilgan lokatsiyani o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() =>
locationDelete && viewLocation === "user_send_object"
? deleteUser(locationDelete.id)
: locationDelete &&
viewLocation === "user_send" &&
deleteUserLocation(locationDelete.id)
}
>
{isPending || deleteUserLocationPen ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteLocation;

View File

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

View File

@@ -2,6 +2,13 @@ import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { ChevronDownIcon } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
@@ -12,6 +19,8 @@ interface Props {
setDateFilter: Dispatch<SetStateAction<Date | undefined>>;
searchUser: string;
setSearchUser: Dispatch<SetStateAction<string>>;
viewLocation: "user_send" | "user_send_object";
setViewLocation: Dispatch<SetStateAction<"user_send" | "user_send_object">>;
}
const LocationFilter = ({
@@ -21,9 +30,28 @@ const LocationFilter = ({
setDateFilter,
searchUser,
setSearchUser,
viewLocation,
setViewLocation,
}: Props) => {
return (
<div className="flex gap-2 w-full md:w-auto">
<Select
value={viewLocation}
onValueChange={(v) =>
setViewLocation(v as "user_send" | "user_send_object")
}
>
<SelectTrigger className="w-[240px] !h-12">
<SelectValue placeholder="Tanlang" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user_send_object">
Obyektdan jo'natilgan
</SelectItem>
<SelectItem value="user_send">Turgan joyidan jo'natilgan</SelectItem>
</SelectContent>
</Select>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button

View File

@@ -1,44 +1,66 @@
import {
LocationFakeData,
type LocationListType,
} from "@/features/location/lib/data";
import { location_api } from "@/features/location/lib/api";
import { type LocationListDataRes } from "@/features/location/lib/data";
import DeleteLocation from "@/features/location/ui/DeleteLocation";
import LocationDetailDialog from "@/features/location/ui/LocationDetailDialog";
import LocationFilter from "@/features/location/ui/LocationFilter";
import LocationTable from "@/features/location/ui/LocationTable";
import UserLocationTable from "@/features/location/ui/UserLocationTable";
import formatDate from "@/shared/lib/formatDate";
import Pagination from "@/shared/ui/pagination";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
const LocationList = () => {
const [data, setData] = useState<LocationListType[]>(LocationFakeData);
const [detail, setDetail] = useState<LocationListType | null>(null);
const [detail, setDetail] = useState<LocationListDataRes | null>(null);
const [detailDialog, setDetailDialog] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
// Filter state
const limit = 20;
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
const [searchUser, setSearchUser] = useState<string>("");
const [viewLocation, setViewLocation] = useState<
"user_send" | "user_send_object"
>("user_send_object");
const [open, setOpen] = useState<boolean>(false);
const handleDelete = (id: number) => {
setData((prev) => prev.filter((e) => e.id !== id));
const { data: location } = useQuery({
queryKey: ["location_list", currentPage, searchUser, dateFilter],
queryFn: () =>
location_api.list({
limit,
offset: (currentPage - 1) * limit,
user: searchUser,
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
}),
select(data) {
return data.data.data;
},
});
const { data: user_location } = useQuery({
queryKey: ["user_location_list", currentPage, searchUser, dateFilter],
queryFn: () =>
location_api.list_user_location({
limit,
offset: (currentPage - 1) * limit,
user: searchUser,
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
}),
select(data) {
return data.data.data;
},
});
const totalPages = location ? Math.ceil(location.count / limit) : 1;
const [openDelete, setOpenDelete] = useState<boolean>(false);
const [locationDelete, setLocationDelete] =
useState<LocationListDataRes | null>(null);
const handleDelete = (id: LocationListDataRes) => {
setOpenDelete(true);
setLocationDelete(id);
};
// Filtered data
const filtered = useMemo(() => {
return data.filter((item) => {
const dateMatch = dateFilter
? item.createdAt.toDateString() === dateFilter.toDateString()
: true;
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return dateMatch && userMatch;
});
}, [data, dateFilter, searchUser]);
return (
<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">
@@ -51,6 +73,8 @@ const LocationList = () => {
setDateFilter={setDateFilter}
setOpen={setOpen}
setSearchUser={setSearchUser}
setViewLocation={setViewLocation}
viewLocation={viewLocation}
/>
<LocationDetailDialog
@@ -59,18 +83,46 @@ const LocationList = () => {
object={detail}
/>
</div>
{viewLocation === "user_send_object" && (
<>
<LocationTable
filtered={location ? location.results : []}
handleDelete={handleDelete}
setDetail={setDetail}
setDetailDialog={setDetailDialog}
/>
<LocationTable
filtered={filtered}
handleDelete={handleDelete}
setDetail={setDetail}
setDetailDialog={setDetailDialog}
/>
<Pagination
currentPage={currentPage}
setCurrentPage={setCurrentPage}
totalPages={totalPages}
/>
</>
)}
<Pagination
currentPage={currentPage}
setCurrentPage={setCurrentPage}
totalPages={totalPages}
{viewLocation === "user_send" && (
<>
<UserLocationTable
filtered={user_location ? user_location.results : []}
handleDelete={handleDelete}
setDetail={setDetail}
setDetailDialog={setDetailDialog}
/>
<Pagination
currentPage={currentPage}
setCurrentPage={setCurrentPage}
totalPages={totalPages}
/>
</>
)}
<DeleteLocation
locationDelete={locationDelete}
opneDelete={openDelete}
setOpenDelete={setOpenDelete}
setLocationDelete={setLocationDelete}
viewLocation={viewLocation}
/>
</div>
);

View File

@@ -1,4 +1,4 @@
import type { LocationListType } from "@/features/location/lib/data";
import type { LocationListDataRes } from "@/features/location/lib/data";
import formatDate from "@/shared/lib/formatDate";
import { Button } from "@/shared/ui/button";
import {
@@ -13,10 +13,10 @@ import { Eye, Trash2 } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filtered: LocationListType[];
setDetail: Dispatch<SetStateAction<LocationListType | null>>;
filtered: LocationListDataRes[] | [];
setDetail: Dispatch<SetStateAction<LocationListDataRes | null>>;
setDetailDialog: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: number) => void;
handleDelete: (id: LocationListDataRes) => void;
}
const LocationTable = ({
@@ -42,20 +42,20 @@ const LocationTable = ({
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
{item.user.firstName} {item.user.lastName}
{item.user.first_name} {item.user.last_name}
</TableCell>
<TableCell>
{formatDate.format(item.createdAt, "DD-MM-YYYY")}
{formatDate.format(item.created_at, "DD-MM-YYYY")}
</TableCell>
<TableCell>
{item.district
? "Tuman"
: item.object
: item.place
? "Obyekt"
: item.doctor
? "Shifokor"
: item.pharmcies
: item.pharmacy
? "Dorixona"
: "Turgan joyidan"}
</TableCell>
@@ -76,7 +76,7 @@ const LocationTable = ({
variant="destructive"
size="icon"
className="cursor-pointer"
onClick={() => handleDelete(item.id)}
onClick={() => handleDelete(item)}
>
<Trash2 size={18} />
</Button>

View File

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

View File

@@ -4,7 +4,7 @@ import type {
ObjectUpdate,
} from "@/features/objects/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { OBJECT } from "@/shared/config/api/URLs";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const object_api = {
@@ -15,22 +15,22 @@ export const object_api = {
district?: string;
user?: string;
}): Promise<AxiosResponse<ObjectListRes>> {
const res = await httpClient.get(`${OBJECT}list/`, { params });
const res = await httpClient.get(`${API_URLS.OBJECT}list/`, { params });
return res;
},
async create(body: ObjectCreate) {
const res = await httpClient.post(`${OBJECT}create/`, body);
const res = await httpClient.post(`${API_URLS.OBJECT}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: ObjectUpdate }) {
const res = await httpClient.patch(`${OBJECT}${id}/update/`, body);
const res = await httpClient.patch(`${API_URLS.OBJECT}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${OBJECT}${id}/delete/`);
const res = await httpClient.delete(`${API_URLS.OBJECT}${id}/delete/`);
return res;
},
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
import { factory_api } from "@/features/pharm/lib/api";
import type { FactoryListDataRes } from "@/features/pharm/lib/data";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Trash, X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
opneDelete: boolean;
setOpenDelete: Dispatch<SetStateAction<boolean>>;
setPillDelete: Dispatch<SetStateAction<FactoryListDataRes | null>>;
pillDelete: FactoryListDataRes | null;
}
const DeletePharm = ({
opneDelete,
setOpenDelete,
pillDelete,
setPillDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => factory_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["factory_list"] });
toast.success(`Farmasevtika o'chirildi`);
setOpenDelete(false);
setPillDelete(null);
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Farmasevtikani o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {pillDelete?.name} nomli farmasevtikani
o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() => pillDelete && deleteUser(pillDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeletePharm;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
import { pill_api } from "@/features/pill/lib/api";
import type { PillListData } from "@/features/pill/lib/data";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Trash, X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
opneDelete: boolean;
setOpenDelete: Dispatch<SetStateAction<boolean>>;
setPillDelete: Dispatch<SetStateAction<PillListData | null>>;
pillDelete: PillListData | null;
}
const DeletePill = ({
opneDelete,
setOpenDelete,
pillDelete,
setPillDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => pill_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["pill_list"] });
toast.success(`Foydalanuvchi o'chirildi`);
setOpenDelete(false);
setPillDelete(null);
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dorini o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {pillDelete?.name} nomli dorini o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() => pillDelete && deleteUser(pillDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeletePill;

View File

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

View File

@@ -0,0 +1,36 @@
import type {
PlanCreateReq,
PlanListRes,
PlanUpdateReq,
} from "@/features/plans/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { API_URLS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
export const plans_api = {
async list(params: {
limit?: number;
offset?: number;
status?: boolean;
date?: string;
user?: string;
}): Promise<AxiosResponse<PlanListRes>> {
const res = await httpClient.get(`${API_URLS.PLANS}list/`, { params });
return res;
},
async create(body: PlanCreateReq) {
const res = await httpClient.post(`${API_URLS.PLANS}create/`, body);
return res;
},
async update({ body, id }: { id: number; body: PlanUpdateReq }) {
const res = await httpClient.patch(`${API_URLS.PLANS}${id}/update/`, body);
return res;
},
async delete(id: number) {
const res = await httpClient.delete(`${API_URLS.PLANS}${id}/delete/`);
return res;
},
};

View File

@@ -8,3 +8,42 @@ export interface Plan {
status: "Bajarildi" | "Bajarilmagan";
createdAt: Date;
}
export interface PlanListRes {
status_code: number;
status: string;
message: string;
data: {
count: number;
next: null | string;
previous: null | string;
results: PlanListData[];
};
}
export interface PlanListData {
id: number;
title: string;
description: string;
date: string;
user: {
id: number;
first_name: string;
last_name: string;
};
is_done: true;
created_at: string;
}
export interface PlanCreateReq {
title: string;
description: string;
date: string;
user_id: number;
}
export interface PlanUpdateReq {
title: string;
description: string;
date: string;
}

View File

@@ -4,4 +4,5 @@ export const createPlanFormData = z.object({
name: z.string().min(1, { message: "Majburiy maydon" }),
description: z.string().min(1, { message: "Majburiy maydon" }),
user: z.string().min(1, { message: "Majburiy maydon" }),
date: z.string().min(1, { message: "Majburiy maydon" }),
});

View File

@@ -1,7 +1,23 @@
import type { Plan } from "@/features/plans/lib/data";
import { plans_api } from "@/features/plans/lib/api";
import type {
PlanCreateReq,
PlanListData,
PlanUpdateReq,
} from "@/features/plans/lib/data";
import { createPlanFormData } from "@/features/plans/lib/form";
import { FakeUserList } from "@/features/users/lib/data";
import { user_api } from "@/features/users/lib/api";
import formatDate from "@/shared/lib/formatDate";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/ui/command";
import {
Form,
FormControl,
@@ -11,71 +27,110 @@ import {
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import React, { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Check, ChevronDownIcon, ChevronsUpDown, Loader2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
interface Props {
initialValues?: Plan | null;
initialValues?: PlanListData | null;
setDialogOpen: (open: boolean) => void;
setPlans: React.Dispatch<React.SetStateAction<Plan[]>>;
}
const AddedPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => {
const [load, setLoad] = useState(false);
const AddedPlan = ({ initialValues, setDialogOpen }: Props) => {
const form = useForm<z.infer<typeof createPlanFormData>>({
resolver: zodResolver(createPlanFormData),
defaultValues: {
name: initialValues?.name || "",
name: initialValues?.title || "",
description: initialValues?.description || "",
user: initialValues ? String(initialValues.user.id) : "",
date: initialValues ? initialValues?.date : "",
},
});
const [searchUser, setSearchUser] = useState<string>("");
const [openUser, setOpenUser] = useState<boolean>(false);
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const { data: user, isLoading: isUserLoading } = useQuery({
queryKey: ["user_list", searchUser],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
search?: string;
is_active?: boolean | string;
region_id?: number;
} = {
limit: 8,
search: searchUser,
};
return user_api.list(params);
},
select(data) {
return data.data.data;
},
});
const { mutate, isPending } = useMutation({
mutationFn: (body: PlanCreateReq) => plans_api.create(body),
onSuccess: () => {
setDialogOpen(false);
toast.success("Reja qo'shildi");
queryClient.refetchQueries({ queryKey: ["plan_list"] });
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
const { mutate: edit, isPending: editPending } = useMutation({
mutationFn: ({ body, id }: { body: PlanUpdateReq; id: number }) =>
plans_api.update({ body, id }),
onSuccess: () => {
setDialogOpen(false);
toast.success("Reja tahrirlandi");
queryClient.refetchQueries({ queryKey: ["plan_list"] });
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
function onSubmit(data: z.infer<typeof createPlanFormData>) {
setLoad(true);
if (initialValues) {
setTimeout(() => {
setPlans((prev) =>
prev.map((plan) =>
plan.id === initialValues.id
? {
...plan,
...data,
user: FakeUserList.find((u) => u.id === Number(data.user))!, // user obyekt
}
: plan,
),
);
setLoad(false);
setDialogOpen(false);
}, 2000);
edit({
id: initialValues.id,
body: {
date: formatDate.format(data.date, "YYYY-MM-DD"),
description: data.description,
title: data.name,
},
});
} else {
setTimeout(() => {
setPlans((prev) => [
...prev,
{
id: prev.length ? prev[prev.length - 1].id + 1 : 1,
name: data.name,
description: data.description,
user: FakeUserList.find((u) => u.id === Number(data.user))!, // user obyekt
status: "Bajarilmagan",
createdAt: new Date(),
},
]);
setLoad(false);
setDialogOpen(false);
}, 2000);
mutate({
date: formatDate.format(data.date, "YYYY-MM-DD"),
description: data.description,
title: data.name,
user_id: Number(data.user),
});
}
}
@@ -86,26 +141,87 @@ const AddedPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => {
<FormField
name="user"
control={form.control}
render={({ field }) => (
<FormItem>
<Label className="text-md">Kimga tegishli</Label>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-full !h-12">
<SelectValue placeholder="foydalanuvchi" />
</SelectTrigger>
<SelectContent>
{FakeUserList.map((e) => (
<SelectItem value={String(e.id)}>
{e.firstName} {e.lastName}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
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>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={openUser}
className={cn(
"w-full h-12 justify-between",
!field.value && "text-muted-foreground",
)}
>
{selectedUser
? `${selectedUser.first_name} ${selectedUser.last_name}`
: "Foydalanuvchi tanlang"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Qidirish..."
className="h-9"
value={searchUser}
onValueChange={setSearchUser}
/>
<CommandList>
{isUserLoading ? (
<div className="py-6 text-center text-sm">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</div>
) : user && user.results.length > 0 ? (
<CommandGroup>
{user.results.map((u) => (
<CommandItem
key={u.id}
value={`${u.id}`}
onSelect={() => {
field.onChange(String(u.id));
setOpenUser(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === String(u.id)
? "opacity-100"
: "opacity-0",
)}
/>
{u.first_name} {u.last_name} {u.region.name}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>Foydalanuvchi topilmadi</CommandEmpty>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
@@ -144,11 +260,56 @@ const AddedPlan = ({ initialValues, setDialogOpen, setPlans }: Props) => {
)}
/>
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem className="flex flex-col">
<Label className="text-md">Rejani bajarish kuni</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-full h-12 justify-between font-normal"
>
{field.value
? new Date(field.value).toLocaleDateString()
: "Sanani tanlang"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
mode="single"
toYear={new Date().getFullYear() + 50}
selected={field.value ? new Date(field.value) : undefined}
captionLayout="dropdown"
onSelect={(value) => {
if (value) {
field.onChange(value.toISOString()); // ⬅️ forma ichiga yozamiz
}
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<Button
className="w-full h-12 text-lg rounded-lg bg-blue-600 hover:bg-blue-600 cursor-pointer"
disabled={load}
disabled={isPending || editPending || initialValues?.is_done}
>
{load ? (
{isPending || editPending ? (
<Loader2 className="animate-spin" />
) : initialValues ? (
"Tahrirlash"

View File

@@ -0,0 +1,89 @@
import { plans_api } from "@/features/plans/lib/api";
import type { PlanListData } from "@/features/plans/lib/data";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Trash, X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
opneDelete: boolean;
setOpenDelete: Dispatch<SetStateAction<boolean>>;
setPlanDelete: Dispatch<SetStateAction<PlanListData | null>>;
planDelete: PlanListData | null;
}
const DeletePlan = ({
opneDelete,
setOpenDelete,
planDelete,
setPlanDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => plans_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["plan_list"] });
toast.success(`Foydalanuvchi o'chirildi`);
setOpenDelete(false);
setPlanDelete(null);
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rejani o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {planDelete?.user.first_name}{" "}
{planDelete?.user.last_name} ha tegishli rejani o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() => planDelete && deleteUser(planDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeletePlan;

View File

@@ -1,4 +1,4 @@
import type { Plan } from "@/features/plans/lib/data";
import type { PlanListData } from "@/features/plans/lib/data";
import AddedPlan from "@/features/plans/ui/AddedPlan";
import { Button } from "@/shared/ui/button";
import { Calendar } from "@/shared/ui/calendar";
@@ -32,9 +32,8 @@ interface Props {
setSearchUser: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
editingPlan: Plan | null;
setEditingPlan: Dispatch<SetStateAction<Plan | null>>;
setPlans: Dispatch<SetStateAction<Plan[]>>;
editingPlan: PlanListData | null;
setEditingPlan: Dispatch<SetStateAction<PlanListData | null>>;
}
const FilterPlans = ({
@@ -50,7 +49,6 @@ const FilterPlans = ({
setDialogOpen,
setEditingPlan,
editingPlan,
setPlans,
}: Props) => {
return (
<div className="flex gap-2 mb-4">
@@ -60,8 +58,8 @@ const FilterPlans = ({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Barchasi</SelectItem>
<SelectItem value="Bajarildi">Bajarildi</SelectItem>
<SelectItem value="Bajarilmagan">Bajarilmagan</SelectItem>
<SelectItem value="true">Bajarildi</SelectItem>
<SelectItem value="false">Bajarilmagan</SelectItem>
</SelectContent>
</Select>
@@ -128,7 +126,6 @@ const FilterPlans = ({
<AddedPlan
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
setPlans={setPlans}
/>
</DialogContent>
</Dialog>

View File

@@ -1,4 +1,4 @@
import type { Plan } from "@/features/plans/lib/data";
import type { PlanListData } from "@/features/plans/lib/data";
import { Button } from "@/shared/ui/button";
import {
Table,
@@ -9,91 +9,115 @@ import {
TableRow,
} from "@/shared/ui/table";
import clsx from "clsx";
import { Edit, Eye, Trash } from "lucide-react";
import { Edit, Eye, Loader2, Trash } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
interface Props {
filteredPlans: Plan[];
setEditingPlan: Dispatch<SetStateAction<Plan | null>>;
filteredPlans: PlanListData[] | [];
setEditingPlan: Dispatch<SetStateAction<PlanListData | null>>;
setDetail: Dispatch<SetStateAction<boolean>>;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
handleDelete: (id: number) => void;
handleDelete: (id: PlanListData) => void;
isLoading: boolean;
isError: boolean;
isFetching: boolean;
}
const PalanTable = ({
filteredPlans,
setEditingPlan,
setDetail,
isError,
isFetching,
isLoading,
setDialogOpen,
handleDelete,
}: Props) => {
return (
<div className="flex-1 overflow-auto">
<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>
{(isLoading || isFetching) && (
<div className="h-full flex items-center justify-center bg-white/70 z-10">
<span className="text-lg font-medium">
<Loader2 className="animate-spin" />
</span>
</div>
)}
{isError && (
<div className="h-full flex items-center justify-center z-10">
<span className="text-lg font-medium text-red-600">
Ma'lumotlarni olishda xatolik yuz berdi.
</span>
</div>
)}
{!isLoading && !isError && (
<Table>
<TableHeader>
<TableRow className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Reja nomi</TableHead>
<TableHead className="text-start">Tavsifi</TableHead>
<TableHead className="text-start">Kimga tegishli</TableHead>
<TableHead className="text-start">Status</TableHead>
<TableHead className="text-right">Harakatlar</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{filteredPlans.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>{plan.title}</TableCell>
<TableCell>{plan.description}</TableCell>
<TableCell>
{plan.user.first_name + " " + plan.user.last_name}
</TableCell>
<TableCell
className={clsx(
plan.is_done ? "text-green-500" : "text-red-500",
)}
>
{plan.is_done ? "Bajarilgan" : "Bajarilmagan"}
</TableCell>
<TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
className="bg-green-500 text-white cursor-pointer hover:bg-green-500 hover:text-white"
onClick={() => {
setEditingPlan(plan);
setDetail(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={plan.is_done}
className="bg-blue-500 text-white hover:bg-blue-500 hover:text-white cursor-pointer"
onClick={() => {
setEditingPlan(plan);
setDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
className="cursor-pointer"
disabled={plan.is_done}
onClick={() => handleDelete(plan)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
};

View File

@@ -1,4 +1,4 @@
import type { Plan } from "@/features/plans/lib/data";
import type { PlanListData } from "@/features/plans/lib/data";
import {
Dialog,
DialogContent,
@@ -14,7 +14,7 @@ import { type Dispatch, type SetStateAction } from "react";
interface Props {
setDetail: Dispatch<SetStateAction<boolean>>;
detail: boolean;
plan: Plan | null;
plan: PlanListData | null;
}
const PlanDetail = ({ detail, setDetail, plan }: Props) => {
@@ -33,7 +33,7 @@ const PlanDetail = ({ detail, setDetail, plan }: Props) => {
{/* Reja nomi */}
<div>
<p className="font-semibold text-gray-900">Reja nomi:</p>
<p>{plan.name}</p>
<p>{plan.title}</p>
</div>
{/* Reja tavsifi */}
@@ -46,7 +46,7 @@ const PlanDetail = ({ detail, setDetail, plan }: Props) => {
<div>
<p className="font-semibold text-gray-900">Kimga tegishli:</p>
<p>
{plan.user.firstName} {plan.user.lastName}
{plan.user.first_name} {plan.user.last_name}
</p>
</div>
@@ -56,13 +56,13 @@ const PlanDetail = ({ detail, setDetail, plan }: Props) => {
<Badge
className={clsx(
plan.status === "Bajarildi"
plan.is_done
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700",
"text-sm px-4 py-2 mt-2",
)}
>
{plan.status}
{plan.is_done ? "Bajarilgan" : "Bajarilmagan"}
</Badge>
</div>
</DialogDescription>

View File

@@ -1,63 +1,61 @@
import type { Plan } from "@/features/plans/lib/data";
import { plans_api } from "@/features/plans/lib/api";
import type { PlanListData } from "@/features/plans/lib/data";
import DeletePlan from "@/features/plans/ui/DeletePlan";
import FilterPlans from "@/features/plans/ui/FilterPlans";
import PalanTable from "@/features/plans/ui/PalanTable";
import PlanDetail from "@/features/plans/ui/PlanDetail";
import { FakeUserList } from "@/features/users/lib/data";
import formatDate from "@/shared/lib/formatDate";
import Pagination from "@/shared/ui/pagination";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
const PlansList = () => {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
const [plans, setPlans] = useState<Plan[]>([
{
id: 1,
name: "Tumanga borish",
description: "Tumanga borish rejasi",
user: FakeUserList[0],
status: "Bajarildi",
createdAt: new Date("2025-02-03"),
},
{
id: 2,
name: "Yangi reja",
description: "Yangi reja tavsifi",
user: FakeUserList[1],
status: "Bajarilmagan",
createdAt: new Date("2025-01-12"),
},
]);
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<boolean>(false);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
const [open, setOpen] = useState<boolean>(false);
const [searchUser, setSearchUser] = useState<string>("");
const limit = 20;
const { data, isLoading, isError, isFetching } = useQuery({
queryKey: ["plan_list", dateFilter, searchUser, statusFilter, currentPage],
queryFn: () => {
const params: {
limit?: number;
offset?: number;
status?: boolean;
date?: string;
user?: string;
} = {
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
user: searchUser,
limit,
offset: (currentPage - 1) * limit,
};
const handleDelete = (id: number) => {
setPlans(plans.filter((p) => p.id !== id));
if (statusFilter !== "all") {
params.status = statusFilter === "true" ? true : false;
}
return plans_api.list(params);
},
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data.count / limit) : 1;
const [editingPlan, setEditingPlan] = useState<PlanListData | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<boolean>(false);
const [openDelete, setOpenDelete] = useState<boolean>(false);
const [planDelete, setPlanDelete] = useState<PlanListData | null>(null);
const handleDelete = (id: PlanListData) => {
setOpenDelete(true);
setPlanDelete(id);
};
const filteredPlans = useMemo(() => {
return plans.filter((item) => {
const statusMatch =
statusFilter === "all" || item.status === statusFilter;
const dateMatch = dateFilter
? item.createdAt.toDateString() === dateFilter.toDateString()
: true;
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return statusMatch && dateMatch && userMatch;
});
}, [plans, statusFilter, dateFilter, searchUser]);
return (
<div className="flex flex-col h-full p-10 w-full">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
@@ -72,7 +70,6 @@ const PlansList = () => {
setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
setOpen={setOpen}
setPlans={setPlans}
setSearchUser={setSearchUser}
setStatusFilter={setStatusFilter}
statusFilter={statusFilter}
@@ -82,11 +79,14 @@ const PlansList = () => {
</div>
<PalanTable
filteredPlans={filteredPlans}
filteredPlans={data ? data.results : []}
handleDelete={handleDelete}
setDetail={setDetail}
setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
isError={isError}
isFetching={isFetching}
isLoading={isLoading}
/>
<Pagination
@@ -94,6 +94,13 @@ const PlansList = () => {
setCurrentPage={setCurrentPage}
totalPages={totalPages}
/>
<DeletePlan
opneDelete={openDelete}
planDelete={planDelete}
setOpenDelete={setOpenDelete}
setPlanDelete={setPlanDelete}
/>
</div>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,36 @@
import { ReportsData, type ReportsTypeList } from "@/features/reports/lib/data";
import AddedReport from "@/features/reports/ui/AddedReport";
import { report_api } from "@/features/reports/lib/api";
import ReportsTable from "@/features/reports/ui/ReportsTable";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import Pagination from "@/shared/ui/pagination";
import { Plus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
const ReportsList = () => {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
const [plans, setPlans] = useState<ReportsTypeList[]>(ReportsData);
const limit = 20;
const { data, isLoading, isError } = useQuery({
queryKey: ["report_list", currentPage],
queryFn: () =>
report_api.list({ limit, offset: (currentPage - 1) * limit }),
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data.count / limit) : 1;
// const [plans, setPlans] = useState<ReportsTypeList[]>(ReportsData);
const [editingPlan, setEditingPlan] = useState<ReportsTypeList | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
// const [editingPlan, setEditingPlan] = useState<ReportsTypeList | null>(null);
// const [dialogOpen, setDialogOpen] = useState(false);
const handleDelete = (id: number) => {
setPlans(plans.filter((p) => p.id !== id));
};
// const handleDelete = (id: number) => {
// setPlans(plans.filter((p) => p.id !== id));
// };
return (
<div className="flex flex-col h-full p-10 w-full">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-4 gap-4">
<h1 className="text-2xl font-bold">To'lovlar</h1>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{/* <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button
variant="default"
@@ -53,14 +53,16 @@ const ReportsList = () => {
setPlans={setPlans}
/>
</DialogContent>
</Dialog>
</Dialog> */}
</div>
<ReportsTable
handleDelete={handleDelete}
plans={plans}
setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
// handleDelete={handleDelete}
plans={data ? data.results : []}
// setDialogOpen={setDialogOpen}
// setEditingPlan={setEditingPlan}
isLoading={isLoading}
isError={isError}
/>
<Pagination

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
import { order_api } from "@/features/specifications/lib/api";
import type { OrderListDataRes } from "@/features/specifications/lib/data";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Trash, X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
opneDelete: boolean;
setOpenDelete: Dispatch<SetStateAction<boolean>>;
setPillDelete: Dispatch<SetStateAction<OrderListDataRes | null>>;
pillDelete: OrderListDataRes | null;
}
const DeleteOrder = ({
opneDelete,
setOpenDelete,
pillDelete,
setPillDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => order_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["order_list"] });
toast.success(`Foydalanuvchi o'chirildi`);
setOpenDelete(false);
setPillDelete(null);
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dorini o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham bu zakazni o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() => pillDelete && deleteUser(pillDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteOrder;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
import { tour_plan_api } from "@/features/tour-plan/lib/api";
import type { PlanTourListDataRes } from "@/features/tour-plan/lib/data";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Trash, X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
opneDelete: boolean;
setOpenDelete: Dispatch<SetStateAction<boolean>>;
setPlanDelete: Dispatch<SetStateAction<PlanTourListDataRes | null>>;
planDelete: PlanTourListDataRes | null;
}
const DeleteTourPlan = ({
opneDelete,
setOpenDelete,
planDelete,
setPlanDelete,
}: Props) => {
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useMutation({
mutationFn: (id: number) => tour_plan_api.delete(id),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["tour_plan_list"] });
toast.success(`Foydalanuvchi o'chirildi`);
setOpenDelete(false);
setPlanDelete(null);
},
onError: (err: AxiosError) => {
const errMessage = err.response?.data as { message: string };
const messageText = errMessage.message;
toast.error(messageText || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<Dialog open={opneDelete} onOpenChange={setOpenDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Tur Planni o'chirish</DialogTitle>
<DialogDescription className="text-md font-semibold">
Siz rostan ham {planDelete?.user.first_name}{" "}
{planDelete?.user.last_name} ha tegishli rejani o'chirmoqchimisiz
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-blue-600 cursor-pointer hover:bg-blue-600"
onClick={() => setOpenDelete(false)}
>
<X />
Bekor qilish
</Button>
<Button
variant={"destructive"}
onClick={() => planDelete && deleteUser(planDelete.id)}
>
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<>
<Trash />
O'chirish
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteTourPlan;

View File

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

View File

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

View File

@@ -1,151 +1,67 @@
import { fakeTourPlan, type TourPlanType } from "@/features/tour-plan/lib/data";
import AddedTourPlan from "@/features/tour-plan/ui/AddedTourPlan";
import { tour_plan_api } from "@/features/tour-plan/lib/api";
import { type PlanTourListDataRes } from "@/features/tour-plan/lib/data";
import DeleteTourPlan from "@/features/tour-plan/ui/DeleteTourPlab";
import FilterTourPlan from "@/features/tour-plan/ui/FilterTourPlan";
import TourPlanDetailDialog from "@/features/tour-plan/ui/TourPlanDetailDialog";
import TourPlanTable from "@/features/tour-plan/ui/TourPlanTable";
import formatDate from "@/shared/lib/formatDate";
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 {
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 Pagination from "@/shared/ui/pagination";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
const TourPlanList = () => {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
const [plans, setPlans] = useState<TourPlanType[]>(fakeTourPlan);
const [editingPlan, setEditingPlan] = useState<TourPlanType | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<TourPlanType | null>(null);
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const limit = 20;
const [dateFilter, setDateFilter] = useState<Date | undefined>(undefined);
const [open, setOpen] = useState<boolean>(false);
const [searchUser, setSearchUser] = useState<string>("");
const handleDelete = (id: number) => {
setPlans(plans.filter((p) => p.id !== id));
const { data, isError, isLoading, isFetching } = useQuery({
queryKey: ["tour_plan_list", currentPage, dateFilter, searchUser],
queryFn: () =>
tour_plan_api.list({
limit,
offset: (currentPage - 1) * limit,
date: dateFilter && formatDate.format(dateFilter, "YYYY-MM-DD"),
user: searchUser,
}),
select(data) {
return data.data.data;
},
});
const totalPages = data ? Math.ceil(data.count / limit) : 1;
const [editingPlan, setEditingPlan] = useState<PlanTourListDataRes | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState(false);
const [detail, setDetail] = useState<PlanTourListDataRes | null>(null);
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const [openDelete, setOpenDelete] = useState<boolean>(false);
const [planDelete, setPlanDelete] = useState<PlanTourListDataRes | null>(
null,
);
const handleDelete = (id: PlanTourListDataRes) => {
setOpenDelete(true);
setPlanDelete(id);
};
const filteredPlans = useMemo(() => {
return plans.filter((item) => {
// 2) Sana filtri: createdAt === tanlangan sana
const dateMatch = dateFilter
? item.date.toDateString() === dateFilter.toDateString()
: true;
// 3) User ism familiya bo'yicha qidiruv
const userMatch = `${item.user.firstName} ${item.user.lastName}`
.toLowerCase()
.includes(searchUser.toLowerCase());
return dateMatch && userMatch;
});
}, [plans, dateFilter, searchUser]);
return (
<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">
<h1 className="text-2xl font-bold">Rejalarni boshqarish</h1>
<div className="flex gap-2 mb-4">
{/* 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>
<AddedTourPlan
initialValues={editingPlan}
setDialogOpen={setDialogOpen}
setPlans={setPlans}
/>
</DialogContent>
</Dialog>
</div>
<FilterTourPlan
dateFilter={dateFilter}
dialogOpen={dialogOpen}
editingPlan={editingPlan}
searchUser={searchUser}
setDateFilter={setDateFilter}
setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
setSearchUser={setSearchUser}
/>
<TourPlanDetailDialog
plan={detail}
@@ -154,116 +70,30 @@ const TourPlanList = () => {
/>
</div>
{/* Table */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-center">
<TableHead className="text-start">ID</TableHead>
<TableHead className="text-start">Kimga tegishli</TableHead>
<TableHead className="text-start">Boriladigan joyi</TableHead>
<TableHead className="text-start">Sanasi</TableHead>
<TableHead className="text-start">Statusi</TableHead>
<TableHead className="text-right">Harakatlar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlans.map((plan) => (
<TableRow key={plan.id} className="text-start">
<TableCell>{plan.id}</TableCell>
<TableCell>
{plan.user.firstName + " " + plan.user.lastName}
</TableCell>
<TableCell>{plan.district}</TableCell>
<TableCell>
{formatDate.format(plan.date, "DD-MM-YYYY")}
</TableCell>
<TableCell
className={clsx(
plan.status === "completed"
? "text-green-500"
: "text-red-500",
)}
>
{plan.status === "completed" ? "Borildi" : "Borilmagan"}
</TableCell>
<TourPlanTable
data={data ? data.results : []}
handleDelete={handleDelete}
isError={isError}
isFetching={isFetching}
isLoading={isLoading}
setDetail={setDetail}
setDetailOpen={setDetailOpen}
setDialogOpen={setDialogOpen}
setEditingPlan={setEditingPlan}
/>
<TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="icon"
onClick={() => {
setDetail(plan);
setDetailOpen(true);
}}
className="bg-green-600 text-white cursor-pointer hover:bg-green-600 hover:text-white"
>
<Eye size={18} />
</Button>
<Button
variant="outline"
size="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>
<Pagination
currentPage={currentPage}
setCurrentPage={setCurrentPage}
totalPages={totalPages}
/>
<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>
<DeleteTourPlan
opneDelete={openDelete}
planDelete={planDelete}
setOpenDelete={setOpenDelete}
setPlanDelete={setPlanDelete}
/>
</div>
);
};

View File

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

View File

@@ -5,7 +5,7 @@ import type {
UserUpdateReq,
} from "@/features/users/lib/data";
import httpClient from "@/shared/config/api/httpClient";
import { USER } from "@/shared/config/api/URLs";
import { API_URLS } from "@/shared/config/api/URLs";
import axios, { type AxiosResponse } from "axios";
export const user_api = {
@@ -16,27 +16,27 @@ export const user_api = {
is_active?: boolean | string;
region_id?: number;
}): Promise<AxiosResponse<UserListRes>> {
const res = await httpClient.get(`${USER}list/`, { params });
const res = await httpClient.get(`${API_URLS.USER}list/`, { params });
return res;
},
async update({ body, id }: { id: number; body: UserUpdateReq }) {
const res = await httpClient.patch(`${USER}${id}/update/`, body);
const res = await httpClient.patch(`${API_URLS.USER}${id}/update/`, body);
return res;
},
async create(body: UserCreateReq) {
const res = await httpClient.post(`${USER}create/`, body);
const res = await httpClient.post(`${API_URLS.USER}create/`, body);
return res;
},
async active(id: number) {
const res = await httpClient.post(`${USER}${id}/activate/`);
const res = await httpClient.post(`${API_URLS.USER}${id}/activate/`);
return res;
},
async delete({ id }: { id: number }) {
const res = await httpClient.delete(`${USER}${id}/delete/`);
const res = await httpClient.delete(`${API_URLS.USER}${id}/delete/`);
return res;
},

View File

@@ -1,23 +1,19 @@
const BASE_URL =
import.meta.env.VITE_API_URL || "https://api.meridynpharma.com";
const LOGIN = "/api/v1/authentication/admin_login/";
const USER = "/api/v1/admin/user/";
const REGION = "/api/v1/admin/district/";
const REGIONS = "/api/v1/admin/region/";
const DISTRICT = "/api/v1/admin/district/";
const DOCTOR = "/api/v1/admin/doctor/";
const OBJECT = "/api/v1/admin/place/";
const PHARMACIES = "/api/v1/admin/pharmacy/";
export {
BASE_URL,
DISTRICT,
DOCTOR,
LOGIN,
OBJECT,
PHARMACIES,
REGION,
REGIONS,
USER,
export const API_URLS = {
BASE_URL: import.meta.env.VITE_API_URL || "https://api.meridynpharma.com",
LOGIN: "/api/v1/authentication/admin_login/",
USER: "/api/v1/admin/user/",
REGION: "/api/v1/admin/district/",
REGIONS: "/api/v1/admin/region/",
DISTRICT: "/api/v1/admin/district/",
DOCTOR: "/api/v1/admin/doctor/",
OBJECT: "/api/v1/admin/place/",
PHARMACIES: "/api/v1/admin/pharmacy/",
PLANS: "/api/v1/admin/plan/",
PILL: "/api/v1/admin/product/",
LOCATION: "/api/v1/admin/location/",
USER_LOCATION: "/api/v1/admin/user_location/",
ORDER: "/api/v1/admin/order/",
FACTORY: "/api/v1/admin/factory/",
REPORT: "/api/v1/admin/payment/",
TOUR_PLAN: "/api/v1/admin/tour_plan/",
};

View File

@@ -1,10 +1,10 @@
import i18n from "@/shared/config/i18n";
import { getToken } from "@/shared/lib/cookie";
import axios from "axios";
import { BASE_URL } from "./URLs";
import { API_URLS } from "./URLs";
const httpClient = axios.create({
baseURL: BASE_URL,
baseURL: API_URLS.BASE_URL,
timeout: 10000,
});

View File

@@ -61,11 +61,6 @@ const items = [
url: "/dashboard/pharmacies",
icon: Hospital,
},
{
title: "Hisobotlar",
url: "/dashboard/reports",
icon: ClipboardList,
},
{
title: "Jo'natilgan joylar",
url: "/dashboard/sent-locations",
@@ -76,6 +71,11 @@ const items = [
url: "/dashboard/specifications",
icon: FileText,
},
{
title: "Hisobotlar",
url: "/dashboard/reports",
icon: ClipboardList,
},
{
title: "Tur planlar",
url: "/dashboard/tour-plan",