design invoice

This commit is contained in:
hamid zarghami
2026-02-21 11:38:39 +03:30
parent 7d702c41f8
commit 8a0a62b6d7
10 changed files with 427 additions and 5 deletions
+2
View File
@@ -6,6 +6,7 @@ import 'react-toastify/dist/ReactToastify.css'
import { BrowserRouter } from 'react-router-dom'
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ToastContainer } from 'react-toastify'
import Toast from '@/components/Toast'
import { getRefreshToken, getToken, removeRefreshToken, removeToken, setRefreshToken, setToken } from './config/func'
import MainRouter from './router/MainRouter'
import AuthRouter from './router/AuthRouter'
@@ -131,6 +132,7 @@ const App: FC = () => {
<BrowserRouter>
{isLoggedIn ? <MainRouter /> : <AuthRouter />}
</BrowserRouter>
<Toast />
<ToastContainer
position="top-left"
autoClose={3000}
+3 -2
View File
@@ -51,8 +51,9 @@ export const Paths = {
home: '/home',
myOrders: '/my-orders',
perfomaInvoice: {
list: '/perfoma-invoice/list',
detail: '/perfoma-invoice/',
list: '/invoice/list',
create: '/invoice/create',
detail: '/invoice/',
},
newOrder: '/new-order',
orderDetails: '/order/',
+236
View File
@@ -0,0 +1,236 @@
import { useState, useEffect, type FC } from "react";
import Button from "@/components/Button";
import Select from "@/components/Select";
import { TickSquare } from "iconsax-react";
import { toast } from "@/shared/toast";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Paths } from "@/config/Paths";
import { extractErrorMessage } from "@/config/func";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useGetUsers } from "@/pages/user/hooks/useUserData";
import { useGetRequestDetail } from "@/pages/requests/hooks/useRequestData";
import { useCreatePreInvoice } from "./hooks/useInvoiceData";
import type { CreatePreInvoiceType, CreatePreInvoiceItemType } from "./types/Types";
import InvoiceItemRow from "./components/InvoiceItemRow";
import { Checkbox } from "@/components/ui/checkbox";
import UploadBox from "@/components/UploadBox";
import Textarea from "@/components/Textarea";
const createEmptyItem = (): CreatePreInvoiceItemType => ({
productId: "",
quantity: 1,
unitPrice: 0,
discount: 0,
description: "",
});
const CreateInvoice: FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId") ?? undefined;
const { mutate: submitInvoice, isPending } = useCreatePreInvoice();
const { data: usersData } = useGetUsers();
const { data: requestData } = useGetRequestDetail(requestId || "");
const [items, setItems] = useState<CreatePreInvoiceItemType[]>([createEmptyItem()]);
const [, setAttachmentFiles] = useState<File[]>([]);
const formik = useFormik<Pick<CreatePreInvoiceType, "userId" | "enableTax" | "approvalDeadline" | "notes">>({
initialValues: {
userId: "",
enableTax: false,
approvalDeadline: "",
notes: "",
},
validationSchema: Yup.object({
userId: Yup.string().required("انتخاب مشتری اجباری است."),
approvalDeadline: Yup.string().required("نحوه پرداخت اجباری است."),
}),
onSubmit: (values) => {
const validItems = items.filter(
(i) => i.productId && i.quantity > 0 && i.unitPrice >= 0
);
if (validItems.length === 0) {
toast("حداقل یک آیتم با محصول، تعداد و مبلغ واحد معتبر اضافه کنید.", "error");
return;
}
const params: CreatePreInvoiceType = {
requestId,
userId: values.userId,
items: validItems.map((i) => ({
productId: i.productId,
quantity: i.quantity,
unitPrice: i.unitPrice,
discount: i.discount || 0,
description: i.description || "",
})),
enableTax: values.enableTax,
approvalDeadline: values.approvalDeadline,
notes: values.notes,
};
submitInvoice(params, {
onSuccess: () => {
toast("پیش فاکتور با موفقیت ثبت شد", "success");
navigate(Paths.perfomaInvoice.list);
},
onError: (error) => {
toast(extractErrorMessage(error), "error");
},
});
},
});
useEffect(() => {
if (requestId && requestData?.data?.user?.id) {
formik.setFieldValue("userId", String(requestData.data.user.id));
}
}, [requestId, requestData?.data?.user?.id]);
const handleItemChange = (
index: number,
field: keyof CreatePreInvoiceItemType,
value: string | number
) => {
const next = [...items];
next[index] = { ...next[index], [field]: value };
setItems(next);
};
const addItem = (currentIndex: number) => {
const current = items[currentIndex];
const hasProduct = !!current?.productId;
const hasQuantity = (current?.quantity ?? 0) > 0;
const hasUnitPrice = (current?.unitPrice ?? 0) > 0;
if (!hasProduct || !hasQuantity || !hasUnitPrice) {
toast("لطفاً محصول، تعداد و مبلغ واحد را وارد کنید.", "error");
return;
}
setItems([...items, createEmptyItem()]);
};
const removeItem = (index: number) => {
if (items.length <= 1) return;
setItems(items.filter((_, i) => i !== index));
};
const totalAmount = items.reduce(
(sum, i) => sum + (i.quantity || 0) * (i.unitPrice || 0) - (i.discount || 0),
0
);
const users = usersData?.data ?? [];
return (
<div className="mt-5">
<div className="justify-between items-center flex">
<h1 className="text-lg font-light">پیش فاکتور جدید</h1>
<Button
className="w-fit px-5"
onClick={() => formik.handleSubmit()}
isLoading={isPending}
>
<div className="flex gap-2 items-center">
<TickSquare size={20} color="black" />
<div>ثبت پیش فاکتور</div>
</div>
</Button>
</div>
<div className="bg-white rounded-3xl p-6 mt-8">
<div className="font-light">اطلاعات پیش فاکتور</div>
<div className="rowTwoInput mt-6">
<Select
items={users.map((u) => ({
label: u.firstName && u.lastName ? `${u.firstName} ${u.lastName}` : u.phone,
value: String(u.id),
}))}
label="مشتری"
placeholder="انتخاب"
value={formik.values.userId}
onChange={(e) => formik.setFieldValue("userId", e.target.value)}
onBlur={formik.handleBlur}
error_text={
formik.errors.userId && formik.touched.userId ? formik.errors.userId : ""
}
/>
</div>
<div className="mt-6">
{items.map((item, index) => (
<InvoiceItemRow
key={index}
item={item}
index={index}
onChange={handleItemChange}
onAdd={() => addItem(index)}
onRemove={() => removeItem(index)}
canRemove={items.length > 1}
/>
))}
</div>
<div className="mt-6 flex gap-2 items-center">
<Checkbox
id="enableTax"
checked={formik.values.enableTax}
onCheckedChange={(checked) =>
formik.setFieldValue("enableTax", !!checked)
}
/>
<label
htmlFor="enableTax"
className="text-[13px] cursor-pointer select-none"
>
مالیات بر ارزش افزوده (۱۰ درصد)
</label>
</div>
<div className="mt-6">
<Textarea
label="نحوه پرداخت"
placeholder="مثال: مبلغ ۷۰ درصد پیش پرداخت / اعتبار ۲ روز"
value={formik.values.approvalDeadline}
onChange={(e) => formik.setFieldValue("approvalDeadline", e.target.value)}
onBlur={formik.handleBlur}
error_text={
formik.errors.approvalDeadline && formik.touched.approvalDeadline
? formik.errors.approvalDeadline
: ""
}
/>
</div>
<div className="mt-6">
<Textarea
label="توضیحات"
placeholder="توضیحات پیش فاکتور را وارد کنید..."
value={formik.values.notes}
onChange={(e) => formik.setFieldValue("notes", e.target.value)}
onBlur={formik.handleBlur}
name="notes"
/>
</div>
<div className="mt-6">
<UploadBox label="فایل ضمیمه" onChange={setAttachmentFiles} />
</div>
<div className="mt-6 bg-[#F5F7FC] h-12 px-4 flex justify-between items-center rounded-xl">
<div className="text-[#888888] text-[13px]">مبلغ کل:</div>
<div className="font-semibold text-[13px]">
{totalAmount.toLocaleString("fa-IR")} ریال
</div>
</div>
</div>
</div>
);
};
export default CreateInvoice;
+10 -2
View File
@@ -3,7 +3,7 @@ import { useMemo, useState, type FC } from 'react'
import { ProformaInvoiceStatusEnum } from './enum/InvoiceEnum'
import Filters from '@/components/Filters'
import Table from '@/components/Table'
import { Eye } from 'iconsax-react'
import { Eye, Add } from 'iconsax-react'
import { Link } from 'react-router-dom'
import { Paths } from '@/config/Paths'
import { useGetOrdersInvoiced } from '../order/hooks/useOrderData'
@@ -79,7 +79,15 @@ const ProformaInvoice: FC = () => {
return (
<div className='mt-5'>
<h1 className='text-lg font-light'>پیش فاکتورها</h1>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>پیش فاکتورها</h1>
<Link to={Paths.perfomaInvoice.create}>
<button className='flex items-center gap-2 px-4 py-2 bg-primary text-black text-sm font-medium rounded-xl hover:opacity-90 transition-opacity'>
<Add size={18} />
پیش فاکتور جدید
</button>
</Link>
</div>
<div className='mt-8'>
<Tabs
@@ -0,0 +1,125 @@
import { type FC, type ChangeEvent } from "react";
import Input from "@/components/Input";
import ProductsSelect from "@/pages/order/components/ProductsSelect";
import type { CreatePreInvoiceItemType } from "../types/Types";
import { Add, Trash } from "iconsax-react";
type Props = {
item: CreatePreInvoiceItemType;
index: number;
onChange: (index: number, field: keyof CreatePreInvoiceItemType, value: string | number) => void;
onAdd: () => void;
onRemove: () => void;
canRemove: boolean;
error?: Partial<Record<keyof CreatePreInvoiceItemType, string>>;
};
const InvoiceItemRow: FC<Props> = ({
item,
index,
onChange,
onAdd,
onRemove,
canRemove,
error,
}) => {
const total = (item.quantity || 0) * (item.unitPrice || 0) - (item.discount || 0);
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
onChange(index, "productId", e.target.value);
};
return (
<div className="flex flex-wrap items-end gap-4 mt-6">
<div className="min-w-[140px] flex-1">
<ProductsSelect
value={item.productId}
onChange={handleProductChange}
error_text={error?.productId}
/>
</div>
<div className="min-w-[140px] flex-1">
<Input
label="توضیحات"
value={item.description}
onChange={(e) => onChange(index, "description", e.target.value)}
error_text={error?.description}
/>
</div>
<div className="w-[100px]">
<Input
label="تعداد"
type="number"
min={1}
value={item.quantity || ""}
onChange={(e) =>
onChange(index, "quantity", e.target.value ? Number(e.target.value) : 0)
}
error_text={error?.quantity}
/>
</div>
<div className="w-[120px]">
<Input
label="مبلغ واحد"
type="text"
inputMode="numeric"
value={item.unitPrice ? String(item.unitPrice) : ""}
seprator
onChange={(e) => {
const raw = String(e.target.value).replace(/,/g, "");
onChange(index, "unitPrice", raw ? Number(raw) : 0);
}}
error_text={error?.unitPrice}
/>
</div>
<div className="w-[120px]">
<Input
label="تخفیف"
type="text"
inputMode="numeric"
value={item.discount ? String(item.discount) : ""}
seprator
onChange={(e) => {
const raw = String(e.target.value).replace(/,/g, "");
onChange(index, "discount", raw ? Number(raw) : 0);
}}
/>
</div>
<div className="w-[120px]">
<Input
label="مبلغ کل"
readOnly
value={total >= 0 ? total.toLocaleString("fa-IR") : "۰"}
unit="ریال"
/>
</div>
<div className="flex gap-2 items-end h-10">
<button
type="button"
onClick={onAdd}
className="w-10 h-10 rounded-full bg-primary flex items-center justify-center flex-shrink-0 hover:opacity-90 transition-opacity"
aria-label="افزودن آیتم"
>
<Add size={20} color="black" />
</button>
{canRemove && (
<button
type="button"
onClick={onRemove}
className="w-10 h-10 rounded-full border border-border flex items-center justify-center flex-shrink-0 hover:bg-red-50 hover:border-red-200 transition-colors"
aria-label="حذف آیتم"
>
<Trash size={18} color="#dc2626" />
</button>
)}
</div>
</div>
);
};
export default InvoiceItemRow;
+14
View File
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/InvoiceService";
import type { CreatePreInvoiceType } from "../types/Types";
export const useCreatePreInvoice = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: CreatePreInvoiceType) => api.createPreInvoice(params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["orders-invoiced"] });
},
});
};
@@ -0,0 +1,7 @@
import axios from "@/config/axios";
import type { CreatePreInvoiceType } from "../types/Types";
export const createPreInvoice = async (params: CreatePreInvoiceType) => {
const { data } = await axios.post("/admin/pre-invoice", params);
return data;
};
+16
View File
@@ -0,0 +1,16 @@
export type CreatePreInvoiceItemType = {
productId: string;
quantity: number;
unitPrice: number;
discount: number;
description: string;
};
export type CreatePreInvoiceType = {
requestId?: string;
userId: string;
items: CreatePreInvoiceItemType[];
enableTax: boolean;
approvalDeadline: string;
notes?: string;
};
+12 -1
View File
@@ -1,7 +1,10 @@
import { type FC } from 'react'
import { useGetRequestDetail } from './hooks/useRequestData'
import { useParams } from 'react-router-dom'
import { Link, useParams } from 'react-router-dom'
import RequestItem from './components/RequestItem'
import { Paths } from '@/config/Paths'
import Button from '@/components/Button'
import { TickSquare } from 'iconsax-react'
const RequestDetail: FC = () => {
const { id } = useParams()
@@ -14,6 +17,14 @@ const RequestDetail: FC = () => {
<div className="text-sm text-[#8C90A3]">
درخواست #{data?.data?.requestNumber}
</div>
<Link to={Paths.perfomaInvoice.create + `?requestId=${id}`}>
<Button className="w-fit px-5">
<div className="flex gap-2 items-center">
<TickSquare size={20} color="black" />
<div>ثبت پیش فاکتور</div>
</div>
</Button>
</Link>
</div>
{/* Request Information Section */}
+2
View File
@@ -5,6 +5,7 @@ import SideBar from "../shared/Sidebar";
import Header from "../shared/Header";
import { Paths } from "@/config/Paths";
import ProformaInvoice from "@/pages/invoice/ProformaInvoice";
import CreateInvoice from "@/pages/invoice/Create";
import RequestList from "@/pages/requests/RequestList";
import ProductList from "@/pages/product/List";
import OrdersList from "@/pages/order/List";
@@ -60,6 +61,7 @@ const MainRouter: FC = () => {
<Route path="/" element={<Home />} />
<Route path={Paths.home} element={<Home />} />
<Route path={Paths.perfomaInvoice.list} element={<ProformaInvoice />} />
<Route path={Paths.perfomaInvoice.create} element={<CreateInvoice />} />
<Route path={Paths.perfomaInvoice.detail + ":id"} element={<DetailPerfomaInvoice />} />
<Route path={Paths.requests.list} element={<RequestList />} />
<Route path={Paths.requests.detail + ":id"} element={<RequestDetail />} />