This commit is contained in:
2026-05-29 16:21:46 +03:30
parent 1140fbae3c
commit 9db796b8e1
12 changed files with 285 additions and 71 deletions
+36 -11
View File
@@ -1,4 +1,4 @@
import { useState, type FC } from "react";
import { useEffect, useRef, useState, type FC } from "react";
import Button from "@/components/Button";
import Select from "@/components/Select";
import { TickSquare } from "iconsax-react";
@@ -16,14 +16,17 @@ import UploadBox from "@/components/UploadBox";
import Textarea from "@/components/Textarea";
import DatePickerComponent from "@/components/DatePicker";
import { useCreateInvoice } from "./hooks/useInvoiceData";
import { useGetRequestDetail } from "@/pages/requests/hooks/useRequestData";
import { useMultiUpload } from "../uploader/hooks/useUploader";
import moment from "moment-jalaali";
import { getItemLineTotal } from "./utils/invoiceItem";
const createEmptyItem = (): CreatePreInvoiceItemType => ({
productId: "",
quantity: 1,
unitPrice: 0,
discount: 0,
discountPercent: null,
description: "",
});
@@ -35,6 +38,8 @@ const CreateInvoice: FC = () => {
const { mutate: submitInvoice, isPending } = useCreateInvoice();
const { data: usersData } = useGetUsers();
const { data: requestData, isLoading: isLoadingRequest } = useGetRequestDetail(requestId ?? "");
const hydratedRequestId = useRef<string | null>(null);
const multiUpload = useMultiUpload()
const [items, setItems] = useState<CreatePreInvoiceItemType[]>([createEmptyItem()]);
@@ -81,7 +86,9 @@ const CreateInvoice: FC = () => {
productId: i.productId,
quantity: i.quantity,
unitPrice: i.unitPrice,
discount: i.discount || 0,
...(i.discountPercent != null && i.discountPercent > 0
? { discountPercent: i.discountPercent }
: { discount: i.discount || 0 }),
description: i.description || "",
})),
enableTax: values.enableTax,
@@ -103,14 +110,35 @@ const CreateInvoice: FC = () => {
},
});
useEffect(() => {
const request = requestData?.data;
if (!requestId || !request || hydratedRequestId.current === requestId) return;
const mappedItems: CreatePreInvoiceItemType[] = request.items?.length
? request.items.map((item) => ({
productId: item.product?.id ?? "",
quantity: item.quantity ?? 1,
unitPrice: 0,
discount: 0,
discountPercent: null,
description: item.description ?? "",
}))
: [createEmptyItem()];
setItems(mappedItems);
hydratedRequestId.current = requestId;
}, [requestData, requestId]);
const handleItemChange = (
index: number,
field: keyof CreatePreInvoiceItemType,
value: string | number
value: string | number | null
) => {
const next = [...items];
next[index] = { ...next[index], [field]: value };
setItems(next);
setItems((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index ? { ...item, [field]: value } : item
)
);
};
const addItem = (currentIndex: number) => {
@@ -131,10 +159,7 @@ const CreateInvoice: FC = () => {
setItems(items.filter((_, i) => i !== index));
};
const totalAmount = items.reduce(
(sum, i) => sum + (i.quantity || 0) * (i.unitPrice || 0) - (i.discount || 0),
0
);
const totalAmount = items.reduce((sum, i) => sum + getItemLineTotal(i), 0);
const users = usersData?.data ?? [];
@@ -145,7 +170,7 @@ const CreateInvoice: FC = () => {
<Button
className="w-fit px-5"
onClick={() => formik.handleSubmit()}
isLoading={isPending || multiUpload.isPending}
isLoading={isPending || multiUpload.isPending || isLoadingRequest}
>
<div className="flex gap-2 items-center">
<TickSquare size={20} color="black" />
+7 -9
View File
@@ -5,6 +5,7 @@ import { Edit } from 'iconsax-react';
import { Paths } from '@/config/Paths';
import { useGetInvoiceDetail } from './hooks/useInvoiceData';
import InvoicePaymentsSection from './components/InvoicePaymentsSection';
import { formatItemDiscountDisplay } from './utils/invoiceItem';
const formatAmount = (amount: number) =>
Number(amount).toLocaleString('fa-IR') + ' ریال';
@@ -78,16 +79,16 @@ const DetailPerfomaInvoice: FC = () => {
<th className="pb-3 font-normal">توضیحات</th>
<th className="pb-3 font-normal">تعداد</th>
<th className="pb-3 font-normal">مبلغ واحد</th>
<th className="pb-3 font-normal">تخفیف</th>
<th className="pb-3 font-normal">تخفیف (مبلغ)</th>
<th className="pb-3 font-normal">تخفیف (درصد)</th>
<th className="pb-3 font-normal">مبلغ کل</th>
<th className="pb-3 font-normal">عملیات</th>
</tr>
</thead>
<tbody>
{invoice.items?.map((item) => {
const lineTotal =
(item.quantity || 0) * (item.unitPrice || 0) -
(item.discount || 0);
const discounts = formatItemDiscountDisplay(item);
const lineTotal = item.total ?? 0;
return (
<tr key={item.id} className="border-b border-border/50">
<td className="py-3">{item.product?.title ?? '-'}</td>
@@ -98,11 +99,8 @@ const DetailPerfomaInvoice: FC = () => {
? formatAmount(item.unitPrice)
: '-'}
</td>
<td className="py-3">
{item.discount != null
? formatAmount(item.discount)
: '-'}
</td>
<td className="py-3">{discounts.value}</td>
<td className="py-3">{discounts.percent}</td>
<td className="py-3">{formatAmount(lineTotal)}</td>
<td className="py-3">
{item.id && (
+14 -9
View File
@@ -16,12 +16,14 @@ import DatePickerComponent from "@/components/DatePicker";
import { useGetInvoiceDetail, useUpdateInvoice } from "./hooks/useInvoiceData";
import { useMultiUpload } from "../uploader/hooks/useUploader";
import moment from "moment-jalaali";
import { getItemLineTotal } from "./utils/invoiceItem";
const createEmptyItem = (): CreatePreInvoiceItemType => ({
productId: "",
quantity: 1,
unitPrice: 0,
discount: 0,
discountPercent: null,
description: "",
});
@@ -88,7 +90,9 @@ const UpdateInvoice: FC = () => {
productId: i.productId,
quantity: i.quantity,
unitPrice: i.unitPrice,
discount: i.discount || 0,
...(i.discountPercent != null && i.discountPercent > 0
? { discountPercent: i.discountPercent }
: { discount: i.discount || 0 }),
description: i.description || "",
})),
enableTax: values.enableTax,
@@ -137,6 +141,8 @@ const UpdateInvoice: FC = () => {
quantity: i.quantity ?? 1,
unitPrice: i.unitPrice ?? 0,
discount: i.discount ?? 0,
discountPercent:
i.discountPercent != null && i.discountPercent > 0 ? i.discountPercent : null,
description: i.description ?? "",
}))
: [createEmptyItem()];
@@ -149,11 +155,13 @@ const UpdateInvoice: FC = () => {
const handleItemChange = (
index: number,
field: keyof CreatePreInvoiceItemType,
value: string | number
value: string | number | null
) => {
const next = [...items];
next[index] = { ...next[index], [field]: value };
setItems(next);
setItems((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index ? { ...item, [field]: value } : item
)
);
};
const addItem = (currentIndex: number) => {
@@ -174,10 +182,7 @@ const UpdateInvoice: FC = () => {
setItems(items.filter((_, i) => i !== index));
};
const totalAmount = items.reduce(
(sum, i) => sum + (i.quantity || 0) * (i.unitPrice || 0) - (i.discount || 0),
0
);
const totalAmount = items.reduce((sum, i) => sum + getItemLineTotal(i), 0);
if (isLoadingInvoice || !invoice) {
return (
+72 -12
View File
@@ -1,14 +1,25 @@
import { type FC, type ChangeEvent } from "react";
import Input from "@/components/Input";
import SwitchComponent from "@/components/Switch";
import ProductsSelect from "@/pages/order/components/ProductsSelect";
import type { CreatePreInvoiceItemType } from "../types/Types";
import {
clampDiscountPercent,
clampDiscountValue,
getItemLineTotal,
getItemSubTotal,
} from "../utils/invoiceItem";
import { Add, Trash } from "iconsax-react";
import { Link } from "react-router-dom";
import { Paths } from "@/config/Paths";
type Props = {
item: CreatePreInvoiceItemType;
index: number;
onChange: (index: number, field: keyof CreatePreInvoiceItemType, value: string | number) => void;
onChange: (
index: number,
field: keyof CreatePreInvoiceItemType,
value: string | number | null,
) => void;
onAdd: () => void;
onRemove: () => void;
canRemove: boolean;
@@ -26,12 +37,25 @@ const InvoiceItemRow: FC<Props> = ({
onConvertToOrder,
error,
}) => {
const total = (item.quantity || 0) * (item.unitPrice || 0) - (item.discount || 0);
const total = getItemLineTotal(item);
const subTotal = getItemSubTotal(item);
const isPercentDiscount = item.discountPercent != null;
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
onChange(index, "productId", e.target.value);
};
const handleDiscountTypeChange = (isPercent: boolean) => {
if (isPercent) {
onChange(index, "discount", 0);
onChange(index, "discountPercent", 0);
return;
}
onChange(index, "discountPercent", null);
onChange(index, "discount", 0);
};
return (
<div className="flex flex-wrap items-end gap-4 mt-6">
@@ -58,9 +82,14 @@ const InvoiceItemRow: FC<Props> = ({
type="number"
min={1}
value={item.quantity || ""}
onChange={(e) =>
onChange(index, "quantity", e.target.value ? Number(e.target.value) : 0)
}
onChange={(e) => {
const nextQuantity = e.target.value ? Number(e.target.value) : 0;
const nextSubTotal = nextQuantity * (item.unitPrice || 0);
onChange(index, "quantity", nextQuantity);
if (!isPercentDiscount) {
onChange(index, "discount", clampDiscountValue(item.discount, nextSubTotal));
}
}}
error_text={error?.quantity}
/>
</div>
@@ -74,23 +103,54 @@ const InvoiceItemRow: FC<Props> = ({
seprator
onChange={(e) => {
const raw = String(e.target.value).replace(/,/g, "");
onChange(index, "unitPrice", raw ? Number(raw) : 0);
const nextUnitPrice = raw ? Number(raw) : 0;
const nextSubTotal = (item.quantity || 0) * nextUnitPrice;
onChange(index, "unitPrice", nextUnitPrice);
if (!isPercentDiscount) {
onChange(index, "discount", clampDiscountValue(item.discount, nextSubTotal));
}
}}
error_text={error?.unitPrice}
/>
</div>
<div className="w-[120px]">
<div className="w-[150px]">
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span>تخفیف </span>
<div className="flex items-center gap-1">
<span className={isPercentDiscount ? "text-[11px] text-black" : "text-[11px] text-[#888888]"}>
درصد
</span>
<SwitchComponent active={isPercentDiscount} onChange={handleDiscountTypeChange} />
<span className={!isPercentDiscount ? "text-[11px] text-black" : "text-[11px] text-[#888888]"}>
مبلغ
</span>
</div>
</div>
<Input
label="تخفیف"
type="text"
type={isPercentDiscount ? "number" : "text"}
inputMode="numeric"
value={item.discount ? String(item.discount) : ""}
seprator
min={0}
max={isPercentDiscount ? 100 : subTotal}
value={
isPercentDiscount
? item.discountPercent ? String(item.discountPercent) : ""
: item.discount ? String(item.discount) : ""
}
seprator={!isPercentDiscount}
onChange={(e) => {
const raw = String(e.target.value).replace(/,/g, "");
onChange(index, "discount", raw ? Number(raw) : 0);
const value = raw ? Number(raw) : 0;
if (isPercentDiscount) {
onChange(index, "discountPercent", clampDiscountPercent(value));
onChange(index, "discount", 0);
return;
}
onChange(index, "discount", clampDiscountValue(value, subTotal));
onChange(index, "discountPercent", null);
}}
unit={isPercentDiscount ? "٪" : undefined}
/>
</div>
+14 -2
View File
@@ -4,9 +4,20 @@ export type CreatePreInvoiceItemType = {
quantity: number;
unitPrice: number;
discount: number;
discountPercent?: number | null;
description: string;
};
export type InvoiceItemSubmitType = {
id?: string;
productId: string;
quantity: number;
unitPrice: number;
description: string;
discount?: number;
discountPercent?: number;
};
export type InvoiceAttachmentType = {
type: string;
url: string;
@@ -16,7 +27,7 @@ export type CreatePreInvoiceType = {
id?: string;
requestId?: string;
userId?: string;
items: CreatePreInvoiceItemType[];
items: InvoiceItemSubmitType[];
enableTax: boolean;
approvalDeadline: string;
paymentMethod?: string;
@@ -25,7 +36,7 @@ export type CreatePreInvoiceType = {
};
export type UpdatePreInvoiceType = {
items: CreatePreInvoiceItemType[];
items: InvoiceItemSubmitType[];
enableTax: boolean;
approvalDeadline: string;
paymentMethod?: string;
@@ -80,6 +91,7 @@ export type InvoiceItemType = {
unitPrice: number;
subTotal: number | null;
discount: number | null;
discountPercent: number | null;
total: number | null;
description: string;
confirmedAt: string | null;
+48
View File
@@ -0,0 +1,48 @@
import type { CreatePreInvoiceItemType } from '../types/Types';
export function getItemSubTotal(
item: Pick<CreatePreInvoiceItemType, 'quantity' | 'unitPrice'>,
): number {
return (item.quantity || 0) * (item.unitPrice || 0);
}
export function clampDiscountValue(value: number, itemPrice: number): number {
return Math.min(Math.max(value || 0, 0), Math.max(itemPrice || 0, 0));
}
export function clampDiscountPercent(value: number): number {
return Math.min(Math.max(value || 0, 0), 100);
}
export function getItemDiscountAmount(
item: Pick<CreatePreInvoiceItemType, 'quantity' | 'unitPrice' | 'discount' | 'discountPercent'>,
): number {
const subTotal = getItemSubTotal(item);
if (item.discountPercent != null && item.discountPercent > 0) {
return Math.round((subTotal * clampDiscountPercent(item.discountPercent)) / 100);
}
return clampDiscountValue(item.discount || 0, subTotal);
}
export function getItemLineTotal(
item: Pick<CreatePreInvoiceItemType, 'quantity' | 'unitPrice' | 'discount' | 'discountPercent'>,
): number {
const subTotal = getItemSubTotal(item);
return subTotal - getItemDiscountAmount(item);
}
export function formatItemDiscountDisplay(item: {
discount?: number | null;
discountPercent?: number | null;
}): { value: string; percent: string } {
return {
value:
item.discount != null && item.discount > 0
? Number(item.discount).toLocaleString('fa-IR') + ' ریال'
: '-',
percent:
item.discountPercent != null && item.discountPercent > 0
? `${item.discountPercent}٪`
: '-',
};
}
+30 -10
View File
@@ -9,6 +9,29 @@ type Props = {
const RequestItem: FC<Props> = ({ item }) => {
const { data: fields } = useGetEntityField(String(item.product?.id ?? ''))
const attributes = item.attributes ?? []
const getAttributeFieldId = (attribute: NonNullable<RequestDetailItemType['attributes']>[number]) =>
attribute.fieldId ?? attribute.attributeId ?? attribute.field?.id
const fieldRows = fields?.data?.map((field) => ({
id: field.id,
name: field.name,
value: attributes.find((attribute) => String(getAttributeFieldId(attribute)) === String(field.id))?.value,
})) ?? []
const matchedFieldIds = new Set(fieldRows.map((field) => String(field.id)))
const extraAttributeRows = attributes
.filter((attribute) => {
const fieldId = getAttributeFieldId(attribute)
return fieldId && !matchedFieldIds.has(String(fieldId))
})
.map((attribute) => ({
id: String(getAttributeFieldId(attribute)),
name: attribute.field?.name ?? String(getAttributeFieldId(attribute)),
value: attribute.value,
}))
const attributeRows = [...fieldRows, ...extraAttributeRows]
const handleOpenAttachment = (url: string) => {
window.open(url, '_blank')
@@ -53,19 +76,16 @@ const RequestItem: FC<Props> = ({ item }) => {
)}
{/* Attributes */}
{fields?.data?.length ? (
{attributeRows.length ? (
<>
<div className='font-bold mb-5 mt-7'>ویژگی ها</div>
{
fields.data.map((field) => {
const value = item.attributes?.find((o) => String(o.attributeId) === String(field.id))
return (
<div className='flex gap-3 items-center mt-3' key={field.id}>
<div className='text-sm text-gray-500'>{field.name}:</div>
<div className='text-sm -mt-px'>{value?.value || '-'}</div>
</div>
)
})
attributeRows.map((field) => (
<div className='flex gap-3 items-center mt-3' key={field.id}>
<div className='text-sm text-gray-500'>{field.name}:</div>
<div className='text-sm -mt-px'>{field.value || '-'}</div>
</div>
))
}
</>
) : null}
+6 -1
View File
@@ -5,7 +5,12 @@ import type { BaseResponse } from "@/shared/types/Types";
export type RequestItemAttributeType = {
value: string;
attributeId: string;
attributeId?: string;
fieldId?: string;
field?: {
id: string;
name: string;
} | null;
};
export type RequestItemType = {