Compare commits
92 Commits
c404ac14be
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| e283d4a5c4 | |||
| f30ee4f6e3 | |||
| b9fc6ab247 | |||
| 7ad840fbda | |||
| 6dea561b05 | |||
| b73f00299b | |||
| a6593ed0e8 | |||
| 9f8877daf9 | |||
| 4f2bf864e4 | |||
| ee1655493c | |||
| 66992dd2c3 | |||
| c0fa388ac3 | |||
| 366d9faff3 | |||
| 985a29b50f | |||
| b979bb610a | |||
| 06873bae01 | |||
| 3e17cf9e12 | |||
| 7913c43bc0 | |||
| 331b5fe02d | |||
| dcc071d357 | |||
| 7918547137 | |||
| 8b9ca1707c | |||
| fa83d7d092 | |||
| 9aed33d73b | |||
| 435fe3e947 | |||
| 0b3b7218b9 | |||
| 57284c0641 | |||
| 9f18e33ef6 | |||
| 6a7d5523a7 | |||
| 6a6aa76b1e | |||
| 23f47705bb | |||
| 95814d6b00 | |||
| a4eaa50fdf | |||
| 457361e759 | |||
| eb9973678c | |||
| bcf03887f1 | |||
| 6187e7c26d | |||
| 2d35a428b1 | |||
| aac1a4f81b | |||
| 282e001933 | |||
| 8ec3f307b0 | |||
| 4c240ac9a1 | |||
| d52ff4f07e | |||
| c7b4e77421 | |||
| 3c6d3f009a | |||
| 0539d4bc48 | |||
| e6b3610bfc | |||
| 4fc7d19766 | |||
| b155c0b959 | |||
| 53ebbc8d07 | |||
| 6bff75109a | |||
| b92faa81a9 | |||
| a248064800 | |||
| 72ca39c284 | |||
| 6324cabb32 | |||
| 3e7cc74d57 | |||
| 047bfc6f46 | |||
| 050ffc255e | |||
| 67dfc03c7b | |||
| 7130c304c5 | |||
| f83d0743c6 | |||
| 45494d7ae5 | |||
| 223b1b5e8f | |||
| 6975e2d0b0 | |||
| c6e38184f5 | |||
| 18dcc5b2ba | |||
| f8c401f2ed | |||
| fe78b218cc | |||
| f639cbd3e6 | |||
| 5638f91c81 | |||
| b7751a566b | |||
| 3bb4755a8e | |||
| b2a8975053 | |||
| 4d27448450 | |||
| 13c6de66b3 | |||
| d4b6d85cb5 | |||
| e89a74035b | |||
| 3ba1290e56 | |||
| 1137780cf0 | |||
| c588b6f6b4 | |||
| 59f892cb10 | |||
| 8e27bf39e8 | |||
| 20309be061 | |||
| 22e443cf7b | |||
| 1dd639c0b1 | |||
| da738a8adc | |||
| 041fdf5f20 | |||
| 0643d98a79 | |||
| b438b39ada | |||
| 9af1df0a44 | |||
| 09babad479 | |||
| ad06b0c8fe |
@@ -2,6 +2,7 @@ VITE_TOKEN_NAME = 'dmnu_a_t'
|
||||
VITE_REFRESH_TOKEN_NAME = 'dmnu-a-rt'
|
||||
|
||||
VITE_BASE_URL = 'https://dmenu-api.danakcorp.com'
|
||||
# VITE_BASE_URL = 'http://192.168.99.242:4000'
|
||||
# VITE_BASE_URL = 'http://192.168.99.131:2000'
|
||||
|
||||
VITE_SOCKET_URL = 'https://dmenu-api.danakcorp.com'
|
||||
VITE_SOCKET_URL = 'https://dmenu-api.danakcorp.com'
|
||||
VITE_INVOICE_URL = 'https://console.danakcorp.com/receipts/'
|
||||
@@ -3,7 +3,13 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ARG NPM_REGISTRY=https://mirror-npm.runflare.com
|
||||
# ARG NPM_REGISTRY=https://mirror-npm.runflare.com
|
||||
RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirror.de.velop.ir/alpine|g' /etc/apk/repositories
|
||||
|
||||
# Configure npm registry mirror (Liara)
|
||||
ENV NPM_CONFIG_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
||||
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
|
||||
|
||||
|
||||
# Vite embeds these at build time
|
||||
ARG VITE_BASE_URL=https://dmenu-api.danakcorp.com
|
||||
@@ -18,7 +24,7 @@ ENV VITE_BASE_URL=$VITE_BASE_URL \
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --registry="${NPM_REGISTRY}"
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { IncomingMessage as ProxyIncomingMessage } from "node:http";
|
||||
import type { ProxyOptions } from "vite";
|
||||
import { MENU_SITE_ORIGIN, isMenuSlugProxyPath, stripMenuPreviewCacheParam, toMenuSitePath } from "./src/config/menuPreview";
|
||||
|
||||
const stripFrameHeaders = (_proxyRes: ProxyIncomingMessage, _req: IncomingMessage, res: ServerResponse) => {
|
||||
res.removeHeader("x-frame-options");
|
||||
res.removeHeader("content-security-policy");
|
||||
};
|
||||
|
||||
const stripPreviewPageHeaders = (_proxyRes: ProxyIncomingMessage, _req: IncomingMessage, res: ServerResponse) => {
|
||||
stripFrameHeaders(_proxyRes, _req, res);
|
||||
res.removeHeader("cache-control");
|
||||
res.removeHeader("etag");
|
||||
res.removeHeader("last-modified");
|
||||
res.setHeader("cache-control", "no-store, no-cache, must-revalidate");
|
||||
res.setHeader("pragma", "no-cache");
|
||||
res.setHeader("expires", "0");
|
||||
};
|
||||
|
||||
const menuAssetProxy = (): ProxyOptions => ({
|
||||
target: MENU_SITE_ORIGIN,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on("proxyRes", stripFrameHeaders);
|
||||
},
|
||||
});
|
||||
|
||||
export const createMenuPreviewProxy = (): Record<string, ProxyOptions> => ({
|
||||
"/assets/fonts": menuAssetProxy(),
|
||||
"/assets/images": menuAssetProxy(),
|
||||
"/_next": menuAssetProxy(),
|
||||
"/icons": menuAssetProxy(),
|
||||
});
|
||||
|
||||
export const shouldProxyMenuSlugRequest = (url: string | undefined) => {
|
||||
if (!url) return false;
|
||||
|
||||
const pathname = url.split("?")[0] ?? "";
|
||||
if (pathname.startsWith("/@") || pathname.startsWith("/src/") || pathname.startsWith("/node_modules/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/assets/fonts/") ||
|
||||
pathname.startsWith("/assets/images/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/icons/")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/assets/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isMenuSlugProxyPath(pathname);
|
||||
};
|
||||
|
||||
export const getMenuSiteTargetUrl = (url: string) => {
|
||||
const [pathname, search = ""] = url.split("?");
|
||||
const menuPath = toMenuSitePath(pathname ?? "");
|
||||
if (!menuPath) return null;
|
||||
return `${MENU_SITE_ORIGIN}${menuPath}${stripMenuPreviewCacheParam(search)}`;
|
||||
};
|
||||
|
||||
export const createMenuSlugProxyAgent = (): { configure: NonNullable<ProxyOptions["configure"]> } => ({
|
||||
configure: (proxy) => {
|
||||
proxy.on("proxyReq", (proxyReq) => {
|
||||
proxyReq.removeHeader("origin");
|
||||
});
|
||||
proxy.on("proxyRes", stripPreviewPageHeaders);
|
||||
},
|
||||
});
|
||||
@@ -5,17 +5,92 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
# Public menu site assets (Next.js)
|
||||
location ^~ /_next/ {
|
||||
proxy_pass https://dmenu.danakcorp.com/_next/;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host dmenu.danakcorp.com;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
|
||||
location ^~ /icons/ {
|
||||
proxy_pass https://dmenu.danakcorp.com/icons/;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host dmenu.danakcorp.com;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
|
||||
location ^~ /assets/fonts/ {
|
||||
proxy_pass https://dmenu.danakcorp.com/assets/fonts/;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host dmenu.danakcorp.com;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
|
||||
location ^~ /assets/images/ {
|
||||
proxy_pass https://dmenu.danakcorp.com/assets/images/;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host dmenu.danakcorp.com;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
|
||||
# Vite build outputs to /assets by default
|
||||
location ^~ /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Admin SPA routes
|
||||
location ^~ /auth/ {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Vite build outputs to /assets by default
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
location = /dashboard {
|
||||
try_files $uri /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location = /setting {
|
||||
try_files $uri /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location = /statistics {
|
||||
try_files $uri /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location ~ ^/(foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|wallet)/ {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Restaurant menu preview (same-origin iframe; strips X-Frame-Options from upstream)
|
||||
location ~ ^/(?!auth|dashboard|setting|statistics|foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|wallet|assets|icons|_next)([a-zA-Z0-9][a-zA-Z0-9_-]*)(/.*)?$ {
|
||||
proxy_pass https://dmenu.danakcorp.com/$1$2$is_args$args;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host dmenu.danakcorp.com;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Cache-Control;
|
||||
proxy_hide_header ETag;
|
||||
proxy_hide_header Last-Modified;
|
||||
proxy_hide_header Expires;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# optional: favicon (avoid noisy logs if missing)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"iconsax-react": "^0.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"moment-jalaali": "^0.10.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"rc-rate": "^2.13.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
@@ -37,6 +38,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swiper": "^11.2.10",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@@ -45,6 +47,7 @@
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/moment-jalaali": "^0.7.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qrcode.react": "^1.0.5",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
@@ -1630,9 +1633,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1646,9 +1646,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1662,9 +1659,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1678,9 +1672,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1694,9 +1685,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1710,9 +1698,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1726,9 +1711,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1742,9 +1724,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1758,9 +1737,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1774,9 +1750,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1790,9 +1763,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1806,9 +1776,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1822,9 +1789,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2421,6 +2385,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode.react": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.5.tgz",
|
||||
"integrity": "sha512-BghPtnlwvrvq8QkGa1H25YnN+5OIgCKFuQruncGWLGJYOzeSKiix/4+B9BtfKF2wf5ja8yfyWYA3OXju995G8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
|
||||
@@ -2761,6 +2735,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -2986,6 +2969,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -3027,6 +3023,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3086,6 +3091,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3842,6 +3859,15 @@
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -4973,6 +4999,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5510,6 +5545,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -5995,6 +6042,24 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -6026,6 +6091,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"iconsax-react": "^0.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"moment-jalaali": "^0.10.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"rc-rate": "^2.13.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
@@ -39,6 +40,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swiper": "^11.2.10",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@@ -47,6 +49,7 @@
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/moment-jalaali": "^0.7.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qrcode.react": "^1.0.5",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,39 @@
|
||||
<svg width="440" height="957" viewBox="0 0 440 957" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_13761_13988" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="440" height="957">
|
||||
<rect width="440" height="957" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_13761_13988)">
|
||||
<path d="M355.887 122.632L340.815 101.612C340.579 101.281 340.204 101.086 339.798 101.086C339.393 101.086 339.017 101.281 338.782 101.612L323.71 122.632C323.435 123.013 323.399 123.513 323.615 123.934C323.825 124.35 324.256 124.615 324.726 124.615H354.87C355.341 124.615 355.771 124.35 355.982 123.934C356.197 123.513 356.162 123.013 355.887 122.632ZM327.165 122.111L339.798 104.486L352.432 122.111H327.165Z" fill="#C3C7DD"/>
|
||||
<path d="M233.404 122.632L218.327 101.612C218.097 101.281 217.717 101.086 217.311 101.086C216.91 101.086 216.53 101.281 216.294 101.612L201.223 122.632C200.947 123.013 200.912 123.513 201.127 123.934C201.343 124.35 201.768 124.615 202.239 124.615H232.383C232.853 124.615 233.284 124.35 233.499 123.934C233.715 123.513 233.675 123.013 233.404 122.632ZM204.678 122.111L217.311 104.486L229.949 122.111H204.678Z" fill="#C3C7DD"/>
|
||||
<path d="M110.916 122.632L95.8445 101.612C95.6091 101.281 95.2286 101.086 94.828 101.086C94.4224 101.086 94.0419 101.281 93.8115 101.612L78.7347 122.632C78.4643 123.013 78.4242 123.513 78.6396 123.934C78.8549 124.35 79.2855 124.615 79.7562 124.615H109.9C110.366 124.615 110.796 124.35 111.011 123.934C111.227 123.513 111.192 123.013 110.916 122.632ZM82.1897 122.111L94.828 104.486L107.461 122.111H82.1897Z" fill="#C3C7DD"/>
|
||||
<path d="M417.13 11.5462L402.058 -9.47924C401.823 -9.80472 401.442 -10 401.042 -10C400.636 -10 400.261 -9.80472 400.025 -9.47924L384.954 11.5462C384.678 11.9268 384.643 12.4275 384.853 12.8481C385.069 13.2637 385.499 13.5241 385.97 13.5241H416.114C416.584 13.5241 417.015 13.2637 417.225 12.8481C417.441 12.4275 417.406 11.9268 417.13 11.5462ZM388.408 11.0205L401.042 -6.60007L413.675 11.0205H388.408Z" fill="#C3C7DD"/>
|
||||
<path d="M294.643 11.5462L279.571 -9.47924C279.336 -9.80472 278.96 -10 278.555 -10C278.154 -10 277.773 -9.80472 277.538 -9.47924L262.466 11.5462C262.191 11.9268 262.156 12.4275 262.371 12.8481C262.581 13.2637 263.012 13.5241 263.483 13.5241H293.626C294.097 13.5241 294.528 13.2637 294.743 12.8481C294.953 12.4275 294.918 11.9268 294.643 11.5462ZM265.921 11.0205L278.555 -6.60007L291.188 11.0205H265.921Z" fill="#C3C7DD"/>
|
||||
<path d="M172.16 11.5462L157.089 -9.47924C156.853 -9.80472 156.473 -10 156.067 -10C155.667 -10 155.286 -9.80472 155.051 -9.47924L139.979 11.5462C139.708 11.9268 139.668 12.4275 139.884 12.8481C140.099 13.2637 140.53 13.5241 140.995 13.5241H171.144C171.61 13.5241 172.04 13.2637 172.256 12.8481C172.471 12.4275 172.431 11.9268 172.16 11.5462ZM143.434 11.0205L156.067 -6.60007L168.705 11.0205H143.434Z" fill="#C3C7DD"/>
|
||||
<path d="M49.6728 11.5462L34.6009 -9.47924C34.3656 -9.80472 33.985 -10 33.5845 -10C33.1789 -10 32.8033 -9.80472 32.568 -9.47924L17.4961 11.5462C17.2207 11.9268 17.1807 12.4275 17.396 12.8481C17.6113 13.2637 18.0419 13.5241 18.5126 13.5241H48.6563C49.127 13.5241 49.5526 13.2637 49.7679 12.8481C49.9832 12.4275 49.9482 11.9268 49.6728 11.5462ZM20.9512 11.0205L33.5845 -6.60007L46.2178 11.0205H20.9512Z" fill="#C3C7DD"/>
|
||||
<path d="M355.887 344.804L340.815 323.784C340.579 323.458 340.204 323.263 339.798 323.263C339.393 323.263 339.017 323.458 338.782 323.784L323.71 344.804C323.435 345.19 323.399 345.691 323.615 346.106C323.825 346.527 324.256 346.787 324.726 346.787H354.87C355.341 346.787 355.771 346.527 355.982 346.106C356.197 345.691 356.162 345.19 355.887 344.804ZM327.165 344.284L339.798 326.663L352.432 344.284H327.165Z" fill="#C3C7DD"/>
|
||||
<path d="M233.404 344.804L218.327 323.784C218.097 323.458 217.717 323.263 217.311 323.263C216.91 323.263 216.53 323.458 216.294 323.784L201.223 344.804C200.947 345.19 200.912 345.691 201.127 346.106C201.343 346.527 201.768 346.787 202.239 346.787H232.383C232.853 346.787 233.284 346.527 233.499 346.106C233.715 345.691 233.675 345.19 233.404 344.804ZM204.678 344.284L217.311 326.663L229.949 344.284H204.678Z" fill="#C3C7DD"/>
|
||||
<path d="M110.916 344.804L95.8445 323.784C95.6091 323.458 95.2286 323.263 94.828 323.263C94.4224 323.263 94.0419 323.458 93.8115 323.784L78.7347 344.804C78.4643 345.19 78.4242 345.691 78.6396 346.106C78.8549 346.527 79.2855 346.787 79.7562 346.787H109.9C110.366 346.787 110.796 346.527 111.011 346.106C111.227 345.691 111.192 345.19 110.916 344.804ZM82.1897 344.284L94.828 326.663L107.461 344.284H82.1897Z" fill="#C3C7DD"/>
|
||||
<path d="M417.13 233.718L402.058 212.698C401.823 212.368 401.442 212.177 401.042 212.177C400.636 212.177 400.261 212.368 400.025 212.698L384.954 233.718C384.678 234.099 384.643 234.605 384.853 235.02C385.069 235.436 385.499 235.701 385.97 235.701H416.114C416.584 235.701 417.015 235.436 417.225 235.02C417.441 234.605 417.406 234.099 417.13 233.718ZM388.408 233.198L401.042 215.577L413.675 233.198H388.408Z" fill="#C3C7DD"/>
|
||||
<path d="M294.643 233.718L279.571 212.698C279.336 212.368 278.96 212.177 278.555 212.177C278.154 212.177 277.773 212.368 277.538 212.698L262.466 233.718C262.191 234.099 262.156 234.605 262.371 235.02C262.581 235.436 263.012 235.701 263.483 235.701H293.626C294.097 235.701 294.528 235.436 294.743 235.02C294.953 234.605 294.918 234.099 294.643 233.718ZM265.921 233.198L278.555 215.577L291.188 233.198H265.921Z" fill="#C3C7DD"/>
|
||||
<path d="M172.16 233.718L157.089 212.698C156.853 212.368 156.473 212.177 156.067 212.177C155.667 212.177 155.286 212.368 155.051 212.698L139.979 233.718C139.708 234.099 139.668 234.605 139.884 235.02C140.099 235.436 140.53 235.701 140.995 235.701H171.144C171.61 235.701 172.04 235.436 172.256 235.02C172.471 234.605 172.431 234.099 172.16 233.718ZM143.434 233.198L156.067 215.577L168.705 233.198H143.434Z" fill="#C3C7DD"/>
|
||||
<path d="M49.6728 233.718L34.6009 212.698C34.3656 212.368 33.985 212.177 33.5845 212.177C33.1789 212.177 32.8033 212.368 32.568 212.698L17.4961 233.718C17.2207 234.099 17.1807 234.605 17.396 235.02C17.6113 235.436 18.0419 235.701 18.5126 235.701H48.6563C49.127 235.701 49.5526 235.436 49.7679 235.02C49.9832 234.605 49.9482 234.099 49.6728 233.718ZM20.9512 233.198L33.5845 215.577L46.2178 233.198H20.9512Z" fill="#C3C7DD"/>
|
||||
<path d="M355.887 566.981L340.815 545.961C340.579 545.63 340.204 545.435 339.798 545.435C339.393 545.435 339.017 545.63 338.782 545.961L323.71 566.981C323.435 567.362 323.399 567.863 323.615 568.283C323.825 568.699 324.256 568.964 324.726 568.964H354.87C355.341 568.964 355.771 568.699 355.982 568.283C356.197 567.863 356.162 567.362 355.887 566.981ZM327.165 566.461L339.798 548.835L352.432 566.461H327.165Z" fill="#C3C7DD"/>
|
||||
<path d="M233.404 566.981L218.327 545.961C218.097 545.63 217.717 545.435 217.311 545.435C216.91 545.435 216.53 545.63 216.294 545.961L201.223 566.981C200.947 567.362 200.912 567.863 201.127 568.283C201.343 568.699 201.768 568.964 202.239 568.964H232.383C232.853 568.964 233.284 568.699 233.499 568.283C233.715 567.863 233.675 567.362 233.404 566.981ZM204.678 566.461L217.311 548.835L229.949 566.461H204.678Z" fill="#C3C7DD"/>
|
||||
<path d="M110.916 566.981L95.8445 545.961C95.6091 545.63 95.2286 545.435 94.828 545.435C94.4224 545.435 94.0419 545.63 93.8115 545.961L78.7347 566.981C78.4643 567.362 78.4242 567.863 78.6396 568.283C78.8549 568.699 79.2855 568.964 79.7562 568.964H109.9C110.366 568.964 110.796 568.699 111.011 568.283C111.227 567.863 111.192 567.362 110.916 566.981ZM82.1897 566.461L94.828 548.835L107.461 566.461H82.1897Z" fill="#C3C7DD"/>
|
||||
<path d="M417.13 455.895L402.058 434.87C401.823 434.544 401.442 434.349 401.042 434.349C400.636 434.349 400.261 434.544 400.025 434.87L384.954 455.895C384.678 456.276 384.643 456.777 384.853 457.197C385.069 457.613 385.499 457.873 385.97 457.873H416.114C416.584 457.873 417.015 457.613 417.225 457.197C417.441 456.777 417.406 456.276 417.13 455.895ZM388.408 455.37L401.042 437.749L413.675 455.37H388.408Z" fill="#C3C7DD"/>
|
||||
<path d="M294.643 455.895L279.571 434.87C279.336 434.544 278.96 434.349 278.555 434.349C278.154 434.349 277.773 434.544 277.538 434.87L262.466 455.895C262.191 456.276 262.156 456.777 262.371 457.197C262.581 457.613 263.012 457.873 263.483 457.873H293.626C294.097 457.873 294.528 457.613 294.743 457.197C294.953 456.777 294.918 456.276 294.643 455.895ZM265.921 455.37L278.555 437.749L291.188 455.37H265.921Z" fill="#C3C7DD"/>
|
||||
<path d="M172.16 455.895L157.089 434.87C156.853 434.544 156.473 434.349 156.067 434.349C155.667 434.349 155.286 434.544 155.051 434.87L139.979 455.895C139.708 456.276 139.668 456.777 139.884 457.197C140.099 457.613 140.53 457.873 140.995 457.873H171.144C171.61 457.873 172.04 457.613 172.256 457.197C172.471 456.777 172.431 456.276 172.16 455.895ZM143.434 455.37L156.067 437.749L168.705 455.37H143.434Z" fill="#C3C7DD"/>
|
||||
<path d="M49.6728 455.895L34.6009 434.87C34.3656 434.544 33.985 434.349 33.5845 434.349C33.1789 434.349 32.8033 434.544 32.568 434.87L17.4961 455.895C17.2207 456.276 17.1807 456.777 17.396 457.197C17.6113 457.613 18.0419 457.873 18.5126 457.873H48.6563C49.127 457.873 49.5526 457.613 49.7679 457.197C49.9832 456.777 49.9482 456.276 49.6728 455.895ZM20.9512 455.37L33.5845 437.749L46.2178 455.37H20.9512Z" fill="#C3C7DD"/>
|
||||
<path d="M417.13 678.067L402.058 657.047C401.823 656.721 401.442 656.526 401.042 656.526C400.636 656.526 400.261 656.721 400.025 657.047L384.954 678.067C384.678 678.448 384.643 678.953 384.853 679.369C385.069 679.785 385.499 680.05 385.97 680.05H416.114C416.584 680.05 417.015 679.785 417.225 679.369C417.441 678.953 417.406 678.448 417.13 678.067ZM388.408 677.546L401.042 659.926L413.675 677.546H388.408Z" fill="#C3C7DD"/>
|
||||
<path d="M294.643 678.067L279.571 657.047C279.336 656.721 278.96 656.526 278.555 656.526C278.154 656.526 277.773 656.721 277.538 657.047L262.466 678.067C262.191 678.448 262.156 678.953 262.371 679.369C262.581 679.785 263.012 680.05 263.483 680.05H293.626C294.097 680.05 294.528 679.785 294.743 679.369C294.953 678.953 294.918 678.448 294.643 678.067ZM265.921 677.546L278.555 659.926L291.188 677.546H265.921Z" fill="#C3C7DD"/>
|
||||
<path d="M172.16 678.067L157.089 657.047C156.853 656.721 156.473 656.526 156.067 656.526C155.667 656.526 155.286 656.721 155.051 657.047L139.979 678.067C139.708 678.448 139.668 678.953 139.884 679.369C140.099 679.785 140.53 680.05 140.995 680.05H171.144C171.61 680.05 172.04 679.785 172.256 679.369C172.471 678.953 172.431 678.448 172.16 678.067ZM143.434 677.546L156.067 659.926L168.705 677.546H143.434Z" fill="#C3C7DD"/>
|
||||
<path d="M49.6728 678.067L34.6009 657.047C34.3656 656.721 33.985 656.526 33.5845 656.526C33.1789 656.526 32.8033 656.721 32.568 657.047L17.4961 678.067C17.2207 678.448 17.1807 678.953 17.396 679.369C17.6113 679.785 18.0419 680.05 18.5126 680.05H48.6563C49.127 680.05 49.5526 679.785 49.7679 679.369C49.9832 678.953 49.9482 678.448 49.6728 678.067ZM20.9512 677.546L33.5845 659.926L46.2178 677.546H20.9512Z" fill="#C3C7DD"/>
|
||||
<path d="M415.887 897.632L400.815 876.612C400.579 876.281 400.204 876.086 399.798 876.086C399.393 876.086 399.017 876.281 398.782 876.612L383.71 897.632C383.435 898.013 383.399 898.513 383.615 898.934C383.825 899.35 384.256 899.615 384.726 899.615H414.87C415.341 899.615 415.771 899.35 415.982 898.934C416.197 898.513 416.162 898.013 415.887 897.632ZM387.165 897.111L399.798 879.486L412.432 897.111H387.165Z" fill="#C3C7DD"/>
|
||||
<path d="M293.404 897.632L278.327 876.612C278.097 876.281 277.717 876.086 277.311 876.086C276.91 876.086 276.53 876.281 276.294 876.612L261.223 897.632C260.947 898.013 260.912 898.513 261.127 898.934C261.343 899.35 261.768 899.615 262.239 899.615H292.383C292.853 899.615 293.284 899.35 293.499 898.934C293.715 898.513 293.675 898.013 293.404 897.632ZM264.678 897.111L277.311 879.486L289.949 897.111H264.678Z" fill="#C3C7DD"/>
|
||||
<path d="M170.916 897.632L155.844 876.612C155.609 876.281 155.229 876.086 154.828 876.086C154.422 876.086 154.042 876.281 153.812 876.612L138.735 897.632C138.464 898.013 138.424 898.513 138.64 898.934C138.855 899.35 139.285 899.615 139.756 899.615H169.9C170.366 899.615 170.796 899.35 171.011 898.934C171.227 898.513 171.192 898.013 170.916 897.632ZM142.19 897.111L154.828 879.486L167.461 897.111H142.19Z" fill="#C3C7DD"/>
|
||||
<path d="M48.429 897.632L33.3571 876.612C33.1218 876.281 32.7462 876.086 32.3407 876.086C31.9351 876.086 31.5595 876.281 31.3242 876.612L16.2523 897.632C15.9769 898.013 15.9419 898.513 16.1522 898.934C16.3675 899.35 16.7981 899.615 17.2688 899.615H47.4125C47.8832 899.615 48.3138 899.35 48.5241 898.934C48.7394 898.513 48.7044 898.013 48.429 897.632ZM19.7074 897.111L32.3407 879.486L44.974 897.111H19.7074Z" fill="#C3C7DD"/>
|
||||
<path d="M354.643 786.546L339.571 765.521C339.336 765.195 338.96 765 338.555 765C338.154 765 337.773 765.195 337.538 765.521L322.466 786.546C322.191 786.927 322.156 787.428 322.371 787.848C322.581 788.264 323.012 788.524 323.483 788.524H353.626C354.097 788.524 354.528 788.264 354.743 787.848C354.953 787.428 354.918 786.927 354.643 786.546ZM325.921 786.02L338.555 768.4L351.188 786.02H325.921Z" fill="#C3C7DD"/>
|
||||
<path d="M232.16 786.546L217.089 765.521C216.853 765.195 216.473 765 216.067 765C215.667 765 215.286 765.195 215.051 765.521L199.979 786.546C199.708 786.927 199.668 787.428 199.884 787.848C200.099 788.264 200.53 788.524 200.995 788.524H231.144C231.61 788.524 232.04 788.264 232.256 787.848C232.471 787.428 232.431 786.927 232.16 786.546ZM203.434 786.02L216.067 768.4L228.705 786.02H203.434Z" fill="#C3C7DD"/>
|
||||
<path d="M109.673 786.546L94.6009 765.521C94.3656 765.195 93.985 765 93.5845 765C93.1789 765 92.8033 765.195 92.568 765.521L77.4961 786.546C77.2207 786.927 77.1807 787.428 77.396 787.848C77.6113 788.264 78.0419 788.524 78.5126 788.524H108.656C109.127 788.524 109.553 788.264 109.768 787.848C109.983 787.428 109.948 786.927 109.673 786.546ZM80.9512 786.02L93.5845 768.4L106.218 786.02H80.9512Z" fill="#C3C7DD"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 768 KiB |
@@ -14,7 +14,8 @@ type Props = {
|
||||
reset?: boolean;
|
||||
isDateTime?: boolean;
|
||||
className?: string;
|
||||
label?: string
|
||||
label?: string;
|
||||
isNotRequired?: boolean;
|
||||
};
|
||||
|
||||
const DatePickerComponent: FC<Props> = (props: Props) => {
|
||||
@@ -85,8 +86,11 @@ const DatePickerComponent: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.label &&
|
||||
<div className='text-sm'>
|
||||
{props.label}
|
||||
<div className='flex items-center gap-1 text-sm'>
|
||||
<div>{props.label}</div>
|
||||
{props.isNotRequired && (
|
||||
<span className='text-xs text-description'>(اختیاری)</span>
|
||||
)}
|
||||
</div>}
|
||||
<div className={clx(
|
||||
'relative ',
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState, type FC } from 'react';
|
||||
import DatePicker from './DatePicker';
|
||||
import TimePicker from './TimePicker';
|
||||
|
||||
type Props = {
|
||||
onChange: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const parseDateTime = (value?: string) => {
|
||||
if (!value) {
|
||||
return { date: '', time: '' };
|
||||
}
|
||||
|
||||
const [datePart, timePart] = value.split('T');
|
||||
const time = timePart?.slice(0, 5) || '';
|
||||
|
||||
return {
|
||||
date: datePart || '',
|
||||
time,
|
||||
};
|
||||
};
|
||||
|
||||
const DateTimePicker: FC<Props> = ({ onChange, defaultValue, placeholder }) => {
|
||||
const [date, setDate] = useState('');
|
||||
const [time, setTime] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseDateTime(defaultValue);
|
||||
setDate(parsed.date);
|
||||
setTime(parsed.time);
|
||||
}, [defaultValue]);
|
||||
|
||||
const emitChange = (nextDate: string, nextTime: string) => {
|
||||
if (nextDate && nextTime) {
|
||||
onChange(`${nextDate}T${nextTime}:00`);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange('');
|
||||
};
|
||||
|
||||
const handleDateChange = (nextDate: string) => {
|
||||
setDate(nextDate);
|
||||
emitChange(nextDate, time);
|
||||
};
|
||||
|
||||
const handleTimeChange = (nextTime: string) => {
|
||||
setTime(nextTime);
|
||||
emitChange(date, nextTime);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-2 min-w-[280px]">
|
||||
<DatePicker
|
||||
placeholder={placeholder || 'تاریخ'}
|
||||
onChange={handleDateChange}
|
||||
defaulValue={date}
|
||||
/>
|
||||
<TimePicker
|
||||
placeholder="زمان"
|
||||
onChange={handleTimeChange}
|
||||
defaultValue={time}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimePicker;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FC, useEffect, useState, type ChangeEvent, useRef } from 'react';
|
||||
import { Filter } from 'iconsax-react';
|
||||
import DatePicker from './DatePicker';
|
||||
import DateTimePicker from './DateTimePicker';
|
||||
import Input from './Input';
|
||||
import Select, { type ItemsSelectType } from './Select';
|
||||
import MultiSelect from './MultiSelect';
|
||||
@@ -15,6 +16,13 @@ export type DateFieldType = {
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type DateTimeFieldType = {
|
||||
type: 'datetime';
|
||||
name: string;
|
||||
placeholder: string;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type SelectFieldType = {
|
||||
type: 'select';
|
||||
name: string;
|
||||
@@ -38,7 +46,7 @@ export type InputFieldType = {
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type FieldType = DateFieldType | SelectFieldType | MultiSelectFieldType | InputFieldType;
|
||||
export type FieldType = DateFieldType | DateTimeFieldType | SelectFieldType | MultiSelectFieldType | InputFieldType;
|
||||
|
||||
export type FilterValues = Record<string, string | string[] | null>;
|
||||
|
||||
@@ -49,6 +57,7 @@ interface FiltersProps {
|
||||
className?: string;
|
||||
fieldClassName?: string;
|
||||
searchField?: string;
|
||||
showClearButton?: boolean;
|
||||
}
|
||||
|
||||
const Filters: FC<FiltersProps> = ({
|
||||
@@ -57,7 +66,8 @@ const Filters: FC<FiltersProps> = ({
|
||||
initialValues = {},
|
||||
className = "flex flex-col md:flex-row justify-between items-start md:items-center gap-4",
|
||||
fieldClassName = "flex flex-col sm:flex-row gap-3 md:gap-4 w-full md:w-auto",
|
||||
searchField = "search"
|
||||
searchField = "search",
|
||||
showClearButton = false,
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
@@ -155,6 +165,42 @@ const Filters: FC<FiltersProps> = ({
|
||||
setInputValues(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const hasActiveFilters = fields.some((field) => {
|
||||
const value = field.type === 'input'
|
||||
? inputValues[field.name]
|
||||
: field.type === 'multiselect'
|
||||
? multiSelectValues[field.name]
|
||||
: filters[field.name];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
return Boolean(value);
|
||||
});
|
||||
|
||||
const handleClear = () => {
|
||||
setFilters({});
|
||||
setInputValues({});
|
||||
setMultiSelectValues({});
|
||||
onChange({});
|
||||
};
|
||||
|
||||
const renderClearButton = () => {
|
||||
if (!showClearButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
label="پاک کردن فیلترها"
|
||||
onClick={handleClear}
|
||||
disabled={!hasActiveFilters}
|
||||
className="w-fit min-w-[140px] px-4 bg-transparent text-foreground border border-border hover:bg-secondary disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = (field: FieldType) => {
|
||||
const currentValue = field.type === 'input' ? inputValues[field.name] : filters[field.name];
|
||||
const currentMultiSelectValue = field.type === 'multiselect'
|
||||
@@ -172,6 +218,16 @@ const Filters: FC<FiltersProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
case 'datetime':
|
||||
return (
|
||||
<DateTimePicker
|
||||
key={field.name}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(value) => handleChange(field.name, value || null)}
|
||||
defaultValue={(currentValue as string) || field.defaultValue || ''}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className='mt-1'>
|
||||
@@ -179,7 +235,7 @@ const Filters: FC<FiltersProps> = ({
|
||||
key={field.name}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
defaultValue={currentValue as string || field.defaultValue || ''}
|
||||
value={(currentValue as string) || field.defaultValue || ''}
|
||||
items={field.options}
|
||||
className='min-w-[170px]'
|
||||
/>
|
||||
@@ -237,6 +293,7 @@ const Filters: FC<FiltersProps> = ({
|
||||
<div className="hidden md:flex md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className={fieldClassName}>
|
||||
{fields.map(renderField)}
|
||||
{renderClearButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +315,18 @@ const Filters: FC<FiltersProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
{showClearButton && (
|
||||
<Button
|
||||
label="پاک کردن فیلترها"
|
||||
onClick={() => {
|
||||
handleClear();
|
||||
setIsFiltersOpen(false);
|
||||
}}
|
||||
disabled={!hasActiveFilters}
|
||||
className="w-full bg-transparent text-foreground border border-border hover:bg-secondary disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
label='بستن'
|
||||
onClick={() => setIsFiltersOpen(false)}
|
||||
|
||||
@@ -84,15 +84,20 @@ const Input: FC<Props> = (props: Props) => {
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<label className='text-sm'>
|
||||
{props.label}
|
||||
</label>
|
||||
{props.label && (
|
||||
<div className='flex items-center gap-1'>
|
||||
<label className='text-sm'>{props.label}</label>
|
||||
{props.isNotRequired && (
|
||||
<span className='text-xs text-description'>(اختیاری)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='w-full relative mt-1'>
|
||||
<input {...props} onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
handleInputChange(e)
|
||||
}} value={props.seprator ? formattedValue : props.value} type={props.type === 'password' && showPassword ? 'text' : props.type === 'password' ? 'password' : undefined} className={inputClass} />
|
||||
}} value={props.seprator ? formattedValue : props.value} type={props.seprator ? 'text' : props.type === 'password' ? (showPassword ? 'text' : 'password') : props.type} inputMode={props.seprator ? 'numeric' : props.inputMode} className={inputClass} />
|
||||
|
||||
{
|
||||
props.type === 'password' &&
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { getToken } from '@/config/func'
|
||||
import { useSocket } from '@/pages/pager/hooks/useSocket'
|
||||
import { type FC, useEffect, useCallback, useState } from 'react'
|
||||
import { type FC, useEffect, useCallback, useState, useRef } from 'react'
|
||||
import type { NotificationPayload } from '@/types/notification.types'
|
||||
import { NotificationSubject } from '@/types/notification.types'
|
||||
import NotificationItem from '@/components/NotificationItem'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
const NOTIFICATION_SOUND_SRC = '/Qoddusi_improved.mp3'
|
||||
|
||||
interface NotificationItemData extends NotificationPayload {
|
||||
id: string
|
||||
timestamp: number
|
||||
@@ -13,13 +15,26 @@ interface NotificationItemData extends NotificationPayload {
|
||||
|
||||
const Notification: FC = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationItemData[]>([])
|
||||
const [audioEnabled, setAudioEnabled] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const audioUnlockedRef = useRef(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const token = getToken()
|
||||
const socketUrl = `${import.meta.env.VITE_SOCKET_URL}/notifications`
|
||||
const { connect, socket, error } = useSocket(socketUrl, token!)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio(NOTIFICATION_SOUND_SRC)
|
||||
audio.preload = 'auto'
|
||||
audio.volume = 0.5
|
||||
audioRef.current = audio
|
||||
|
||||
return () => {
|
||||
audio.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
console.error('❌ Notification: خطا در اتصال:', error)
|
||||
@@ -31,35 +46,54 @@ const Notification: FC = () => {
|
||||
}, [connect])
|
||||
|
||||
useEffect(() => {
|
||||
const enableAudio = async () => {
|
||||
const unlockAudio = async () => {
|
||||
if (audioUnlockedRef.current || !audioRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const audio = new Audio('/mixkit-correct-answer-tone-2870.wav')
|
||||
audio.volume = 0
|
||||
const audio = audioRef.current
|
||||
audio.muted = true
|
||||
await audio.play()
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
setAudioEnabled(true)
|
||||
audio.muted = false
|
||||
audioUnlockedRef.current = true
|
||||
} catch {
|
||||
console.warn('صدا هنوز فعال نشده است')
|
||||
// Browser may still block until a later gesture — keep listening.
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserInteraction = () => {
|
||||
if (!audioEnabled) {
|
||||
enableAudio()
|
||||
}
|
||||
void unlockAudio()
|
||||
}
|
||||
|
||||
window.addEventListener('click', handleUserInteraction, { once: true })
|
||||
window.addEventListener('touchstart', handleUserInteraction, { once: true })
|
||||
window.addEventListener('keydown', handleUserInteraction, { once: true })
|
||||
window.addEventListener('click', handleUserInteraction)
|
||||
window.addEventListener('touchstart', handleUserInteraction)
|
||||
window.addEventListener('keydown', handleUserInteraction)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleUserInteraction)
|
||||
window.removeEventListener('touchstart', handleUserInteraction)
|
||||
window.removeEventListener('keydown', handleUserInteraction)
|
||||
}
|
||||
}, [audioEnabled])
|
||||
}, [])
|
||||
|
||||
const playNotificationSound = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audioUnlockedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
audio.currentTime = 0
|
||||
void audio.play().catch((err) => {
|
||||
console.warn('خطا در پخش صدا:', err)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('خطا در پخش صدا:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGetNotification = useCallback((data: NotificationPayload) => {
|
||||
const newNotification: NotificationItemData = {
|
||||
@@ -68,14 +102,14 @@ const Notification: FC = () => {
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setNotifications((prev) => [...prev, newNotification])
|
||||
playNotificationSound()
|
||||
|
||||
// Refetch بر اساس نوع نوتیفیکیشن
|
||||
if (data.subject === NotificationSubject.PAGER_CREATED) {
|
||||
queryClient.invalidateQueries({ queryKey: ['pagers'] })
|
||||
} else if (data.subject === NotificationSubject.ORDER_CREATED) {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] })
|
||||
}
|
||||
}, [queryClient])
|
||||
}, [playNotificationSound, queryClient])
|
||||
|
||||
const handleRemoveNotification = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((notif) => notif.id !== id))
|
||||
@@ -105,7 +139,6 @@ const Notification: FC = () => {
|
||||
subject={notification.subject}
|
||||
body={notification.body}
|
||||
onRemove={handleRemoveNotification}
|
||||
audioEnabled={audioEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, useEffect } from 'react'
|
||||
import { type FC } from 'react'
|
||||
import { CloseCircle, NotificationBing, ShoppingBag, MessageText1 } from 'iconsax-react'
|
||||
import Button from '@/components/Button'
|
||||
import { NotificationSubject } from '@/types/notification.types'
|
||||
@@ -8,32 +8,9 @@ interface NotificationItemProps {
|
||||
subject: string
|
||||
body: string
|
||||
onRemove: (id: string) => void
|
||||
audioEnabled: boolean
|
||||
}
|
||||
|
||||
const NotificationItem: FC<NotificationItemProps> = ({ id, subject, body, onRemove, audioEnabled }) => {
|
||||
useEffect(() => {
|
||||
if (!audioEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const playNotificationSound = async () => {
|
||||
try {
|
||||
const audio = new Audio('/mixkit-correct-answer-tone-2870.wav')
|
||||
audio.volume = 0.5
|
||||
|
||||
audio.addEventListener('error', (err) => {
|
||||
console.error('❌ خطا در لود فایل صوتی:', err)
|
||||
}, { once: true })
|
||||
|
||||
await audio.play()
|
||||
} catch (err) {
|
||||
console.warn('خطا در پخش صدا:', err)
|
||||
}
|
||||
}
|
||||
|
||||
playNotificationSound()
|
||||
}, [audioEnabled])
|
||||
const NotificationItem: FC<NotificationItemProps> = ({ id, subject, body, onRemove }) => {
|
||||
const getNotificationConfig = (subjectType: string) => {
|
||||
switch (subjectType) {
|
||||
case NotificationSubject.PAGER_CREATED:
|
||||
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
|
||||
const RadioGroup: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='flex gap-5 items-center text-xs'>
|
||||
<div className='flex flex-wrap gap-5 items-center text-xs'>
|
||||
{
|
||||
props.items.map((item, index) => (
|
||||
<div key={index} className='flex gap-2 items-center'>
|
||||
|
||||
@@ -13,28 +13,58 @@ type Props = {
|
||||
error_text?: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
isNotRequired?: boolean,
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const Select: FC<Props> = (props: Props) => {
|
||||
const {
|
||||
className,
|
||||
items,
|
||||
error_text,
|
||||
placeholder,
|
||||
label,
|
||||
isNotRequired,
|
||||
value,
|
||||
...selectProps
|
||||
} = props
|
||||
|
||||
const hasMatchingItem =
|
||||
value !== undefined &&
|
||||
value !== '' &&
|
||||
items?.some((item) => String(item.value) === String(value))
|
||||
|
||||
const selectValue =
|
||||
!value || hasMatchingItem || !items?.length
|
||||
? (value ?? '')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className='w-full relative'>
|
||||
{
|
||||
props.label &&
|
||||
<label className='text-sm'>
|
||||
{props.label}
|
||||
</label>
|
||||
label &&
|
||||
<div className='flex items-center gap-1'>
|
||||
<label className='text-sm'>
|
||||
{label}
|
||||
</label>
|
||||
{isNotRequired && (
|
||||
<span className='text-xs text-description'>(اختیاری)</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
<select {...props} className={clx(
|
||||
<select
|
||||
{...selectProps}
|
||||
value={selectValue}
|
||||
className={clx(
|
||||
'w-full text-black block border appearance-none border-border !bg-white px-2.5 h-10 text-sm rounded-[10px] bg-gray',
|
||||
props.className,
|
||||
props.label && 'mt-1'
|
||||
className,
|
||||
label && 'mt-1'
|
||||
)}>
|
||||
{
|
||||
props.placeholder &&
|
||||
<option value="" disabled selected>{props.placeholder}</option>
|
||||
placeholder &&
|
||||
<option value="" disabled>{placeholder}</option>
|
||||
}
|
||||
{
|
||||
props.items?.map((item) => {
|
||||
items?.map((item) => {
|
||||
return (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
@@ -45,12 +75,12 @@ const Select: FC<Props> = (props: Props) => {
|
||||
</select>
|
||||
<ArrowDown2 size={16} color='black' className={clx(
|
||||
'absolute z-0 top-3 left-2',
|
||||
props.label && 'top-10'
|
||||
label && 'top-10'
|
||||
)} />
|
||||
{
|
||||
props.error_text && props.error_text !== '' ?
|
||||
error_text && error_text !== '' ?
|
||||
<Error
|
||||
errorText={props.error_text}
|
||||
errorText={error_text}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import { type FC, type InputHTMLAttributes } from 'react'
|
||||
import { type FC, type TextareaHTMLAttributes } from 'react'
|
||||
import Error from './Error'
|
||||
import { clx } from '../helpers/utils'
|
||||
|
||||
type Props = {
|
||||
label?: string,
|
||||
error_text?: string
|
||||
} & InputHTMLAttributes<HTMLTextAreaElement>
|
||||
isNotRequired?: boolean
|
||||
} & TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
|
||||
const Textarea: FC<Props> = (props: Props) => {
|
||||
const Textarea: FC<Props> = ({ label, error_text, isNotRequired, className, ...textareaProps }) => {
|
||||
return (
|
||||
<div className='w-full relative'>
|
||||
{
|
||||
props.label &&
|
||||
<div className='text-sm' >
|
||||
{props.label}
|
||||
label &&
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='text-sm'>{label}</div>
|
||||
{isNotRequired && (
|
||||
<span className='text-xs text-description'>(اختیاری)</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
||||
<textarea
|
||||
{...props}
|
||||
{...textareaProps}
|
||||
className={clx(
|
||||
'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs',
|
||||
props.className
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
</textarea>
|
||||
/>
|
||||
|
||||
{
|
||||
props.error_text &&
|
||||
<Error errorText={props.error_text} />
|
||||
error_text &&
|
||||
<Error errorText={error_text} />
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = {
|
||||
isMultiple?: boolean,
|
||||
onChange?: (file: File[]) => void;
|
||||
isReset?: boolean;
|
||||
isNotRequired?: boolean;
|
||||
}
|
||||
|
||||
const UploadBox: FC<Props> = (props: Props) => {
|
||||
@@ -55,7 +56,12 @@ const UploadBox: FC<Props> = (props: Props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='text-sm'>{props.label}</div>
|
||||
<div className='flex items-center gap-1 text-sm'>
|
||||
<div>{props.label}</div>
|
||||
{props.isNotRequired && (
|
||||
<span className='text-xs text-description'>(اختیاری)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='w-full h-10 mt-1 border border-border rounded-xl items-center px-2 flex gap-2.5'>
|
||||
<button {...getRootProps()} className=' w-fit h-7 rounded-lg text-xs px-6 bg-secondary'>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
@@ -25,12 +25,20 @@ export const Pages = {
|
||||
list: "/orders/list",
|
||||
add: "/orders/add",
|
||||
detail: "/orders/detail/",
|
||||
foodReport: "/orders/report/by-food",
|
||||
dailyReport: "/orders/report/by-day",
|
||||
cashShifts: "/orders/cash-shifts",
|
||||
},
|
||||
customers: {
|
||||
list: "/customers/list",
|
||||
add: "/customers/add",
|
||||
detail: "/customers/detail/",
|
||||
update: "/customers/update/",
|
||||
groups: "/customers/groups",
|
||||
groupsAdd: "/customers/groups/add",
|
||||
groupsUpdate: "/customers/groups/update/",
|
||||
campaigns: "/customers/campaigns",
|
||||
campaignsAdd: "/customers/campaigns/add",
|
||||
},
|
||||
discount: {
|
||||
list: "/discounts/list",
|
||||
@@ -81,7 +89,18 @@ export const Pages = {
|
||||
notifications: {
|
||||
list: "/notifications/list",
|
||||
},
|
||||
sliders: {
|
||||
list: "/sliders/list",
|
||||
add: "/sliders/create",
|
||||
update: "/sliders/update/",
|
||||
},
|
||||
statistics: {
|
||||
list: "/statistics",
|
||||
},
|
||||
wallet: {
|
||||
transactions: "/wallet/transactions",
|
||||
},
|
||||
aiChats: {
|
||||
list: "/ai-chats/list",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export const MENU_SITE_ORIGIN = "https://dmenu.danakcorp.com";
|
||||
|
||||
/** Admin routes whose first segment must not be proxied to the public menu site. */
|
||||
export const MENU_PREVIEW_RESERVED_SEGMENTS = new Set([
|
||||
"auth",
|
||||
"dashboard",
|
||||
"setting",
|
||||
"statistics",
|
||||
"schedule",
|
||||
"payment_methods",
|
||||
"foods",
|
||||
"orders",
|
||||
"customers",
|
||||
"discounts",
|
||||
"announcements",
|
||||
"reports",
|
||||
"comments",
|
||||
"roles",
|
||||
"admins",
|
||||
"shipment_methods",
|
||||
"coupons",
|
||||
"reviews",
|
||||
"pagers",
|
||||
"notifications",
|
||||
"sliders",
|
||||
"wallet",
|
||||
"assets",
|
||||
"menu-preview",
|
||||
]);
|
||||
|
||||
export const isMenuPreviewProxyPath = (pathname: string) => {
|
||||
if (!pathname.startsWith("/menu-preview/")) return false;
|
||||
const rest = pathname.slice("/menu-preview".length);
|
||||
return rest.length > 0;
|
||||
};
|
||||
|
||||
export const isMenuSlugProxyPath = (pathname: string) => {
|
||||
const [firstSegment] = pathname.split("/").filter(Boolean);
|
||||
if (!firstSegment) return false;
|
||||
return !MENU_PREVIEW_RESERVED_SEGMENTS.has(firstSegment);
|
||||
};
|
||||
|
||||
export const toMenuPreviewProxyPath = (slug: string) => `/${slug}`;
|
||||
|
||||
export const MENU_PREVIEW_CACHE_PARAM = "_preview";
|
||||
|
||||
export const buildMenuPreviewUrl = (slug: string, cacheKey: number) =>
|
||||
`${toMenuPreviewProxyPath(slug)}?${MENU_PREVIEW_CACHE_PARAM}=${cacheKey}`;
|
||||
|
||||
export const stripMenuPreviewCacheParam = (search: string) => {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
params.delete(MENU_PREVIEW_CACHE_PARAM);
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
};
|
||||
|
||||
export const toMenuSitePath = (pathname: string) => {
|
||||
if (isMenuPreviewProxyPath(pathname)) {
|
||||
return pathname.replace(/^\/menu-preview/, "") || "/";
|
||||
}
|
||||
|
||||
if (isMenuSlugProxyPath(pathname)) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -102,3 +102,36 @@ tbody tr:nth-child(odd) {
|
||||
|
||||
backdrop-filter: blur(44px);
|
||||
}
|
||||
|
||||
.design-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: black;
|
||||
cursor: pointer;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.design-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: black;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.design-slider::-webkit-slider-runnable-track {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.design-slider::-moz-range-track {
|
||||
background: transparent;
|
||||
height: 1px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"mobile_or_email": " شماره موبایل یا ایمیل",
|
||||
"enter_mobile_or_email": " شماره موبایل یا ایمیل خود را وارد کنید",
|
||||
"mobile": "شماره موبایل",
|
||||
"enter_mobile": "شماره موبایل خود را وارد کنید",
|
||||
"enter_mobile": "09120000000",
|
||||
"otp_sent": "کد تایید ارسال شد",
|
||||
"have_account": "آیا حساب کاربری ندارید؟",
|
||||
"register": "ثبت نام",
|
||||
@@ -40,7 +40,7 @@
|
||||
"forgot_password": "فراموشی رمز عبور",
|
||||
"login_with_otp": "ورود با رمز عبور یکبار مصرف",
|
||||
"slug": "نام کاربری",
|
||||
"enter_slug": "نام کاربری خود را وارد کنید"
|
||||
"enter_slug": ""
|
||||
},
|
||||
"errors": {
|
||||
"required": "این فیلد اجباری می باشد",
|
||||
@@ -68,7 +68,9 @@
|
||||
"reviews": "نظرات",
|
||||
"pagers": "پیجرها",
|
||||
"notifications": "تنظیمات اعلانات",
|
||||
"statistics": "گزارشات"
|
||||
"statistics": "گزارشات",
|
||||
"sliders": "اسلایدرها",
|
||||
"ai_chats": "چتهای AI"
|
||||
},
|
||||
"shipment": {
|
||||
"dineIn": "سرو در رستوران",
|
||||
@@ -97,11 +99,16 @@
|
||||
"add_food": "افزودن غذا",
|
||||
"categories": "دسته بندی ها",
|
||||
"orders_list": "لیست سفارشات",
|
||||
"orders_food_report": "گزارش فروش به تفکیک غذا",
|
||||
"orders_daily_report": "گزارش فروش روزانه",
|
||||
"cash_shifts": "شیفتهای صندوق",
|
||||
"add_order": "افزودن سفارش",
|
||||
"discount_list": "لیست تخفیف",
|
||||
"add_discount": "افزودن تخفیف",
|
||||
"customers_list": "لیست مشتریان",
|
||||
"add_customer": "افزودن مشتری"
|
||||
"add_customer": "افزودن مشتری",
|
||||
"groups": "گروه مشتری",
|
||||
"campaigns": "کمپینها"
|
||||
},
|
||||
"guide": {
|
||||
"list_guide": "لیست راهنمای سرویس",
|
||||
@@ -606,6 +613,30 @@
|
||||
"exsist_special_character": "شامل کاراکتر های خاص (نماد ها) باشد"
|
||||
},
|
||||
"wallet": {
|
||||
"charge": "شارژ",
|
||||
"charge_title": "شارژ کیف پول",
|
||||
"amount": "مبلغ",
|
||||
"amount_placeholder": "مبلغ را وارد کنید",
|
||||
"amount_required": "مبلغ را وارد کنید",
|
||||
"charge_success": "درخواست شارژ با موفقیت ثبت شد",
|
||||
"invoice_issued": "صورتحساب صادر شد",
|
||||
"cancel": "انصراف",
|
||||
"balance": "موجودی کیف پول",
|
||||
"balance_after": "موجودی پس از تراکنش",
|
||||
"type_credit": "واریز",
|
||||
"type_debit": "برداشت",
|
||||
"reason": "دلیل",
|
||||
"reason_deposit": "شارژ کیف پول",
|
||||
"reason_sms_send": "ارسال پیامک",
|
||||
"transactions_tab": "تراکنشها",
|
||||
"charge_requests_tab": "درخواستهای شارژ",
|
||||
"invoice": "صورتحساب",
|
||||
"view_invoice": "مشاهده صورتحساب",
|
||||
"charge_status_pending": "در انتظار پرداخت",
|
||||
"charge_status_paid": "پرداخت شده",
|
||||
"charge_status_success": "پرداخت موفق",
|
||||
"charge_status_failed": "ناموفق",
|
||||
"charge_status_cancelled": "لغو شده",
|
||||
"increese_wallet": "افزایش موجودی حساب اعتباری",
|
||||
"online_pay": "پرداخت آنلاین",
|
||||
"card_to_card": "کارت به کارت",
|
||||
@@ -614,7 +645,6 @@
|
||||
"enter_your_price": "مبلغ دلخواه خودرا وارد کنید",
|
||||
"select_payment": "انتخاب درگاه پرداخت",
|
||||
"pay": "پرداخت",
|
||||
"amount": "مبلغ",
|
||||
"enter_amount_card": "مبلغ کارت به کارت شده را وارد کنید",
|
||||
"upload_deposit_slip": "آپلود فیش واریزی",
|
||||
"submit_slip": "ثبت فیش",
|
||||
@@ -734,7 +764,21 @@
|
||||
"address": "آدرس",
|
||||
"image_profile": "تصویر پروفایل",
|
||||
"upload_image": "آپلود تصویر",
|
||||
"new_password": "رمز عبور جدید"
|
||||
"new_password": "رمز عبور جدید",
|
||||
"import_excel": "ورود از Excel",
|
||||
"download_sample": "دانلود فایل نمونه",
|
||||
"import_file": "فایل Excel",
|
||||
"import_submit": "بارگذاری و ورود",
|
||||
"import_description": "فیلد phone اجباری است و باقی فیلدها اختیاری هستند . حداکثر 500 سطر میتواند باشد",
|
||||
"import_file_required": "لطفاً یک فایل Excel انتخاب کنید",
|
||||
"import_success": "مشتریان با موفقیت وارد شدند",
|
||||
"import_failed": "خطا در ورود مشتریان",
|
||||
"import_total": "تعداد کل: {{count}}",
|
||||
"import_created": "کاربران جدید: {{count}}",
|
||||
"import_linked": "متصلشده به رستوران: {{count}}",
|
||||
"import_already_linked": "قبلاً متصل بودند: {{count}}",
|
||||
"import_errors": "{{count}} خطا",
|
||||
"import_error_row": "ردیف {{row}}{{phone}}: {{message}}"
|
||||
},
|
||||
"priority": "الویت",
|
||||
"messages": {
|
||||
@@ -770,7 +814,7 @@
|
||||
"payment": {
|
||||
"Online": "آنلاین",
|
||||
"Cash": "نقدی",
|
||||
"CardOnDelivery": "کارت هنگام تحویل",
|
||||
"CreditCard": "کارت به کارت",
|
||||
"Wallet": "کیف پول",
|
||||
"zarinpal": "زرینپال",
|
||||
"payments": "پرداخت ها",
|
||||
@@ -847,11 +891,9 @@
|
||||
"payment_methods_update": "ویرایش روش پرداخت"
|
||||
},
|
||||
"order_status": {
|
||||
"pendingPayment": "در انتظار پرداخت",
|
||||
"paid": "پرداخت شده",
|
||||
"new": "جدید",
|
||||
"confirmed": "تایید شده",
|
||||
"preparing": "در حال آماده سازی",
|
||||
"ready": "آماده",
|
||||
"shipped": "ارسال شده",
|
||||
"completed": "تکمیل شده",
|
||||
"canceled": "لغو شده",
|
||||
@@ -887,5 +929,38 @@
|
||||
},
|
||||
"change_status": "تغییر وضعیت",
|
||||
"save": "ذخیره"
|
||||
},
|
||||
"cash_shift": {
|
||||
"list_title": "شیفتهای صندوق",
|
||||
"current_shift": "شیفت فعال",
|
||||
"open_shift": "باز کردن شیفت",
|
||||
"close_shift": "بستن شیفت",
|
||||
"open_title": "باز کردن شیفت صندوق",
|
||||
"close_title": "بستن شیفت صندوق",
|
||||
"detail_title": "جزئیات شیفت صندوق",
|
||||
"summary_title": "خلاصه شیفت",
|
||||
"operator": "اپراتور",
|
||||
"status_label": "وضعیت",
|
||||
"opened_at": "شروع شیفت",
|
||||
"closed_at": "پایان شیفت",
|
||||
"opening_amount": "موجودی اولیه",
|
||||
"opening_amount_placeholder": "مبلغ موجودی اولیه صندوق",
|
||||
"opening_amount_required": "موجودی اولیه الزامی است",
|
||||
"expected_amount": "مبلغ مورد انتظار",
|
||||
"counted_amount": "مبلغ شمارششده",
|
||||
"counted_amount_placeholder": "مبلغ شمارششده در صندوق",
|
||||
"counted_amount_required": "مبلغ شمارششده الزامی است",
|
||||
"difference_amount": "اختلاف",
|
||||
"orders_count": "تعداد سفارشات",
|
||||
"notes": "یادداشت",
|
||||
"notes_placeholder": "یادداشت (اختیاری)",
|
||||
"cancel": "انصراف",
|
||||
"view_details": "مشاهده جزئیات",
|
||||
"opened_success": "شیفت صندوق با موفقیت باز شد",
|
||||
"closed_success": "شیفت صندوق با موفقیت بسته شد",
|
||||
"status_values": {
|
||||
"open": "باز",
|
||||
"closed": "بسته"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +1,148 @@
|
||||
import { type FC, useEffect } from 'react'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import Button from '@/components/Button'
|
||||
import Input from '@/components/Input'
|
||||
import Select from '@/components/Select'
|
||||
import { useGetAdminById, useUpdateAdmin } from './hooks/useAdminData'
|
||||
import { useGetRoles } from '@/pages/roles/hooks/useRolesData'
|
||||
import type { CreateAdminType } from './types/Types'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import type { ErrorType } from '@/helpers/types'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import Button from "@/components/Button";
|
||||
import Input from "@/components/Input";
|
||||
import Select from "@/components/Select";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { useGetRoles } from "@/pages/roles/hooks/useRolesData";
|
||||
import { useFormik } from "formik";
|
||||
import { TickCircle } from "iconsax-react";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import * as Yup from "yup";
|
||||
import { useGetAdminById, useUpdateAdmin } from "./hooks/useAdminData";
|
||||
import type { CreateAdminType } from "./types/Types";
|
||||
|
||||
const UpdateAdmin: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const { data: adminData, isLoading } = useGetAdminById(id!)
|
||||
const { mutate: updateAdmin, isPending } = useUpdateAdmin()
|
||||
const { data: rolesData } = useGetRoles()
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { data: adminData, isLoading } = useGetAdminById(id!);
|
||||
const { mutate: updateAdmin, isPending } = useUpdateAdmin();
|
||||
const { data: rolesData } = useGetRoles();
|
||||
|
||||
const roles = rolesData?.data?.map(role => ({
|
||||
label: role.name,
|
||||
value: role.id
|
||||
})) || []
|
||||
const roles =
|
||||
rolesData?.data?.map((role) => ({
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
})) || [];
|
||||
|
||||
const admin = adminData?.data
|
||||
const admin = adminData?.data;
|
||||
|
||||
const formik = useFormik<CreateAdminType>({
|
||||
initialValues: {
|
||||
phone: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
roleId: '',
|
||||
const formik = useFormik<CreateAdminType>({
|
||||
initialValues: {
|
||||
phone: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
roleId: "",
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
phone: Yup.string().required("شماره تلفن الزامی است"),
|
||||
firstName: Yup.string().required("نام الزامی است"),
|
||||
lastName: Yup.string().required("نام خانوادگی الزامی است"),
|
||||
roleId: Yup.string().required("نقش الزامی است"),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (!id) {
|
||||
toast.error("شناسه مدیر یافت نشد");
|
||||
return;
|
||||
}
|
||||
|
||||
updateAdmin(
|
||||
{ id, params: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("مدیر با موفقیت بهروزرسانی شد");
|
||||
navigate(Pages.admins.list);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
phone: Yup.string().required('شماره تلفن الزامی است'),
|
||||
firstName: Yup.string().required('نام الزامی است'),
|
||||
lastName: Yup.string().required('نام خانوادگی الزامی است'),
|
||||
roleId: Yup.string().required('نقش الزامی است'),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (!id) {
|
||||
toast.error('شناسه مدیر یافت نشد')
|
||||
return
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
updateAdmin({ id, params: values }, {
|
||||
onSuccess: () => {
|
||||
toast.success('مدیر با موفقیت بهروزرسانی شد')
|
||||
navigate(Pages.admins.list)
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (admin) {
|
||||
formik.setValues({
|
||||
phone: admin.phone || '',
|
||||
firstName: admin.firstName || '',
|
||||
lastName: admin.lastName || '',
|
||||
roleId: admin.roles?.[0]?.role?.id || '',
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [admin])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='w-full mt-4 flex justify-center items-center'>
|
||||
<div>در حال بارگذاری...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
return (
|
||||
<div className='w-full mt-4 flex justify-center items-center'>
|
||||
<div>مدیر یافت نشد</div>
|
||||
</div>
|
||||
)
|
||||
useEffect(() => {
|
||||
if (admin) {
|
||||
formik.setValues({
|
||||
phone: admin.phone || "",
|
||||
firstName: admin.firstName || "",
|
||||
lastName: admin.lastName || "",
|
||||
roleId: admin.role?.id || "",
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [admin]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex w-full justify-between items-center'>
|
||||
<div className='text-lg font-light'>
|
||||
ویرایش مدیر
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
className='px-5'
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isloading={isPending}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TickCircle className='size-5' color='white' />
|
||||
<div>ذخیره تغییرات</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-4 flex justify-center items-center">
|
||||
<div>در حال بارگذاری...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className='mt-6'>
|
||||
<div className='bg-white rounded-3xl p-6'>
|
||||
<div className='mb-4'>
|
||||
<Input
|
||||
label='نام'
|
||||
name='firstName'
|
||||
value={formik.values.firstName}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<Input
|
||||
label='نام خانوادگی'
|
||||
name='lastName'
|
||||
value={formik.values.lastName}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<Input
|
||||
label='شماره تلفن'
|
||||
name='phone'
|
||||
value={formik.values.phone}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.phone && formik.errors.phone ? formik.errors.phone : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<Select
|
||||
label='نقش'
|
||||
name='roleId'
|
||||
value={formik.values.roleId}
|
||||
onChange={formik.handleChange}
|
||||
items={roles}
|
||||
placeholder='نقش را انتخاب کنید'
|
||||
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
if (!admin) {
|
||||
return (
|
||||
<div className="w-full mt-4 flex justify-center items-center">
|
||||
<div>مدیر یافت نشد</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div className="text-lg font-light">ویرایش مدیر</div>
|
||||
<div>
|
||||
<Button className="px-5" onClick={() => formik.handleSubmit()} isloading={isPending}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickCircle className="size-5" color="white" />
|
||||
<div>ذخیره تغییرات</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
export default UpdateAdmin
|
||||
<div className="mt-6">
|
||||
<div className="bg-white rounded-3xl p-6">
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
label="نام"
|
||||
name="firstName"
|
||||
value={formik.values.firstName}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Input
|
||||
label="نام خانوادگی"
|
||||
name="lastName"
|
||||
value={formik.values.lastName}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Input label="شماره تلفن" name="phone" value={formik.values.phone} onChange={formik.handleChange} error_text={formik.touched.phone && formik.errors.phone ? formik.errors.phone : ""} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Select
|
||||
label="نقش"
|
||||
name="roleId"
|
||||
value={formik.values.roleId}
|
||||
onChange={formik.handleChange}
|
||||
items={roles}
|
||||
placeholder="نقش را انتخاب کنید"
|
||||
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateAdmin;
|
||||
|
||||
@@ -28,6 +28,7 @@ export type Admin = {
|
||||
lastName: string;
|
||||
phone: string;
|
||||
roles: AdminRole[];
|
||||
role: RoleDetail;
|
||||
};
|
||||
|
||||
export type GetAdminsParams = {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import Table from "@/components/Table";
|
||||
import Filters from "@/components/Filters";
|
||||
import DefaulModal from "@/components/DefaulModal";
|
||||
import { useGetAiChats } from "./hooks/useAiChatData";
|
||||
import { useAiChatFilters } from "./hooks/useAiChatFilters";
|
||||
import { getAiChatTableColumns } from "./components/AiChatTableColumns";
|
||||
import { useAiChatFiltersFields } from "./components/AiChatFiltersFields";
|
||||
import {
|
||||
getAiChatAnswer,
|
||||
getAiChatQuestion,
|
||||
type AiChat,
|
||||
} from "./types/Types";
|
||||
import type { RowDataType } from "@/components/types/TableTypes";
|
||||
import { formatPrice } from "@/helpers/func";
|
||||
|
||||
const AiChatsList: FC = () => {
|
||||
const [selectedChat, setSelectedChat] = useState<AiChat | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
} = useAiChatFilters();
|
||||
|
||||
const { data: chatsData, isLoading } = useGetAiChats(apiParams);
|
||||
|
||||
const chats = chatsData?.data || [];
|
||||
const totalPages = chatsData?.meta?.totalPages || 1;
|
||||
|
||||
const filterFields = useAiChatFiltersFields();
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getAiChatTableColumns({
|
||||
onViewAnswer: (chat) => {
|
||||
setSelectedChat(chat);
|
||||
setIsModalOpen(true);
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedChat(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-light">لیست چتهای AI</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Filters
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
initialValues={filters}
|
||||
searchField="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<AiChat & RowDataType>
|
||||
columns={columns}
|
||||
data={chats as (AiChat & RowDataType)[]}
|
||||
isloading={isLoading}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DefaulModal
|
||||
open={isModalOpen}
|
||||
close={handleCloseModal}
|
||||
isHeader={true}
|
||||
title_header="جزئیات چت"
|
||||
>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs text-description mb-1">سوال</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
||||
{(selectedChat && getAiChatQuestion(selectedChat)) || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-description mb-1">پاسخ</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
||||
{(selectedChat && getAiChatAnswer(selectedChat)) || "-"}
|
||||
</p>
|
||||
</div>
|
||||
{typeof selectedChat?.cost === "number" && (
|
||||
<div>
|
||||
<div className="text-xs text-description mb-1">هزینه</div>
|
||||
<p className="text-sm text-foreground">
|
||||
{formatPrice(selectedChat.cost)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DefaulModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiChatsList;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import type { FieldType } from "@/components/Filters";
|
||||
|
||||
export const useAiChatFiltersFields = (): FieldType[] => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "input",
|
||||
name: "search",
|
||||
placeholder: "جستجو در سوالات",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Eye } from "iconsax-react";
|
||||
import type { ColumnType } from "@/components/types/TableTypes";
|
||||
import {
|
||||
getAiChatAnswer,
|
||||
getAiChatQuestion,
|
||||
getAiChatTokens,
|
||||
type AiChat,
|
||||
} from "../types/Types";
|
||||
import { formatOptionalDate, formatPrice } from "@/helpers/func";
|
||||
|
||||
interface GetAiChatTableColumnsProps {
|
||||
onViewAnswer?: (chat: AiChat) => void;
|
||||
}
|
||||
|
||||
export const getAiChatTableColumns = (
|
||||
props?: GetAiChatTableColumnsProps
|
||||
): ColumnType<AiChat>[] => {
|
||||
return [
|
||||
{
|
||||
key: "question",
|
||||
title: "سوال",
|
||||
render: (item: AiChat) => {
|
||||
const question = getAiChatQuestion(item);
|
||||
return (
|
||||
<div className="max-w-md truncate" title={question}>
|
||||
{question || "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
title: "نوع",
|
||||
render: (item: AiChat) => {
|
||||
return item.type || "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
title: "هزینه مصرفشده",
|
||||
render: (item: AiChat) => {
|
||||
return typeof item.cost === "number" ? formatPrice(item.cost) : "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "consumedTokens",
|
||||
title: "توکن مصرفی",
|
||||
render: (item: AiChat) => {
|
||||
const tokens = getAiChatTokens(item);
|
||||
return typeof tokens === "number"
|
||||
? tokens.toLocaleString("fa-IR")
|
||||
: "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
title: "تاریخ",
|
||||
render: (item: AiChat) => {
|
||||
return formatOptionalDate(item.createdAt, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "details",
|
||||
title: "",
|
||||
render: (item: AiChat) => {
|
||||
if (!getAiChatAnswer(item) || !props?.onViewAnswer) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Eye
|
||||
size={20}
|
||||
color="#8C90A3"
|
||||
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onViewAnswer?.(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as api from "../service/AiChatService";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { GetAiChatsParams } from "../types/Types";
|
||||
|
||||
export const useGetAiChats = (params?: GetAiChatsParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["ai-chats", params],
|
||||
queryFn: () => api.getAiChats(params),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
import type { GetAiChatsParams } from "../types/Types";
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
export const useAiChatFilters = () => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||
const [limit] = useState(DEFAULT_LIMIT);
|
||||
|
||||
const apiParams = useMemo<GetAiChatsParams>(() => {
|
||||
const params: GetAiChatsParams = {
|
||||
page: currentPage,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
params.search = filters.search as string;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [filters, currentPage, limit]);
|
||||
|
||||
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(DEFAULT_PAGE);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
limit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import axios from "@/config/axios";
|
||||
import type { GetAiChatsParams, GetAiChatsResponse } from "../types/Types";
|
||||
|
||||
export const getAiChats = async (
|
||||
params?: GetAiChatsParams
|
||||
): Promise<GetAiChatsResponse> => {
|
||||
const { data } = await axios.get<GetAiChatsResponse>("/admin/ai/chats", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { IResponse } from "@/types/response.types";
|
||||
import type { RowDataType } from "@/components/types/TableTypes";
|
||||
|
||||
export interface AiChat extends RowDataType {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
question?: string;
|
||||
prompt?: string;
|
||||
message?: string;
|
||||
answer?: string | null;
|
||||
response?: string | null;
|
||||
cost?: number;
|
||||
consumedTokens?: number;
|
||||
totalTokens?: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
export type PaginationMeta = {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
totalCost?: number;
|
||||
};
|
||||
|
||||
export type GetAiChatsParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type GetAiChatsResponse = IResponse<AiChat[]> & {
|
||||
meta: PaginationMeta;
|
||||
};
|
||||
|
||||
export const getAiChatQuestion = (chat: AiChat): string =>
|
||||
chat.question || chat.prompt || chat.message || "";
|
||||
|
||||
export const getAiChatAnswer = (chat: AiChat): string =>
|
||||
chat.answer || chat.response || "";
|
||||
|
||||
export const getAiChatTokens = (chat: AiChat): number | undefined =>
|
||||
chat.consumedTokens ?? chat.totalTokens ?? chat.promptTokens;
|
||||
@@ -1,42 +1,30 @@
|
||||
import { type FC } from 'react'
|
||||
import LogoImage from '../../assets/images/logo.png'
|
||||
import LogoSmallImage from '../../assets/images/logo-small.png'
|
||||
import { useAuthStore } from './store/AuthStore'
|
||||
import LoginStep1 from './components/LoginStep1'
|
||||
import LoginStep2 from './components/LoginStep2'
|
||||
import { type FC } from "react";
|
||||
import LogoSmallImage from "../../assets/images/logo-small.png";
|
||||
import LogoImage from "../../assets/images/logo.png";
|
||||
import LoginStep1 from "./components/LoginStep1";
|
||||
import LoginStep2 from "./components/LoginStep2";
|
||||
import { useAuthStore } from "./store/AuthStore";
|
||||
|
||||
const Login: FC = () => {
|
||||
const { stepLogin, phone } = useAuthStore();
|
||||
|
||||
const { stepLogin, phone } = useAuthStore()
|
||||
return (
|
||||
<div className="w-full h-full flex justify-center lg:py-[75px] py-4 lg:items-center lg:px-10 px-4">
|
||||
<div className="flex w-full max-h-[812px] max-w-[1200px] bg-secondary h-full rounded-3xl overflow-hidden">
|
||||
<div className="flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7">
|
||||
<div className="flex-1 h-full flex flex-col">
|
||||
<img src={LogoSmallImage} className="w-8" />
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex justify-center lg:py-[75px] py-4 lg:items-center lg:px-10 px-4'>
|
||||
<div className='flex w-full max-h-[812px] max-w-[1200px] bg-secondary h-full rounded-3xl overflow-hidden'>
|
||||
<div className='flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7'>
|
||||
<div className='flex-1 h-full flex flex-col'>
|
||||
<img src={LogoSmallImage} className='w-8' />
|
||||
|
||||
<div className='flex flex-1 flex-col h-full justify-center'>
|
||||
{
|
||||
stepLogin === 1 ?
|
||||
<LoginStep1 />
|
||||
:
|
||||
stepLogin === 2 && !!phone ?
|
||||
<LoginStep2 />
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-w-[50%] lg:flex hidden justify-center items-center'>
|
||||
<img src={LogoImage} className='h-20' />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col h-full justify-center">{stepLogin === 1 ? <LoginStep1 /> : stepLogin === 2 && !!phone ? <LoginStep2 /> : null}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
<div className="flex-1 min-w-[50%] lg:flex hidden justify-center items-center">
|
||||
<img src={LogoImage} className="h-14" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
||||
@@ -62,8 +62,9 @@ const LoginStep1: FC = () => {
|
||||
<Input
|
||||
label={t('auth.mobile')}
|
||||
placeholder={t('auth.enter_mobile')}
|
||||
type='text'
|
||||
className='text-right'
|
||||
type='tel'
|
||||
dir='ltr'
|
||||
className='text-left dltr'
|
||||
name='phone'
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.phone}
|
||||
@@ -83,7 +84,8 @@ const LoginStep1: FC = () => {
|
||||
label={t('auth.slug')}
|
||||
placeholder={t('auth.enter_slug')}
|
||||
type='text'
|
||||
className='text-right'
|
||||
dir='ltr'
|
||||
className='text-left dltr'
|
||||
name='slug'
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.slug}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Add, Lock1 } from "iconsax-react";
|
||||
import Table from "@/components/Table";
|
||||
import Filters from "@/components/Filters";
|
||||
import Button from "@/components/Button";
|
||||
import type { RowDataType } from "@/components/types/TableTypes";
|
||||
import {
|
||||
useGetCashShifts,
|
||||
useGetCurrentCashShift,
|
||||
useGetCurrentCashShiftSummary,
|
||||
} from "./hooks/useCashShiftData";
|
||||
import { useCashShiftFilters } from "./hooks/useCashShiftFilters";
|
||||
import { getCashShiftTableColumns } from "./components/CashShiftTableColumns";
|
||||
import { useCashShiftFiltersFields } from "./components/CashShiftFiltersFields";
|
||||
import CashShiftSummaryCard from "./components/CashShiftSummaryCard";
|
||||
import OpenCashShiftModal from "./components/OpenCashShiftModal";
|
||||
import CloseCashShiftModal from "./components/CloseCashShiftModal";
|
||||
import CashShiftDetailModal from "./components/CashShiftDetailModal";
|
||||
import type { CashShift } from "./types/Types";
|
||||
import { CashShiftStatus } from "./enum/Enum";
|
||||
import { formatOptionalDate } from "@/helpers/func";
|
||||
import Status from "@/components/Status";
|
||||
import MoonLoader from "react-spinners/MoonLoader";
|
||||
|
||||
const CashShiftsList: FC = () => {
|
||||
const { t } = useTranslation("global");
|
||||
const [isOpenModalVisible, setIsOpenModalVisible] = useState(false);
|
||||
const [isCloseModalVisible, setIsCloseModalVisible] = useState(false);
|
||||
const [isDetailModalVisible, setIsDetailModalVisible] = useState(false);
|
||||
const [selectedShift, setSelectedShift] = useState<CashShift | null>(null);
|
||||
|
||||
const {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
} = useCashShiftFilters();
|
||||
|
||||
const { data: shiftsData, isLoading } = useGetCashShifts(apiParams);
|
||||
const { data: currentShiftData, isLoading: isCurrentShiftLoading } =
|
||||
useGetCurrentCashShift();
|
||||
|
||||
const currentShift = currentShiftData?.data ?? null;
|
||||
const hasOpenShift = currentShift?.status === CashShiftStatus.OPEN;
|
||||
|
||||
const { data: currentSummaryData, isLoading: isCurrentSummaryLoading } =
|
||||
useGetCurrentCashShiftSummary(hasOpenShift);
|
||||
|
||||
const shifts = shiftsData?.data || [];
|
||||
const totalPages = shiftsData?.meta?.totalPages || 1;
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getCashShiftTableColumns({
|
||||
t,
|
||||
onView: (shift) => {
|
||||
setSelectedShift(shift);
|
||||
setIsDetailModalVisible(true);
|
||||
},
|
||||
onClose: (shift) => {
|
||||
setSelectedShift(shift);
|
||||
setIsCloseModalVisible(true);
|
||||
},
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const filterFields = useCashShiftFiltersFields();
|
||||
|
||||
const getAdminName = (shift: CashShift) => {
|
||||
const name = [shift.admin?.firstName, shift.admin?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return name || shift.admin?.phone || "-";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-light">{t("cash_shift.list_title")}</h1>
|
||||
{!hasOpenShift && (
|
||||
<Button className="w-fit px-6" onClick={() => setIsOpenModalVisible(true)}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Add color="#fff" size={20} />
|
||||
<span>{t("cash_shift.open_shift")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrentShiftLoading ? (
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<MoonLoader size={16} color="#000" />
|
||||
</div>
|
||||
) : (
|
||||
hasOpenShift &&
|
||||
currentShift && (
|
||||
<div className="mt-6 bg-[#F5F5F5] rounded-2xl p-5 space-y-4">
|
||||
<div className="flex flex-wrap justify-between items-start gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("cash_shift.current_shift")}
|
||||
</div>
|
||||
<div className="text-xs text-description mt-1">
|
||||
{t("cash_shift.operator")}: {getAdminName(currentShift)}
|
||||
</div>
|
||||
<div className="text-xs text-description mt-1">
|
||||
{t("cash_shift.opened_at")}:{" "}
|
||||
{formatOptionalDate(currentShift.openedAt, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Status
|
||||
variant="warning"
|
||||
label={t(`cash_shift.status_values.${CashShiftStatus.OPEN}`)}
|
||||
/>
|
||||
<Button
|
||||
className="w-fit px-5"
|
||||
onClick={() => {
|
||||
setSelectedShift(currentShift);
|
||||
setIsCloseModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Lock1 color="#fff" size={18} />
|
||||
<span>{t("cash_shift.close_shift")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CashShiftSummaryCard
|
||||
summary={currentSummaryData?.data}
|
||||
openingAmount={currentShift.openingAmount}
|
||||
isLoading={isCurrentSummaryLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<Filters
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
initialValues={filters}
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<CashShift & RowDataType>
|
||||
columns={columns}
|
||||
data={shifts as (CashShift & RowDataType)[]}
|
||||
isloading={isLoading}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
<OpenCashShiftModal
|
||||
open={isOpenModalVisible}
|
||||
onClose={() => setIsOpenModalVisible(false)}
|
||||
/>
|
||||
|
||||
<CloseCashShiftModal
|
||||
open={isCloseModalVisible}
|
||||
onClose={() => {
|
||||
setIsCloseModalVisible(false);
|
||||
setSelectedShift(null);
|
||||
}}
|
||||
shift={selectedShift}
|
||||
/>
|
||||
|
||||
<CashShiftDetailModal
|
||||
open={isDetailModalVisible}
|
||||
onClose={() => {
|
||||
setIsDetailModalVisible(false);
|
||||
setSelectedShift(null);
|
||||
}}
|
||||
shift={selectedShift}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashShiftsList;
|
||||
@@ -0,0 +1,185 @@
|
||||
import { type FC } from "react";
|
||||
import DefaulModal from "@/components/DefaulModal";
|
||||
import Status from "@/components/Status";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatFaNumber,
|
||||
formatOptionalDate,
|
||||
formatPrice,
|
||||
} from "@/helpers/func";
|
||||
import { useGetCashShiftSummary } from "../hooks/useCashShiftData";
|
||||
import CashShiftSummaryCard from "./CashShiftSummaryCard";
|
||||
import type { CashShift } from "../types/Types";
|
||||
import { CashShiftStatus } from "../enum/Enum";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shift: CashShift | null;
|
||||
};
|
||||
|
||||
const getAdminName = (shift: CashShift) => {
|
||||
const name = [shift.admin?.firstName, shift.admin?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return name || shift.admin?.phone || "-";
|
||||
};
|
||||
|
||||
const CashShiftDetailModal: FC<Props> = ({ open, onClose, shift }) => {
|
||||
const { t } = useTranslation("global");
|
||||
|
||||
const { data: summaryData, isLoading: isSummaryLoading } =
|
||||
useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id);
|
||||
|
||||
if (!shift) return null;
|
||||
|
||||
const summary = summaryData?.data;
|
||||
const isOpen = shift.status === CashShiftStatus.OPEN;
|
||||
const statusVariant = isOpen ? "warning" : "success";
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={onClose}
|
||||
isHeader
|
||||
title_header={t("cash_shift.detail_title")}
|
||||
width={720}
|
||||
>
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.operator")}
|
||||
</label>
|
||||
<p className="text-sm font-medium">{getAdminName(shift)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.status_label")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Status
|
||||
variant={statusVariant}
|
||||
label={t(`cash_shift.status_values.${shift.status}`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.opened_at")}
|
||||
</label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatOptionalDate(shift.openedAt, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.closed_at")}
|
||||
</label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatOptionalDate(shift.closedAt, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.opening_amount")}
|
||||
</label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatPrice(shift.openingAmount)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.orders_count")}
|
||||
</label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatFaNumber(shift.ordersCount)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isOpen && shift.expectedAmount !== null && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.expected_amount")}
|
||||
</label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatPrice(shift.expectedAmount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOpen && shift.countedAmount !== null && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.counted_amount")}
|
||||
</label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatPrice(shift.countedAmount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOpen && shift.differenceAmount !== null && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.difference_amount")}
|
||||
</label>
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
shift.differenceAmount > 0
|
||||
? "text-green-600"
|
||||
: shift.differenceAmount < 0
|
||||
? "text-red-600"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{formatPrice(shift.differenceAmount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shift.notes && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mb-1 block">
|
||||
{t("cash_shift.notes")}
|
||||
</label>
|
||||
<p className="text-sm font-medium bg-gray-50 p-3 rounded-lg whitespace-pre-wrap break-words">
|
||||
{shift.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#F5F5F5] rounded-2xl p-4">
|
||||
<div className="text-sm font-medium mb-3">
|
||||
{t("cash_shift.summary_title")}
|
||||
</div>
|
||||
<CashShiftSummaryCard
|
||||
summary={summary}
|
||||
openingAmount={shift.openingAmount}
|
||||
isLoading={isSummaryLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashShiftDetailModal;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
import type { FieldType } from "@/components/Filters";
|
||||
import { CashShiftStatus } from "../enum/Enum";
|
||||
|
||||
export const useCashShiftFiltersFields = (): FieldType[] => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "select",
|
||||
name: "status",
|
||||
placeholder: "وضعیت شیفت",
|
||||
defaultValue: "",
|
||||
options: [
|
||||
{ label: "همه", value: "" },
|
||||
{ label: "باز", value: CashShiftStatus.OPEN },
|
||||
{ label: "بسته", value: CashShiftStatus.CLOSED },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "datetime",
|
||||
name: "startDate",
|
||||
placeholder: "از تاریخ",
|
||||
},
|
||||
{
|
||||
type: "datetime",
|
||||
name: "endDate",
|
||||
placeholder: "تا تاریخ",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type FC } from "react";
|
||||
import MoonLoader from "react-spinners/MoonLoader";
|
||||
import { formatFaNumber, formatPrice } from "@/helpers/func";
|
||||
import type { CashShiftSummary } from "../types/Types";
|
||||
|
||||
type Props = {
|
||||
summary?: CashShiftSummary | null;
|
||||
openingAmount?: number;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
const SummaryItem: FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<div className="bg-white rounded-xl px-4 py-3 min-w-[140px]">
|
||||
<div className="text-xs text-description">{label}</div>
|
||||
<div className="text-sm font-medium mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CashShiftSummaryCard: FC<Props> = ({ summary, openingAmount = 0, isLoading }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<MoonLoader size={20} color="#000" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SummaryItem label="موجودی اولیه" value={formatPrice(openingAmount)} />
|
||||
<SummaryItem label="نقدی" value={formatPrice(summary.cashReceived)} />
|
||||
<SummaryItem label="آنلاین" value={formatPrice(summary.onlineReceived)} />
|
||||
<SummaryItem label="کارت به کارت" value={formatPrice(summary.creditCardReceived)} />
|
||||
<SummaryItem label="مجموع دریافتی" value={formatPrice(summary.totalReceived)} />
|
||||
<SummaryItem label="مبلغ مورد انتظار" value={formatPrice(summary.expectedAmount)} />
|
||||
<SummaryItem
|
||||
label="تعداد سفارشات"
|
||||
value={formatFaNumber(summary.ordersCount)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashShiftSummaryCard;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Eye, Lock1 } from "iconsax-react";
|
||||
import type { ColumnType } from "@/components/types/TableTypes";
|
||||
import type { CashShift } from "../types/Types";
|
||||
import {
|
||||
formatFaNumber,
|
||||
formatOptionalDate,
|
||||
formatPrice,
|
||||
} from "@/helpers/func";
|
||||
import Status from "@/components/Status";
|
||||
import { CashShiftStatus } from "../enum/Enum";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
interface GetCashShiftTableColumnsParams {
|
||||
t: TFunction;
|
||||
onView: (shift: CashShift) => void;
|
||||
onClose?: (shift: CashShift) => void;
|
||||
}
|
||||
|
||||
const getAdminName = (shift: CashShift) => {
|
||||
const name = [shift.admin?.firstName, shift.admin?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return name || shift.admin?.phone || "-";
|
||||
};
|
||||
|
||||
export const getCashShiftTableColumns = ({
|
||||
t,
|
||||
onView,
|
||||
onClose,
|
||||
}: GetCashShiftTableColumnsParams): ColumnType<CashShift>[] => [
|
||||
{
|
||||
key: "openedAt",
|
||||
title: t("cash_shift.opened_at"),
|
||||
render: (item) =>
|
||||
formatOptionalDate(item.openedAt, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "closedAt",
|
||||
title: t("cash_shift.closed_at"),
|
||||
render: (item) =>
|
||||
formatOptionalDate(item.closedAt, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
title: t("cash_shift.operator"),
|
||||
render: (item) => getAdminName(item),
|
||||
},
|
||||
{
|
||||
key: "openingAmount",
|
||||
title: t("cash_shift.opening_amount"),
|
||||
render: (item) => formatPrice(item.openingAmount),
|
||||
},
|
||||
{
|
||||
key: "expectedAmount",
|
||||
title: t("cash_shift.expected_amount"),
|
||||
render: (item) =>
|
||||
item.expectedAmount !== null ? formatPrice(item.expectedAmount) : "-",
|
||||
},
|
||||
{
|
||||
key: "differenceAmount",
|
||||
title: t("cash_shift.difference_amount"),
|
||||
render: (item) => {
|
||||
if (item.differenceAmount === null) return "-";
|
||||
const value = formatPrice(item.differenceAmount);
|
||||
if (item.differenceAmount > 0) {
|
||||
return <span className="text-green-600">{value}</span>;
|
||||
}
|
||||
if (item.differenceAmount < 0) {
|
||||
return <span className="text-red-600">{value}</span>;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ordersCount",
|
||||
title: t("cash_shift.orders_count"),
|
||||
render: (item) => formatFaNumber(item.ordersCount),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: t("cash_shift.status_label"),
|
||||
render: (item) => (
|
||||
<Status
|
||||
variant={item.status === CashShiftStatus.OPEN ? "warning" : "success"}
|
||||
label={t(`cash_shift.status_values.${item.status}`)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "",
|
||||
render: (item) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onView(item)}
|
||||
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||
title={t("cash_shift.view_details")}
|
||||
>
|
||||
<Eye size={20} color="#8C90A3" />
|
||||
</button>
|
||||
{item.status === CashShiftStatus.OPEN && onClose && (
|
||||
<button
|
||||
onClick={() => onClose(item)}
|
||||
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||
title={t("cash_shift.close_shift")}
|
||||
>
|
||||
<Lock1 size={20} color="#8C90A3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,135 @@
|
||||
import { type FC, useState } from "react";
|
||||
import DefaulModal from "@/components/DefaulModal";
|
||||
import Input from "@/components/Input";
|
||||
import Textarea from "@/components/Textarea";
|
||||
import Button from "@/components/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "react-toastify";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import {
|
||||
useCloseCashShift,
|
||||
useGetCashShiftSummary,
|
||||
} from "../hooks/useCashShiftData";
|
||||
import CashShiftSummaryCard from "./CashShiftSummaryCard";
|
||||
import type { CashShift } from "../types/Types";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shift: CashShift | null;
|
||||
};
|
||||
|
||||
const CloseCashShiftModal: FC<Props> = ({ open, onClose, shift }) => {
|
||||
const { t } = useTranslation("global");
|
||||
const [countedAmount, setCountedAmount] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const closeShift = useCloseCashShift();
|
||||
|
||||
const { data: summaryData, isLoading: isSummaryLoading } =
|
||||
useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id);
|
||||
|
||||
const summary = summaryData?.data;
|
||||
|
||||
const handleClose = () => {
|
||||
setCountedAmount("");
|
||||
setNotes("");
|
||||
setError("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!shift) return;
|
||||
|
||||
const numericAmount = Number(countedAmount);
|
||||
|
||||
if (Number.isNaN(numericAmount) || numericAmount < 0) {
|
||||
setError(t("cash_shift.counted_amount_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
closeShift.mutate(
|
||||
{
|
||||
shiftId: shift.id,
|
||||
payload: {
|
||||
countedAmount: numericAmount,
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("cash_shift.closed_success"));
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(extractErrorMessage(err));
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (!shift) return null;
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={handleClose}
|
||||
isHeader
|
||||
title_header={t("cash_shift.close_title")}
|
||||
width={720}
|
||||
>
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="bg-[#F5F5F5] rounded-2xl p-4">
|
||||
<div className="text-sm font-medium mb-3">
|
||||
{t("cash_shift.summary_title")}
|
||||
</div>
|
||||
<CashShiftSummaryCard
|
||||
summary={summary}
|
||||
openingAmount={shift.openingAmount}
|
||||
isLoading={isSummaryLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
name="countedAmount"
|
||||
label={t("cash_shift.counted_amount")}
|
||||
placeholder={t("cash_shift.counted_amount_placeholder")}
|
||||
type="number"
|
||||
seprator
|
||||
value={countedAmount}
|
||||
onChange={(e) => {
|
||||
setCountedAmount(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
error_text={error}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
name="notes"
|
||||
label={t("cash_shift.notes")}
|
||||
placeholder={t("cash_shift.notes_placeholder")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
isNotRequired
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
label={t("cash_shift.cancel")}
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="bg-gray-100 text-gray-700"
|
||||
/>
|
||||
<Button
|
||||
label={t("cash_shift.close_shift")}
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
isloading={closeShift.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloseCashShiftModal;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type FC, useState } from "react";
|
||||
import DefaulModal from "@/components/DefaulModal";
|
||||
import Input from "@/components/Input";
|
||||
import Textarea from "@/components/Textarea";
|
||||
import Button from "@/components/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "react-toastify";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import { useOpenCashShift } from "../hooks/useCashShiftData";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const OpenCashShiftModal: FC<Props> = ({ open, onClose }) => {
|
||||
const { t } = useTranslation("global");
|
||||
const [openingAmount, setOpeningAmount] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const openShift = useOpenCashShift();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpeningAmount("");
|
||||
setNotes("");
|
||||
setError("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const numericAmount = Number(openingAmount);
|
||||
|
||||
if (Number.isNaN(numericAmount) || numericAmount < 0) {
|
||||
setError(t("cash_shift.opening_amount_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
openShift.mutate(
|
||||
{
|
||||
openingAmount: numericAmount,
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("cash_shift.opened_success"));
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(extractErrorMessage(err));
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={handleClose}
|
||||
isHeader
|
||||
title_header={t("cash_shift.open_title")}
|
||||
width={480}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Input
|
||||
name="openingAmount"
|
||||
label={t("cash_shift.opening_amount")}
|
||||
placeholder={t("cash_shift.opening_amount_placeholder")}
|
||||
type="number"
|
||||
seprator
|
||||
value={openingAmount}
|
||||
onChange={(e) => {
|
||||
setOpeningAmount(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
error_text={error}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Textarea
|
||||
name="notes"
|
||||
label={t("cash_shift.notes")}
|
||||
placeholder={t("cash_shift.notes_placeholder")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
isNotRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
label={t("cash_shift.cancel")}
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="bg-gray-100 text-gray-700"
|
||||
/>
|
||||
<Button
|
||||
label={t("cash_shift.open_shift")}
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
isloading={openShift.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenCashShiftModal;
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum CashShiftStatus {
|
||||
OPEN = "open",
|
||||
CLOSED = "closed",
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/CashShiftService";
|
||||
import type {
|
||||
CloseCashShiftPayload,
|
||||
OpenCashShiftPayload,
|
||||
} from "../types/Types";
|
||||
import type { GetCashShiftsParams } from "../service/CashShiftService";
|
||||
|
||||
const invalidateCashShiftQueries = (queryClient: ReturnType<typeof useQueryClient>) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cash-shifts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cash-shift-current"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cash-shift-current-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cash-shift-summary"] });
|
||||
};
|
||||
|
||||
export const useGetCashShifts = (params?: GetCashShiftsParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["cash-shifts", params],
|
||||
queryFn: () => api.getCashShifts(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCurrentCashShift = () => {
|
||||
return useQuery({
|
||||
queryKey: ["cash-shift-current"],
|
||||
queryFn: () => api.getCurrentCashShift(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCurrentCashShiftSummary = (enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: ["cash-shift-current-summary"],
|
||||
queryFn: () => api.getCurrentCashShiftSummary(),
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCashShiftSummary = (shiftId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: ["cash-shift-summary", shiftId],
|
||||
queryFn: () => api.getCashShiftSummary(shiftId),
|
||||
enabled: enabled && !!shiftId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useOpenCashShift = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: OpenCashShiftPayload) => api.openCashShift(payload),
|
||||
onSuccess: () => invalidateCashShiftQueries(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCloseCashShift = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
shiftId,
|
||||
payload,
|
||||
}: {
|
||||
shiftId: string;
|
||||
payload: CloseCashShiftPayload;
|
||||
}) => api.closeCashShift(shiftId, payload),
|
||||
onSuccess: () => invalidateCashShiftQueries(queryClient),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const DEFAULT_FILTERS: FilterValues = {
|
||||
status: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
};
|
||||
|
||||
const getFiltersFromParams = (searchParams: URLSearchParams): FilterValues => {
|
||||
const filters: FilterValues = { ...DEFAULT_FILTERS };
|
||||
const status = searchParams.get("status");
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
if (status) filters.status = status;
|
||||
if (startDate) filters.startDate = startDate;
|
||||
if (endDate) filters.endDate = endDate;
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
const getPageFromParams = (searchParams: URLSearchParams): number => {
|
||||
const page = searchParams.get("page");
|
||||
const parsed = page ? parseInt(page, 10) : DEFAULT_PAGE;
|
||||
return Number.isNaN(parsed) || parsed < 1 ? DEFAULT_PAGE : parsed;
|
||||
};
|
||||
|
||||
const buildSearchParams = (filters: FilterValues, page: number): URLSearchParams => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (page > DEFAULT_PAGE) {
|
||||
params.set("page", String(page));
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
params.set("status", filters.status as string);
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
params.set("startDate", filters.startDate as string);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
params.set("endDate", filters.endDate as string);
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const useCashShiftFilters = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = useState<FilterValues>(() =>
|
||||
getFiltersFromParams(searchParams)
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(() =>
|
||||
getPageFromParams(searchParams)
|
||||
);
|
||||
const [limit] = useState(DEFAULT_LIMIT);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(getFiltersFromParams(searchParams));
|
||||
setCurrentPage(getPageFromParams(searchParams));
|
||||
}, [searchParams]);
|
||||
|
||||
const updateUrlParams = useCallback(
|
||||
(newFilters: FilterValues, page: number) => {
|
||||
setSearchParams(buildSearchParams(newFilters, page), { replace: true });
|
||||
},
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
const apiParams = useMemo(() => {
|
||||
const params: Record<string, string | number> = {
|
||||
page: currentPage,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (filters.status) {
|
||||
params.status = filters.status as string;
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
params.startDate = filters.startDate as string;
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
params.endDate = filters.endDate as string;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [filters, currentPage, limit]);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(DEFAULT_PAGE);
|
||||
updateUrlParams(newFilters, DEFAULT_PAGE);
|
||||
},
|
||||
[updateUrlParams]
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
setCurrentPage(page);
|
||||
updateUrlParams(filters, page);
|
||||
},
|
||||
[filters, updateUrlParams]
|
||||
);
|
||||
|
||||
return {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
limit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import axios from "@/config/axios";
|
||||
import type {
|
||||
CloseCashShiftPayload,
|
||||
GetCashShiftResponse,
|
||||
GetCashShiftsResponse,
|
||||
GetCashShiftSummaryResponse,
|
||||
OpenCashShiftPayload,
|
||||
} from "../types/Types";
|
||||
|
||||
export interface GetCashShiftsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
adminId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: string;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export const getCashShifts = async (
|
||||
params?: GetCashShiftsParams
|
||||
): Promise<GetCashShiftsResponse> => {
|
||||
const { data } = await axios.get<GetCashShiftsResponse>("/admin/cash-shifts", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCurrentCashShift = async (): Promise<GetCashShiftResponse> => {
|
||||
const { data } = await axios.get<GetCashShiftResponse>(
|
||||
"/admin/cash-shifts/current"
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCurrentCashShiftSummary =
|
||||
async (): Promise<GetCashShiftSummaryResponse> => {
|
||||
const { data } = await axios.get<GetCashShiftSummaryResponse>(
|
||||
"/admin/cash-shifts/current/summary"
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCashShiftSummary = async (
|
||||
shiftId: string
|
||||
): Promise<GetCashShiftSummaryResponse> => {
|
||||
const { data } = await axios.get<GetCashShiftSummaryResponse>(
|
||||
`/admin/cash-shifts/${shiftId}/summary`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCashShiftById = async (
|
||||
shiftId: string
|
||||
): Promise<GetCashShiftResponse> => {
|
||||
const { data } = await axios.get<GetCashShiftResponse>(
|
||||
`/admin/cash-shifts/${shiftId}`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const openCashShift = async (
|
||||
payload: OpenCashShiftPayload
|
||||
): Promise<GetCashShiftResponse> => {
|
||||
const { data } = await axios.post<GetCashShiftResponse>(
|
||||
"/admin/cash-shifts/open",
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const closeCashShift = async (
|
||||
shiftId: string,
|
||||
payload: CloseCashShiftPayload
|
||||
): Promise<GetCashShiftResponse> => {
|
||||
const { data } = await axios.patch<GetCashShiftResponse>(
|
||||
`/admin/cash-shifts/${shiftId}/close`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { IResponse } from "@/types/response.types";
|
||||
import type { RowDataType } from "@/components/types/TableTypes";
|
||||
import type { CashShiftStatus } from "../enum/Enum";
|
||||
|
||||
export interface CashShiftAdmin {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface CashShift extends RowDataType {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
openingAmount: number;
|
||||
expectedAmount: number | null;
|
||||
countedAmount: number | null;
|
||||
differenceAmount: number | null;
|
||||
ordersCount: number;
|
||||
status: CashShiftStatus;
|
||||
notes: string | null;
|
||||
admin: CashShiftAdmin;
|
||||
}
|
||||
|
||||
export interface CashShiftSummary {
|
||||
ordersCount: number;
|
||||
cashReceived: number;
|
||||
onlineReceived: number;
|
||||
creditCardReceived: number;
|
||||
totalReceived: number;
|
||||
expectedAmount: number;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type GetCashShiftsResponse = IResponse<CashShift[]> & {
|
||||
meta: PaginationMeta;
|
||||
};
|
||||
|
||||
export type GetCashShiftResponse = IResponse<CashShift>;
|
||||
export type GetCashShiftSummaryResponse = IResponse<CashShiftSummary | null>;
|
||||
|
||||
export interface OpenCashShiftPayload {
|
||||
openingAmount: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CloseCashShiftPayload {
|
||||
countedAmount: number;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -1,155 +1,250 @@
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Button from '@/components/Button'
|
||||
import Input from '@/components/Input'
|
||||
import MultiSelect from '@/components/MultiSelect'
|
||||
import Select from '@/components/Select'
|
||||
import DatePicker from '@/components/DatePicker'
|
||||
import Textarea from '@/components/Textarea'
|
||||
import CustomerStats from '@/components/CustomerStats'
|
||||
import { TickCircle, Wallet2, TicketDiscount } from 'iconsax-react'
|
||||
import { type FC } from 'react'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import { useCreateUser } from './hooks/useUsersData'
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
||||
import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
|
||||
import type { CreateUserType } from './types/Types'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import type { ErrorType } from '@/helpers/types'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
|
||||
type CreateCustomerFormValues = {
|
||||
phone: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
birthDate: string
|
||||
marriageDate: string
|
||||
gender: string
|
||||
groupIds: string[]
|
||||
}
|
||||
|
||||
const buildCreateUserPayload = (
|
||||
values: CreateCustomerFormValues,
|
||||
avatarUrl?: string,
|
||||
): CreateUserType => {
|
||||
const payload: CreateUserType = {
|
||||
phone: values.phone.trim(),
|
||||
firstName: values.firstName.trim(),
|
||||
lastName: values.lastName.trim(),
|
||||
}
|
||||
|
||||
if (values.birthDate) payload.birthDate = values.birthDate
|
||||
if (values.marriageDate) payload.marriageDate = values.marriageDate
|
||||
if (values.gender !== '') payload.gender = values.gender === 'true'
|
||||
if (avatarUrl) payload.avatarUrl = avatarUrl
|
||||
if (values.groupIds.length > 0) payload.groupIds = values.groupIds
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const CreateCustomer: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { mutate: createUser, isPending } = useCreateUser()
|
||||
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
|
||||
const { data: groupsData } = useGetUserGroups()
|
||||
const [avatarFile, setAvatarFile] = useState<File[]>([])
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
(groupsData?.data || []).map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
})),
|
||||
[groupsData],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (avatarFile.length > 0) {
|
||||
const url = URL.createObjectURL(avatarFile[0])
|
||||
setAvatarPreviewUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}
|
||||
setAvatarPreviewUrl('')
|
||||
}, [avatarFile])
|
||||
|
||||
const formik = useFormik<CreateCustomerFormValues>({
|
||||
initialValues: {
|
||||
phone: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
birthDate: '',
|
||||
marriageDate: '',
|
||||
gender: '',
|
||||
groupIds: [],
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
phone: Yup.string().required('شماره تلفن الزامی است'),
|
||||
firstName: Yup.string().required('نام الزامی است'),
|
||||
lastName: Yup.string().required('نام خانوادگی الزامی است'),
|
||||
birthDate: Yup.string(),
|
||||
marriageDate: Yup.string(),
|
||||
gender: Yup.string(),
|
||||
groupIds: Yup.array().of(Yup.string()),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
const submitCustomer = (avatarUrl?: string) => {
|
||||
createUser(buildCreateUserPayload(values, avatarUrl), {
|
||||
onSuccess: (response) => {
|
||||
toast.success('مشتری با موفقیت ایجاد شد')
|
||||
const userId = response.data.user.id
|
||||
navigate(Pages.customers.detail + userId)
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (avatarFile.length > 0) {
|
||||
singleUpload(avatarFile[0], {
|
||||
onSuccess: (response) => {
|
||||
const avatarUrl = response?.data?.url || ''
|
||||
submitCustomer(avatarUrl)
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error, 'خطا در آپلود تصویر'))
|
||||
},
|
||||
})
|
||||
} else {
|
||||
submitCustomer()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>افزودن مشتری</h1>
|
||||
<Button className='w-fit px-6'>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
isloading={isPending || isUploading}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TickCircle
|
||||
size={20}
|
||||
color='#fff'
|
||||
/>
|
||||
<TickCircle size={20} color='#fff' />
|
||||
<span>ثبت مشتری</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-7 mt-9'>
|
||||
<div className='flex-1'>
|
||||
<div className='w-full bg-white rounded-4xl p-8'>
|
||||
<div className='text-lg font-light'>
|
||||
اطلاعات مشتری
|
||||
</div>
|
||||
<div className='mt-9'>
|
||||
<div className='w-full bg-white rounded-4xl p-8'>
|
||||
<div className='text-lg font-light'>اطلاعات مشتری</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4 mt-6'>
|
||||
<Input
|
||||
label='نام'
|
||||
placeholder=''
|
||||
name='name'
|
||||
/>
|
||||
<Input
|
||||
label='کد ملی'
|
||||
placeholder=''
|
||||
name='nationalCode'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
|
||||
<Input
|
||||
label='شماره تماس'
|
||||
placeholder='۰۹۱۲۱۲۳۴۵۶۷'
|
||||
name='phone'
|
||||
value={formik.values.phone}
|
||||
onChange={formik.handleChange}
|
||||
error_text={
|
||||
formik.touched.phone && formik.errors.phone
|
||||
? formik.errors.phone
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label='جنسیت'
|
||||
placeholder='انتخاب'
|
||||
name='gender'
|
||||
value={formik.values.gender}
|
||||
onChange={formik.handleChange}
|
||||
isNotRequired
|
||||
items={[
|
||||
{ value: 'true', label: 'مرد' },
|
||||
{ value: 'false', label: 'زن' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4 mt-5'>
|
||||
<Input
|
||||
label='شماره تماس'
|
||||
placeholder=''
|
||||
name='phone'
|
||||
/>
|
||||
<Input
|
||||
label='ایمیل'
|
||||
placeholder=''
|
||||
name='email'
|
||||
type='email'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
|
||||
<Input
|
||||
label='نام'
|
||||
placeholder=''
|
||||
name='firstName'
|
||||
value={formik.values.firstName}
|
||||
onChange={formik.handleChange}
|
||||
error_text={
|
||||
formik.touched.firstName && formik.errors.firstName
|
||||
? formik.errors.firstName
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label='نام خانوادگی'
|
||||
placeholder=''
|
||||
name='lastName'
|
||||
value={formik.values.lastName}
|
||||
onChange={formik.handleChange}
|
||||
error_text={
|
||||
formik.touched.lastName && formik.errors.lastName
|
||||
? formik.errors.lastName
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4 mt-5'>
|
||||
<div>
|
||||
<DatePicker
|
||||
label='تاریخ تولد'
|
||||
placeholder='انتخاب'
|
||||
onChange={() => { }}
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
|
||||
<DatePicker
|
||||
label='تاریخ تولد'
|
||||
placeholder='انتخاب'
|
||||
isNotRequired
|
||||
defaulValue={formik.values.birthDate}
|
||||
onChange={(date) => formik.setFieldValue('birthDate', date)}
|
||||
/>
|
||||
<DatePicker
|
||||
label='تاریخ ازدواج'
|
||||
placeholder='انتخاب'
|
||||
isNotRequired
|
||||
defaulValue={formik.values.marriageDate}
|
||||
onChange={(date) => formik.setFieldValue('marriageDate', date)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<MultiSelect
|
||||
label='گروهها'
|
||||
items={groupOptions}
|
||||
value={formik.values.groupIds}
|
||||
onChange={(values) => formik.setFieldValue('groupIds', values)}
|
||||
placeholder='انتخاب گروهها'
|
||||
error_text={
|
||||
formik.touched.groupIds && formik.errors.groupIds
|
||||
? String(formik.errors.groupIds)
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<UploadBox
|
||||
label='تصویر پروفایل'
|
||||
isNotRequired
|
||||
onChange={(files) => setAvatarFile(files)}
|
||||
isReset={avatarFile.length === 0}
|
||||
/>
|
||||
|
||||
{avatarPreviewUrl && (
|
||||
<div className='mt-3'>
|
||||
<img
|
||||
src={avatarPreviewUrl}
|
||||
alt='avatar preview'
|
||||
className='w-20 h-20 rounded-full object-cover'
|
||||
/>
|
||||
</div>
|
||||
<DatePicker
|
||||
label='تاریخ ازدواج'
|
||||
placeholder='انتخاب'
|
||||
onChange={() => { }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full bg-white rounded-4xl p-8 mt-8'>
|
||||
<div className='text-lg font-light'>
|
||||
آدرس
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4 mt-6'>
|
||||
<Select
|
||||
label='استان'
|
||||
placeholder='انتخاب'
|
||||
items={[]}
|
||||
/>
|
||||
<Select
|
||||
label='شهر'
|
||||
placeholder='انتخاب'
|
||||
items={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<Input
|
||||
label='عنوان آدرس'
|
||||
placeholder=''
|
||||
name='addressTitle'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<Textarea
|
||||
label='آدرس'
|
||||
placeholder=''
|
||||
name='address'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-[330px]'>
|
||||
<div className='bg-white rounded-4xl p-8'>
|
||||
<CustomerStats points={0} walletBalance='۰ تومان' />
|
||||
<Button className='w-full mt-5'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Wallet2 size={16} color='#fff' />
|
||||
<span>افزایش موجودی کیف پول</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 bg-white rounded-4xl p-8'>
|
||||
<div className='font-light'>
|
||||
کدهای تخفیف
|
||||
</div>
|
||||
<div className='mt-4'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-b border-border'>
|
||||
<th className='text-right pb-2 font-light text-xs text-description'>
|
||||
کد
|
||||
</th>
|
||||
<th className='text-center pb-2 font-light text-xs text-description'>
|
||||
انقضا
|
||||
</th>
|
||||
<th className='text-center pb-2 font-light text-xs text-description'>
|
||||
وضعیت
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div className='text-center py-8 text-xs text-description'>
|
||||
کد تخفیفی موجود نیست
|
||||
</div>
|
||||
<Button className='w-full'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TicketDiscount size={16} color='#fff' />
|
||||
<span>اضافه کردن کد تخفیف</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,4 +252,4 @@ const CreateCustomer: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateCustomer
|
||||
export default CreateCustomer
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./groups/List";
|
||||
@@ -1,167 +1,158 @@
|
||||
import Table from '@/components/Table'
|
||||
import Filters, { type FilterValues, type FieldType } from '@/components/Filters'
|
||||
import { type FC, useState, useMemo, useCallback } from 'react'
|
||||
import { useGetUsers } from './hooks/useUsersData'
|
||||
import type { Customer, CustomerListRow } from '@/pages/customers/types/Types'
|
||||
import { formatOptionalDate, formatFaNumber } from '@/helpers/func'
|
||||
import Button from "@/components/Button";
|
||||
import Filters, { type FieldType } from "@/components/Filters";
|
||||
import Table from "@/components/Table";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import { formatFaNumber, formatOptionalDate } from "@/helpers/func";
|
||||
import type { CustomerListRow, UserRestaurant } from "@/pages/customers/types/Types";
|
||||
import { DocumentDownload, DocumentUpload, Edit } from "iconsax-react";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ImportCustomersModal from "./components/ImportCustomersModal";
|
||||
import { useCustomerFilters } from "./hooks/useCustomerFilters";
|
||||
import { useGetUsers } from "./hooks/useUsersData";
|
||||
import { downloadCustomerImportSampleExcel } from "./utils/downloadCustomerImportSampleExcel";
|
||||
|
||||
const mapCustomerToRow = (customer: Customer): CustomerListRow => {
|
||||
const fullName = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
|
||||
return {
|
||||
id: customer.id,
|
||||
fullName: fullName || 'بدون نام',
|
||||
phone: customer.phone || '-',
|
||||
birthDate: formatOptionalDate(customer.birthDate),
|
||||
marriageDate: formatOptionalDate(customer.marriageDate),
|
||||
wallet: formatFaNumber(customer.wallet),
|
||||
points: formatFaNumber(customer.points),
|
||||
status: customer.isActive ? 'فعال' : 'غیرفعال',
|
||||
}
|
||||
}
|
||||
const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => {
|
||||
const { user, orderCount, totalOrderAmount } = userRestaurant;
|
||||
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||
return {
|
||||
id: user.id,
|
||||
fullName: fullName || "بدون نام",
|
||||
phone: user.phone || "-",
|
||||
birthDate: formatOptionalDate(user.birthDate),
|
||||
marriageDate: formatOptionalDate(user.marriageDate),
|
||||
orderCount: formatFaNumber(orderCount),
|
||||
totalOrderAmount: formatFaNumber(totalOrderAmount),
|
||||
status: user.isActive !== false ? "فعال" : "غیرفعال",
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
birthDateRaw: user.birthDate,
|
||||
marriageDateRaw: user.marriageDate,
|
||||
gender: user.gender,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const CustomersList: FC = () => {
|
||||
const { data: usersData, isLoading } = useGetUsers()
|
||||
const [filters, setFilters] = useState<FilterValues>({ search: '', status: 'all' })
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const limit = 10
|
||||
const { t } = useTranslation("global");
|
||||
const navigate = useNavigate();
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
|
||||
const filterFields = useMemo<FieldType[]>(() => [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'search',
|
||||
placeholder: 'نام یا شماره تماس',
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'status',
|
||||
placeholder: 'انتخاب وضعیت',
|
||||
defaultValue: 'all',
|
||||
options: [
|
||||
{ label: 'همه وضعیتها', value: 'all' },
|
||||
{ label: 'فعال', value: 'active' },
|
||||
{ label: 'غیرفعال', value: 'inactive' },
|
||||
],
|
||||
},
|
||||
], [])
|
||||
const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange } = useCustomerFilters();
|
||||
|
||||
const customers = useMemo(() => usersData?.data ?? [], [usersData])
|
||||
const { data: usersData, isLoading } = useGetUsers(apiParams);
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const searchValue = (filters.search as string || '').toLowerCase().trim()
|
||||
const statusFilter = filters.status || 'all'
|
||||
const filterFields = useMemo<FieldType[]>(
|
||||
() => [
|
||||
{
|
||||
type: "input",
|
||||
name: "search",
|
||||
placeholder: "نام یا شماره تماس",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return customers.filter((customer) => {
|
||||
const fullName = [customer.firstName, customer.lastName].filter(Boolean).join(' ').toLowerCase().trim()
|
||||
const matchesSearch =
|
||||
searchValue === '' ||
|
||||
fullName.includes(searchValue) ||
|
||||
(customer.phone || '').includes(searchValue)
|
||||
const userRestaurants = usersData?.data ?? [];
|
||||
const tableData = useMemo<CustomerListRow[]>(() => userRestaurants.map(mapUserRestaurantToRow), [userRestaurants]);
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'active' && customer.isActive) ||
|
||||
(statusFilter === 'inactive' && !customer.isActive)
|
||||
const totalPages = usersData?.meta?.totalPages || 1;
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [customers, filters])
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "fullName",
|
||||
title: "نام و نام خانوادگی",
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
title: "شماره تماس",
|
||||
},
|
||||
{
|
||||
key: "birthDate",
|
||||
title: "تاریخ تولد",
|
||||
},
|
||||
{
|
||||
key: "marriageDate",
|
||||
title: "تاریخ ازدواج",
|
||||
},
|
||||
{
|
||||
key: "orderCount",
|
||||
title: "تعداد سفارش",
|
||||
},
|
||||
{
|
||||
key: "totalOrderAmount",
|
||||
title: "مجموع سفارش (تومان)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: "وضعیت",
|
||||
render: (item: CustomerListRow) => (
|
||||
<span className={`px-3 py-1 rounded-full text-xs ${item.status === "فعال" ? "bg-green-100 text-green-700" : "bg-red-100 text-red-600"}`}>{item.status}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "details",
|
||||
title: "عملیات",
|
||||
render: (item: CustomerListRow) => (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary text-xs flex items-center gap-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(Pages.customers.update + item.id, {
|
||||
state: { customer: item },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Edit size={18} color="#0047FF" />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredCustomers.length / limit) || 1)
|
||||
|
||||
const paginatedCustomers = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * limit
|
||||
return filteredCustomers.slice(startIndex, startIndex + limit)
|
||||
}, [filteredCustomers, currentPage, limit])
|
||||
|
||||
const tableData = useMemo<CustomerListRow[]>(() => paginatedCustomers.map(mapCustomerToRow), [paginatedCustomers])
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'fullName',
|
||||
title: 'نام و نام خانوادگی',
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
title: 'شماره تماس',
|
||||
},
|
||||
{
|
||||
key: 'birthDate',
|
||||
title: 'تاریخ تولد',
|
||||
},
|
||||
{
|
||||
key: 'marriageDate',
|
||||
title: 'تاریخ ازدواج',
|
||||
},
|
||||
{
|
||||
key: 'wallet',
|
||||
title: 'کیف پول (تومان)',
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
title: 'امتیاز',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'وضعیت',
|
||||
render: (item: CustomerListRow) => (
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs ${item.status === 'فعال' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
title: 'جزئیات',
|
||||
},
|
||||
], [])
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
|
||||
const handleFiltersChange = useCallback((newFilters: FilterValues) => {
|
||||
setFilters(newFilters)
|
||||
setCurrentPage(1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>لیست مشتریان</h1>
|
||||
{/* <Link to={Pages.customers.add}>
|
||||
<Button className='w-fit px-6'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Add color='#fff' size={20} />
|
||||
<span>افزودن مشتری</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link> */}
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex flex-wrap justify-between items-center gap-3">
|
||||
<h1 className="text-lg font-light">{t("customer.customer_list")}</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" className="w-fit px-4 bg-gray-100 text-gray-700" onClick={downloadCustomerImportSampleExcel}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentDownload size={18} color="#374151" />
|
||||
<span>{t("customer.download_sample")}</span>
|
||||
</div>
|
||||
|
||||
<div className='mt-8'>
|
||||
<Filters
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
initialValues={filters}
|
||||
searchField="search"
|
||||
/>
|
||||
</Button>
|
||||
<Button type="button" className="w-fit px-4" onClick={() => setIsImportModalOpen(true)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentUpload size={18} color="#fff" />
|
||||
<span>{t("customer.import_excel")}</span>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
data={tableData}
|
||||
isloading={isLoading}
|
||||
selectable
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
export default CustomersList
|
||||
<div className="mt-8">
|
||||
<Filters fields={filterFields} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
data={tableData}
|
||||
isloading={isLoading}
|
||||
selectable
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImportCustomersModal open={isImportModalOpen} onClose={() => setIsImportModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersList;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useFormik } from 'formik'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import Button from '@/components/Button'
|
||||
import Input from '@/components/Input'
|
||||
import MultiSelect from '@/components/MultiSelect'
|
||||
import Select from '@/components/Select'
|
||||
import DatePicker from '@/components/DatePicker'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
||||
import { useGetUserGroupsForUser, useUpdateUser } from './hooks/useUsersData'
|
||||
import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
|
||||
import type { CustomerListRow, UpdateUserType } from './types/Types'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import type { ErrorType } from '@/helpers/types'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
|
||||
type UpdateCustomerFormValues = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
birthDate: string
|
||||
marriageDate: string
|
||||
gender: string
|
||||
groupIds: string[]
|
||||
}
|
||||
|
||||
type CustomerUpdateLocationState = {
|
||||
customer?: CustomerListRow
|
||||
}
|
||||
|
||||
const UpdateCustomer: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
|
||||
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
|
||||
const { data: userGroupsData } = useGetUserGroupsForUser(id || '')
|
||||
const { data: allGroupsData } = useGetUserGroups()
|
||||
const [avatarFile, setAvatarFile] = useState<File[]>([])
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
|
||||
|
||||
const customer = (location.state as CustomerUpdateLocationState | null)?.customer
|
||||
|
||||
const initialGroupIds = useMemo(
|
||||
() => (userGroupsData?.data || []).map((group) => group.id),
|
||||
[userGroupsData],
|
||||
)
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
(allGroupsData?.data || []).map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
})),
|
||||
[allGroupsData],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!customer?.avatarUrl) {
|
||||
setAvatarPreviewUrl('')
|
||||
return
|
||||
}
|
||||
setAvatarPreviewUrl(customer.avatarUrl)
|
||||
}, [customer?.avatarUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (avatarFile.length > 0) {
|
||||
const url = URL.createObjectURL(avatarFile[0])
|
||||
setAvatarPreviewUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}
|
||||
}, [avatarFile])
|
||||
|
||||
const formik = useFormik<UpdateCustomerFormValues>({
|
||||
initialValues: {
|
||||
firstName: customer?.firstName || '',
|
||||
lastName: customer?.lastName || '',
|
||||
birthDate: customer?.birthDateRaw || '',
|
||||
marriageDate: customer?.marriageDateRaw || '',
|
||||
gender:
|
||||
customer?.gender === undefined || customer?.gender === null
|
||||
? ''
|
||||
: String(customer.gender),
|
||||
groupIds: initialGroupIds,
|
||||
},
|
||||
enableReinitialize: true,
|
||||
onSubmit: (values) => {
|
||||
if (!id) {
|
||||
toast.error('شناسه مشتری یافت نشد')
|
||||
return
|
||||
}
|
||||
|
||||
const submitUpdate = (avatarUrl?: string) => {
|
||||
const payload: UpdateUserType = {
|
||||
groupIds: values.groupIds,
|
||||
}
|
||||
|
||||
if (values.firstName.trim()) payload.firstName = values.firstName.trim()
|
||||
if (values.lastName.trim()) payload.lastName = values.lastName.trim()
|
||||
if (values.birthDate) payload.birthDate = values.birthDate
|
||||
if (values.marriageDate) payload.marriageDate = values.marriageDate
|
||||
if (values.gender !== '') payload.gender = values.gender === 'true'
|
||||
if (avatarUrl) payload.avatarUrl = avatarUrl
|
||||
|
||||
const groupsChanged =
|
||||
JSON.stringify([...values.groupIds].sort()) !==
|
||||
JSON.stringify([...initialGroupIds].sort())
|
||||
const hasProfileChanges =
|
||||
Boolean(payload.firstName) ||
|
||||
Boolean(payload.lastName) ||
|
||||
Boolean(payload.birthDate) ||
|
||||
Boolean(payload.marriageDate) ||
|
||||
payload.gender !== undefined ||
|
||||
Boolean(payload.avatarUrl)
|
||||
|
||||
if (!hasProfileChanges && !groupsChanged) {
|
||||
toast.info('حداقل یک فیلد را برای ویرایش وارد کنید')
|
||||
return
|
||||
}
|
||||
|
||||
updateUser(
|
||||
{ userId: id, params: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('اطلاعات مشتری با موفقیت ویرایش شد')
|
||||
navigate(Pages.customers.list)
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error, 'خطا در ویرایش مشتری'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (avatarFile.length > 0) {
|
||||
singleUpload(avatarFile[0], {
|
||||
onSuccess: (response) => {
|
||||
const avatarUrl = response?.data?.url || ''
|
||||
submitUpdate(avatarUrl)
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error, 'خطا در آپلود تصویر'))
|
||||
},
|
||||
})
|
||||
} else {
|
||||
submitUpdate(customer?.avatarUrl || undefined)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>ویرایش مشتری</h1>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
isloading={isUpdating || isUploading}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TickCircle size={20} color='#fff' />
|
||||
<span>ذخیره تغییرات</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-9'>
|
||||
<div className='w-full bg-white rounded-4xl p-8'>
|
||||
<div className='text-lg font-light'>اطلاعات مشتری</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
|
||||
<Input
|
||||
label='نام'
|
||||
placeholder=''
|
||||
name='firstName'
|
||||
value={formik.values.firstName}
|
||||
onChange={formik.handleChange}
|
||||
isNotRequired
|
||||
/>
|
||||
<Input
|
||||
label='نام خانوادگی'
|
||||
placeholder=''
|
||||
name='lastName'
|
||||
value={formik.values.lastName}
|
||||
onChange={formik.handleChange}
|
||||
isNotRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
|
||||
<DatePicker
|
||||
label='تاریخ تولد'
|
||||
placeholder='انتخاب'
|
||||
isNotRequired
|
||||
defaulValue={formik.values.birthDate}
|
||||
onChange={(date) => formik.setFieldValue('birthDate', date)}
|
||||
/>
|
||||
<DatePicker
|
||||
label='تاریخ ازدواج'
|
||||
placeholder='انتخاب'
|
||||
isNotRequired
|
||||
defaulValue={formik.values.marriageDate}
|
||||
onChange={(date) => formik.setFieldValue('marriageDate', date)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
|
||||
<Select
|
||||
label='جنسیت'
|
||||
placeholder='انتخاب'
|
||||
name='gender'
|
||||
value={formik.values.gender}
|
||||
onChange={formik.handleChange}
|
||||
isNotRequired
|
||||
items={[
|
||||
{ value: 'true', label: 'مرد' },
|
||||
{ value: 'false', label: 'زن' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<MultiSelect
|
||||
label='گروهها'
|
||||
items={groupOptions}
|
||||
value={formik.values.groupIds}
|
||||
onChange={(values) => formik.setFieldValue('groupIds', values)}
|
||||
placeholder='انتخاب گروهها'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<UploadBox
|
||||
label='تصویر پروفایل'
|
||||
isNotRequired
|
||||
onChange={(files) => setAvatarFile(files)}
|
||||
isReset={avatarFile.length === 0}
|
||||
/>
|
||||
|
||||
{avatarPreviewUrl && (
|
||||
<div className='mt-3'>
|
||||
<img
|
||||
src={avatarPreviewUrl}
|
||||
alt='avatar preview'
|
||||
className='w-20 h-20 rounded-full object-cover'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateCustomer
|
||||
@@ -0,0 +1,152 @@
|
||||
import Button from "@/components/Button";
|
||||
import Input from "@/components/Input";
|
||||
import MultiSelect from "@/components/MultiSelect";
|
||||
import Select from "@/components/Select";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { useFormik } from "formik";
|
||||
import { TickCircle } from "iconsax-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import * as Yup from "yup";
|
||||
import { useGetUserGroups } from "@/pages/customers/groups/hooks/useUserGroupData";
|
||||
import { useCreateDiscountCampaign } from "./hooks/useCampaignData";
|
||||
import type { CreateDiscountCampaignType } from "./types/Types";
|
||||
|
||||
const sentTypeOptions = [
|
||||
{ value: "push", label: "پوش نوتیفیکیشن" },
|
||||
{ value: "sms", label: "پیامک" },
|
||||
];
|
||||
|
||||
const CreateCampaign: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutate: createCampaign, isPending } = useCreateDiscountCampaign();
|
||||
const { data: groupsData } = useGetUserGroups();
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
(groupsData?.data || []).map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
})),
|
||||
[groupsData]
|
||||
);
|
||||
|
||||
const formik = useFormik<CreateDiscountCampaignType>({
|
||||
initialValues: {
|
||||
title: "",
|
||||
groups: [],
|
||||
sentType: "push",
|
||||
discountCode: "",
|
||||
ocasion: "",
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
title: Yup.string().required("عنوان کمپین الزامی است"),
|
||||
groups: Yup.array()
|
||||
.of(Yup.string())
|
||||
.min(1, "حداقل یک گروه باید انتخاب شود"),
|
||||
sentType: Yup.string().required("نوع ارسال الزامی است"),
|
||||
discountCode: Yup.string().required("کد تخفیف الزامی است"),
|
||||
ocasion: Yup.string().required("مناسبت الزامی است"),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
createCampaign(values, {
|
||||
onSuccess: () => {
|
||||
toast.success("کمپین با موفقیت ایجاد شد");
|
||||
navigate(Pages.customers.campaigns);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div className="text-lg font-light">افزودن کمپین تخفیف</div>
|
||||
<Button
|
||||
className="px-5 w-fit"
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isloading={isPending}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickCircle className="size-5" color="white" />
|
||||
<div>ثبت کمپین</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="bg-white rounded-3xl p-6 flex flex-col gap-6">
|
||||
<Input
|
||||
label="عنوان کمپین"
|
||||
name="title"
|
||||
value={formik.values.title}
|
||||
onChange={formik.handleChange}
|
||||
error_text={
|
||||
formik.touched.title && formik.errors.title
|
||||
? formik.errors.title
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="کد تخفیف"
|
||||
name="discountCode"
|
||||
value={formik.values.discountCode}
|
||||
onChange={formik.handleChange}
|
||||
error_text={
|
||||
formik.touched.discountCode && formik.errors.discountCode
|
||||
? formik.errors.discountCode
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="مناسبت"
|
||||
name="ocasion"
|
||||
value={formik.values.ocasion}
|
||||
onChange={formik.handleChange}
|
||||
error_text={
|
||||
formik.touched.ocasion && formik.errors.ocasion
|
||||
? formik.errors.ocasion
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="نوع ارسال"
|
||||
name="sentType"
|
||||
items={sentTypeOptions}
|
||||
value={formik.values.sentType}
|
||||
onChange={formik.handleChange}
|
||||
error_text={
|
||||
formik.touched.sentType && formik.errors.sentType
|
||||
? formik.errors.sentType
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="گروههای مشتری"
|
||||
items={groupOptions}
|
||||
value={formik.values.groups}
|
||||
onChange={(values) => formik.setFieldValue("groups", values)}
|
||||
placeholder="انتخاب گروهها"
|
||||
error_text={
|
||||
formik.touched.groups && formik.errors.groups
|
||||
? String(formik.errors.groups)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCampaign;
|
||||
@@ -0,0 +1,54 @@
|
||||
import Button from "@/components/Button";
|
||||
import Filters from "@/components/Filters";
|
||||
import Table from "@/components/Table";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import { Add } from "iconsax-react";
|
||||
import { type FC } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getCampaignTableColumns } from "./components/CampaignTableColumns";
|
||||
import { useGetCampaigns } from "./hooks/useCampaignData";
|
||||
import { useCampaignFilters } from "./hooks/useCampaignFilters";
|
||||
|
||||
const CampaignsList: FC = () => {
|
||||
const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange, limit } = useCampaignFilters();
|
||||
|
||||
const { data: campaignsData, isLoading } = useGetCampaigns(apiParams);
|
||||
|
||||
const campaigns = campaignsData?.data || [];
|
||||
const totalPages = campaignsData?.meta?.totalPages || Math.max(1, Math.ceil(campaigns.length / limit) || 1);
|
||||
|
||||
const columns = getCampaignTableColumns();
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-light">کمپینها</h1>
|
||||
<Link to={Pages.customers.campaignsAdd}>
|
||||
<Button className="w-fit px-6">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Add color="#fff" size={20} />
|
||||
<span>افزودن کمپین</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Filters fields={[]} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
data={campaigns}
|
||||
isloading={isLoading}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampaignsList;
|
||||
@@ -0,0 +1,114 @@
|
||||
import Status from "@/components/Status";
|
||||
import type { ColumnType } from "@/components/types/TableTypes";
|
||||
import { formatFaNumber, formatOptionalDate, formatPrice } from "@/helpers/func";
|
||||
import type { Campaign } from "../types/Types";
|
||||
|
||||
const getGroupNames = (groups: Campaign["groups"]) => {
|
||||
if (!groups?.length) return "-";
|
||||
|
||||
return groups
|
||||
.map((group) => {
|
||||
if (typeof group === "string") return group;
|
||||
const count = group.count != null ? ` (${formatFaNumber(group.count)})` : "";
|
||||
return `${group.name}${count}`;
|
||||
})
|
||||
.join(" ، ");
|
||||
};
|
||||
|
||||
const sentTypeLabels: Record<string, string> = {
|
||||
push: "پوش نوتیفیکیشن",
|
||||
sms: "پیامک",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "در انتظار",
|
||||
processing: "در حال ارسال",
|
||||
completed: "تکمیل شده",
|
||||
failed: "ناموفق",
|
||||
partial: "ناقص",
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: string): "success" | "error" | "warning" | "info" | "pending" => {
|
||||
const variantMap: Record<string, "success" | "error" | "warning" | "info" | "pending"> = {
|
||||
pending: "pending",
|
||||
processing: "info",
|
||||
completed: "success",
|
||||
failed: "error",
|
||||
partial: "warning",
|
||||
};
|
||||
return variantMap[status] || "pending";
|
||||
};
|
||||
|
||||
export const getCampaignTableColumns = (): ColumnType<Campaign>[] => {
|
||||
return [
|
||||
{
|
||||
key: "title",
|
||||
title: "عنوان",
|
||||
render: (item: Campaign) => item.title || "-",
|
||||
},
|
||||
{
|
||||
key: "discountCode",
|
||||
title: "کد تخفیف",
|
||||
render: (item: Campaign) => item.discountCode || "-",
|
||||
},
|
||||
{
|
||||
key: "ocasion",
|
||||
title: "مناسبت",
|
||||
render: (item: Campaign) => item.ocasion || "-",
|
||||
},
|
||||
{
|
||||
key: "sentType",
|
||||
title: "نوع ارسال",
|
||||
render: (item: Campaign) => sentTypeLabels[item.sentType] || item.sentType,
|
||||
},
|
||||
{
|
||||
key: "groups",
|
||||
title: "گروهها",
|
||||
render: (item: Campaign) => getGroupNames(item.groups),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: "وضعیت",
|
||||
render: (item: Campaign) => (
|
||||
<Status variant={getStatusVariant(item.status)} label={statusLabels[item.status] || item.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "totalCount",
|
||||
title: "تعداد کل",
|
||||
render: (item: Campaign) => formatFaNumber(item.totalCount),
|
||||
},
|
||||
{
|
||||
key: "sentCount",
|
||||
title: "ارسال موفق",
|
||||
render: (item: Campaign) => formatFaNumber(item.sentCount),
|
||||
},
|
||||
{
|
||||
key: "failedCount",
|
||||
title: "ارسال ناموفق",
|
||||
render: (item: Campaign) => formatFaNumber(item.failedCount),
|
||||
},
|
||||
{
|
||||
key: "totalCost",
|
||||
title: "هزینه کل",
|
||||
render: (item: Campaign) => formatPrice(item.totalCost),
|
||||
},
|
||||
{
|
||||
key: "refunded",
|
||||
title: "بازگشتی",
|
||||
render: (item: Campaign) => formatPrice(item.refunded),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
title: "تاریخ ایجاد",
|
||||
render: (item: Campaign) =>
|
||||
formatOptionalDate(item.createdAt, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/CampaignService";
|
||||
import type { GetCampaignsParams } from "../types/Types";
|
||||
|
||||
export const useGetCampaigns = (params?: GetCampaignsParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["campaigns", params],
|
||||
queryFn: () => api.getCampaigns(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCampaignById = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["campaign", id],
|
||||
queryFn: () => api.getCampaignById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateDiscountCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.createDiscountCampaign,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.deleteCampaign,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
import type { GetCampaignsParams } from "../types/Types";
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
export const useCampaignFilters = () => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||
const [limit] = useState(DEFAULT_LIMIT);
|
||||
|
||||
const apiParams = useMemo<GetCampaignsParams>(() => {
|
||||
const params: GetCampaignsParams = {
|
||||
page: currentPage,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
params.search = filters.search as string;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [filters, currentPage, limit]);
|
||||
|
||||
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(DEFAULT_PAGE);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
limit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import axios from "@/config/axios";
|
||||
import type {
|
||||
CampaignResponse,
|
||||
CampaignsResponse,
|
||||
CreateDiscountCampaignType,
|
||||
GetCampaignsParams,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getCampaigns = async (
|
||||
params?: GetCampaignsParams
|
||||
): Promise<CampaignsResponse> => {
|
||||
const { data } = await axios.get<CampaignsResponse>("/admin/campaigns", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCampaignById = async (id: string): Promise<CampaignResponse> => {
|
||||
const { data } = await axios.get<CampaignResponse>(`/admin/campaigns/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createDiscountCampaign = async (
|
||||
params: CreateDiscountCampaignType
|
||||
) => {
|
||||
const { data } = await axios.post("/admin/campaigns/discount", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteCampaign = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/campaigns/${id}`);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { UserGroup } from "@/pages/customers/groups/types/Types";
|
||||
import type { IResponse } from "@/types/response.types";
|
||||
|
||||
export type CampaignSentType = "push" | "sms";
|
||||
|
||||
export type CampaignStatus = "pending" | "processing" | "completed" | "failed" | "partial";
|
||||
|
||||
export type CampaignGroup = UserGroup & {
|
||||
restaurant?: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export type Campaign = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant?: string;
|
||||
title: string;
|
||||
groups: CampaignGroup[] | string[];
|
||||
sentType: CampaignSentType;
|
||||
discountCode: string;
|
||||
ocasion?: string;
|
||||
totalCount: number;
|
||||
totalCost: number;
|
||||
refunded: number;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
status: CampaignStatus | string;
|
||||
};
|
||||
|
||||
export type PaginationMeta = {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export type GetCampaignsParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type CampaignsResponse = IResponse<Campaign[]> & {
|
||||
meta?: PaginationMeta;
|
||||
};
|
||||
export type CampaignResponse = IResponse<Campaign>;
|
||||
|
||||
export type CreateDiscountCampaignType = {
|
||||
title: string;
|
||||
groups: string[];
|
||||
sentType: CampaignSentType;
|
||||
discountCode: string;
|
||||
ocasion: string;
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { type FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DocumentDownload } from 'iconsax-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import DefaulModal from '@/components/DefaulModal';
|
||||
import Button from '@/components/Button';
|
||||
import UploadBox from '@/components/UploadBox';
|
||||
import { extractErrorMessage } from '@/config/func';
|
||||
import { useImportUsersFromExcel } from '../hooks/useUsersData';
|
||||
import { downloadCustomerImportSampleExcel } from '../utils/downloadCustomerImportSampleExcel';
|
||||
import type { ImportUsersResult } from '../types/Types';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ImportCustomersModal: FC<Props> = ({ open, onClose }) => {
|
||||
const { t } = useTranslation('global');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportUsersResult | null>(null);
|
||||
const [isResetUpload, setIsResetUpload] = useState(false);
|
||||
|
||||
const { mutate: importUsers, isPending } = useImportUsersFromExcel();
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedFile(null);
|
||||
setImportResult(null);
|
||||
setIsResetUpload(prev => !prev);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (!selectedFile) {
|
||||
toast.error(t('customer.import_file_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
importUsers(selectedFile, {
|
||||
onSuccess: (response) => {
|
||||
setImportResult(response);
|
||||
toast.success(response.message || t('customer.import_success'));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error, t('customer.import_failed')));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={handleClose}
|
||||
isHeader
|
||||
title_header={t('customer.import_excel')}
|
||||
width={520}
|
||||
>
|
||||
<div className='mt-6 flex flex-col gap-5'>
|
||||
<p className='text-sm text-gray-600 leading-6'>
|
||||
{t('customer.import_description')}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
className='w-fit px-4 bg-gray-100 text-gray-700'
|
||||
onClick={downloadCustomerImportSampleExcel}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<DocumentDownload size={18} color='#374151' />
|
||||
<span>{t('customer.download_sample')}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<UploadBox
|
||||
label={t('customer.import_file')}
|
||||
onChange={(files) => setSelectedFile(files[0] ?? null)}
|
||||
isReset={isResetUpload}
|
||||
/>
|
||||
|
||||
{importResult && (
|
||||
<div className='rounded-xl border border-border p-4 text-sm space-y-2'>
|
||||
<p>{t('customer.import_total', { count: importResult.total })}</p>
|
||||
<p>{t('customer.import_created', { count: importResult.usersCreated })}</p>
|
||||
<p>{t('customer.import_linked', { count: importResult.usersLinked })}</p>
|
||||
<p>{t('customer.import_already_linked', { count: importResult.usersAlreadyLinked })}</p>
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className='mt-3'>
|
||||
<p className='font-medium text-red-600 mb-2'>
|
||||
{t('customer.import_errors', { count: importResult.errors.length })}
|
||||
</p>
|
||||
<ul className='max-h-32 overflow-y-auto space-y-1 text-red-600'>
|
||||
{importResult.errors.map((error, index) => (
|
||||
<li key={`${error.row}-${index}`}>
|
||||
{t('customer.import_error_row', {
|
||||
row: error.row,
|
||||
message: error.message,
|
||||
phone: error.phone ? ` (${error.phone})` : '',
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<Button
|
||||
label='انصراف'
|
||||
type='button'
|
||||
onClick={handleClose}
|
||||
className='bg-gray-100 text-gray-700'
|
||||
/>
|
||||
<Button
|
||||
label={t('customer.import_submit')}
|
||||
type='button'
|
||||
onClick={handleImport}
|
||||
isloading={isPending}
|
||||
disabled={!selectedFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportCustomersModal;
|
||||
@@ -0,0 +1,60 @@
|
||||
import Button from "@/components/Button";
|
||||
import Input from "@/components/Input";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { useFormik } from "formik";
|
||||
import { TickCircle } from "iconsax-react";
|
||||
import { type FC } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import * as Yup from "yup";
|
||||
import { useCreateUserGroup } from "./hooks/useUserGroupData";
|
||||
import type { CreateUserGroupType } from "./types/Types";
|
||||
|
||||
const CreateUserGroup: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutate: createUserGroup, isPending } = useCreateUserGroup();
|
||||
|
||||
const formik = useFormik<CreateUserGroupType>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
name: Yup.string().required("نام گروه الزامی است"),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
createUserGroup(values, {
|
||||
onSuccess: () => {
|
||||
toast.success("گروه با موفقیت ایجاد شد");
|
||||
navigate(Pages.customers.groups);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div className="text-lg font-light">افزودن گروه مشتری</div>
|
||||
<Button className="px-5 w-fit" onClick={() => formik.handleSubmit()} isloading={isPending}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickCircle className="size-5" color="white" />
|
||||
<div>ثبت گروه</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="bg-white rounded-3xl p-6">
|
||||
<Input label="نام گروه" name="name" value={formik.values.name} onChange={formik.handleChange} error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ""} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUserGroup;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type FC } from "react";
|
||||
import Table from "@/components/Table";
|
||||
import Filters from "@/components/Filters";
|
||||
import { Add } from "iconsax-react";
|
||||
import Button from "@/components/Button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import {
|
||||
useDeleteUserGroup,
|
||||
useGetUserGroups,
|
||||
} from "./hooks/useUserGroupData";
|
||||
import { useUserGroupFilters } from "./hooks/useUserGroupFilters";
|
||||
import { getUserGroupTableColumns } from "./components/UserGroupTableColumns";
|
||||
import { useUserGroupFiltersFields } from "./components/UserGroupFiltersFields";
|
||||
|
||||
const UserGroupsList: FC = () => {
|
||||
const {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
limit,
|
||||
} = useUserGroupFilters();
|
||||
|
||||
const { data: groupsData, isLoading } = useGetUserGroups(apiParams);
|
||||
const { mutate: deleteUserGroup, isPending: isDeleting } =
|
||||
useDeleteUserGroup();
|
||||
|
||||
const groups = groupsData?.data || [];
|
||||
const columns = getUserGroupTableColumns({
|
||||
onDelete: (id: string) => deleteUserGroup(id),
|
||||
isDeleting,
|
||||
});
|
||||
const filterFields = useUserGroupFiltersFields();
|
||||
const totalPages = Math.ceil(groups.length / limit) || 1;
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-light">گروههای مشتری</h1>
|
||||
<Link to={Pages.customers.groupsAdd}>
|
||||
<Button className="w-fit px-6">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Add color="#fff" size={20} />
|
||||
<span>افزودن گروه</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Filters
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
initialValues={filters}
|
||||
searchField="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
data={groups}
|
||||
isloading={isLoading}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGroupsList;
|
||||
@@ -0,0 +1,98 @@
|
||||
import Button from "@/components/Button";
|
||||
import Input from "@/components/Input";
|
||||
import PageLoading from "@/components/PageLoading";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { useFormik } from "formik";
|
||||
import { TickCircle } from "iconsax-react";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import * as Yup from "yup";
|
||||
import { useGetUserGroup, useUpdateUserGroup } from "./hooks/useUserGroupData";
|
||||
import type { CreateUserGroupType } from "./types/Types";
|
||||
import GroupUsersSection from "./components/GroupUsersSection";
|
||||
|
||||
const UpdateUserGroup: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: groupData, isLoading } = useGetUserGroup(id || "");
|
||||
const { mutate: updateUserGroup, isPending } = useUpdateUserGroup();
|
||||
|
||||
const group = groupData?.data;
|
||||
|
||||
const formik = useFormik<CreateUserGroupType>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
name: Yup.string().required("نام گروه الزامی است"),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (!id) return;
|
||||
|
||||
updateUserGroup(
|
||||
{ id, params: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("گروه با موفقیت ویرایش شد");
|
||||
navigate(Pages.customers.groups);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (group) {
|
||||
formik.setValues({
|
||||
name: group.name || "",
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [group]);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="w-full mt-4 flex justify-center items-center">
|
||||
<div>گروه یافت نشد</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div className="text-lg font-light">ویرایش گروه مشتری</div>
|
||||
<Button className="px-5 w-fit" onClick={() => formik.handleSubmit()} isloading={isPending}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickCircle className="size-5" color="white" />
|
||||
<div>ثبت تغییرات</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white rounded-3xl p-6">
|
||||
<Input
|
||||
label="نام گروه"
|
||||
name="name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{id && <GroupUsersSection groupId={id} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateUserGroup;
|
||||
@@ -0,0 +1,144 @@
|
||||
import Button from "@/components/Button";
|
||||
import DefaulModal from "@/components/DefaulModal";
|
||||
import Input from "@/components/Input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { useGetUsers } from "@/pages/customers/hooks/useUsersData";
|
||||
import type { User } from "@/pages/customers/types/Types";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useAddUsersToGroup } from "../hooks/useUserGroupData";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
groupId: string;
|
||||
existingUserIds: string[];
|
||||
};
|
||||
|
||||
const AddUsersModal: FC<Props> = ({ open, onClose, groupId, existingUserIds }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
|
||||
const { data: usersData, isLoading } = useGetUsers({
|
||||
search: search || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const { mutate: addUsersToGroup, isPending } = useAddUsersToGroup();
|
||||
|
||||
const availableUsers = useMemo(() => {
|
||||
const users =
|
||||
usersData?.data?.map((userRestaurant) => userRestaurant.user) ?? [];
|
||||
return users.filter((user) => !existingUserIds.includes(user.id));
|
||||
}, [usersData?.data, existingUserIds]);
|
||||
|
||||
const handleToggle = (userId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUserIds((prev) => [...prev, userId]);
|
||||
} else {
|
||||
setSelectedUserIds((prev) => prev.filter((id) => id !== userId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUserIds(availableUsers.map((user) => user.id));
|
||||
} else {
|
||||
setSelectedUserIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = useMemo(() => {
|
||||
return (
|
||||
availableUsers.length > 0 &&
|
||||
availableUsers.every((user) => selectedUserIds.includes(user.id))
|
||||
);
|
||||
}, [availableUsers, selectedUserIds]);
|
||||
|
||||
const handleClose = () => {
|
||||
setSearch("");
|
||||
setSelectedUserIds([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedUserIds.length === 0) {
|
||||
toast.error("حداقل یک کاربر باید انتخاب شود");
|
||||
return;
|
||||
}
|
||||
|
||||
addUsersToGroup(
|
||||
{ groupId, params: { userIds: selectedUserIds } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("کاربران با موفقیت به گروه اضافه شدند");
|
||||
handleClose();
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getUserLabel = (user: User) => {
|
||||
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||
return fullName ? `${fullName} - ${user.phone}` : user.phone;
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={handleClose}
|
||||
isHeader
|
||||
title_header="افزودن کاربر به گروه"
|
||||
width={520}
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<Input
|
||||
variant="search"
|
||||
className="bg-[#EEF0F7]"
|
||||
placeholder="جستجو با نام یا شماره تماس"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="border border-border rounded-xl p-4 max-h-72 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4">در حال بارگذاری...</div>
|
||||
) : availableUsers.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4">کاربری یافت نشد</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} />
|
||||
<label className="text-sm cursor-pointer">همه</label>
|
||||
</div>
|
||||
{availableUsers.map((user) => (
|
||||
<div key={user.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(user.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<label className="text-sm cursor-pointer">{getUserLabel(user)}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="px-6 w-fit" onClick={handleSubmit} isloading={isPending}>
|
||||
افزودن کاربران
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddUsersModal;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ColumnType } from "@/components/types/TableTypes";
|
||||
import TrashWithConfrim from "@/components/TrashWithConfrim";
|
||||
import type { User } from "@/pages/customers/types/Types";
|
||||
|
||||
interface GetGroupUserTableColumnsParams {
|
||||
onDelete?: (userId: string) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const getGroupUserTableColumns = ({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: GetGroupUserTableColumnsParams = {}): ColumnType<User>[] => {
|
||||
return [
|
||||
{
|
||||
key: "fullName",
|
||||
title: "نام و نام خانوادگی",
|
||||
render: (item: User) => {
|
||||
const fullName = [item.firstName, item.lastName].filter(Boolean).join(" ").trim();
|
||||
return fullName || "بدون نام";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
title: "شماره تماس",
|
||||
render: (item: User) => item.phone || "-",
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "",
|
||||
render: (item: User) =>
|
||||
onDelete ? (
|
||||
<TrashWithConfrim
|
||||
onDelete={() => onDelete(item.id)}
|
||||
isloading={isDeleting}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import Button from "@/components/Button";
|
||||
import Table from "@/components/Table";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { Add } from "iconsax-react";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
useGetGroupUsers,
|
||||
useRemoveUserFromGroup,
|
||||
} from "../hooks/useUserGroupData";
|
||||
import AddUsersModal from "./AddUsersModal";
|
||||
import { getGroupUserTableColumns } from "./GroupUserTableColumns";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
};
|
||||
|
||||
const GroupUsersSection: FC<Props> = ({ groupId }) => {
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
||||
const { data: groupUsersData, isLoading } = useGetGroupUsers(groupId);
|
||||
const { mutate: removeUserFromGroup, isPending: isRemoving } =
|
||||
useRemoveUserFromGroup();
|
||||
|
||||
const users = groupUsersData?.data ?? [];
|
||||
const existingUserIds = useMemo(() => users.map((user) => user.id), [users]);
|
||||
|
||||
const columns = getGroupUserTableColumns({
|
||||
onDelete: (userId) => {
|
||||
removeUserFromGroup(
|
||||
{ groupId, userId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("کاربر از گروه حذف شد");
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
isDeleting: isRemoving,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="bg-white rounded-3xl p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm">کاربران گروه</div>
|
||||
<Button className="w-fit px-5" onClick={() => setIsAddModalOpen(true)}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Add color="#fff" size={18} />
|
||||
<span>افزودن کاربر</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table columns={columns} data={users} isloading={isLoading} />
|
||||
</div>
|
||||
|
||||
<AddUsersModal
|
||||
open={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
groupId={groupId}
|
||||
existingUserIds={existingUserIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupUsersSection;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import type { FieldType } from "@/components/Filters";
|
||||
|
||||
export const useUserGroupFiltersFields = (): FieldType[] => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "input",
|
||||
name: "search",
|
||||
placeholder: "جستجو",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ColumnType } from "@/components/types/TableTypes";
|
||||
import type { UserGroup } from "../types/Types";
|
||||
import TrashWithConfrim from "@/components/TrashWithConfrim";
|
||||
import { Eye } from "iconsax-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Pages } from "@/config/Pages";
|
||||
import { formatOptionalDate } from "@/helpers/func";
|
||||
|
||||
interface GetUserGroupTableColumnsParams {
|
||||
onDelete?: (id: string) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const getUserGroupTableColumns = ({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: GetUserGroupTableColumnsParams = {}): ColumnType<UserGroup>[] => {
|
||||
return [
|
||||
{
|
||||
key: "name",
|
||||
title: "نام گروه",
|
||||
render: (item: UserGroup) => item.name || "-",
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
title: "تاریخ ایجاد",
|
||||
render: (item: UserGroup) => formatOptionalDate(item.createdAt),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "",
|
||||
render: (item: UserGroup) => (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Link to={Pages.customers.groupsUpdate + item.id}>
|
||||
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
|
||||
</Link>
|
||||
{onDelete && (
|
||||
<TrashWithConfrim
|
||||
onDelete={() => onDelete(item.id)}
|
||||
isloading={isDeleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/UserGroupService";
|
||||
import type {
|
||||
AddUsersToGroupType,
|
||||
CreateUserGroupType,
|
||||
GetUserGroupsParams,
|
||||
} from "../types/Types";
|
||||
|
||||
export const useGetUserGroups = (params?: GetUserGroupsParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["user-groups", params],
|
||||
queryFn: () => api.getUserGroups(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateUserGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.createUserGroup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteUserGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.deleteUserGroup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetUserGroup = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["user-group", id],
|
||||
queryFn: () => api.getUserGroup(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUserGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, params }: { id: string; params: CreateUserGroupType }) =>
|
||||
api.updateUserGroup(id, params),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user-group", id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetGroupUsers = (groupId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["user-group-users", groupId],
|
||||
queryFn: () => api.getGroupUsers(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddUsersToGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
groupId,
|
||||
params,
|
||||
}: {
|
||||
groupId: string;
|
||||
params: AddUsersToGroupType;
|
||||
}) => api.addUsersToGroup(groupId, params),
|
||||
onSuccess: (_, { groupId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user-group-users", groupId],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveUserFromGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
groupId,
|
||||
userId,
|
||||
}: {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
}) => api.removeUserFromGroup(groupId, userId),
|
||||
onSuccess: (_, { groupId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user-group-users", groupId],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
export const useUserGroupFilters = () => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||
const [limit] = useState(DEFAULT_LIMIT);
|
||||
|
||||
const apiParams = useMemo(() => {
|
||||
const params: Record<string, unknown> = {
|
||||
page: currentPage,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
params.search = filters.search;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [filters, currentPage, limit]);
|
||||
|
||||
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(DEFAULT_PAGE);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
limit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import axios from "@/config/axios";
|
||||
import type {
|
||||
AddUsersToGroupType,
|
||||
CreateUserGroupType,
|
||||
GetUserGroupsParams,
|
||||
UserGroupResponse,
|
||||
UserGroupsResponse,
|
||||
UsersInGroupResponse,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getUserGroups = async (
|
||||
params?: GetUserGroupsParams
|
||||
): Promise<UserGroupsResponse> => {
|
||||
const { data } = await axios.get<UserGroupsResponse>("/admin/user-groups", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createUserGroup = async (params: CreateUserGroupType) => {
|
||||
const { data } = await axios.post("/admin/user-groups", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteUserGroup = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/user-groups/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getUserGroup = async (id: string): Promise<UserGroupResponse> => {
|
||||
const { data } = await axios.get<UserGroupResponse>(
|
||||
`/admin/user-groups/${id}`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateUserGroup = async (
|
||||
id: string,
|
||||
params: CreateUserGroupType
|
||||
) => {
|
||||
const { data } = await axios.patch(`/admin/user-groups/${id}`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getGroupUsers = async (
|
||||
groupId: string
|
||||
): Promise<UsersInGroupResponse> => {
|
||||
const { data } = await axios.get<UsersInGroupResponse>(
|
||||
`/admin/user-groups/${groupId}/users`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const addUsersToGroup = async (
|
||||
groupId: string,
|
||||
params: AddUsersToGroupType
|
||||
) => {
|
||||
const { data } = await axios.post(
|
||||
`/admin/user-groups/${groupId}/users`,
|
||||
params
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const removeUserFromGroup = async (groupId: string, userId: string) => {
|
||||
const { data } = await axios.delete(
|
||||
`/admin/user-groups/${groupId}/users/${userId}`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { User } from "@/pages/customers/types/Types";
|
||||
import type { IResponse } from "@/types/response.types";
|
||||
|
||||
export type UserGroup = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UserGroupsResponse = IResponse<UserGroup[]>;
|
||||
export type UserGroupResponse = IResponse<UserGroup>;
|
||||
|
||||
export type CreateUserGroupType = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type GetUserGroupsParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type UsersInGroupResponse = IResponse<User[]>;
|
||||
|
||||
export type AddUsersToGroupType = {
|
||||
userIds: string[];
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
import type { GetUsersParams } from "../types/Types";
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
export const useCustomerFilters = () => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||
const [limit] = useState(DEFAULT_LIMIT);
|
||||
|
||||
const apiParams = useMemo<GetUsersParams>(() => {
|
||||
const params: GetUsersParams = {
|
||||
page: currentPage,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
params.search = filters.search as string;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [filters, currentPage, limit]);
|
||||
|
||||
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(DEFAULT_PAGE);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
currentPage,
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
limit,
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,73 @@
|
||||
import * as api from "../service/UsersService";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
CreateUserAddressType,
|
||||
GetUsersParams,
|
||||
UpdateUserType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const useGetUsers = () => {
|
||||
export const useGetUsers = (params?: GetUsersParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: api.getUsers,
|
||||
queryKey: ["users", params],
|
||||
queryFn: () => api.getUsers(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetUserGroupsForUser = (userId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["user-groups-for-user", userId],
|
||||
queryFn: () => api.getUserGroups(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSearchUserByPhone = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.searchUserByPhone,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.createUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, params }: { userId: string; params: UpdateUserType }) =>
|
||||
api.updateUser(userId, params),
|
||||
onSuccess: (_, { userId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user-groups-for-user", userId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateUserAddress = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
userId,
|
||||
...params
|
||||
}: CreateUserAddressType & { userId: string }) =>
|
||||
api.createUserAddress(userId, params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useImportUsersFromExcel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.importUsersFromExcel,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,77 @@
|
||||
import axios from "@/config/axios";
|
||||
import type { GetCustomersResponse } from "@/pages/customers/types/Types";
|
||||
import type {
|
||||
CreateUserAddressResponse,
|
||||
CreateUserAddressType,
|
||||
CreateUserResponse,
|
||||
CreateUserType,
|
||||
GetCustomersResponse,
|
||||
GetUserGroupsForUserResponse,
|
||||
GetUsersParams,
|
||||
ImportUsersResponse,
|
||||
SearchUserByPhoneResponse,
|
||||
UpdateUserResponse,
|
||||
UpdateUserType,
|
||||
} from "@/pages/customers/types/Types";
|
||||
|
||||
export const getUsers = async () => {
|
||||
const { data } = await axios.get<GetCustomersResponse>(`/admin/users`);
|
||||
export const getUsers = async (params?: GetUsersParams) => {
|
||||
const { data } = await axios.get<GetCustomersResponse>(`/admin/users`, {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const searchUserByPhone = async (phone: string) => {
|
||||
const { data } = await axios.get<SearchUserByPhoneResponse>(
|
||||
"/admin/users/search-by-phone",
|
||||
{ params: { phone } },
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createUser = async (params: CreateUserType) => {
|
||||
const { data } = await axios.post<CreateUserResponse>("/admin/users", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateUser = async (userId: string, params: UpdateUserType) => {
|
||||
const { data } = await axios.patch<UpdateUserResponse>(
|
||||
`/admin/users/${userId}`,
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getUserGroups = async (userId: string) => {
|
||||
const { data } = await axios.get<GetUserGroupsForUserResponse>(
|
||||
`/admin/users/${userId}/groups`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createUserAddress = async (
|
||||
userId: string,
|
||||
params: CreateUserAddressType,
|
||||
) => {
|
||||
const { data } = await axios.post<CreateUserAddressResponse>(
|
||||
`/admin/users/${userId}/addresses`,
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const importUsersFromExcel = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const { data } = await axios.post<ImportUsersResponse>(
|
||||
`/admin/users/import`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return data.data;
|
||||
};
|
||||
|
||||
@@ -1,32 +1,153 @@
|
||||
import type { IResponse } from "@/types/response.types";
|
||||
import type { RowDataType } from "@/components/types/TableTypes";
|
||||
import type { UserGroup } from "@/pages/customers/groups/types/Types";
|
||||
|
||||
export interface Customer {
|
||||
export type User = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
lastName?: string;
|
||||
phone: string;
|
||||
birthDate: string;
|
||||
marriageDate: string;
|
||||
referrer: string | null;
|
||||
isActive: boolean;
|
||||
gender: boolean;
|
||||
wallet: number;
|
||||
points: number;
|
||||
}
|
||||
birthDate?: string;
|
||||
marriageDate?: string;
|
||||
referrer?: string | null;
|
||||
isActive?: boolean;
|
||||
gender?: boolean;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
|
||||
export type GetCustomersResponse = IResponse<Customer[]>;
|
||||
export type UserSavedAddress = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: string;
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
phone: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type UserWithAddresses = User & {
|
||||
addresses?: UserSavedAddress[];
|
||||
};
|
||||
|
||||
export type UserRestaurant = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: User;
|
||||
restaurant: string;
|
||||
orderCount: number;
|
||||
totalOrderAmount: number;
|
||||
};
|
||||
|
||||
export type PaginationMeta = {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export type GetUsersParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
orderBy?: string;
|
||||
order?: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type GetCustomersResponse = IResponse<UserRestaurant[]> & {
|
||||
meta: PaginationMeta;
|
||||
};
|
||||
|
||||
export type CustomerListRow = RowDataType & {
|
||||
fullName: string;
|
||||
phone: string;
|
||||
birthDate: string;
|
||||
marriageDate: string;
|
||||
wallet: string;
|
||||
points: string;
|
||||
orderCount: string;
|
||||
totalOrderAmount: string;
|
||||
status: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
birthDateRaw?: string;
|
||||
marriageDateRaw?: string;
|
||||
gender?: boolean;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
|
||||
export type ImportUserRowError = {
|
||||
row: number;
|
||||
phone?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ImportUsersResult = {
|
||||
message?: string;
|
||||
total: number;
|
||||
usersCreated: number;
|
||||
usersLinked: number;
|
||||
usersAlreadyLinked: number;
|
||||
errors: ImportUserRowError[];
|
||||
};
|
||||
|
||||
export type ImportUsersResponse = IResponse<ImportUsersResult>;
|
||||
|
||||
export type SearchUserByPhoneResponse = IResponse<UserWithAddresses>;
|
||||
|
||||
export type CreateUserType = {
|
||||
phone: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthDate?: string;
|
||||
marriageDate?: string;
|
||||
gender?: boolean;
|
||||
avatarUrl?: string;
|
||||
groupIds?: string[];
|
||||
};
|
||||
|
||||
export type CreateUserResult = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: User;
|
||||
orderCount: number;
|
||||
totalOrderAmount: number;
|
||||
};
|
||||
|
||||
export type CreateUserResponse = IResponse<CreateUserResult>;
|
||||
|
||||
export type CreateUserAddressType = {
|
||||
title: string;
|
||||
address: string;
|
||||
postalCode?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
phone: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type CreateUserAddressResponse = IResponse<UserSavedAddress>;
|
||||
|
||||
export type UpdateUserType = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
birthDate?: string;
|
||||
marriageDate?: string;
|
||||
gender?: boolean;
|
||||
avatarUrl?: string;
|
||||
groupIds?: string[];
|
||||
};
|
||||
|
||||
export type UpdateUserResponse = IResponse<User>;
|
||||
|
||||
export type GetUserGroupsForUserResponse = IResponse<UserGroup[]>;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const SAMPLE_ROWS = [
|
||||
{
|
||||
phone: '9123456789',
|
||||
firstName: 'علی',
|
||||
lastName: 'محمدی',
|
||||
gender: 'مرد',
|
||||
},
|
||||
{
|
||||
phone: '9129876543',
|
||||
firstName: 'سارا',
|
||||
lastName: 'احمدی',
|
||||
gender: 'زن',
|
||||
},
|
||||
];
|
||||
|
||||
export const downloadCustomerImportSampleExcel = () => {
|
||||
const worksheet = XLSX.utils.json_to_sheet(SAMPLE_ROWS);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'customers');
|
||||
|
||||
XLSX.writeFile(workbook, 'customer-import-sample.xlsx');
|
||||
};
|
||||
@@ -1,295 +1,135 @@
|
||||
import { type FC, useState } from 'react'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
import { toast } from 'react-toastify'
|
||||
import Input from '@/components/Input'
|
||||
import Select from '@/components/Select'
|
||||
import Textarea from '@/components/Textarea'
|
||||
import Button from '@/components/Button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import CreateFoodSidebar from './components/CreateFoodSidebar'
|
||||
import WeekDaysSection from './components/WeekDaysSection'
|
||||
import MealTimesSection from './components/MealTimesSection'
|
||||
import type { CreateFoodType } from './types/Types'
|
||||
import { useCreateFood, useGetCategories } from './hooks/useFoodData'
|
||||
import { useMultipleUpload } from '../uploader/hooks/useUploaderData'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import type { ErrorType } from '@/helpers/types'
|
||||
|
||||
|
||||
import Button from "@/components/Button";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { useFormik } from "formik";
|
||||
import { TickCircle } from "iconsax-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import * as Yup from "yup";
|
||||
import { useMultipleUpload } from "../uploader/hooks/useUploaderData";
|
||||
import BasicInfoSection from "./components/BasicInfoSection";
|
||||
import CreateFoodSidebar from "./components/CreateFoodSidebar";
|
||||
import FoodContentSection from "./components/FoodContentSection";
|
||||
import MealTimesSection from "./components/MealTimesSection";
|
||||
import ServiceOptionsSection from "./components/ServiceOptionsSection";
|
||||
import WeekDaysSection from "./components/WeekDaysSection";
|
||||
import { useCreateFood, useGetCategories } from "./hooks/useFoodData";
|
||||
import type { CreateFoodType } from "./types/Types";
|
||||
|
||||
const CreateFood: FC = () => {
|
||||
const { data } = useGetCategories();
|
||||
const [isActive, setIsActive] = useState<boolean>(true);
|
||||
const [isSpecial, setIsSpecial] = useState<boolean>(false);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const { mutate: createFood, isPending } = useCreateFood();
|
||||
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload();
|
||||
const navigate = useNavigate();
|
||||
const categories =
|
||||
data?.data?.map((category) => ({
|
||||
label: category.title,
|
||||
value: category.id,
|
||||
})) || [];
|
||||
|
||||
const { data } = useGetCategories();
|
||||
const [isActive, setIsActive] = useState<boolean>(true)
|
||||
const [isSpecial, setIsSpecial] = useState<boolean>(false)
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([])
|
||||
const { mutate: createFood, isPending } = useCreateFood()
|
||||
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload()
|
||||
const formik = useFormik<CreateFoodType>({
|
||||
initialValues: {
|
||||
title: "",
|
||||
desc: "",
|
||||
content: [],
|
||||
categoryId: "",
|
||||
price: 0,
|
||||
discount: 0,
|
||||
prepareTime: 0,
|
||||
weekDays: [0, 1, 2, 3, 4, 5, 6],
|
||||
mealTypes: ["breakfast", "lunch", "dinner"],
|
||||
pickupServe: true,
|
||||
inPlaceServe: true,
|
||||
isActive: true,
|
||||
images: [],
|
||||
isSpecialOffer: false,
|
||||
dailyStock: 0,
|
||||
availableStock: 0,
|
||||
order: 0,
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
title: Yup.string().required("نام غذا الزامی است"),
|
||||
categoryId: Yup.string().required("دستهبندی الزامی است"),
|
||||
price: Yup.number().required("قیمت الزامی است").min(0, "قیمت باید مثبت باشد"),
|
||||
prepareTime: Yup.number().required("زمان آمادهسازی الزامی است").min(0, "زمان آمادهسازی باید مثبت باشد"),
|
||||
discount: Yup.number().min(0, "تخفیف باید مثبت باشد"),
|
||||
dailyStock: Yup.number().required("موجودی روزانه الزامی است").min(0, "موجودی روزانه باید مثبت باشد"),
|
||||
availableStock: Yup.number().required("موجودی فعلی الزامی است").min(0, "موجودی فعلی باید مثبت باشد"),
|
||||
content: Yup.array().of(Yup.string()),
|
||||
weekDays: Yup.array().of(Yup.number()),
|
||||
mealTypes: Yup.array().of(Yup.string()),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
const submitFood = (imageUrls: string[] = []) => {
|
||||
const foodData: CreateFoodType = {
|
||||
...values,
|
||||
isActive,
|
||||
isSpecialOffer: isSpecial,
|
||||
images: imageUrls,
|
||||
content: values.content.filter((item) => item && item.trim().length > 0),
|
||||
};
|
||||
|
||||
const categories = data?.data?.map(category => ({
|
||||
label: category.title,
|
||||
value: category.id
|
||||
})) || []
|
||||
createFood(foodData, {
|
||||
onSuccess: () => {
|
||||
toast.success("غذا با موفقیت ایجاد شد");
|
||||
navigate(-1);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(error?.response?.data?.error?.message[0] || "خطا در ایجاد غذا");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formik = useFormik<CreateFoodType>({
|
||||
initialValues: {
|
||||
title: '',
|
||||
desc: '',
|
||||
content: [],
|
||||
categoryId: '',
|
||||
price: 0,
|
||||
discount: 0,
|
||||
prepareTime: 0,
|
||||
weekDays: [0, 1, 2, 3, 4, 5, 6],
|
||||
mealTypes: ['breakfast', 'lunch', 'dinner'],
|
||||
pickupServe: true,
|
||||
inPlaceServe: true,
|
||||
isActive: true,
|
||||
images: [],
|
||||
isSpecialOffer: false,
|
||||
dailyStock: 0,
|
||||
order: 0
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
title: Yup.string().required('نام غذا الزامی است'),
|
||||
categoryId: Yup.string().required('دستهبندی الزامی است'),
|
||||
price: Yup.number().required('قیمت الزامی است').min(0, 'قیمت باید مثبت باشد'),
|
||||
prepareTime: Yup.number().required('زمان آمادهسازی الزامی است').min(0, 'زمان آمادهسازی باید مثبت باشد'),
|
||||
discount: Yup.number().min(0, 'تخفیف باید مثبت باشد'),
|
||||
dailyStock: Yup.number().required('موجودی روزانه الزامی است').min(0, 'موجودی روزانه باید مثبت باشد'),
|
||||
content: Yup.array().of(Yup.string()),
|
||||
weekDays: Yup.array().of(Yup.number()),
|
||||
mealTypes: Yup.array().of(Yup.string())
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
const submitFood = (imageUrls: string[] = []) => {
|
||||
const foodData: CreateFoodType = {
|
||||
...values,
|
||||
isActive,
|
||||
isSpecialOffer: isSpecial,
|
||||
images: imageUrls,
|
||||
content: values.content.filter(item => item && item.trim().length > 0)
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
multipleUpload(imageFiles, {
|
||||
onSuccess: (response) => {
|
||||
const imageUrls = response?.data?.map((item) => item.url) || [];
|
||||
submitFood(imageUrls);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(error?.response?.data?.error?.message[0] || "خطا در آپلود تصاویر");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
submitFood();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
createFood(foodData, {
|
||||
onSuccess: () => {
|
||||
toast.success('غذا با موفقیت ایجاد شد')
|
||||
window.location.href = Pages.foods.list
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(error?.response?.data?.error?.message[0] || 'خطا در ایجاد غذا')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
multipleUpload(imageFiles, {
|
||||
onSuccess: (response) => {
|
||||
const imageUrls = response?.data?.map(item => item.url) || []
|
||||
submitFood(imageUrls)
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(error?.response?.data?.error?.message[0] || 'خطا در آپلود تصاویر')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
submitFood()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='w-full mt-4'>
|
||||
<div className='flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4'>
|
||||
<div className='text-lg font-light'>
|
||||
غذای جدید
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
className='px-5 w-full sm:w-auto'
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isloading={isPending || isUploading}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TickCircle className='size-5' color='white' />
|
||||
<div>ثبت غذا</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col lg:flex-row gap-6 mt-6'>
|
||||
<div className='flex-1'>
|
||||
<div className='bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl'>
|
||||
<Input
|
||||
label='نام غذا'
|
||||
name='title'
|
||||
placeholder=''
|
||||
value={formik.values.title}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
|
||||
/>
|
||||
|
||||
<div className='mt-5'>
|
||||
<Select
|
||||
items={categories}
|
||||
label='دسته بندی'
|
||||
placeholder='انتخاب کنید'
|
||||
name='categoryId'
|
||||
value={formik.values.categoryId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
formik.setFieldValue('categoryId', value)
|
||||
}}
|
||||
error_text={formik.touched.categoryId && formik.errors.categoryId ? String(formik.errors.categoryId) : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-5'>
|
||||
<Input
|
||||
name='price'
|
||||
label='قیمت'
|
||||
placeholder='تومان'
|
||||
type='number'
|
||||
seprator={true}
|
||||
value={formik.values.price}
|
||||
onChange={(e) => formik.setFieldValue('price', Number(e.target.value))}
|
||||
error_text={formik.touched.price && formik.errors.price ? formik.errors.price : ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
name='discount'
|
||||
label='تخفیف'
|
||||
placeholder='تومان'
|
||||
type='number'
|
||||
seprator={true}
|
||||
value={formik.values.discount}
|
||||
onChange={(e) => formik.setFieldValue('discount', Number(e.target.value))}
|
||||
error_text={formik.touched.discount && formik.errors.discount ? formik.errors.discount : ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
name='prepareTime'
|
||||
label='زمان آماده سازی'
|
||||
placeholder='دقیقه'
|
||||
type='number'
|
||||
value={formik.values.prepareTime}
|
||||
onChange={(e) => formik.setFieldValue('prepareTime', Number(e.target.value))}
|
||||
error_text={formik.touched.prepareTime && formik.errors.prepareTime ? formik.errors.prepareTime : ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
name='dailyStock'
|
||||
label='موجودی روزانه'
|
||||
placeholder='عدد'
|
||||
type='number'
|
||||
seprator={true}
|
||||
value={formik.values.dailyStock}
|
||||
onChange={(e) => formik.setFieldValue('dailyStock', Number(e.target.value))}
|
||||
error_text={formik.touched.dailyStock && formik.errors.dailyStock ? formik.errors.dailyStock : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<Textarea
|
||||
name='desc'
|
||||
label='توضیحات غذا'
|
||||
placeholder=''
|
||||
value={formik.values.desc}
|
||||
onChange={formik.handleChange}
|
||||
error_text={formik.touched.desc && formik.errors.desc ? formik.errors.desc : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<Input
|
||||
name='order'
|
||||
label='اولویت'
|
||||
placeholder='عدد'
|
||||
type='number'
|
||||
value={formik.values.order}
|
||||
onChange={(e) => formik.setFieldValue('order', Number(e.target.value))}
|
||||
error_text={formik.touched.order && formik.errors.order ? formik.errors.order : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<div className='text-sm mb-2'>محتوای غذا</div>
|
||||
<div className='space-y-2'>
|
||||
{formik.values.content.map((item, index) => (
|
||||
<div key={index} className='flex gap-2 items-center'>
|
||||
<Input
|
||||
placeholder='متن محتوا'
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newContent = [...formik.values.content]
|
||||
newContent[index] = e.target.value
|
||||
formik.setFieldValue('content', newContent)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
className='px-3 bg-red-500 hover:bg-red-600 w-auto'
|
||||
onClick={() => {
|
||||
const newContent = formik.values.content.filter((_, i) => i !== index)
|
||||
formik.setFieldValue('content', newContent)
|
||||
}}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type='button'
|
||||
className='w-full bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
onClick={() => {
|
||||
formik.setFieldValue('content', [...formik.values.content, ''])
|
||||
}}
|
||||
>
|
||||
افزودن محتوا
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MealTimesSection formik={formik} />
|
||||
<WeekDaysSection formik={formik} />
|
||||
|
||||
<div className='mt-6'>
|
||||
<div className='text-sm mb-3'>گزینههای سرویس</div>
|
||||
<div className='flex gap-6 flex-wrap'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={formik.values.pickupServe}
|
||||
onCheckedChange={(checked) => formik.setFieldValue('pickupServe', checked)}
|
||||
/>
|
||||
<label className='text-xs cursor-pointer'> بیرونبر</label>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={formik.values.inPlaceServe}
|
||||
onCheckedChange={(checked) => formik.setFieldValue('inPlaceServe', checked)}
|
||||
/>
|
||||
<label className='text-xs cursor-pointer'>سرو در محل</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full lg:w-auto'>
|
||||
<CreateFoodSidebar
|
||||
isActive={isActive}
|
||||
isSpecial={isSpecial}
|
||||
onChangeActive={setIsActive}
|
||||
onChangeSpecial={setIsSpecial}
|
||||
onChangeImages={setImageFiles}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="w-full mt-4">
|
||||
<div className="flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4">
|
||||
<div className="text-lg font-light">غذای جدید</div>
|
||||
<div>
|
||||
<Button className="px-5 w-full sm:w-auto" onClick={() => formik.handleSubmit()} isloading={isPending || isUploading}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickCircle className="size-5" color="white" />
|
||||
<div>ثبت غذا</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
export default CreateFood
|
||||
<div className="flex flex-col lg:flex-row gap-6 mt-6">
|
||||
<div className="flex-1">
|
||||
<div className="bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl">
|
||||
<BasicInfoSection formik={formik} categories={categories} />
|
||||
<FoodContentSection formik={formik} />
|
||||
<MealTimesSection formik={formik} />
|
||||
<WeekDaysSection formik={formik} />
|
||||
<ServiceOptionsSection formik={formik} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-auto">
|
||||
<CreateFoodSidebar isActive={isActive} isSpecial={isSpecial} onChangeActive={setIsActive} onChangeSpecial={setIsSpecial} onChangeImages={setImageFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateFood;
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { type FC } from 'react'
|
||||
import { type FC, useCallback, useState } from 'react'
|
||||
import Table from '@/components/Table'
|
||||
import Filters from '@/components/Filters'
|
||||
import { Add } from 'iconsax-react'
|
||||
import Button from '@/components/Button'
|
||||
import { useGetFoods, useDeleteFood } from './hooks/useFoodData'
|
||||
import { useGetFoods, useDeleteFood, useCloneFood, useSetStockForFood } from './hooks/useFoodData'
|
||||
import { useFoodFilters } from './hooks/useFoodFilters'
|
||||
import { getFoodTableColumns } from './components/FoodTableColumns'
|
||||
import { useFoodFiltersFields } from './components/FoodFiltersFields'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import type { ActionType } from '@/components/types/TableTypes'
|
||||
import type { Food } from './types/Types'
|
||||
import StockUpdateModal from './components/StockUpdateModal'
|
||||
import ModalConfrim from '@/components/ModalConfrim'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
|
||||
const FoodsList: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [stockModalFood, setStockModalFood] = useState<Food | null>(null)
|
||||
const [deleteConfirmFood, setDeleteConfirmFood] = useState<Food | null>(null)
|
||||
const [cloneConfirmFood, setCloneConfirmFood] = useState<Food | null>(null)
|
||||
|
||||
const {
|
||||
filters,
|
||||
currentPage,
|
||||
@@ -21,18 +32,79 @@ const FoodsList: FC = () => {
|
||||
|
||||
const { data: foodsData, isLoading } = useGetFoods(apiParams, filters)
|
||||
const { mutate: deleteFood, isPending: isDeleting } = useDeleteFood()
|
||||
|
||||
|
||||
const { mutate: cloneFood, isPending: isCloning } = useCloneFood()
|
||||
const { mutate: setStock, isPending: isUpdatingStock } = useSetStockForFood()
|
||||
|
||||
const foods = foodsData?.data || []
|
||||
const columns = getFoodTableColumns({
|
||||
onDelete: (id: string) => deleteFood(id),
|
||||
isDeleting
|
||||
})
|
||||
const columns = getFoodTableColumns()
|
||||
const filterFields = useFoodFiltersFields()
|
||||
|
||||
const totalPages = foodsData?.meta?.totalPages || 1
|
||||
|
||||
const handleRowActions = useCallback((item: Food): ActionType[] => {
|
||||
return [
|
||||
{
|
||||
label: 'ویرایش',
|
||||
onClick: () => navigate(Pages.foods.update + item.id),
|
||||
},
|
||||
{
|
||||
label: 'بروزرسانی موجودی',
|
||||
onClick: () => setStockModalFood(item),
|
||||
},
|
||||
{
|
||||
label: 'دابلیکیت کردن',
|
||||
onClick: () => setCloneConfirmFood(item),
|
||||
},
|
||||
{
|
||||
label: 'حذف',
|
||||
onClick: () => setDeleteConfirmFood(item),
|
||||
},
|
||||
]
|
||||
}, [navigate])
|
||||
|
||||
const handleStockSubmit = (foodId: string, totalStock: number, availableStock: number) => {
|
||||
setStock(
|
||||
{ foodId, params: { totalStock, availableStock } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('موجودی با موفقیت بروزرسانی شد')
|
||||
setStockModalFood(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteConfirmFood) return
|
||||
|
||||
deleteFood(deleteConfirmFood.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('غذا با موفقیت حذف شد')
|
||||
setDeleteConfirmFood(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloneConfirm = () => {
|
||||
if (!cloneConfirmFood) return
|
||||
|
||||
cloneFood(cloneConfirmFood.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('غذا با موفقیت دابلیکیت شد')
|
||||
setCloneConfirmFood(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
@@ -55,6 +127,7 @@ const FoodsList: FC = () => {
|
||||
onChange={handleFiltersChange}
|
||||
initialValues={filters}
|
||||
searchField="search"
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -62,14 +135,39 @@ const FoodsList: FC = () => {
|
||||
columns={columns}
|
||||
data={foods}
|
||||
isloading={isLoading}
|
||||
rowActions={handleRowActions}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
<StockUpdateModal
|
||||
food={stockModalFood}
|
||||
open={!!stockModalFood}
|
||||
onClose={() => setStockModalFood(null)}
|
||||
onSubmit={handleStockSubmit}
|
||||
isLoading={isUpdatingStock}
|
||||
/>
|
||||
|
||||
<ModalConfrim
|
||||
isOpen={!!deleteConfirmFood}
|
||||
close={() => setDeleteConfirmFood(null)}
|
||||
onConfrim={handleDeleteConfirm}
|
||||
isloading={isDeleting}
|
||||
label={deleteConfirmFood ? `آیا از حذف «${deleteConfirmFood.title}» اطمینان دارید؟` : undefined}
|
||||
/>
|
||||
|
||||
<ModalConfrim
|
||||
isOpen={!!cloneConfirmFood}
|
||||
close={() => setCloneConfirmFood(null)}
|
||||
onConfrim={handleCloneConfirm}
|
||||
isloading={isCloning}
|
||||
label={cloneConfirmFood ? `آیا از دابلیکیت کردن «${cloneConfirmFood.title}» اطمینان دارید؟` : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FoodsList
|
||||
export default FoodsList
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import { type FC, useState, useEffect } from 'react'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import Button from '@/components/Button'
|
||||
import CreateFoodSidebar from './components/CreateFoodSidebar'
|
||||
import BasicInfoSection from './components/BasicInfoSection'
|
||||
import FoodContentSection from './components/FoodContentSection'
|
||||
import MealTimesSection from './components/MealTimesSection'
|
||||
import WeekDaysSection from './components/WeekDaysSection'
|
||||
import ServiceOptionsSection from './components/ServiceOptionsSection'
|
||||
import type { CreateFoodType } from './types/Types'
|
||||
import { useUpdateFood, useGetFoodDetails, useGetCategories } from './hooks/useFoodData'
|
||||
import { useMultipleUpload } from '../uploader/hooks/useUploaderData'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import type { ErrorType } from '@/helpers/types'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import Button from "@/components/Button";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import type { ErrorType } from "@/helpers/types";
|
||||
import { useFormik } from "formik";
|
||||
import { TickCircle } from "iconsax-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import * as Yup from "yup";
|
||||
import { useMultipleUpload } from "../uploader/hooks/useUploaderData";
|
||||
import BasicInfoSection from "./components/BasicInfoSection";
|
||||
import CreateFoodSidebar from "./components/CreateFoodSidebar";
|
||||
import FoodContentSection from "./components/FoodContentSection";
|
||||
import MealTimesSection from "./components/MealTimesSection";
|
||||
import ServiceOptionsSection from "./components/ServiceOptionsSection";
|
||||
import WeekDaysSection from "./components/WeekDaysSection";
|
||||
import { useGetCategories, useGetFoodDetails, useUpdateFood } from "./hooks/useFoodData";
|
||||
import type { CreateFoodType } from "./types/Types";
|
||||
|
||||
const UpdateFood: FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: foodData, isLoading } = useGetFoodDetails(id || '')
|
||||
const { data: categoriesData } = useGetCategories()
|
||||
const [isActive, setIsActive] = useState<boolean>(true)
|
||||
const [isSpecial, setIsSpecial] = useState<boolean>(false)
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([])
|
||||
const [existingImages, setExistingImages] = useState<string[]>([])
|
||||
const { mutate: updateFood, isPending } = useUpdateFood()
|
||||
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload()
|
||||
const navigate = useNavigate()
|
||||
const categories = categoriesData?.data?.map(category => ({
|
||||
label: category.title,
|
||||
value: category.id
|
||||
})) || []
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: foodData, isLoading } = useGetFoodDetails(id || "");
|
||||
const { data: categoriesData } = useGetCategories();
|
||||
const [isActive, setIsActive] = useState<boolean>(true);
|
||||
const [isSpecial, setIsSpecial] = useState<boolean>(false);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [existingImages, setExistingImages] = useState<string[]>([]);
|
||||
const { mutate: updateFood, isPending } = useUpdateFood();
|
||||
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload();
|
||||
const navigate = useNavigate();
|
||||
const categories =
|
||||
categoriesData?.data?.map((category) => ({
|
||||
label: category.title,
|
||||
value: category.id,
|
||||
})) || [];
|
||||
|
||||
const food = foodData?.data
|
||||
const food = foodData?.data;
|
||||
|
||||
const formik = useFormik<CreateFoodType>({
|
||||
initialValues: {
|
||||
title: '',
|
||||
desc: '',
|
||||
title: "",
|
||||
desc: "",
|
||||
content: [],
|
||||
categoryId: '',
|
||||
categoryId: "",
|
||||
price: 0,
|
||||
discount: 0,
|
||||
prepareTime: 0,
|
||||
@@ -53,69 +53,74 @@ const UpdateFood: FC = () => {
|
||||
images: [],
|
||||
isSpecialOffer: false,
|
||||
dailyStock: 0,
|
||||
order: 0
|
||||
availableStock: 0,
|
||||
order: 0,
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
title: Yup.string().required('نام غذا الزامی است'),
|
||||
categoryId: Yup.string().required('دستهبندی الزامی است'),
|
||||
price: Yup.number().required('قیمت الزامی است').min(0, 'قیمت باید مثبت باشد'),
|
||||
prepareTime: Yup.number().required('زمان آمادهسازی الزامی است').min(0, 'زمان آمادهسازی باید مثبت باشد'),
|
||||
discount: Yup.number().min(0, 'تخفیف باید مثبت باشد'),
|
||||
dailyStock: Yup.number().required('موجودی روزانه الزامی است').min(0, 'موجودی روزانه باید مثبت باشد'),
|
||||
title: Yup.string().required("نام غذا الزامی است"),
|
||||
categoryId: Yup.string().required("دستهبندی الزامی است"),
|
||||
price: Yup.number().required("قیمت الزامی است").min(0, "قیمت باید مثبت باشد"),
|
||||
prepareTime: Yup.number().required("زمان آمادهسازی الزامی است").min(0, "زمان آمادهسازی باید مثبت باشد"),
|
||||
discount: Yup.number().min(0, "تخفیف باید مثبت باشد"),
|
||||
dailyStock: Yup.number().required("موجودی روزانه الزامی است").min(0, "موجودی روزانه باید مثبت باشد"),
|
||||
availableStock: Yup.number().required("موجودی فعلی الزامی است").min(0, "موجودی فعلی باید مثبت باشد"),
|
||||
content: Yup.array().of(Yup.string()),
|
||||
weekDays: Yup.array().of(Yup.number()),
|
||||
mealTypes: Yup.array().of(Yup.string())
|
||||
mealTypes: Yup.array().of(Yup.string()),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (!id) {
|
||||
toast.error('شناسه غذا یافت نشد')
|
||||
return
|
||||
toast.error("شناسه غذا یافت نشد");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitFood = (newImageUrls: string[] = []) => {
|
||||
const allImages = [...values.images, ...newImageUrls]
|
||||
const allImages = [...values.images, ...newImageUrls];
|
||||
const foodData: CreateFoodType = {
|
||||
...values,
|
||||
isActive,
|
||||
isSpecialOffer: isSpecial,
|
||||
images: allImages,
|
||||
content: values.content.filter(item => item && item.trim().length > 0)
|
||||
}
|
||||
content: values.content.filter((item) => item && item.trim().length > 0),
|
||||
};
|
||||
|
||||
updateFood({ id, params: foodData }, {
|
||||
onSuccess: () => {
|
||||
toast.success('غذا با موفقیت بهروزرسانی شد')
|
||||
navigate(Pages.foods.list)
|
||||
updateFood(
|
||||
{ id, params: foodData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("غذا با موفقیت بهروزرسانی شد");
|
||||
navigate(-1);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
multipleUpload(imageFiles, {
|
||||
onSuccess: (response) => {
|
||||
const imageUrls = response?.data?.map(item => item.url) || []
|
||||
submitFood(imageUrls)
|
||||
const imageUrls = response?.data?.map((item) => item.url) || [];
|
||||
submitFood(imageUrls);
|
||||
},
|
||||
onError: (error: ErrorType) => {
|
||||
toast.error(error?.response?.data?.error?.message[0] || 'خطا در آپلود تصاویر')
|
||||
}
|
||||
})
|
||||
toast.error(error?.response?.data?.error?.message[0] || "خطا در آپلود تصاویر");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
submitFood()
|
||||
submitFood();
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (food) {
|
||||
formik.setValues({
|
||||
title: food.title || '',
|
||||
desc: food.desc || '',
|
||||
title: food.title || "",
|
||||
desc: food.desc || "",
|
||||
content: food.content || [],
|
||||
categoryId: food.category?.id || '',
|
||||
categoryId: food.category?.id || "",
|
||||
price: food.price || 0,
|
||||
discount: food.discount || 0,
|
||||
prepareTime: food.prepareTime || 0,
|
||||
@@ -127,59 +132,54 @@ const UpdateFood: FC = () => {
|
||||
images: food.images || [],
|
||||
isSpecialOffer: food.isSpecialOffer || false,
|
||||
dailyStock: food.inventory?.totalStock || 0,
|
||||
order: food.order || 0
|
||||
})
|
||||
setIsActive(food.isActive || false)
|
||||
setIsSpecial(food.isSpecialOffer || false)
|
||||
setExistingImages(food.images || [])
|
||||
availableStock: food.inventory?.availableStock || 0,
|
||||
order: food.order || 0,
|
||||
});
|
||||
setIsActive(food.isActive || false);
|
||||
setIsSpecial(food.isSpecialOffer || false);
|
||||
setExistingImages(food.images || []);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [food])
|
||||
}, [food]);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setFieldValue('images', existingImages)
|
||||
formik.setFieldValue("images", existingImages);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [existingImages])
|
||||
}, [existingImages]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='w-full mt-4 flex justify-center items-center'>
|
||||
<div className="w-full mt-4 flex justify-center items-center">
|
||||
<div>در حال بارگذاری...</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!food) {
|
||||
return (
|
||||
<div className='w-full mt-4 flex justify-center items-center'>
|
||||
<div className="w-full mt-4 flex justify-center items-center">
|
||||
<div>غذا یافت نشد</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full mt-4'>
|
||||
<div className='flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4'>
|
||||
<div className='text-lg font-light'>
|
||||
ویرایش غذا
|
||||
</div>
|
||||
<div className="w-full mt-4">
|
||||
<div className="flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4">
|
||||
<div className="text-lg font-light">ویرایش غذا</div>
|
||||
<div>
|
||||
<Button
|
||||
className='px-5 w-full sm:w-auto'
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isloading={isPending || isUploading}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TickCircle className='size-5' color='white' />
|
||||
<Button className="px-5 w-full sm:w-auto" onClick={() => formik.handleSubmit()} isloading={isPending || isUploading}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickCircle className="size-5" color="white" />
|
||||
<div>ذخیره تغییرات</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col lg:flex-row gap-6 mt-6'>
|
||||
<div className='flex-1'>
|
||||
<div className='bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl'>
|
||||
<div className="flex flex-col lg:flex-row gap-6 mt-6">
|
||||
<div className="flex-1">
|
||||
<div className="bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl">
|
||||
<BasicInfoSection formik={formik} categories={categories} />
|
||||
<FoodContentSection formik={formik} />
|
||||
<MealTimesSection formik={formik} />
|
||||
@@ -188,7 +188,7 @@ const UpdateFood: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full lg:w-auto'>
|
||||
<div className="w-full lg:w-auto">
|
||||
<CreateFoodSidebar
|
||||
isActive={isActive}
|
||||
isSpecial={isSpecial}
|
||||
@@ -201,7 +201,7 @@ const UpdateFood: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateFood
|
||||
export default UpdateFood;
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
import { type FC } from 'react'
|
||||
import type { FormikProps } from 'formik'
|
||||
import { Magicpen } from 'iconsax-react'
|
||||
import Input from '@/components/Input'
|
||||
import Select from '@/components/Select'
|
||||
import Textarea from '@/components/Textarea'
|
||||
import Button from '@/components/Button'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { toast } from 'react-toastify'
|
||||
import type { CreateFoodType } from '../types/Types'
|
||||
import { useGenerateFoodDescription } from '../hooks/useFoodData'
|
||||
|
||||
type BasicInfoSectionProps = {
|
||||
formik: FormikProps<CreateFoodType>
|
||||
categories: Array<{ label: string; value: string }>
|
||||
}
|
||||
|
||||
const parseDescription = (data: { answer?: string } | undefined): string => {
|
||||
return data?.answer?.trim() || ''
|
||||
}
|
||||
|
||||
const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) => {
|
||||
const { mutate: generateDescription, isPending: isGeneratingDesc } = useGenerateFoodDescription()
|
||||
|
||||
const handleGenerateDescription = () => {
|
||||
const foodName = formik.values.title.trim()
|
||||
if (!foodName) {
|
||||
toast.error('لطفاً ابتدا نام کامل غذا را وارد کنید')
|
||||
return
|
||||
}
|
||||
|
||||
generateDescription(
|
||||
{ foodName },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
const description = parseDescription(response.data)
|
||||
if (!description) {
|
||||
toast.error('توضیحی از هوش مصنوعی دریافت نشد')
|
||||
return
|
||||
}
|
||||
formik.setFieldValue('desc', description)
|
||||
toast.success('توضیحات با موفقیت تولید شد')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
@@ -37,7 +74,7 @@ const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) =>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-5'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mt-5'>
|
||||
<Input
|
||||
name='price'
|
||||
label='قیمت'
|
||||
@@ -80,12 +117,36 @@ const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) =>
|
||||
onChange={(e) => formik.setFieldValue('dailyStock', Number(e.target.value))}
|
||||
error_text={formik.touched.dailyStock && formik.errors.dailyStock ? formik.errors.dailyStock : ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
name='availableStock'
|
||||
label='موجودی فعلی'
|
||||
placeholder='عدد'
|
||||
type='number'
|
||||
seprator={true}
|
||||
value={formik.values.availableStock}
|
||||
onChange={(e) => formik.setFieldValue('availableStock', Number(e.target.value))}
|
||||
error_text={formik.touched.availableStock && formik.errors.availableStock ? formik.errors.availableStock : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<div className='flex items-center justify-between mb-1'>
|
||||
<div className='text-sm'>توضیحات غذا</div>
|
||||
<Button
|
||||
type='button'
|
||||
className='w-auto h-8 px-3 bg-transparent text-primary border border-primary text-xs gap-1.5'
|
||||
onClick={handleGenerateDescription}
|
||||
isloading={isGeneratingDesc}
|
||||
colorLoading='black'
|
||||
disabled={!formik.values.title.trim() || isGeneratingDesc}
|
||||
>
|
||||
<Magicpen size={16} color='currentColor' />
|
||||
تولید با AI
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
name='desc'
|
||||
label='توضیحات غذا'
|
||||
placeholder=''
|
||||
value={formik.values.desc}
|
||||
onChange={formik.handleChange}
|
||||
@@ -109,4 +170,3 @@ const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) =>
|
||||
}
|
||||
|
||||
export default BasicInfoSection
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type FC, useState } from 'react'
|
||||
import { Add, Image } from 'iconsax-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { Add, DocumentUpload, Image } from 'iconsax-react'
|
||||
import Input from '@/components/Input'
|
||||
import SwitchComponent from '@/components/Switch'
|
||||
import Button from '@/components/Button'
|
||||
@@ -29,6 +29,8 @@ const CategoryForm: FC<Props> = ({
|
||||
formik,
|
||||
isActive,
|
||||
onActiveChange,
|
||||
onIconChange,
|
||||
iconFiles = [],
|
||||
isSubmitting,
|
||||
isEditing = false,
|
||||
onIconUrlChange,
|
||||
@@ -37,6 +39,17 @@ const CategoryForm: FC<Props> = ({
|
||||
const { data: iconsData } = useGetIcons()
|
||||
const icons = iconsData?.data || []
|
||||
const [isIconModalOpen, setIsIconModalOpen] = useState(false)
|
||||
const [uploadPreviewUrl, setUploadPreviewUrl] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (iconFiles.length > 0) {
|
||||
const url = URL.createObjectURL(iconFiles[0])
|
||||
setUploadPreviewUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}
|
||||
setUploadPreviewUrl('')
|
||||
}, [iconFiles])
|
||||
|
||||
const handleIconSelect = (url: string) => {
|
||||
if (onIconUrlChange) {
|
||||
@@ -45,6 +58,18 @@ const CategoryForm: FC<Props> = ({
|
||||
setIsIconModalOpen(false)
|
||||
}
|
||||
|
||||
const handleUploadIconChange = (files: File[]) => {
|
||||
onIconChange?.(files)
|
||||
formik.setFieldValue('icon', files)
|
||||
}
|
||||
|
||||
const handleClearUploadedIcon = () => {
|
||||
handleUploadIconChange([])
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[300px] bg-white rounded-3xl p-4 lg:p-6 h-fit lg:sticky top-4 lg:top-6">
|
||||
<h2 className="font-light mb-4 lg:mb-6 text-sm lg:text-base">{isEditing ? 'ویرایش دسته بندی' : 'اضافه کردن دسته بندی'}</h2>
|
||||
@@ -88,6 +113,51 @@ const CategoryForm: FC<Props> = ({
|
||||
<Image size={18} color="#fff" className="lg:w-5 lg:h-5" />
|
||||
<span>انتخاب آیکون</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files ? Array.from(e.target.files) : []
|
||||
if (files.length > 0) {
|
||||
handleUploadIconChange(files)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full max-h-10 lg:h-12 flex items-center justify-center gap-2 text-xs lg:text-sm mt-2 bg-transparent text-primary border-primary border"
|
||||
>
|
||||
<DocumentUpload size={18} color="currentColor" className="lg:w-5 lg:h-5" />
|
||||
<span>آپلود آیکون</span>
|
||||
</Button>
|
||||
{iconFiles.length > 0 && uploadPreviewUrl && (
|
||||
<div className="mt-3 flex items-center gap-2 lg:gap-3 p-2 lg:p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="w-10 h-10 lg:w-12 lg:h-12 flex items-center justify-center bg-white rounded-lg border border-gray-200 p-1.5 lg:p-2 flex-shrink-0">
|
||||
<img
|
||||
src={uploadPreviewUrl}
|
||||
alt="uploaded icon"
|
||||
className="w-full h-full object-contain max-w-full max-h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] lg:text-xs text-gray-500 mb-0.5 lg:mb-1">آیکون آپلود شده</div>
|
||||
<div className="text-[10px] lg:text-xs text-gray-400 truncate">
|
||||
{iconFiles[0].name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearUploadedIcon}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors text-lg lg:text-xl leading-none flex-shrink-0 w-5 h-5 lg:w-6 lg:h-6 flex items-center justify-center"
|
||||
title="حذف آیکون"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedIconUrl && (
|
||||
<div className="mt-3 flex items-center gap-2 lg:gap-3 p-2 lg:p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="w-10 h-10 lg:w-12 lg:h-12 flex items-center justify-center bg-white rounded-lg border border-gray-200 p-1.5 lg:p-2 flex-shrink-0">
|
||||
|
||||
@@ -1,18 +1,70 @@
|
||||
import { type FC } from 'react'
|
||||
import type { FormikProps } from 'formik'
|
||||
import { Magicpen } from 'iconsax-react'
|
||||
import Input from '@/components/Input'
|
||||
import Button from '@/components/Button'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { toast } from 'react-toastify'
|
||||
import type { CreateFoodType } from '../types/Types'
|
||||
import { useGenerateFoodContent } from '../hooks/useFoodData'
|
||||
|
||||
type FoodContentSectionProps = {
|
||||
formik: FormikProps<CreateFoodType>
|
||||
}
|
||||
|
||||
const parseContent = (data: { answer?: string } | undefined): string[] => {
|
||||
if (!data?.answer) return []
|
||||
return data.answer
|
||||
.split('،')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const FoodContentSection: FC<FoodContentSectionProps> = ({ formik }) => {
|
||||
const { mutate: generateContent, isPending: isGeneratingContent } = useGenerateFoodContent()
|
||||
|
||||
const handleGenerateContent = () => {
|
||||
const foodName = formik.values.title.trim()
|
||||
if (!foodName) {
|
||||
toast.error('لطفاً ابتدا نام کامل غذا را وارد کنید')
|
||||
return
|
||||
}
|
||||
|
||||
generateContent(
|
||||
{ foodName },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
const content = parseContent(response.data)
|
||||
if (!content.length) {
|
||||
toast.error('محتوایی از هوش مصنوعی دریافت نشد')
|
||||
return
|
||||
}
|
||||
formik.setFieldValue('content', content)
|
||||
toast.success('محتوای غذا با موفقیت تولید شد')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='text-sm mb-2'>محتوای غذا</div>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='text-sm'>محتوای غذا</div>
|
||||
<Button
|
||||
type='button'
|
||||
className='w-auto h-8 px-3 bg-transparent text-primary border border-primary text-xs gap-1.5'
|
||||
onClick={handleGenerateContent}
|
||||
isloading={isGeneratingContent}
|
||||
colorLoading='black'
|
||||
disabled={!formik.values.title.trim() || isGeneratingContent}
|
||||
>
|
||||
<Magicpen size={16} color='currentColor' />
|
||||
تولید با AI
|
||||
</Button>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
{formik.values.content.map((item, index) => (
|
||||
<div key={index} className='flex gap-2 items-center'>
|
||||
@@ -51,4 +103,4 @@ const FoodContentSection: FC<FoodContentSectionProps> = ({ formik }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default FoodContentSection
|
||||
export default FoodContentSection
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { Eye } from 'iconsax-react'
|
||||
import type { ColumnType } from '@/components/types/TableTypes'
|
||||
import type { Food } from '../types/Types'
|
||||
import { formatPrice, formatTime } from '../utils/formatters'
|
||||
import Status from '@/components/Status'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
|
||||
interface GetFoodTableColumnsParams {
|
||||
onDelete: (id: string) => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
export const getFoodTableColumns = ({ onDelete, isDeleting }: GetFoodTableColumnsParams): ColumnType<Food>[] => {
|
||||
export const getFoodTableColumns = (): ColumnType<Food>[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -87,23 +78,6 @@ export const getFoodTableColumns = ({ onDelete, isDeleting }: GetFoodTableColumn
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'view',
|
||||
title: '',
|
||||
render: (item: Food) => {
|
||||
return (
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Link to={Pages.foods.update + item.id} className='flex gap-2 items-center'>
|
||||
<Eye size={20} color='#8C90A3' />
|
||||
</Link>
|
||||
<TrashWithConfrim
|
||||
onDelete={() => onDelete(item.id)}
|
||||
isloading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import DefaulModal from '@/components/DefaulModal'
|
||||
import Input from '@/components/Input'
|
||||
import Button from '@/components/Button'
|
||||
import type { Food } from '../types/Types'
|
||||
|
||||
type Props = {
|
||||
food: Food | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (foodId: string, totalStock: number, availableStock: number) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const StockUpdateModal: FC<Props> = ({
|
||||
food,
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [totalStock, setTotalStock] = useState(0)
|
||||
const [availableStock, setAvailableStock] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (food) {
|
||||
setTotalStock(food.inventory?.totalStock ?? 0)
|
||||
setAvailableStock(food.inventory?.availableStock ?? 0)
|
||||
setError('')
|
||||
}
|
||||
}, [food])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (availableStock > totalStock) {
|
||||
setError('موجودی فعلی نمیتواند بیشتر از موجودی روزانه باشد')
|
||||
return
|
||||
}
|
||||
|
||||
if (!food) return
|
||||
|
||||
onSubmit(food.id, totalStock, availableStock)
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={onClose}
|
||||
isHeader
|
||||
title_header='بروزرسانی موجودی'
|
||||
width={480}
|
||||
>
|
||||
{food && (
|
||||
<div className='mt-6'>
|
||||
<p className='text-sm text-gray-600 mb-5'>
|
||||
غذا: <span className='font-medium text-gray-800'>{food.title}</span>
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Input
|
||||
name='totalStock'
|
||||
label='موجودی روزانه'
|
||||
placeholder='عدد'
|
||||
type='number'
|
||||
seprator
|
||||
value={totalStock}
|
||||
onChange={(e) => {
|
||||
setTotalStock(Number(e.target.value))
|
||||
setError('')
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
name='availableStock'
|
||||
label='موجودی فعلی'
|
||||
placeholder='عدد'
|
||||
type='number'
|
||||
seprator
|
||||
value={availableStock}
|
||||
onChange={(e) => {
|
||||
setAvailableStock(Number(e.target.value))
|
||||
setError('')
|
||||
}}
|
||||
error_text={error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3 mt-6'>
|
||||
<Button
|
||||
label='انصراف'
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='bg-gray-100 text-gray-700'
|
||||
/>
|
||||
<Button
|
||||
label='ذخیره'
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
isloading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DefaulModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default StockUpdateModal
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CreateCategoryType,
|
||||
CreateFoodType,
|
||||
GetFoodsParams,
|
||||
SetStockType,
|
||||
} from "../types/Types";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
|
||||
@@ -54,6 +55,16 @@ export const useDeleteFood = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useCloneFood = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.cloneFood,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["foods"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -91,3 +102,26 @@ export const useGetIcons = () => {
|
||||
queryFn: api.getIcons,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetStockForFood = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ foodId, params }: { foodId: string; params: SetStockType }) =>
|
||||
api.setStockForFood(foodId, params),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["foods"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGenerateFoodDescription = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.generateFoodDescription,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGenerateFoodContent = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.generateFoodContent,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,15 +1,69 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
import type { GetFoodsParams } from "../types/Types";
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
const getFiltersFromParams = (searchParams: URLSearchParams): FilterValues => {
|
||||
const filters: FilterValues = {};
|
||||
const search = searchParams.get("search");
|
||||
const categoryId = searchParams.get("categoryId");
|
||||
|
||||
if (search) filters.search = search;
|
||||
if (categoryId) filters.categoryId = categoryId;
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
const getPageFromParams = (searchParams: URLSearchParams): number => {
|
||||
const page = searchParams.get("page");
|
||||
const parsed = page ? parseInt(page, 10) : DEFAULT_PAGE;
|
||||
return Number.isNaN(parsed) || parsed < 1 ? DEFAULT_PAGE : parsed;
|
||||
};
|
||||
|
||||
const buildSearchParams = (filters: FilterValues, page: number): URLSearchParams => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (page > DEFAULT_PAGE) {
|
||||
params.set("page", String(page));
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
params.set("search", filters.search as string);
|
||||
}
|
||||
|
||||
if (filters.categoryId) {
|
||||
params.set("categoryId", filters.categoryId as string);
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const useFoodFilters = () => {
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = useState<FilterValues>(() =>
|
||||
getFiltersFromParams(searchParams),
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(() =>
|
||||
getPageFromParams(searchParams),
|
||||
);
|
||||
const [limit] = useState(DEFAULT_LIMIT);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(getFiltersFromParams(searchParams));
|
||||
setCurrentPage(getPageFromParams(searchParams));
|
||||
}, [searchParams]);
|
||||
|
||||
const updateUrlParams = useCallback(
|
||||
(newFilters: FilterValues, page: number) => {
|
||||
setSearchParams(buildSearchParams(newFilters, page), { replace: true });
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const apiParams = useMemo<GetFoodsParams>(() => {
|
||||
const params: GetFoodsParams = {
|
||||
page: currentPage,
|
||||
@@ -20,17 +74,29 @@ export const useFoodFilters = () => {
|
||||
params.search = filters.search as string;
|
||||
}
|
||||
|
||||
if (filters.categoryId) {
|
||||
params.categoryId = filters.categoryId as string;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [filters, currentPage, limit]);
|
||||
|
||||
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(DEFAULT_PAGE);
|
||||
};
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(DEFAULT_PAGE);
|
||||
updateUrlParams(newFilters, DEFAULT_PAGE);
|
||||
},
|
||||
[updateUrlParams],
|
||||
);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
setCurrentPage(page);
|
||||
updateUrlParams(filters, page);
|
||||
},
|
||||
[filters, updateUrlParams],
|
||||
);
|
||||
|
||||
return {
|
||||
filters,
|
||||
|
||||
@@ -2,11 +2,15 @@ import axios from "@/config/axios";
|
||||
import type {
|
||||
CreateCategoryType,
|
||||
CreateFoodType,
|
||||
GenerateFoodAiParams,
|
||||
GenerateFoodContentResponse,
|
||||
GenerateFoodDescriptionResponse,
|
||||
GetCategoriesResponseType,
|
||||
GetFoodDetailsResponseType,
|
||||
GetFoodsParams,
|
||||
GetFoodsResponseType,
|
||||
GetIconsResponseType,
|
||||
SetStockType,
|
||||
} from "../types/Types";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
|
||||
@@ -54,6 +58,11 @@ export const deleteFood = async (id: string) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const cloneFood = async (id: string) => {
|
||||
const { data } = await axios.post(`/admin/foods/${id}/clone`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createCategory = async (params: CreateCategoryType) => {
|
||||
const { data } = await axios.post(`/admin/categories`, params);
|
||||
return data;
|
||||
@@ -76,3 +85,31 @@ export const getIcons = async (): Promise<GetIconsResponseType> => {
|
||||
const { data } = await axios.get<GetIconsResponseType>(`/admin/groups/icons`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setStockForFood = async (foodId: string, params: SetStockType) => {
|
||||
const { data } = await axios.patch(
|
||||
`/admin/inventory/food/${foodId}/stock`,
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const generateFoodDescription = async (
|
||||
params: GenerateFoodAiParams,
|
||||
): Promise<GenerateFoodDescriptionResponse> => {
|
||||
const { data } = await axios.post<GenerateFoodDescriptionResponse>(
|
||||
`/admin/ai/food-description`,
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const generateFoodContent = async (
|
||||
params: GenerateFoodAiParams,
|
||||
): Promise<GenerateFoodContentResponse> => {
|
||||
const { data } = await axios.post<GenerateFoodContentResponse>(
|
||||
`/admin/ai/food-content`,
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type CreateFoodType = {
|
||||
discount: number;
|
||||
isSpecialOffer: boolean;
|
||||
dailyStock: number;
|
||||
availableStock: number;
|
||||
order: number;
|
||||
};
|
||||
|
||||
@@ -149,3 +150,23 @@ export type IconGroup = {
|
||||
};
|
||||
|
||||
export type GetIconsResponseType = IResponse<IconGroup[]>;
|
||||
|
||||
export type SetStockType = {
|
||||
totalStock: number;
|
||||
availableStock: number;
|
||||
};
|
||||
|
||||
export type GenerateFoodAiParams = {
|
||||
foodName: string;
|
||||
};
|
||||
|
||||
export type GenerateFoodAiResult = {
|
||||
answer: string;
|
||||
consumedTokens?: number;
|
||||
cost?: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
};
|
||||
|
||||
export type GenerateFoodDescriptionResponse = IResponse<GenerateFoodAiResult>;
|
||||
export type GenerateFoodContentResponse = IResponse<GenerateFoodAiResult>;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { type FC } from 'react'
|
||||
import { TickCircle } from 'iconsax-react'
|
||||
import Button from '@/components/Button'
|
||||
import { useCreateOrderForm } from './hooks/useCreateOrderForm'
|
||||
import CreateOrderCustomerSection from './components/CreateOrderCustomerSection'
|
||||
import CreateOrderItemsSection from './components/CreateOrderItemsSection'
|
||||
import CreateOrderSidebar from './components/CreateOrderSidebar'
|
||||
|
||||
const CreateOrder: FC = () => {
|
||||
const {
|
||||
formik,
|
||||
foods,
|
||||
deliveryMethods,
|
||||
cashPaymentMethod,
|
||||
selectedDeliveryMethod,
|
||||
selectedPaymentMethod,
|
||||
isCourierDelivery,
|
||||
isCarDelivery,
|
||||
isDineIn,
|
||||
orderSummary,
|
||||
addFoodToOrder,
|
||||
updateItemQuantity,
|
||||
removeItem,
|
||||
isSubmitting,
|
||||
customerAddresses,
|
||||
selectedAddressId,
|
||||
handleUserFound,
|
||||
handleUserNotFound,
|
||||
clearCustomerData,
|
||||
applySavedAddress,
|
||||
selectManualAddress,
|
||||
handleAddressFieldChange,
|
||||
handleLocationSelect,
|
||||
} = useCreateOrderForm()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-[calc(100vh-113px)] xl:overflow-hidden overflow-y-auto -mb-20'>
|
||||
<div className='flex justify-between items-center flex-shrink-0 mb-3'>
|
||||
<h1 className='text-lg font-light'>ثبت سفارش جدید</h1>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
isloading={isSubmitting}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TickCircle size={20} color='#fff' />
|
||||
<span>ثبت سفارش</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CreateOrderCustomerSection
|
||||
formik={formik}
|
||||
onUserFound={handleUserFound}
|
||||
onUserNotFound={handleUserNotFound}
|
||||
onClearCustomerData={clearCustomerData}
|
||||
/>
|
||||
|
||||
<div className='flex-1 min-h-0 grid grid-cols-1 xl:grid-cols-[1fr_minmax(0,320px)] gap-4 mt-3 items-stretch'>
|
||||
<div className='min-h-0 h-full overflow-hidden flex flex-col'>
|
||||
<CreateOrderItemsSection
|
||||
formik={formik}
|
||||
foods={foods}
|
||||
onAddFood={addFoodToOrder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 h-full flex flex-col overflow-hidden'>
|
||||
<CreateOrderSidebar
|
||||
formik={formik}
|
||||
orderSummary={orderSummary}
|
||||
deliveryMethods={deliveryMethods}
|
||||
cashPaymentMethod={cashPaymentMethod}
|
||||
selectedDeliveryMethod={selectedDeliveryMethod}
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
isCourierDelivery={isCourierDelivery}
|
||||
isCarDelivery={isCarDelivery}
|
||||
isDineIn={isDineIn}
|
||||
customerAddresses={customerAddresses}
|
||||
selectedAddressId={selectedAddressId}
|
||||
onSelectSavedAddress={applySavedAddress}
|
||||
onSelectManualAddress={selectManualAddress}
|
||||
onAddressFieldChange={handleAddressFieldChange}
|
||||
onLocationSelect={handleLocationSelect}
|
||||
onUpdateQuantity={updateItemQuantity}
|
||||
onRemoveItem={removeItem}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateOrder
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type FC, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DocumentDownload } from 'iconsax-react';
|
||||
import Table from '@/components/Table';
|
||||
import Filters from '@/components/Filters';
|
||||
import Button from '@/components/Button';
|
||||
import PageLoading from '@/components/PageLoading';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { PermissionEnum } from '@/pages/roles/enum/Enum';
|
||||
import { useGetDailyOrderReport } from './hooks/useDailyOrderReportData';
|
||||
import { useDailyOrderReportFilters } from './hooks/useDailyOrderReportFilters';
|
||||
import { getDailyOrderReportTableColumns } from './components/DailyOrderReportTableColumns';
|
||||
import { useDailyOrderReportFiltersFields } from './components/DailyOrderReportFiltersFields';
|
||||
import { getDailyOrderReport } from './service/OrderService';
|
||||
import { exportDailyOrderReportExcel } from './utils/exportDailyOrderReportExcel';
|
||||
import type { DailyOrderReportItem } from './types/Types';
|
||||
import type { RowDataType } from '@/components/types/TableTypes';
|
||||
|
||||
const DailyOrderReport: FC = () => {
|
||||
const { t } = useTranslation('global');
|
||||
const { checkPermission, isLoading: isPermissionsLoading } = usePermissions();
|
||||
const { filters, apiParams, handleFiltersChange } = useDailyOrderReportFilters();
|
||||
const { data: reportData, isLoading } = useGetDailyOrderReport(apiParams);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const columns = getDailyOrderReportTableColumns();
|
||||
const filterFields = useDailyOrderReportFiltersFields();
|
||||
|
||||
const reportItems = useMemo(() => {
|
||||
return (reportData?.data || []).map((item) => ({
|
||||
...item,
|
||||
id: item.date,
|
||||
}));
|
||||
}, [reportData?.data]);
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await getDailyOrderReport(apiParams);
|
||||
const items = (response?.data || []).map((item) => ({
|
||||
...item,
|
||||
id: item.date,
|
||||
}));
|
||||
exportDailyOrderReportExcel(items);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPermissionsLoading) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
if (!checkPermission(PermissionEnum.VIEW_ORDER_REPORTS)) {
|
||||
return (
|
||||
<div className='mt-5 text-sm text-gray-500'>
|
||||
شما دسترسی مشاهده این گزارش را ندارید.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>{t('submenu.orders_daily_report')}</h1>
|
||||
<Button
|
||||
className='w-fit px-4'
|
||||
isloading={isExporting}
|
||||
disabled={isLoading}
|
||||
onClick={handleExportExcel}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<DocumentDownload size={18} color='#fff' />
|
||||
<span>خروجی Excel</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-8'>
|
||||
<Filters
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
initialValues={filters}
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<DailyOrderReportItem & RowDataType>
|
||||
columns={columns}
|
||||
data={reportItems as (DailyOrderReportItem & RowDataType)[]}
|
||||
isloading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyOrderReport;
|
||||
@@ -1,76 +1,78 @@
|
||||
import { type FC, useState } from 'react'
|
||||
import {
|
||||
useGetOrderById,
|
||||
useVerifyCashPayment,
|
||||
useVerifyPayment,
|
||||
useChangeOrderStatus,
|
||||
useRefundPayment
|
||||
useRefundPayment,
|
||||
useAddPayment,
|
||||
useUpdateOrderFees,
|
||||
useAddOrderItem,
|
||||
useUpdateOrderItemQuantity,
|
||||
useRemoveOrderItem,
|
||||
useSendPleaseComeSms,
|
||||
} from './hooks/useOrderData'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRight2 } from 'iconsax-react'
|
||||
import { formatFaNumber } from '@/helpers/func'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import { OrderStatus, PaymentStatusEnum } from './enum/Enum'
|
||||
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
||||
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
|
||||
import Button from '@/components/Button'
|
||||
import ModalConfrim from '@/components/ModalConfrim'
|
||||
import CustomerInfo from './components/CustomerInfo'
|
||||
import OrderItemsDetails from './components/OrderItemsDetails'
|
||||
import PaymentsList from './components/PaymentsList'
|
||||
import PaymentInfo from './components/PaymentInfo'
|
||||
import OrderActions from './components/OrderActions'
|
||||
import UpdateOrderFeesModal from './components/UpdateOrderFeesModal'
|
||||
import AddOrderItemModal from './components/AddOrderItemModal'
|
||||
import RefundPaymentModal from './components/RefundPaymentModal'
|
||||
import AddPaymentModal from './components/AddPaymentModal'
|
||||
import SendPleaseComeSmsModal from './components/SendPleaseComeSmsModal'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { printOrderReceipt } from './print/orderReceiptPrint'
|
||||
import type { UpdateOrderFeesType } from './types/Types'
|
||||
|
||||
const OrderDetails: FC = () => {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: orderData, isLoading } = useGetOrderById(id!)
|
||||
const { mutate: verifyCashPayment, isPending: isVerifyingPayment } = useVerifyCashPayment()
|
||||
const { mutate: verifyPayment, isPending: isVerifyingPayment } = useVerifyPayment()
|
||||
const { mutate: changeOrderStatus, isPending: isChangingStatus } = useChangeOrderStatus()
|
||||
const { mutate: refundPayment, isPending: isRefunding } = useRefundPayment()
|
||||
const { mutate: addPayment, isPending: isAddingPayment } = useAddPayment()
|
||||
const { mutate: updateOrderFees, isPending: isUpdatingFees } = useUpdateOrderFees()
|
||||
const { mutate: addOrderItem, isPending: isAddingItem } = useAddOrderItem()
|
||||
const { mutate: updateOrderItemQuantity, isPending: isUpdatingItemQuantity } = useUpdateOrderItemQuantity()
|
||||
const { mutate: removeOrderItem, isPending: isRemovingItem } = useRemoveOrderItem()
|
||||
const { mutate: sendPleaseComeSms, isPending: isSendingPleaseComeSms } = useSendPleaseComeSms()
|
||||
|
||||
const [showCancelModal, setShowCancelModal] = useState(false)
|
||||
const [showRefundModal, setShowRefundModal] = useState(false)
|
||||
const [showPleaseComeSmsModal, setShowPleaseComeSmsModal] = useState(false)
|
||||
const [refundModal, setRefundModal] = useState<{ defaultAmount: number; maxAmount: number } | null>(null)
|
||||
const [showAddPaymentModal, setShowAddPaymentModal] = useState(false)
|
||||
const [showEditFeesModal, setShowEditFeesModal] = useState(false)
|
||||
const [showAddItemModal, setShowAddItemModal] = useState(false)
|
||||
const [itemIdToRemove, setItemIdToRemove] = useState<string | null>(null)
|
||||
const [verifyingPaymentId, setVerifyingPaymentId] = useState<string | null>(null)
|
||||
|
||||
const order = orderData?.data
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'pendingPayment': '#FFEECC',
|
||||
'paid': '#E3F2FD',
|
||||
'preparing': '#FFF3E0',
|
||||
'ready': '#E3F2FD',
|
||||
'shipped': '#E3F2FD',
|
||||
'completed': '#E8F5E9',
|
||||
'canceled': '#FFEBEE',
|
||||
pendingPayment: '#FFEECC',
|
||||
paid: '#E3F2FD',
|
||||
preparing: '#FFF3E0',
|
||||
ready: '#E3F2FD',
|
||||
shipped: '#E3F2FD',
|
||||
completed: '#E8F5E9',
|
||||
canceled: '#FFEBEE',
|
||||
}
|
||||
return colorMap[status] || '#FFEECC'
|
||||
}
|
||||
|
||||
const getPaymentStatusColor = (status: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
[PaymentStatusEnum.Paid]: 'bg-green-500',
|
||||
[PaymentStatusEnum.Pending]: 'bg-yellow-500',
|
||||
[PaymentStatusEnum.Failed]: 'bg-red-500',
|
||||
}
|
||||
return colorMap[status] || 'bg-gray-500'
|
||||
}
|
||||
|
||||
const getPaymentStatusTextColor = (status: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
[PaymentStatusEnum.Paid]: 'text-green-500',
|
||||
[PaymentStatusEnum.Pending]: 'text-yellow-500',
|
||||
[PaymentStatusEnum.Failed]: 'text-red-500',
|
||||
}
|
||||
return colorMap[status] || 'text-gray-500'
|
||||
}
|
||||
|
||||
const getPaymentStatusText = (status: string): string => {
|
||||
const textMap: Record<string, string> = {
|
||||
[PaymentStatusEnum.Paid]: 'پرداخت شده',
|
||||
[PaymentStatusEnum.Pending]: 'در انتظار پرداخت',
|
||||
[PaymentStatusEnum.Failed]: 'پرداخت ناموفق',
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='mt-5 flex items-center justify-center h-64'>
|
||||
@@ -87,154 +89,295 @@ const OrderDetails: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// منطق نمایش دکمهها
|
||||
const isCashPayment = order.paymentMethod?.method === PaymentMethodEnum.Cash
|
||||
const isPendingPayment = order.status === OrderStatus.PENDING_PAYMENT
|
||||
const isOrderPaid = order.status === OrderStatus.PAID
|
||||
const isOrderPaid = Number(order.paidAmount ?? 0) > 0 || order.payments?.some(
|
||||
(payment) => payment.status === PaymentStatusEnum.Paid
|
||||
)
|
||||
const isPreparing = order.status === OrderStatus.PREPARING
|
||||
const isCanceled = order.status === OrderStatus.CANCELED
|
||||
// بررسی وضعیت پرداخت از payments array یا paymentStatus
|
||||
// ابتدا از paymentStatus استفاده میکنیم، اگر نبود از اولین payment در payments array استفاده میکنیم
|
||||
const paymentStatus = order.paymentStatus || order.payments?.[0]?.status
|
||||
// بررسی وضعیت pending (هم enum و هم string literal)
|
||||
const isPaymentPending = paymentStatus === PaymentStatusEnum.Pending || paymentStatus === 'pending'
|
||||
const isCompleted = order.status === OrderStatus.COMPLETED
|
||||
const isPendingPayment = order.status === OrderStatus.NEW
|
||||
const canEditFees = !isCanceled && !isCompleted
|
||||
const canEditItems = !isCanceled && !isCompleted
|
||||
const canAddPayment = !isCanceled && Number(order.balance ?? 0) > 0
|
||||
const isUpdatingItem = isAddingItem || isUpdatingItemQuantity || isRemovingItem
|
||||
const canCompleteOrder = [
|
||||
OrderStatus.DELIVERED_TO_WAITER,
|
||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
OrderStatus.SHIPPED,
|
||||
].includes(order.status as OrderStatus)
|
||||
const canSendPleaseComeSms = Boolean(order.user?.phone)
|
||||
const isCourierDelivery = order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCourier
|
||||
const isDineInOrCarDelivery = order.deliveryMethod?.method === DeliveryMethodEnum.DineIn || order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCar
|
||||
const isDineInOrCarDelivery =
|
||||
order.deliveryMethod?.method === DeliveryMethodEnum.DineIn ||
|
||||
order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCar
|
||||
const isCustomerPickup = order.deliveryMethod?.method === DeliveryMethodEnum.CustomerPickup
|
||||
|
||||
// نمایش دکمه تایید پرداخت نقدی
|
||||
// وقتی روش پرداخت نقدی است و وضعیت پرداخت Pending باشد
|
||||
const showVerifyCashPaymentButton = isCashPayment && isPaymentPending
|
||||
|
||||
// نمایش دکمه ارسال به آشپزخانه
|
||||
// برای pendingPayment با پرداخت نقدی یا برای paid
|
||||
const showSendToKitchenButton = (isPendingPayment && isCashPayment) || isOrderPaid
|
||||
|
||||
// نمایش دکمه کنسل
|
||||
// همیشه نمایش داده میشود مگر اینکه سفارش قبلاً کنسل شده باشد
|
||||
const showCancelButton = !isCanceled
|
||||
|
||||
const handleVerifyCashPayment = () => {
|
||||
const paymentId = order.paymentId || order.payments?.[0]?.id
|
||||
if (paymentId) {
|
||||
verifyCashPayment(paymentId, {
|
||||
onSuccess: () => {
|
||||
// Success handled by query invalidation
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleVerifyPayment = (paymentId: string) => {
|
||||
setVerifyingPaymentId(paymentId)
|
||||
verifyPayment(paymentId, {
|
||||
onSuccess: () => {
|
||||
setVerifyingPaymentId(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
setVerifyingPaymentId(null)
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSendToKitchen = () => {
|
||||
changeOrderStatus({
|
||||
orderId: order.id,
|
||||
status: OrderStatus.PREPARING
|
||||
}, {
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
changeOrderStatus(
|
||||
{
|
||||
orderId: order.id,
|
||||
status: OrderStatus.PREPARING,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeliverToCourier = () => {
|
||||
changeOrderStatus({
|
||||
orderId: order.id,
|
||||
status: OrderStatus.SHIPPED
|
||||
}, {
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
changeOrderStatus(
|
||||
{
|
||||
orderId: order.id,
|
||||
status: OrderStatus.SHIPPED,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeliverToWaiter = () => {
|
||||
changeOrderStatus({
|
||||
orderId: order.id,
|
||||
status: OrderStatus.DELIVERED_TO_WAITER
|
||||
}, {
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
changeOrderStatus(
|
||||
{
|
||||
orderId: order.id,
|
||||
status: OrderStatus.DELIVERED_TO_WAITER,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeliverToReceptionist = () => {
|
||||
changeOrderStatus({
|
||||
orderId: order.id,
|
||||
status: OrderStatus.DELIVERED_TO_RECEPTIONIST
|
||||
}, {
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
changeOrderStatus(
|
||||
{
|
||||
orderId: order.id,
|
||||
status: OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleCancelOrder = (description?: string) => {
|
||||
changeOrderStatus({
|
||||
orderId: order.id,
|
||||
status: OrderStatus.CANCELED,
|
||||
params: description ? { description } : undefined
|
||||
}, {
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
changeOrderStatus(
|
||||
{
|
||||
orderId: order.id,
|
||||
status: OrderStatus.CANCELED,
|
||||
params: description ? { description } : undefined,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
setShowCancelModal(false)
|
||||
}
|
||||
|
||||
const handleRefund = (text?: string) => {
|
||||
if (!text) return
|
||||
refundPayment({
|
||||
orderId: order.id,
|
||||
params: { description: text }
|
||||
}, {
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
const handleCompleteOrder = () => {
|
||||
changeOrderStatus(
|
||||
{
|
||||
orderId: order.id,
|
||||
status: OrderStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
})
|
||||
setShowRefundModal(false)
|
||||
)
|
||||
}
|
||||
|
||||
const handleRefund = (amount: number, description: string) => {
|
||||
refundPayment(
|
||||
{
|
||||
orderId: order.id,
|
||||
params: { amount, description },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('بازگشت وجه با موفقیت انجام شد')
|
||||
setRefundModal(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddPayment = (amount: number, description: string) => {
|
||||
addPayment(
|
||||
{
|
||||
orderId: order.id,
|
||||
params: {
|
||||
amount,
|
||||
...(description ? { description } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('پرداخت با موفقیت ثبت شد')
|
||||
setShowAddPaymentModal(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handlePrintOrder = () => {
|
||||
printOrderReceipt(order)
|
||||
}
|
||||
|
||||
const handleUpdateOrderFees = (params: UpdateOrderFeesType) => {
|
||||
updateOrderFees(
|
||||
{ orderId: order.id, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('هزینههای سفارش بهروزرسانی شد')
|
||||
setShowEditFeesModal(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddOrderItem = (foodId: string, quantity: number) => {
|
||||
addOrderItem(
|
||||
{ orderId: order.id, params: { foodId, quantity } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('آیتم به سفارش اضافه شد')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdateItemQuantity = (itemId: string, quantity: number) => {
|
||||
if (quantity < 1) return
|
||||
updateOrderItemQuantity(
|
||||
{ orderId: order.id, itemId, params: { quantity } },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirmRemoveItem = () => {
|
||||
if (!itemIdToRemove) return
|
||||
removeOrderItem(
|
||||
{ orderId: order.id, itemId: itemIdToRemove },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('آیتم از سفارش حذف شد')
|
||||
setItemIdToRemove(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirmPleaseComeSms = () => {
|
||||
sendPleaseComeSms(order.id, {
|
||||
onSuccess: (response) => {
|
||||
toast.success(response.message || 'پیامک در صف ارسال قرار گرفت')
|
||||
setShowPleaseComeSmsModal(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='mt-5 pb-8'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>
|
||||
سفارش {formatFaNumber(order.orderNumber)}
|
||||
</h1>
|
||||
{/* <div>
|
||||
<Select
|
||||
items={statusItems}
|
||||
value={order.status}
|
||||
placeholder={t(`order_status.${order.status}`)}
|
||||
className='w-[200px]'
|
||||
style={{ backgroundColor: getStatusColor(order.status) }}
|
||||
/>
|
||||
</div> */}
|
||||
<Button
|
||||
className='w-fit px-6 bg-transparent text-primary border-primary border'
|
||||
onClick={() => navigate(Pages.orders.list)}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<ArrowRight2 color='#000' size={18} />
|
||||
<span>بازگشت</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-7 mt-9'>
|
||||
<div className='flex-1 space-y-8'>
|
||||
<div className='flex flex-col lg:flex-row gap-5 lg:gap-7 mt-6 sm:mt-9'>
|
||||
<div className='flex-1 min-w-0 space-y-5 sm:space-y-8 order-2 lg:order-1'>
|
||||
<CustomerInfo order={order} />
|
||||
<OrderItemsDetails order={order} getStatusColor={getStatusColor} />
|
||||
<OrderItemsDetails
|
||||
order={order}
|
||||
getStatusColor={getStatusColor}
|
||||
canEditItems={canEditItems}
|
||||
isUpdatingItem={isUpdatingItem}
|
||||
onAddItem={() => setShowAddItemModal(true)}
|
||||
onUpdateQuantity={handleUpdateItemQuantity}
|
||||
onRemoveItem={(itemId) => setItemIdToRemove(itemId)}
|
||||
/>
|
||||
<PaymentsList
|
||||
order={order}
|
||||
isVerifyingPayment={isVerifyingPayment}
|
||||
verifyingPaymentId={verifyingPaymentId}
|
||||
onVerifyPayment={handleVerifyPayment}
|
||||
canAddPayment={canAddPayment}
|
||||
onAddPayment={() => setShowAddPaymentModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='w-full lg:w-[330px] shrink-0 order-1 lg:order-2'>
|
||||
<PaymentInfo
|
||||
order={order}
|
||||
getPaymentStatusColor={getPaymentStatusColor}
|
||||
getPaymentStatusTextColor={getPaymentStatusTextColor}
|
||||
getPaymentStatusText={getPaymentStatusText}
|
||||
canEditFees={canEditFees}
|
||||
onEditFees={() => setShowEditFeesModal(true)}
|
||||
isRefunding={isRefunding}
|
||||
onRefund={(defaultAmount, maxAmount) =>
|
||||
setRefundModal({ defaultAmount, maxAmount })
|
||||
}
|
||||
/>
|
||||
<OrderActions
|
||||
onPrint={handlePrintOrder}
|
||||
showVerifyCashPaymentButton={showVerifyCashPaymentButton}
|
||||
showSendToKitchenButton={showSendToKitchenButton}
|
||||
showCancelButton={showCancelButton}
|
||||
isPreparing={isPreparing}
|
||||
@@ -242,19 +385,37 @@ const OrderDetails: FC = () => {
|
||||
isDineInOrCarDelivery={isDineInOrCarDelivery}
|
||||
isCustomerPickup={isCustomerPickup}
|
||||
isCanceled={isCanceled}
|
||||
isVerifyingPayment={isVerifyingPayment}
|
||||
canCompleteOrder={canCompleteOrder}
|
||||
canSendPleaseComeSms={canSendPleaseComeSms}
|
||||
isChangingStatus={isChangingStatus}
|
||||
onVerifyCashPayment={handleVerifyCashPayment}
|
||||
isSendingPleaseComeSms={isSendingPleaseComeSms}
|
||||
onSendToKitchen={handleSendToKitchen}
|
||||
onDeliverToCourier={handleDeliverToCourier}
|
||||
onDeliverToWaiter={handleDeliverToWaiter}
|
||||
onDeliverToReceptionist={handleDeliverToReceptionist}
|
||||
onCompleteOrder={handleCompleteOrder}
|
||||
onCancelOrder={() => setShowCancelModal(true)}
|
||||
onSendPleaseComeSms={() => setShowPleaseComeSmsModal(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* مدال کنسل سفارش */}
|
||||
<UpdateOrderFeesModal
|
||||
isOpen={showEditFeesModal}
|
||||
onClose={() => setShowEditFeesModal(false)}
|
||||
order={order}
|
||||
isLoading={isUpdatingFees}
|
||||
onSubmit={handleUpdateOrderFees}
|
||||
/>
|
||||
|
||||
<AddOrderItemModal
|
||||
isOpen={showAddItemModal}
|
||||
onClose={() => setShowAddItemModal(false)}
|
||||
existingItems={order.items}
|
||||
isLoading={isAddingItem}
|
||||
onAdd={handleAddOrderItem}
|
||||
/>
|
||||
|
||||
<ModalConfrim
|
||||
isOpen={showCancelModal}
|
||||
close={() => setShowCancelModal(false)}
|
||||
@@ -264,17 +425,41 @@ const OrderDetails: FC = () => {
|
||||
isHasDescription
|
||||
/>
|
||||
|
||||
{/* مدال ریفاند */}
|
||||
<RefundPaymentModal
|
||||
isOpen={!!refundModal}
|
||||
onClose={() => setRefundModal(null)}
|
||||
defaultAmount={refundModal?.defaultAmount ?? 0}
|
||||
maxAmount={refundModal?.maxAmount ?? 0}
|
||||
isLoading={isRefunding}
|
||||
onSubmit={handleRefund}
|
||||
/>
|
||||
|
||||
<AddPaymentModal
|
||||
isOpen={showAddPaymentModal}
|
||||
onClose={() => setShowAddPaymentModal(false)}
|
||||
defaultAmount={Number(order.balance ?? 0)}
|
||||
isLoading={isAddingPayment}
|
||||
onSubmit={handleAddPayment}
|
||||
/>
|
||||
|
||||
<ModalConfrim
|
||||
isOpen={showRefundModal}
|
||||
close={() => setShowRefundModal(false)}
|
||||
onConfrim={handleRefund}
|
||||
isloading={isRefunding}
|
||||
label='آیا از ریفاند این سفارش مطمئن هستید؟'
|
||||
isHasDescription
|
||||
isOpen={!!itemIdToRemove}
|
||||
close={() => setItemIdToRemove(null)}
|
||||
onConfrim={handleConfirmRemoveItem}
|
||||
isloading={isRemovingItem}
|
||||
label='آیا از حذف این آیتم مطمئن هستید؟'
|
||||
/>
|
||||
|
||||
<SendPleaseComeSmsModal
|
||||
isOpen={showPleaseComeSmsModal}
|
||||
onClose={() => setShowPleaseComeSmsModal(false)}
|
||||
isLoading={isSendingPleaseComeSms}
|
||||
customerName={order.user?.firstName || ''}
|
||||
restaurantName={order.restaurant?.name || ''}
|
||||
onConfirm={handleConfirmPleaseComeSms}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderDetails
|
||||
export default OrderDetails
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type FC, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DocumentDownload } from 'iconsax-react';
|
||||
import Table from '@/components/Table';
|
||||
import Filters from '@/components/Filters';
|
||||
import Button from '@/components/Button';
|
||||
import PageLoading from '@/components/PageLoading';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { PermissionEnum } from '@/pages/roles/enum/Enum';
|
||||
import { useGetFoodOrderReport } from './hooks/useFoodOrderReportData';
|
||||
import { useFoodOrderReportFilters } from './hooks/useFoodOrderReportFilters';
|
||||
import { getFoodOrderReportTableColumns } from './components/FoodOrderReportTableColumns';
|
||||
import { useFoodOrderReportFiltersFields } from './components/FoodOrderReportFiltersFields';
|
||||
import { getFoodOrderReport } from './service/OrderService';
|
||||
import { exportFoodOrderReportExcel } from './utils/exportFoodOrderReportExcel';
|
||||
import type { FoodOrderReportItem } from './types/Types';
|
||||
import type { RowDataType } from '@/components/types/TableTypes';
|
||||
|
||||
const FoodOrderReport: FC = () => {
|
||||
const { t } = useTranslation('global');
|
||||
const { checkPermission, isLoading: isPermissionsLoading } = usePermissions();
|
||||
const { filters, apiParams, handleFiltersChange } = useFoodOrderReportFilters();
|
||||
const { data: reportData, isLoading } = useGetFoodOrderReport(apiParams);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const columns = getFoodOrderReportTableColumns();
|
||||
const filterFields = useFoodOrderReportFiltersFields();
|
||||
|
||||
const reportItems = useMemo(() => {
|
||||
return (reportData?.data || []).map((item) => ({
|
||||
...item,
|
||||
id: item.food.id,
|
||||
}));
|
||||
}, [reportData?.data]);
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await getFoodOrderReport(apiParams);
|
||||
const items = (response?.data || []).map((item) => ({
|
||||
...item,
|
||||
id: item.food.id,
|
||||
}));
|
||||
exportFoodOrderReportExcel(items);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPermissionsLoading) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
if (!checkPermission(PermissionEnum.VIEW_ORDER_REPORTS)) {
|
||||
return (
|
||||
<div className='mt-5 text-sm text-gray-500'>
|
||||
شما دسترسی مشاهده این گزارش را ندارید.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>{t('submenu.orders_food_report')}</h1>
|
||||
<Button
|
||||
className='w-fit px-4'
|
||||
isloading={isExporting}
|
||||
disabled={isLoading}
|
||||
onClick={handleExportExcel}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<DocumentDownload size={18} color='#fff' />
|
||||
<span>خروجی Excel</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-8'>
|
||||
<Filters
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
initialValues={filters}
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<FoodOrderReportItem & RowDataType>
|
||||
columns={columns}
|
||||
data={reportItems as (FoodOrderReportItem & RowDataType)[]}
|
||||
isloading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FoodOrderReport;
|
||||
@@ -1,13 +1,20 @@
|
||||
import { type FC } from 'react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Add } from 'iconsax-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Table from '@/components/Table'
|
||||
import Filters from '@/components/Filters'
|
||||
import { useGetOrders } from './hooks/useOrderData'
|
||||
import Button from '@/components/Button'
|
||||
import { Pages } from '@/config/Pages'
|
||||
import { useGetOrders, useSendPleaseComeSms } from './hooks/useOrderData'
|
||||
import { useOrderFilters } from './hooks/useOrderFilters'
|
||||
import { getOrderTableColumns } from './components/OrderTableColumns'
|
||||
import { useOrderFiltersFields } from './components/OrderFiltersFields'
|
||||
import SendPleaseComeSmsModal from './components/SendPleaseComeSmsModal'
|
||||
import type { Order } from './types/Types'
|
||||
import type { RowDataType } from '@/components/types/TableTypes'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
|
||||
const OrdersList: FC = () => {
|
||||
const { t } = useTranslation('global')
|
||||
@@ -17,21 +24,47 @@ const OrdersList: FC = () => {
|
||||
apiParams,
|
||||
handleFiltersChange,
|
||||
handlePageChange,
|
||||
limit,
|
||||
} = useOrderFilters()
|
||||
|
||||
const { data: ordersData, isLoading } = useGetOrders(apiParams)
|
||||
const { mutate: sendPleaseComeSms, isPending: isSendingSms } = useSendPleaseComeSms()
|
||||
const [smsOrder, setSmsOrder] = useState<Order | null>(null)
|
||||
|
||||
const orders = ordersData?.data || []
|
||||
const columns = getOrderTableColumns({ t })
|
||||
const columns = getOrderTableColumns({
|
||||
t,
|
||||
onSendPleaseComeSms: (order) => setSmsOrder(order),
|
||||
})
|
||||
const filterFields = useOrderFiltersFields()
|
||||
|
||||
const totalPages = Math.ceil(orders.length / limit) || 1
|
||||
const totalPages = ordersData?.meta?.totalPages || 1
|
||||
|
||||
const handleConfirmSendSms = () => {
|
||||
if (!smsOrder) return
|
||||
|
||||
sendPleaseComeSms(smsOrder.id, {
|
||||
onSuccess: (response) => {
|
||||
toast.success(response.message || 'پیامک در صف ارسال قرار گرفت')
|
||||
setSmsOrder(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>لیست سفارشات</h1>
|
||||
<Link to={Pages.orders.add}>
|
||||
<Button className='w-fit px-6'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Add color='#fff' size={20} />
|
||||
<span>ثبت سفارش</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='mt-8'>
|
||||
@@ -53,8 +86,17 @@ const OrdersList: FC = () => {
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
<SendPleaseComeSmsModal
|
||||
isOpen={!!smsOrder}
|
||||
onClose={() => setSmsOrder(null)}
|
||||
isLoading={isSendingSms}
|
||||
customerName={smsOrder?.user?.firstName || ''}
|
||||
restaurantName={smsOrder?.restaurant?.name || ''}
|
||||
onConfirm={handleConfirmSendSms}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrdersList
|
||||
export default OrdersList
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import DefaulModal from '@/components/DefaulModal'
|
||||
import Input from '@/components/Input'
|
||||
import { Add } from 'iconsax-react'
|
||||
import { useGetFoods } from '@/pages/food/hooks/useFoodData'
|
||||
import { formatPrice } from '@/helpers/func'
|
||||
import { getFoodUnitPrice } from '../hooks/useCreateOrderForm'
|
||||
import type { OrderItem } from '../types/Types'
|
||||
|
||||
interface AddOrderItemModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
existingItems: OrderItem[]
|
||||
isLoading?: boolean
|
||||
onAdd: (foodId: string, quantity: number) => void
|
||||
}
|
||||
|
||||
const AddOrderItemModal: FC<AddOrderItemModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
existingItems,
|
||||
isLoading,
|
||||
onAdd,
|
||||
}) => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
||||
const { data: foodsData } = useGetFoods({ limit: 500 })
|
||||
const foods = foodsData?.data ?? []
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categoryMap = new Map<string, string>()
|
||||
foods.forEach((food) => {
|
||||
if (food.category?.id) {
|
||||
categoryMap.set(food.category.id, food.category.title)
|
||||
}
|
||||
})
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([id, title]) => ({ id, title }))
|
||||
.sort((a, b) => a.title.localeCompare(b.title, 'fa'))
|
||||
}, [foods])
|
||||
|
||||
const filteredFoods = useMemo(() => {
|
||||
let result = foods
|
||||
if (selectedCategoryId) {
|
||||
result = result.filter((food) => food.category?.id === selectedCategoryId)
|
||||
}
|
||||
const query = search.trim().toLowerCase()
|
||||
if (query) {
|
||||
result = result.filter((food) => food.title.toLowerCase().includes(query))
|
||||
}
|
||||
return result
|
||||
}, [foods, search, selectedCategoryId])
|
||||
|
||||
const existingQtyByFoodId = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
existingItems.forEach((item) => {
|
||||
map.set(item.food.id, item.quantity)
|
||||
})
|
||||
return map
|
||||
}, [existingItems])
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={isOpen}
|
||||
close={onClose}
|
||||
isHeader
|
||||
title_header='افزودن آیتم'
|
||||
width={480}
|
||||
>
|
||||
<div className='mt-4 flex flex-col max-h-[60vh]'>
|
||||
<Input
|
||||
variant='search'
|
||||
className='bg-[#EEF0F7]'
|
||||
placeholder='جستجوی غذا...'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className='mt-2 flex gap-1.5 overflow-x-auto flex-shrink-0 pb-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setSelectedCategoryId(null)}
|
||||
className={`flex-shrink-0 px-2.5 h-7 rounded-full text-[11px] transition-colors ${
|
||||
selectedCategoryId === null
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-[#EEF0F7] text-description hover:bg-[#E4E7F0]'
|
||||
}`}
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type='button'
|
||||
onClick={() => setSelectedCategoryId(category.id)}
|
||||
className={`flex-shrink-0 px-2.5 h-7 rounded-full text-[11px] transition-colors whitespace-nowrap ${
|
||||
selectedCategoryId === category.id
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-[#EEF0F7] text-description hover:bg-[#E4E7F0]'
|
||||
}`}
|
||||
>
|
||||
{category.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-3 flex-1 min-h-0 overflow-y-auto space-y-1'>
|
||||
{filteredFoods.length === 0 ? (
|
||||
<div className='text-center py-8 text-xs text-description'>
|
||||
غذایی یافت نشد
|
||||
</div>
|
||||
) : (
|
||||
filteredFoods.map((food) => {
|
||||
const existingQty = existingQtyByFoodId.get(food.id)
|
||||
return (
|
||||
<div
|
||||
key={food.id}
|
||||
className='flex items-center gap-2 p-1.5 rounded-lg hover:bg-[#F8F9FC] transition-colors'
|
||||
>
|
||||
{food.images?.[0] ? (
|
||||
<img
|
||||
src={food.images[0]}
|
||||
alt={food.title}
|
||||
className='w-9 h-9 rounded-lg object-cover flex-shrink-0'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-9 h-9 rounded-lg bg-gray-100 flex-shrink-0' />
|
||||
)}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='text-xs truncate'>{food.title}</div>
|
||||
<div className='text-[11px] text-description'>
|
||||
{formatPrice(getFoodUnitPrice(food))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
disabled={isLoading}
|
||||
onClick={() => onAdd(food.id, 1)}
|
||||
className={`flex items-center gap-1 px-2.5 h-7 rounded-lg text-[11px] transition-colors flex-shrink-0 disabled:opacity-50 ${
|
||||
existingQty
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-primary text-white'
|
||||
}`}
|
||||
>
|
||||
<Add size={12} color={existingQty ? '#000' : '#fff'} />
|
||||
{existingQty ? `×${existingQty}` : 'افزودن'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddOrderItemModal
|
||||
@@ -0,0 +1,102 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import DefaulModal from '@/components/DefaulModal'
|
||||
import Button from '@/components/Button'
|
||||
import Input from '@/components/Input'
|
||||
import Textarea from '@/components/Textarea'
|
||||
import Select from '@/components/Select'
|
||||
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
||||
|
||||
interface AddPaymentModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
defaultAmount: number
|
||||
isLoading?: boolean
|
||||
onSubmit: (amount: number, description: string) => void
|
||||
}
|
||||
|
||||
const AddPaymentModal: FC<AddPaymentModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultAmount,
|
||||
isLoading,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [amount, setAmount] = useState(String(defaultAmount))
|
||||
const [description, setDescription] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setAmount(String(Math.max(0, defaultAmount)))
|
||||
setDescription('')
|
||||
setError('')
|
||||
}, [isOpen, defaultAmount])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const parsed = Number(amount)
|
||||
if (!parsed || parsed <= 0) {
|
||||
setError('مبلغ پرداخت باید بیشتر از صفر باشد')
|
||||
return
|
||||
}
|
||||
onSubmit(parsed, description.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={isOpen}
|
||||
close={onClose}
|
||||
isHeader
|
||||
title_header='افزودن پرداخت'
|
||||
width={420}
|
||||
>
|
||||
<div className='mt-6 space-y-4'>
|
||||
<Select
|
||||
label='روش پرداخت'
|
||||
value={PaymentMethodEnum.Cash}
|
||||
items={[{ value: PaymentMethodEnum.Cash, label: 'نقدی' }]}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Input
|
||||
label='مبلغ پرداخت (تومان)'
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
min={1}
|
||||
seprator
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
placeholder='توضیحات (اختیاری)'
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
className='bg-transparent border border-gray-500 rounded-xl w-full p-2 min-h-[80px]'
|
||||
/>
|
||||
|
||||
{error && <p className='text-xs text-red-500 text-center'>{error}</p>}
|
||||
|
||||
<div className='flex gap-4 justify-center pt-4'>
|
||||
<Button
|
||||
label='تایید'
|
||||
onClick={handleSubmit}
|
||||
isloading={isLoading}
|
||||
/>
|
||||
<Button
|
||||
label='انصراف'
|
||||
className='bg-transparent text-black border border-primary'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddPaymentModal
|
||||