discount
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env*
|
||||
*.md
|
||||
coverage
|
||||
.nyc_output
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
.eslintcache
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -1,4 +1,4 @@
|
||||
# VITE_API_BASE_URL = 'https://negareh-api.dev.danakcorp.com'
|
||||
VITE_API_BASE_URL = 'http://10.23.26.88:4000'
|
||||
VITE_API_BASE_URL = 'http://localhost:4000'
|
||||
VITE_TOKEN_NAME = 'negareh_at'
|
||||
VITE_REFRESH_TOKEN_NAME = 'negareh_art'
|
||||
|
||||
+22
-16
@@ -1,32 +1,38 @@
|
||||
FROM node:20-alpine AS builder
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Install tzdata to support timezone settings
|
||||
RUN apk add --no-cache tzdata
|
||||
FROM node:22-alpine AS base
|
||||
ENV TZ=Asia/Tehran
|
||||
|
||||
# Set the timezone to Asia/Tehran
|
||||
RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --ignore-scripts --no-fund --no-audit
|
||||
|
||||
WORKDIR /build
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . ./
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ARG VITE_TOKEN_NAME
|
||||
ARG VITE_REFRESH_TOKEN_NAME
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||
VITE_TOKEN_NAME=$VITE_TOKEN_NAME \
|
||||
VITE_REFRESH_TOKEN_NAME=$VITE_REFRESH_TOKEN_NAME
|
||||
VITE_REFRESH_TOKEN_NAME=$VITE_REFRESH_TOKEN_NAME \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
FROM nginx:stable-alpine AS production
|
||||
ENV TZ=Asia/Tehran
|
||||
|
||||
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||
|
||||
COPY --from=builder /build/nginx.con[f] /etc/nginx/conf.d/default.conf
|
||||
RUN cat /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://127.0.0.1/health || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
Generated
+14
@@ -82,6 +82,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -2142,6 +2143,7 @@
|
||||
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
}
|
||||
@@ -2151,6 +2153,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -2161,6 +2164,7 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2211,6 +2215,7 @@
|
||||
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
@@ -2463,6 +2468,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2617,6 +2623,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -2990,6 +2997,7 @@
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4415,6 +4423,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4430,6 +4439,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4896,6 +4906,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4992,6 +5003,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5086,6 +5098,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -5196,6 +5209,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}٪`
|
||||
: '-',
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user