product page update

This commit is contained in:
Samandar Turgunboyev
2026-01-14 14:26:59 +05:00
parent 6370a118fe
commit 6bf98545db
20 changed files with 686 additions and 495 deletions

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",

33
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -806,6 +809,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -2977,6 +2993,23 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/number': 1.1.1

View File

@@ -26,4 +26,17 @@ export const categories_api = {
const res = await httpClient.delete(`${API_URLS.CategoryDelete(id)}`);
return res;
},
async import_category() {
const res = await httpClient.post(API_URLS.Import_Category);
return res;
},
async image_upload({ body, id }: { id: number | string; body: FormData }) {
const res = await httpClient.patch(
API_URLS.Upload_Image_Category(id),
body,
);
return res;
},
};

View File

@@ -13,6 +13,7 @@ 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, Upload } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
import { useForm, useWatch } from "react-hook-form";
@@ -22,7 +23,7 @@ import z from "zod";
type FormValues = z.infer<typeof addDistrict>;
interface Props {
initialValues: CategoryItem | null;
initialValues: CategoryItem | undefined;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
}
@@ -30,48 +31,38 @@ export default function AddDistrict({ initialValues, setDialogOpen }: Props) {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: (body: FormData) => categories_api.create(body),
mutationFn: ({ body, id }: { body: FormData; id: number | string }) =>
categories_api.image_upload({ id, body }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setDialogOpen(false);
},
onError: () => {
toast.error("Kategoriyani qo'shishda xatolik yuz berdi");
},
});
const { mutate: updateMutate, isPending: isUpdatePending } = useMutation({
mutationFn: ({ body, id }: { body: FormData; id: string }) =>
categories_api.update({ body, id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setDialogOpen(false);
},
onError: () => {
toast.error("Kategoriyani qo'shishda xatolik yuz berdi");
onError: (err: AxiosError) => {
const errData = (err.response?.data as { data: string }).data;
const errMessage = (err.response?.data as { message: string }).message;
toast.error(errData || errMessage || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
const form = useForm<FormValues>({
resolver: zodResolver(addDistrict),
defaultValues: {
name_uz: initialValues?.name_uz ?? "",
name_ru: initialValues?.name_ru ?? "",
name_uz: initialValues?.name ?? "",
name_ru: initialValues?.name ?? "",
},
});
function onSubmit(values: FormValues) {
const formData = new FormData();
formData.append("name_uz", values.name_uz);
formData.append("name_ru", values.name_ru);
if (values.image) {
formData.append("image", values.image);
}
if (initialValues) {
updateMutate({ body: formData, id: initialValues.id });
} else {
mutate(formData);
mutate({ id: initialValues?.id, body: formData });
}
}
@@ -83,7 +74,7 @@ export default function AddDistrict({ initialValues, setDialogOpen }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
{/* <FormField
name="name_uz"
control={form.control}
render={({ field }) => (
@@ -117,7 +108,7 @@ export default function AddDistrict({ initialValues, setDialogOpen }: Props) {
<FormMessage />
</FormItem>
)}
/>
/> */}
<FormField
name="image"
control={form.control}
@@ -161,12 +152,10 @@ export default function AddDistrict({ initialValues, setDialogOpen }: Props) {
<Button
type="submit"
className="w-full h-12 bg-blue-700 hover:bg-blue-800"
disabled={isPending || isUpdatePending}
disabled={isPending}
>
{isPending || isUpdatePending ? (
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : initialValues ? (
"Tahrirlash"
) : (
"Qo'shish"
)}

View File

@@ -55,6 +55,7 @@ const CategoriesList = () => {
<TableDistrict
data={data ? data.results : []}
isError={isError}
dialogOpen={dialogOpen}
isLoading={isLoading}
setDialogOpen={setDialogOpen}
setEditingDistrict={setEditingDistrict}

View File

@@ -1,53 +1,46 @@
import AddDistrict from "@/features/districts/ui/AddCategories";
import type { CategoryItem } from "@/features/plans/lib/data";
import { categories_api } from "@/features/districts/lib/api";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Plus } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { toast } from "sonner";
interface Props {
search: string;
setSearch: Dispatch<SetStateAction<string>>;
dialogOpen: boolean;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
editing: CategoryItem | null;
setEditing: Dispatch<SetStateAction<CategoryItem | null>>;
}
// interface Props {
// search: string;
// setSearch: Dispatch<SetStateAction<string>>;
// dialogOpen: boolean;
// setDialogOpen: Dispatch<SetStateAction<boolean>>;
// editing: CategoryItem | null;
// setEditing: Dispatch<SetStateAction<CategoryItem | null>>;
// }
const FilterCategory = ({
dialogOpen,
setDialogOpen,
setEditing,
editing,
}: Props) => {
const FilterCategory = () => {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: () => categories_api.import_category(),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["categories"] });
toast.success("Kategoriyalar import qilindi", {
richColors: true,
position: "top-center",
});
},
onError: (err: AxiosError) => {
const errData = (err.response?.data as { data: string }).data;
const errMessage = (err.response?.data as { message: string }).message;
toast.error(errData || errMessage || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<div className="flex gap-4 justify-end">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => setEditing(null)}
className="bg-blue-500 h-12 cursor-pointer hover:bg-blue-500"
>
<Plus className="w-5 h-5 mr-1" /> Kategoriya qoshish
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl">
{editing ? "Kategoriyani tahrirlash" : "Kategoriya qoshish"}
</DialogTitle>
</DialogHeader>
<AddDistrict initialValues={editing} setDialogOpen={setDialogOpen} />
</DialogContent>
</Dialog>
<Button
className="bg-blue-500 h-12 cursor-pointer hover:bg-blue-500"
onClick={() => mutate()}
>
Kategoriya import qilish
</Button>
</div>
);
};

View File

@@ -1,6 +1,13 @@
import AddDistrict from "@/features/districts/ui/AddCategories";
import type { CategoryItem } from "@/features/plans/lib/data";
import { API_URLS } from "@/shared/config/api/URLs";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import {
Table,
TableBody,
@@ -9,8 +16,8 @@ import {
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { Edit, Loader2, Trash } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { Image, Loader2 } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react";
interface Props {
data: CategoryItem[] | [];
@@ -19,6 +26,7 @@ interface Props {
handleDelete: (user: CategoryItem) => void;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setEditingDistrict: Dispatch<SetStateAction<CategoryItem | null>>;
dialogOpen: boolean;
currentPage: number;
}
@@ -26,10 +34,10 @@ const TableDistrict = ({
data,
isError,
isLoading,
handleDelete,
dialogOpen,
setDialogOpen,
setEditingDistrict,
}: Props) => {
const [initialValues, setEditing] = useState<CategoryItem>();
return (
<div className="flex-1 overflow-auto">
{isLoading && (
@@ -55,7 +63,8 @@ const TableDistrict = ({
<TableHead>ID</TableHead>
<TableHead>Rasmi</TableHead>
<TableHead>Nomi (uz)</TableHead>
<TableHead>Nomi (ru)</TableHead>
<TableHead>Kategoriya idsi</TableHead>
<TableHead>Turi</TableHead>
<TableHead className="text-right">Harakatlar</TableHead>
</TableRow>
</TableHeader>
@@ -66,34 +75,35 @@ const TableDistrict = ({
<TableCell>{index + 1}</TableCell>
<TableCell>
<img
src={API_URLS.BASE_URL + d.image}
alt={d.name_uz}
src={d.image ? API_URLS.BASE_URL + d.image : "/logo.png"}
alt={d.name}
className="w-10 h-10 object-cover rounded-md"
/>
</TableCell>
<TableCell>{d.name_uz}</TableCell>
<TableCell>{d.name_ru}</TableCell>
<TableCell>{d.name}</TableCell>
<TableCell>{d.category_id ? d.category_id : "----"}</TableCell>
<TableCell>{d.type ? d.type : "----"}</TableCell>
<TableCell className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingDistrict(d);
setEditing(d);
setDialogOpen(true);
}}
className="bg-blue-500 text-white hover:text-white hover:bg-blue-500 cursor-pointer"
>
<Edit className="w-4 h-4" />
<Image className="w-4 h-4" />
</Button>
<Button
{/* <Button
variant="destructive"
size="sm"
onClick={() => handleDelete(d)}
className="cursor-pointer"
>
<Trash className="w-4 h-4" />
</Button>
</Button> */}
</TableCell>
</TableRow>
))}
@@ -108,6 +118,18 @@ const TableDistrict = ({
</TableBody>
</Table>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl">Rasmdi qo'shish</DialogTitle>
</DialogHeader>
<AddDistrict
setDialogOpen={setDialogOpen}
initialValues={initialValues}
/>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -47,4 +47,16 @@ export const plans_api = {
const res = await httpClient.get(`${API_URLS.UnityList}`, { params });
return res;
},
async exportProduct() {
const res = await httpClient.get(API_URLS.Export_Product, {
responseType: "blob",
});
return res;
},
async importProduct() {
const res = await httpClient.post(API_URLS.Import_Product);
return res;
},
};

View File

@@ -9,30 +9,34 @@ export interface ProductsList {
}
export interface Product {
id: string;
name_uz: string;
name_ru: string;
is_active: boolean;
image: string;
category: string;
price: number;
description_uz: string;
description_ru: string;
unity: string;
tg_id: string;
code: string;
article: string;
quantity_left: number;
min_quantity: number;
brand: null | string;
return_date: null | string;
expires_date: null | string;
manufacturer: null | string;
volume: string;
id: number;
images: {
id: string;
id: number;
image: string;
}[];
meansurement: null | number;
inventory_id: null | string;
product_id: string;
code: string;
name: string;
short_name: string;
weight_netto: null | string;
weight_brutto: null | string;
litr: null | string;
box_type_code: null | string;
box_quant: null | string;
groups: {
id: number;
name: string;
image: string;
type: string;
}[];
state: string;
barcodes: null | string;
article_code: null | string;
marketing_group_code: null | string;
inventory_kinds: { id: number; name: string }[];
sector_codes: { id: number; code: string }[];
}
export interface Category {
@@ -46,11 +50,12 @@ export interface Category {
}
export interface CategoryItem {
id: string;
name_uz: string;
name_ru: string;
image: string;
id: number;
name: string;
image: string | null;
order: number;
category_id: null | number;
type: null | string;
}
export interface UnityList {

View File

@@ -1,51 +1,40 @@
"use client";
import { Button } from "@/shared/ui/button";
import { useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { plans_api } from "../lib/api";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import type { AxiosError } from "axios";
export default function ExcelUpload() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleButtonClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Excel format tekshiruvi
const allowedTypes = [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
if (!allowedTypes.includes(file.type)) {
alert("Iltimos, faqat Excel (.xls, .xlsx) fayl yuklang");
return;
}
// 👉 shu yerda backendga yuborasiz
// uploadExcel(file)
};
const { mutate, isPending } = useMutation({
mutationFn: () => plans_api.importProduct(),
onSuccess: (res) => {
toast.success(res.data.message, {
richColors: true,
position: "top-center",
});
},
onError: (err: AxiosError) => {
const errMessage = (err.response?.data as { message: string }).message;
toast.error(errMessage || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<>
<Button
onClick={handleButtonClick}
onClick={() => mutate()}
className="h-12 bg-blue-500 text-white hover:bg-blue-600"
variant="secondary"
>
Excel yuklash
Import qilish
{isPending && <Loader2 className="animate-spin" />}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xls,.xlsx"
className="hidden"
onChange={handleFileChange}
/>
</>
);
}

View File

@@ -1,17 +1,12 @@
import { plans_api } from "@/features/plans/lib/api";
import type { Product } from "@/features/plans/lib/data";
import AddedPlan from "@/features/plans/ui/AddedPlan";
import ExcelUpload from "@/features/plans/ui/ExcelUpload";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { AlertCircle, Plus } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
searchUser: string;
@@ -22,33 +17,54 @@ interface Props {
setEditingPlan: Dispatch<SetStateAction<Product | null>>;
}
const FilterPlans = ({
searchUser,
setSearchUser,
dialogOpen,
setDialogOpen,
setEditingPlan,
editingPlan,
}: Props) => {
const FilterPlans = ({ searchUser, setSearchUser }: Props) => {
const { mutate } = useMutation({
mutationFn: () => plans_api.exportProduct(),
onSuccess: (response) => {
const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "products.xlsx";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: (err: AxiosError) => {
const errorData = (err.response?.data as { data: string }).data;
const errorMessage = (err.response?.data as { message: string }).message;
toast.error(errorMessage || errorData || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
return (
<div className="flex gap-2 mb-4">
<Input
type="text"
placeholder="Foydalanuvchi ismi"
placeholder="Mahsulot nomi"
className="h-12"
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
<Dialog>
<DialogTrigger asChild>
<Button
className="h-12 bg-blue-500 text-white hover:bg-blue-600 cursor-pointer"
variant={"secondary"}
>
Excel uchun shablon
</Button>
</DialogTrigger>
<DialogContent>
<Button
className="h-12 bg-blue-500 text-white hover:bg-blue-600 cursor-pointer"
variant={"secondary"}
onClick={() => mutate()}
>
Excel yuklab olish
</Button>
{/* <DialogContent>
<DialogHeader className="text-xl font-medium">
Excel uchun kerakli shablon
</DialogHeader>
@@ -60,10 +76,9 @@ const FilterPlans = ({
xatolik yuz berishi mumkin.
</p>
</div>
</DialogContent>
</Dialog>
</DialogContent> */}
<ExcelUpload />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{/*<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button
variant="default"
@@ -85,7 +100,7 @@ const FilterPlans = ({
setDialogOpen={setDialogOpen}
/>
</DialogContent>
</Dialog>
</Dialog>*/}
</div>
);
};

View File

@@ -1,17 +1,8 @@
"use client";
import { plans_api } from "@/features/plans/lib/api";
import type { Product } from "@/features/plans/lib/data";
import { API_URLS } from "@/shared/config/api/URLs";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import {
Table,
TableBody,
@@ -20,12 +11,8 @@ import {
TableHeader,
TableRow,
} from "@/shared/ui/table";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import clsx from "clsx";
import { Edit, Eye, Loader2, Trash } from "lucide-react";
import { Eye, Loader2 } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { toast } from "sonner";
interface Props {
products: Product[] | [];
@@ -45,59 +32,7 @@ const ProductTable = ({
isError,
setEditingProduct,
setDetailOpen,
setDialogOpen,
handleDelete,
}: Props) => {
const { data } = useQuery({
queryKey: ["categories"],
queryFn: () => {
return plans_api.categories({ page: 1, page_size: 1000 });
},
select(data) {
return data.data.results;
},
});
const { data: unityData } = useQuery({
queryKey: ["unity"],
queryFn: () => {
return plans_api.unity({ page: 1, page_size: 1000 });
},
select(data) {
return data.data.results;
},
});
const queryClient = useQueryClient();
const { mutate: updated } = useMutation({
mutationFn: ({ body, id }: { id: string; body: FormData }) =>
plans_api.update({ body, id }),
onSuccess() {
toast.success("Mahsulot statusi o'zgardi", {
richColors: true,
position: "top-center",
});
queryClient.refetchQueries({ queryKey: ["product_list"] });
setDialogOpen(false);
},
onError: (err: AxiosError) => {
toast.error((err.response?.data as string) || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
const handleStatusChange = async (
product: string,
status: "true" | "false",
) => {
const formData = new FormData();
formData.append("is_active", status);
updated({ body: formData, id: product });
};
if (isLoading || isFetching) {
return (
<div className="flex h-full items-center justify-center">
@@ -121,43 +56,39 @@ const ProductTable = ({
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Rasmi</TableHead>
<TableHead>Nomi (UZ)</TableHead>
<TableHead>Nomi (RU)</TableHead>
<TableHead>Tavsif (UZ)</TableHead>
<TableHead>Tavsif (RU)</TableHead>
<TableHead>Kategoriya</TableHead>
<TableHead>Birligi</TableHead>
<TableHead>Brendi</TableHead>
<TableHead>Narx</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Harakatlar</TableHead>
<TableHead>Nomi</TableHead>
<TableHead>Tavsif</TableHead>
<TableHead className="text-end">Harakatlar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product, index) => {
const cat = data?.find((c) => c.id === product.category);
const unity = unityData?.find((u) => u.id === product.unity);
return (
<TableRow key={product.id}>
<TableCell>{index + 1}</TableCell>
{product.images.length > 0 ? (
<TableCell>
<img
src={API_URLS.BASE_URL + product.images[0].image}
alt={product.name}
className="w-16 h-16 object-cover rounded"
/>
</TableCell>
) : (
<TableCell>
<img
src={"/logo.png"}
alt={product.name}
className="w-10 h-10 object-cover rounded"
/>
</TableCell>
)}
<TableCell>{product.name}</TableCell>
<TableCell>
<img
src={API_URLS.BASE_URL + product.image}
alt={product.name_uz}
className="w-16 h-16 object-cover rounded"
/>
{product.short_name && product.short_name.slice(0, 15)}...
</TableCell>
<TableCell>{product.name_uz}</TableCell>
<TableCell>{product.name_ru}</TableCell>
<TableCell>{product.description_uz.slice(0, 15)}...</TableCell>
<TableCell>{product.description_ru.slice(0, 15)}...</TableCell>
<TableCell>{cat?.name_uz}</TableCell>
<TableCell>{unity?.name_uz}</TableCell>
<TableCell>{product.brand}</TableCell>
<TableCell>{formatPrice(product.price, true)}</TableCell>
<TableCell
{/* <TableCell
className={clsx(
product.is_active ? "text-green-600" : "text-red-600",
)}
@@ -176,7 +107,7 @@ const ProductTable = ({
<SelectItem value="false">Nofaol</SelectItem>
</SelectContent>
</Select>
</TableCell>
</TableCell> */}
<TableCell className="space-x-2 text-right">
<Button
@@ -190,7 +121,7 @@ const ProductTable = ({
<Eye className="h-4 w-4" />
</Button>
<Button
{/*<Button
size="sm"
className="bg-blue-500 text-white hover:bg-blue-600"
onClick={() => {
@@ -207,7 +138,7 @@ const ProductTable = ({
onClick={() => handleDelete(product)}
>
<Trash className="h-4 w-4" />
</Button>
</Button>*/}
</TableCell>
</TableRow>
);

View File

@@ -1,218 +1,225 @@
import { categories_api } from "@/features/districts/lib/api";
import type { Product } from "@/features/plans/lib/data";
import { unity_api } from "@/features/units/lib/api";
import { API_URLS } from "@/shared/config/api/URLs";
import { Badge } from "@/shared/ui/badge";
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useQuery } from "@tanstack/react-query";
import { type Dispatch, type SetStateAction } from "react";
import { ScrollArea } from "@/shared/ui/scroll-area";
import type { Product } from "../lib/data";
import { Badge } from "@/shared/ui/badge";
import { API_URLS } from "@/shared/config/api/URLs";
interface Props {
setDetail: Dispatch<SetStateAction<boolean>>;
detail: boolean;
plan: Product | null;
interface ProductDetailModalProps {
product: Product | null;
isOpen: boolean;
onClose: () => void;
}
const PlanDetail = ({ detail, setDetail, plan }: Props) => {
const { data } = useQuery({
queryKey: ["categories"],
queryFn: () =>
categories_api.list({
page: 1,
page_size: 999,
}),
select(data) {
return data.data;
},
});
const { data: unity } = useQuery({
queryKey: ["unity_list"],
queryFn: () =>
unity_api.list({
page: 1,
page_size: 999,
}),
select(data) {
return data.data;
},
});
if (!plan) return null;
export function PlanDetail({
product,
isOpen,
onClose,
}: ProductDetailModalProps) {
if (!product) return null;
return (
<Dialog open={detail} onOpenChange={setDetail}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{plan.name_uz}
</DialogTitle>
</DialogHeader>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] p-0 overflow-hidden">
<ScrollArea className="h-full">
<div className="p-6">
<DialogHeader className="mb-6">
<DialogTitle className="text-2xl">{product.name}</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* Product Images */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{plan.image && (
<img
src={API_URLS.BASE_URL + plan.image}
alt={plan.name_uz}
className="w-full h-48 object-cover rounded-lg border"
/>
)}
{plan.images.map((img) => (
<img
key={img.id}
src={API_URLS.BASE_URL + img.image}
alt={plan.name_uz}
className="w-full h-48 object-cover rounded-lg border"
/>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Images Section */}
<div className="space-y-4">
<h3 className="font-semibold text-lg">Rasmlar</h3>
{product.images && product.images.length > 0 ? (
<div className="grid grid-cols-2 gap-4">
{product.images.map((img) => (
<div
key={img.id}
className="relative w-full aspect-square bg-muted rounded-lg overflow-hidden"
>
<img
src={
API_URLS.BASE_URL + img.image || "/placeholder.svg"
}
alt={product.name}
className="object-cover"
/>
</div>
))}
</div>
) : (
<div className="w-full aspect-square bg-muted rounded-lg flex items-center justify-center text-muted-foreground">
Rasm yo'q
</div>
)}
</div>
{/* Product Status */}
<div className="flex items-center gap-3">
<Badge
className={
plan.is_active
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}
>
{plan.is_active ? "Faol" : "Nofaol"}
</Badge>
{plan.quantity_left <= plan.min_quantity && (
<Badge className="bg-orange-100 text-orange-700">Kam qoldi</Badge>
)}
</div>
{/* Product Details */}
<div className="space-y-4">
<div>
<label className="text-sm text-muted-foreground">
Mahsulot ID
</label>
<p className="font-mono text-sm">{product.product_id}</p>
</div>
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold text-gray-900">Nomi (O'zbekcha):</p>
<p className="text-gray-700">{plan.name_uz}</p>
</div>
<div>
<p className="font-semibold text-gray-900">Nomi (Ruscha):</p>
<p className="text-gray-700">{plan.name_ru}</p>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground">Kod</label>
<p className="font-mono text-sm">{product.code}</p>
</div>
{/* Descriptions */}
<div className="space-y-3">
<div>
<p className="font-semibold text-gray-900">
Tavsifi (O'zbekcha):
</p>
<p className="text-gray-700">{plan.description_uz}</p>
</div>
<div>
<p className="font-semibold text-gray-900">Tavsifi (Ruscha):</p>
<p className="text-gray-700">{plan.description_ru}</p>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground">
Qisqa nom
</label>
<p className="text-sm">{product.short_name || "—"}</p>
</div>
{/* Price and Category */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Narxi:</p>
<p className="text-xl font-bold text-blue-600">
{plan.price.toLocaleString()} so'm
</p>
</div>
<div>
<p className="font-semibold text-gray-900">Kategoriya:</p>
<p className="text-gray-700">
{data?.results.find((e) => e.id === plan.category)?.name_uz}
</p>
</div>
<div>
<p className="font-semibold text-gray-900">O'lchov birligi:</p>
<p className="text-gray-700">
{unity?.results.find((e) => e.id === plan.unity)?.name_uz}
</p>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground">
Holati
</label>
<Badge
variant={
product.state === "active" ? "default" : "secondary"
}
className="mt-1"
>
{product.state}
</Badge>
</div>
{/* Product Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold text-gray-900">Kod:</p>
<p className="text-gray-700">{plan.code}</p>
{product.article_code && (
<div>
<label className="text-sm text-muted-foreground">
Artikul kodi
</label>
<p className="font-mono text-sm">{product.article_code}</p>
</div>
)}
</div>
</div>
<div>
<p className="font-semibold text-gray-900">Artikul:</p>
<p className="text-gray-700">{plan.article}</p>
{/* Weight and Size Info */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t">
{product.weight_netto && (
<div>
<label className="text-xs text-muted-foreground">
Xalol Vazni
</label>
<p className="font-semibold">{product.weight_netto} kg</p>
</div>
)}
{product.weight_brutto && (
<div>
<label className="text-xs text-muted-foreground">
Jami Vazni
</label>
<p className="font-semibold">{product.weight_brutto} kg</p>
</div>
)}
{product.litr && (
<div>
<label className="text-xs text-muted-foreground">Hajmi</label>
<p className="font-semibold">{product.litr} L</p>
</div>
)}
{product.meansurement && (
<div>
<label className="text-xs text-muted-foreground">
O'lchami
</label>
<p className="font-semibold">{product.meansurement}</p>
</div>
)}
</div>
{plan.brand && (
<div>
<p className="font-semibold text-gray-900">Brand:</p>
<p className="text-gray-700">{plan.brand}</p>
{/* Box Info */}
{(product.box_type_code || product.box_quant) && (
<div className="grid grid-cols-2 gap-4 mt-4 p-4 bg-muted rounded-lg">
{product.box_type_code && (
<div>
<label className="text-xs text-muted-foreground">
Qutining turi
</label>
<p className="font-semibold">{product.box_type_code}</p>
</div>
)}
{product.box_quant && (
<div>
<label className="text-xs text-muted-foreground">
Qutida miqdori
</label>
<p className="font-semibold">{product.box_quant}</p>
</div>
)}
</div>
)}
{plan.manufacturer && (
<div>
<p className="font-semibold text-gray-900">
Ishlab chiqaruvchi:
{/* Groups */}
{product.groups && product.groups.length > 0 && (
<div className="mt-6 pt-6 border-t">
<h3 className="font-semibold mb-3">Guruhlari</h3>
<div className="flex flex-wrap gap-2">
{product.groups.map((group) => (
<Badge key={group.id} variant="outline">
{group.name}
</Badge>
))}
</div>
</div>
)}
{/* Inventory Kinds */}
{product.inventory_kinds && product.inventory_kinds.length > 0 && (
<div className="mt-4">
<h3 className="font-semibold mb-3">Omborxona turlari</h3>
<div className="flex flex-wrap gap-2">
{product.inventory_kinds.map((kind) => (
<Badge key={kind.id} variant="secondary">
{kind.name}
</Badge>
))}
</div>
</div>
)}
{/* Sector Codes */}
{product.sector_codes && product.sector_codes.length > 0 && (
<div className="mt-4">
<h3 className="font-semibold mb-3">Sektor kodlari</h3>
<div className="space-y-2">
{product.sector_codes.map((sector) => (
<div
key={sector.id}
className="p-2 bg-muted rounded-md font-mono text-sm"
>
{sector.code}
</div>
))}
</div>
</div>
)}
{/* Barcodes */}
{product.barcodes && (
<div className="mt-6 pt-6 border-t">
<label className="text-sm font-semibold">Barcodlar</label>
<p className="font-mono text-sm mt-2 p-3 bg-muted rounded-lg break-all">
{product.barcodes}
</p>
<p className="text-gray-700">{plan.manufacturer}</p>
</div>
)}
<div>
<p className="font-semibold text-gray-900">Hajm:</p>
<p className="text-gray-700">{plan.volume}</p>
</div>
</div>
{/* Quantity Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-blue-50 p-4 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Qolgan miqdor:</p>
<p className="text-lg font-semibold text-gray-700">
{plan.quantity_left}
</p>
</div>
<div>
<p className="font-semibold text-gray-900">Minimal miqdor:</p>
<p className="text-lg font-semibold text-gray-700">
{plan.min_quantity}
</p>
</div>
</div>
{/* Dates */}
{(plan.return_date || plan.expires_date) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{plan.return_date && (
<div>
<p className="font-semibold text-gray-900">
Qaytarish sanasi:
</p>
<p className="text-gray-700">
{new Date(plan.return_date).toLocaleDateString("uz-UZ")}
</p>
</div>
)}
{plan.expires_date && (
<div>
<p className="font-semibold text-gray-900">
Amal qilish muddati:
</p>
<p className="text-gray-700">
{new Date(plan.expires_date).toLocaleDateString("uz-UZ")}
</p>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
export default PlanDetail;
}

View File

@@ -3,10 +3,10 @@ import type { Product } from "@/features/plans/lib/data";
import DeletePlan from "@/features/plans/ui/DeletePlan";
import FilterPlans from "@/features/plans/ui/FilterPlans";
import PalanTable from "@/features/plans/ui/PalanTable";
import PlanDetail from "@/features/plans/ui/PlanDetail";
import Pagination from "@/shared/ui/pagination";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { PlanDetail } from "./PlanDetail";
const ProductList = () => {
const [currentPage, setCurrentPage] = useState(1);
@@ -51,7 +51,11 @@ const ProductList = () => {
setSearchUser={setSearchUser}
/>
<PlanDetail detail={detail} setDetail={setDetail} plan={editingPlan} />
<PlanDetail
product={editingPlan}
isOpen={detail}
onClose={() => setDetail(false)}
/>
</div>
<PalanTable

View File

@@ -31,4 +31,15 @@ export const user_api = {
const res = await httpClient.post(API_URLS.Import_User);
return res;
},
async password_set({
body,
id,
}: {
id: string | number;
body: { password: string };
}) {
const res = await httpClient.post(API_URLS.PasswordSet(id), body);
return res;
},
};

View File

@@ -8,6 +8,7 @@ export interface UserData {
region: null | string;
address: null | string;
created_at: string;
password_set: boolean;
}
export interface UserListRes {

View File

@@ -1,12 +1,40 @@
"use client";
import { user_api } from "@/features/users/lib/api";
import type { UserData } from "@/features/users/lib/data";
import { Avatar, AvatarFallback } from "@/shared/ui/avatar";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { Calendar, MapPin } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTrigger,
} from "@/shared/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Calendar, Edit, MapPin } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
const passwordSet = z.object({
password: z
.string()
.min(8, { message: "Eng kamida 8ta belgi bolishi kerak" }),
});
export function UserCard({
user,
// setEditingUser,
@@ -56,6 +84,52 @@ export function UserCard({
return parts.length > 0 ? parts.join(" ") : user.username;
};
const form = useForm<z.infer<typeof passwordSet>>({
resolver: zodResolver(passwordSet),
defaultValues: {
password: "",
},
});
const [edit, setEdit] = useState<number | string | null>(null);
const [open, setOpen] = useState<boolean>(false);
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: ({
id,
body,
}: {
id: number | string;
body: { password: string };
}) => user_api.password_set({ body, id }),
onSuccess: () => {
setOpen(false);
setEdit(null);
queryClient.refetchQueries({ queryKey: ["user_list"] });
toast.success("Parol qo'yildi", {
richColors: true,
position: "top-center",
});
},
onError: (err: AxiosError) => {
const errData = (err.response?.data as { data: string }).data;
const errMessage = (err.response?.data as { message: string }).message;
toast.error(errData || errMessage || "Xatolik yuz berdi", {
richColors: true,
position: "top-center",
});
},
});
function onSubmit(value: z.infer<typeof passwordSet>) {
if (edit) {
mutate({ body: { password: value.password }, id: edit });
}
}
return (
<Card className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
@@ -109,33 +183,60 @@ export function UserCard({
ID: {user.id}
</div>
{/* Uncommnet qilish uchun tugmalar
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingUser(user);
setDialogOpen?.(true);
}}
className="flex-1"
>
<Edit className="h-4 w-4 mr-2" />
Tahrirlash
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setOpenDelete(true);
setUserDelete(user);
}}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex-1 cursor-pointer"
onClick={() => {
setOpen(true);
setEdit(user.id);
}}
>
<Edit className="h-4 w-4 mr-2" />
{user.password_set ? "Parolni o'zgartirish" : "Parol qo'yish"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="text-xl font-semibold">
Parolni qo'yish
</DialogHeader>
<div>
<Form {...form}>
<form
className="space-y-2"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem>
<Label>Parolni yozing</Label>
<FormControl>
<Input
placeholder="12345678"
className="h-12 focus-visible:ring-0"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button className="bg-blue-500 hover:bg-blue-600 cursor-pointer">
Tasdiqlash
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
</div>
*/}
</CardContent>
</Card>
);

View File

@@ -34,4 +34,11 @@ export const API_URLS = {
QuestionnaireList: `${API_V}admin/questionnaire/list/`,
Import_User: `${API_V}admin/user/import_users/`,
Refresh_Token: `${API_V}accounts/refresh/token/`,
Export_Product: `${API_V}admin/product/export/`,
Import_Product: `${API_V}admin/product/import/`,
Import_Category: `${API_V}admin/category/import/`,
Upload_Image_Category: (id: number | string) =>
`${API_V}admin/category/${id}/upload_image/`,
PasswordSet: (id: number | string) =>
`${API_V}admin/user/${id}/set_password/`,
};

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/shared/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -11,7 +11,7 @@ export default defineConfig({
alias: [{ find: "@", replacement: path.resolve(__dirname, "src") }],
},
server: {
port: 3000,
port: 3002,
open: true,
},
});