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 = '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_TOKEN_NAME = 'negareh_at'
|
||||||
VITE_REFRESH_TOKEN_NAME = 'negareh_art'
|
VITE_REFRESH_TOKEN_NAME = 'negareh_art'
|
||||||
|
|||||||
+21
-15
@@ -1,32 +1,38 @@
|
|||||||
FROM node:20-alpine AS builder
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Install tzdata to support timezone settings
|
FROM node:22-alpine AS base
|
||||||
RUN apk add --no-cache tzdata
|
ENV TZ=Asia/Tehran
|
||||||
|
|
||||||
# Set the timezone to Asia/Tehran
|
FROM base AS deps
|
||||||
RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone
|
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
|
FROM base AS builder
|
||||||
COPY package*.json ./
|
WORKDIR /app
|
||||||
RUN npm install
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . ./
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_API_BASE_URL
|
ARG VITE_API_BASE_URL
|
||||||
ARG VITE_TOKEN_NAME
|
ARG VITE_TOKEN_NAME
|
||||||
ARG VITE_REFRESH_TOKEN_NAME
|
ARG VITE_REFRESH_TOKEN_NAME
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||||
VITE_TOKEN_NAME=$VITE_TOKEN_NAME \
|
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
|
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 /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=builder /build/nginx.con[f] /etc/nginx/conf.d/default.conf
|
|
||||||
RUN cat /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
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;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
Generated
+14
@@ -82,6 +82,7 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -2142,6 +2143,7 @@
|
|||||||
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
|
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.14.0"
|
"undici-types": "~7.14.0"
|
||||||
}
|
}
|
||||||
@@ -2151,6 +2153,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -2161,6 +2164,7 @@
|
|||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -2211,6 +2215,7 @@
|
|||||||
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
|
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.0",
|
"@typescript-eslint/scope-manager": "8.46.0",
|
||||||
"@typescript-eslint/types": "8.46.0",
|
"@typescript-eslint/types": "8.46.0",
|
||||||
@@ -2463,6 +2468,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2617,6 +2623,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.9",
|
"baseline-browser-mapping": "^2.8.9",
|
||||||
"caniuse-lite": "^1.0.30001746",
|
"caniuse-lite": "^1.0.30001746",
|
||||||
@@ -2990,6 +2997,7 @@
|
|||||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4415,6 +4423,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -4430,6 +4439,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -4896,6 +4906,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4992,6 +5003,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5086,6 +5098,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5196,6 +5209,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"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 Button from "@/components/Button";
|
||||||
import Select from "@/components/Select";
|
import Select from "@/components/Select";
|
||||||
import { TickSquare } from "iconsax-react";
|
import { TickSquare } from "iconsax-react";
|
||||||
@@ -16,14 +16,17 @@ import UploadBox from "@/components/UploadBox";
|
|||||||
import Textarea from "@/components/Textarea";
|
import Textarea from "@/components/Textarea";
|
||||||
import DatePickerComponent from "@/components/DatePicker";
|
import DatePickerComponent from "@/components/DatePicker";
|
||||||
import { useCreateInvoice } from "./hooks/useInvoiceData";
|
import { useCreateInvoice } from "./hooks/useInvoiceData";
|
||||||
|
import { useGetRequestDetail } from "@/pages/requests/hooks/useRequestData";
|
||||||
import { useMultiUpload } from "../uploader/hooks/useUploader";
|
import { useMultiUpload } from "../uploader/hooks/useUploader";
|
||||||
import moment from "moment-jalaali";
|
import moment from "moment-jalaali";
|
||||||
|
import { getItemLineTotal } from "./utils/invoiceItem";
|
||||||
|
|
||||||
const createEmptyItem = (): CreatePreInvoiceItemType => ({
|
const createEmptyItem = (): CreatePreInvoiceItemType => ({
|
||||||
productId: "",
|
productId: "",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: 0,
|
unitPrice: 0,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
|
discountPercent: null,
|
||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +38,8 @@ const CreateInvoice: FC = () => {
|
|||||||
|
|
||||||
const { mutate: submitInvoice, isPending } = useCreateInvoice();
|
const { mutate: submitInvoice, isPending } = useCreateInvoice();
|
||||||
const { data: usersData } = useGetUsers();
|
const { data: usersData } = useGetUsers();
|
||||||
|
const { data: requestData, isLoading: isLoadingRequest } = useGetRequestDetail(requestId ?? "");
|
||||||
|
const hydratedRequestId = useRef<string | null>(null);
|
||||||
const multiUpload = useMultiUpload()
|
const multiUpload = useMultiUpload()
|
||||||
|
|
||||||
const [items, setItems] = useState<CreatePreInvoiceItemType[]>([createEmptyItem()]);
|
const [items, setItems] = useState<CreatePreInvoiceItemType[]>([createEmptyItem()]);
|
||||||
@@ -81,7 +86,9 @@ const CreateInvoice: FC = () => {
|
|||||||
productId: i.productId,
|
productId: i.productId,
|
||||||
quantity: i.quantity,
|
quantity: i.quantity,
|
||||||
unitPrice: i.unitPrice,
|
unitPrice: i.unitPrice,
|
||||||
discount: i.discount || 0,
|
...(i.discountPercent != null && i.discountPercent > 0
|
||||||
|
? { discountPercent: i.discountPercent }
|
||||||
|
: { discount: i.discount || 0 }),
|
||||||
description: i.description || "",
|
description: i.description || "",
|
||||||
})),
|
})),
|
||||||
enableTax: values.enableTax,
|
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 = (
|
const handleItemChange = (
|
||||||
index: number,
|
index: number,
|
||||||
field: keyof CreatePreInvoiceItemType,
|
field: keyof CreatePreInvoiceItemType,
|
||||||
value: string | number
|
value: string | number | null
|
||||||
) => {
|
) => {
|
||||||
const next = [...items];
|
setItems((prev) =>
|
||||||
next[index] = { ...next[index], [field]: value };
|
prev.map((item, itemIndex) =>
|
||||||
setItems(next);
|
itemIndex === index ? { ...item, [field]: value } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addItem = (currentIndex: number) => {
|
const addItem = (currentIndex: number) => {
|
||||||
@@ -131,10 +159,7 @@ const CreateInvoice: FC = () => {
|
|||||||
setItems(items.filter((_, i) => i !== index));
|
setItems(items.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalAmount = items.reduce(
|
const totalAmount = items.reduce((sum, i) => sum + getItemLineTotal(i), 0);
|
||||||
(sum, i) => sum + (i.quantity || 0) * (i.unitPrice || 0) - (i.discount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const users = usersData?.data ?? [];
|
const users = usersData?.data ?? [];
|
||||||
|
|
||||||
@@ -145,7 +170,7 @@ const CreateInvoice: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
className="w-fit px-5"
|
className="w-fit px-5"
|
||||||
onClick={() => formik.handleSubmit()}
|
onClick={() => formik.handleSubmit()}
|
||||||
isLoading={isPending || multiUpload.isPending}
|
isLoading={isPending || multiUpload.isPending || isLoadingRequest}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<TickSquare size={20} color="black" />
|
<TickSquare size={20} color="black" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Edit } from 'iconsax-react';
|
|||||||
import { Paths } from '@/config/Paths';
|
import { Paths } from '@/config/Paths';
|
||||||
import { useGetInvoiceDetail } from './hooks/useInvoiceData';
|
import { useGetInvoiceDetail } from './hooks/useInvoiceData';
|
||||||
import InvoicePaymentsSection from './components/InvoicePaymentsSection';
|
import InvoicePaymentsSection from './components/InvoicePaymentsSection';
|
||||||
|
import { formatItemDiscountDisplay } from './utils/invoiceItem';
|
||||||
|
|
||||||
const formatAmount = (amount: number) =>
|
const formatAmount = (amount: number) =>
|
||||||
Number(amount).toLocaleString('fa-IR') + ' ریال';
|
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>
|
||||||
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{invoice.items?.map((item) => {
|
{invoice.items?.map((item) => {
|
||||||
const lineTotal =
|
const discounts = formatItemDiscountDisplay(item);
|
||||||
(item.quantity || 0) * (item.unitPrice || 0) -
|
const lineTotal = item.total ?? 0;
|
||||||
(item.discount || 0);
|
|
||||||
return (
|
return (
|
||||||
<tr key={item.id} className="border-b border-border/50">
|
<tr key={item.id} className="border-b border-border/50">
|
||||||
<td className="py-3">{item.product?.title ?? '-'}</td>
|
<td className="py-3">{item.product?.title ?? '-'}</td>
|
||||||
@@ -98,11 +99,8 @@ const DetailPerfomaInvoice: FC = () => {
|
|||||||
? formatAmount(item.unitPrice)
|
? formatAmount(item.unitPrice)
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3">
|
<td className="py-3">{discounts.value}</td>
|
||||||
{item.discount != null
|
<td className="py-3">{discounts.percent}</td>
|
||||||
? formatAmount(item.discount)
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3">{formatAmount(lineTotal)}</td>
|
<td className="py-3">{formatAmount(lineTotal)}</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
{item.id && (
|
{item.id && (
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import DatePickerComponent from "@/components/DatePicker";
|
|||||||
import { useGetInvoiceDetail, useUpdateInvoice } from "./hooks/useInvoiceData";
|
import { useGetInvoiceDetail, useUpdateInvoice } from "./hooks/useInvoiceData";
|
||||||
import { useMultiUpload } from "../uploader/hooks/useUploader";
|
import { useMultiUpload } from "../uploader/hooks/useUploader";
|
||||||
import moment from "moment-jalaali";
|
import moment from "moment-jalaali";
|
||||||
|
import { getItemLineTotal } from "./utils/invoiceItem";
|
||||||
|
|
||||||
const createEmptyItem = (): CreatePreInvoiceItemType => ({
|
const createEmptyItem = (): CreatePreInvoiceItemType => ({
|
||||||
productId: "",
|
productId: "",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: 0,
|
unitPrice: 0,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
|
discountPercent: null,
|
||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +90,9 @@ const UpdateInvoice: FC = () => {
|
|||||||
productId: i.productId,
|
productId: i.productId,
|
||||||
quantity: i.quantity,
|
quantity: i.quantity,
|
||||||
unitPrice: i.unitPrice,
|
unitPrice: i.unitPrice,
|
||||||
discount: i.discount || 0,
|
...(i.discountPercent != null && i.discountPercent > 0
|
||||||
|
? { discountPercent: i.discountPercent }
|
||||||
|
: { discount: i.discount || 0 }),
|
||||||
description: i.description || "",
|
description: i.description || "",
|
||||||
})),
|
})),
|
||||||
enableTax: values.enableTax,
|
enableTax: values.enableTax,
|
||||||
@@ -137,6 +141,8 @@ const UpdateInvoice: FC = () => {
|
|||||||
quantity: i.quantity ?? 1,
|
quantity: i.quantity ?? 1,
|
||||||
unitPrice: i.unitPrice ?? 0,
|
unitPrice: i.unitPrice ?? 0,
|
||||||
discount: i.discount ?? 0,
|
discount: i.discount ?? 0,
|
||||||
|
discountPercent:
|
||||||
|
i.discountPercent != null && i.discountPercent > 0 ? i.discountPercent : null,
|
||||||
description: i.description ?? "",
|
description: i.description ?? "",
|
||||||
}))
|
}))
|
||||||
: [createEmptyItem()];
|
: [createEmptyItem()];
|
||||||
@@ -149,11 +155,13 @@ const UpdateInvoice: FC = () => {
|
|||||||
const handleItemChange = (
|
const handleItemChange = (
|
||||||
index: number,
|
index: number,
|
||||||
field: keyof CreatePreInvoiceItemType,
|
field: keyof CreatePreInvoiceItemType,
|
||||||
value: string | number
|
value: string | number | null
|
||||||
) => {
|
) => {
|
||||||
const next = [...items];
|
setItems((prev) =>
|
||||||
next[index] = { ...next[index], [field]: value };
|
prev.map((item, itemIndex) =>
|
||||||
setItems(next);
|
itemIndex === index ? { ...item, [field]: value } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addItem = (currentIndex: number) => {
|
const addItem = (currentIndex: number) => {
|
||||||
@@ -174,10 +182,7 @@ const UpdateInvoice: FC = () => {
|
|||||||
setItems(items.filter((_, i) => i !== index));
|
setItems(items.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalAmount = items.reduce(
|
const totalAmount = items.reduce((sum, i) => sum + getItemLineTotal(i), 0);
|
||||||
(sum, i) => sum + (i.quantity || 0) * (i.unitPrice || 0) - (i.discount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadingInvoice || !invoice) {
|
if (isLoadingInvoice || !invoice) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { type FC, type ChangeEvent } from "react";
|
import { type FC, type ChangeEvent } from "react";
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
|
import SwitchComponent from "@/components/Switch";
|
||||||
import ProductsSelect from "@/pages/order/components/ProductsSelect";
|
import ProductsSelect from "@/pages/order/components/ProductsSelect";
|
||||||
import type { CreatePreInvoiceItemType } from "../types/Types";
|
import type { CreatePreInvoiceItemType } from "../types/Types";
|
||||||
|
import {
|
||||||
|
clampDiscountPercent,
|
||||||
|
clampDiscountValue,
|
||||||
|
getItemLineTotal,
|
||||||
|
getItemSubTotal,
|
||||||
|
} from "../utils/invoiceItem";
|
||||||
import { Add, Trash } from "iconsax-react";
|
import { Add, Trash } from "iconsax-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Paths } from "@/config/Paths";
|
import { Paths } from "@/config/Paths";
|
||||||
type Props = {
|
type Props = {
|
||||||
item: CreatePreInvoiceItemType;
|
item: CreatePreInvoiceItemType;
|
||||||
index: number;
|
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;
|
onAdd: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
@@ -26,12 +37,25 @@ const InvoiceItemRow: FC<Props> = ({
|
|||||||
onConvertToOrder,
|
onConvertToOrder,
|
||||||
error,
|
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>) => {
|
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
onChange(index, "productId", e.target.value);
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap items-end gap-4 mt-6">
|
<div className="flex flex-wrap items-end gap-4 mt-6">
|
||||||
|
|
||||||
@@ -58,9 +82,14 @@ const InvoiceItemRow: FC<Props> = ({
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={item.quantity || ""}
|
value={item.quantity || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
onChange(index, "quantity", e.target.value ? Number(e.target.value) : 0)
|
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}
|
error_text={error?.quantity}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,23 +103,54 @@ const InvoiceItemRow: FC<Props> = ({
|
|||||||
seprator
|
seprator
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = String(e.target.value).replace(/,/g, "");
|
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}
|
error_text={error?.unitPrice}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Input
|
||||||
label="تخفیف"
|
type={isPercentDiscount ? "number" : "text"}
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={item.discount ? String(item.discount) : ""}
|
min={0}
|
||||||
seprator
|
max={isPercentDiscount ? 100 : subTotal}
|
||||||
|
value={
|
||||||
|
isPercentDiscount
|
||||||
|
? item.discountPercent ? String(item.discountPercent) : ""
|
||||||
|
: item.discount ? String(item.discount) : ""
|
||||||
|
}
|
||||||
|
seprator={!isPercentDiscount}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = String(e.target.value).replace(/,/g, "");
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,20 @@ export type CreatePreInvoiceItemType = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
discount: number;
|
discount: number;
|
||||||
|
discountPercent?: number | null;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InvoiceItemSubmitType = {
|
||||||
|
id?: string;
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
description: string;
|
||||||
|
discount?: number;
|
||||||
|
discountPercent?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type InvoiceAttachmentType = {
|
export type InvoiceAttachmentType = {
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -16,7 +27,7 @@ export type CreatePreInvoiceType = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
items: CreatePreInvoiceItemType[];
|
items: InvoiceItemSubmitType[];
|
||||||
enableTax: boolean;
|
enableTax: boolean;
|
||||||
approvalDeadline: string;
|
approvalDeadline: string;
|
||||||
paymentMethod?: string;
|
paymentMethod?: string;
|
||||||
@@ -25,7 +36,7 @@ export type CreatePreInvoiceType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UpdatePreInvoiceType = {
|
export type UpdatePreInvoiceType = {
|
||||||
items: CreatePreInvoiceItemType[];
|
items: InvoiceItemSubmitType[];
|
||||||
enableTax: boolean;
|
enableTax: boolean;
|
||||||
approvalDeadline: string;
|
approvalDeadline: string;
|
||||||
paymentMethod?: string;
|
paymentMethod?: string;
|
||||||
@@ -80,6 +91,7 @@ export type InvoiceItemType = {
|
|||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
subTotal: number | null;
|
subTotal: number | null;
|
||||||
discount: number | null;
|
discount: number | null;
|
||||||
|
discountPercent: number | null;
|
||||||
total: number | null;
|
total: number | null;
|
||||||
description: string;
|
description: string;
|
||||||
confirmedAt: string | null;
|
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 RequestItem: FC<Props> = ({ item }) => {
|
||||||
const { data: fields } = useGetEntityField(String(item.product?.id ?? ''))
|
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) => {
|
const handleOpenAttachment = (url: string) => {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
@@ -53,19 +76,16 @@ const RequestItem: FC<Props> = ({ item }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attributes */}
|
{/* Attributes */}
|
||||||
{fields?.data?.length ? (
|
{attributeRows.length ? (
|
||||||
<>
|
<>
|
||||||
<div className='font-bold mb-5 mt-7'>ویژگی ها</div>
|
<div className='font-bold mb-5 mt-7'>ویژگی ها</div>
|
||||||
{
|
{
|
||||||
fields.data.map((field) => {
|
attributeRows.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='flex gap-3 items-center mt-3' key={field.id}>
|
||||||
<div className='text-sm text-gray-500'>{field.name}:</div>
|
<div className='text-sm text-gray-500'>{field.name}:</div>
|
||||||
<div className='text-sm -mt-px'>{value?.value || '-'}</div>
|
<div className='text-sm -mt-px'>{field.value || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import type { BaseResponse } from "@/shared/types/Types";
|
|||||||
|
|
||||||
export type RequestItemAttributeType = {
|
export type RequestItemAttributeType = {
|
||||||
value: string;
|
value: string;
|
||||||
attributeId: string;
|
attributeId?: string;
|
||||||
|
fieldId?: string;
|
||||||
|
field?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestItemType = {
|
export type RequestItemType = {
|
||||||
|
|||||||
Reference in New Issue
Block a user