Compare commits

..

92 Commits

Author SHA1 Message Date
hamid zarghami e283d4a5c4 generate qr code
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-27 10:09:11 +03:30
morteza f30ee4f6e3 enable chat
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 14:13:12 +03:30
morteza b9fc6ab247 remove summary
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 12:27:56 +03:30
morteza 7ad840fbda send sms to user in order detail
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:56:21 +03:30
morteza 6dea561b05 update voice
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:22:03 +03:30
hamid zarghami b73f00299b base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 11:40:20 +03:30
hamid zarghami a6593ed0e8 questions chats 2026-07-23 11:40:20 +03:30
hamid zarghami 9f8877daf9 food scription and food contents 2026-07-23 11:40:20 +03:30
morteza 4f2bf864e4 update user
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-22 11:51:58 +03:30
morteza ee1655493c remove unused
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 19:37:22 +03:30
morteza 66992dd2c3 notif update
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 16:09:37 +03:30
morteza c0fa388ac3 add payment
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 11:34:59 +03:30
hamid zarghami 366d9faff3 fix build
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 09:36:08 +03:30
hamid zarghami 985a29b50f establishedYear
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 09:09:05 +03:30
morteza b979bb610a edit order
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-18 20:56:48 +03:30
morteza 06873bae01 t a
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-17 11:13:12 +03:30
morteza 3e17cf9e12 review
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-17 10:18:55 +03:30
hamid zarghami 7913c43bc0 fix review detail error
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-15 12:51:59 +03:30
morteza 331b5fe02d remove bg
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 23:08:47 +03:30
morteza dcc071d357 remove unused
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 23:05:58 +03:30
morteza 7918547137 update
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 22:49:57 +03:30
morteza 8b9ca1707c up
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 21:17:44 +03:30
morteza fa83d7d092 shift 2026-07-14 21:04:39 +03:30
morteza 9aed33d73b shift in admin
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 16:28:26 +03:30
morteza 435fe3e947 update costs
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 10:07:15 +03:30
morteza 0b3b7218b9 fix order list when user is null
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-12 11:29:18 +03:30
hamid zarghami 57284c0641 campain list
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 16:13:18 +03:30
hamid zarghami 9f18e33ef6 navigate back 2026-07-04 15:21:31 +03:30
hamid zarghami 6a7d5523a7 dublicate food
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 12:56:01 +03:30
hamid zarghami 6a6aa76b1e wallet 2026-07-04 12:50:02 +03:30
hamid zarghami 23f47705bb wallet 2026-07-04 12:26:28 +03:30
hamid zarghami 95814d6b00 campains
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 11:56:32 +03:30
hamid zarghami a4eaa50fdf group 2026-07-04 10:43:16 +03:30
hamid zarghami 457361e759 Crud group 2026-07-04 10:34:56 +03:30
hamid zarghami eb9973678c slider 2026-07-04 09:45:24 +03:30
hamid zarghami bcf03887f1 pagination orders list 2026-07-02 10:43:55 +03:30
hamid zarghami 6187e7c26d update customer 2026-07-02 10:35:38 +03:30
hamid zarghami 2d35a428b1 base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-02 10:15:14 +03:30
hamid zarghami aac1a4f81b user option and added in modal 2026-07-01 12:55:27 +03:30
hamid zarghami 282e001933 user optional 2026-07-01 12:52:52 +03:30
hamid zarghami 8ec3f307b0 allways show receipt 2026-07-01 12:40:44 +03:30
hamid zarghami 4c240ac9a1 finish order 2026-07-01 12:35:08 +03:30
hamid zarghami d52ff4f07e discountAmount and packingFee in create order admin 2026-07-01 12:28:51 +03:30
hamid zarghami c7b4e77421 fix chart 2026-07-01 12:14:00 +03:30
hamid zarghami 3c6d3f009a create order without scoll
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 10:10:15 +03:30
hamid zarghami 0539d4bc48 Create order filter by category 2026-07-01 09:07:24 +03:30
hamid zarghami e6b3610bfc change design in norder 2026-07-01 09:02:29 +03:30
hamid zarghami 4fc7d19766 fix role not found
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 15:05:30 +03:30
hamid zarghami b155c0b959 revenuechart 2026-06-29 14:54:56 +03:30
hamid zarghami 53ebbc8d07 login image change size 2026-06-29 14:51:32 +03:30
hamid zarghami 6bff75109a fix build
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 12:38:31 +03:30
hamid zarghami b92faa81a9 base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 12:09:26 +03:30
hamid zarghami a248064800 multi phones in setting 2026-06-29 12:09:10 +03:30
hamid zarghami 72ca39c284 multi card number in payment method 2026-06-29 11:41:03 +03:30
hamid zarghami 6324cabb32 fix build
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-28 09:11:12 +03:30
hamid zarghami 3e7cc74d57 create customer
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-27 15:24:50 +03:30
hamid zarghami 047bfc6f46 show demo 2026-06-27 11:31:57 +03:30
hamid zarghami 050ffc255e fix base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-23 14:12:21 +03:30
hamid zarghami 67dfc03c7b search in maps
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-23 12:38:23 +03:30
hamid zarghami 7130c304c5 Create order 2026-06-23 12:31:39 +03:30
hamid zarghami f83d0743c6 Create user 2026-06-23 11:25:19 +03:30
hamid zarghami 45494d7ae5 user Addresses 2026-06-23 11:16:25 +03:30
hamid zarghami 223b1b5e8f base create order 2026-06-23 10:12:56 +03:30
hamid zarghami 6975e2d0b0 liara mirrir
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-22 15:05:40 +03:30
hamid zarghami c6e38184f5 Addes bgtype
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-22 12:22:34 +03:30
hamid zarghami 18dcc5b2ba set empty 2026-06-22 11:15:25 +03:30
hamid zarghami f8c401f2ed fix update settings 2026-06-22 10:07:31 +03:30
hamid zarghami fe78b218cc background 2026-06-22 09:35:15 +03:30
hamid zarghami f639cbd3e6 Set background 2026-06-21 10:06:56 +03:30
hamid zarghami 5638f91c81 get pattenrs from api 2026-06-21 09:58:28 +03:30
hamid zarghami b7751a566b fix bug
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-20 11:48:02 +03:30
morteza 3bb4755a8e update text
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-19 00:47:59 +03:30
morteza b2a8975053 ltr input
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-18 23:09:24 +03:30
morteza 4d27448450 import client by excel 2026-06-18 23:01:25 +03:30
morteza 13c6de66b3 report 2026-06-18 22:07:50 +03:30
morteza d4b6d85cb5 excel export 2026-06-18 21:55:30 +03:30
morteza e89a74035b order report by food 2026-06-18 21:51:39 +03:30
morteza 3ba1290e56 modify users list page 2026-06-18 20:16:28 +03:30
morteza 1137780cf0 add stock modal
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-18 18:52:50 +03:30
morteza c588b6f6b4 persistent filters 2026-06-18 18:46:51 +03:30
morteza 59f892cb10 add avaiable stock
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-18 18:26:17 +03:30
hamid zarghami 8e27bf39e8 Card owner
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-15 12:55:28 +03:30
hamid zarghami 20309be061 cardOwner 2026-06-15 12:55:28 +03:30
hamid zarghami 22e443cf7b payment with card to card 2026-06-15 12:55:28 +03:30
hamid zarghami 1dd639c0b1 added cardNumber in list 2026-06-15 12:55:28 +03:30
hamid zarghami da738a8adc in create payment cardnumber 2026-06-15 12:55:28 +03:30
morteza 041fdf5f20 update
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-14 09:01:15 +03:30
morteza 0643d98a79 update docker
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-14 08:51:17 +03:30
hamid zarghami b438b39ada upload icon
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-13 12:28:51 +03:30
hamid zarghami 9af1df0a44 update design setting
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-08 16:41:04 +03:30
hamid zarghami 09babad479 design with image 2026-06-08 16:31:07 +03:30
hamid zarghami ad06b0c8fe design setting part pattern 2026-06-08 16:06:32 +03:30
200 changed files with 15345 additions and 2680 deletions
+2 -1
View File
@@ -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_INVOICE_URL = 'https://console.danakcorp.com/receipts/'
+8 -2
View File
@@ -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 . .
+75
View File
@@ -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);
},
});
+82 -7
View File
@@ -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)
+125 -39
View File
@@ -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",
+3
View File
@@ -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",
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 529 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 223 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 71 KiB

+39
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 343 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 768 KiB

+7 -3
View File
@@ -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 ',
+70
View File
@@ -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;
+72 -4
View File
@@ -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)}
+9 -4
View File
@@ -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' &&
+50 -17
View File
@@ -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>
+2 -25
View File
@@ -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:
+1 -1
View File
@@ -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'>
+41 -11
View File
@@ -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 &&
<div className='flex items-center gap-1'>
<label className='text-sm'>
{props.label}
{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
}
+15 -13
View File
@@ -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>
+7 -1
View File
@@ -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()} />
+19
View File
@@ -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",
},
};
+67
View File
@@ -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;
};
+33
View File
@@ -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;
}
+85 -10
View File
@@ -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": "بسته"
}
}
}
+80 -88
View File
@@ -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 => ({
const roles =
rolesData?.data?.map((role) => ({
label: role.name,
value: role.id
})) || []
value: role.id,
})) || [];
const admin = adminData?.data
const admin = adminData?.data;
const formik = useFormik<CreateAdminType>({
initialValues: {
phone: '',
firstName: '',
lastName: '',
roleId: '',
phone: "",
firstName: "",
lastName: "",
roleId: "",
},
validationSchema: Yup.object().shape({
phone: Yup.string().required('شماره تلفن الزامی است'),
firstName: Yup.string().required('نام الزامی است'),
lastName: Yup.string().required('نام خانوادگی الزامی است'),
roleId: Yup.string().required('نقش الزامی است'),
phone: Yup.string().required("شماره تلفن الزامی است"),
firstName: Yup.string().required("نام الزامی است"),
lastName: Yup.string().required("نام خانوادگی الزامی است"),
roleId: Yup.string().required("نقش الزامی است"),
}),
onSubmit: (values) => {
if (!id) {
toast.error('شناسه مدیر یافت نشد')
return
toast.error("شناسه مدیر یافت نشد");
return;
}
updateAdmin({ id, params: values }, {
updateAdmin(
{ id, params: values },
{
onSuccess: () => {
toast.success('مدیر با موفقیت به‌روزرسانی شد')
navigate(Pages.admins.list)
toast.success("مدیر با موفقیت به‌روزرسانی شد");
navigate(Pages.admins.list);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error))
toast.error(extractErrorMessage(error));
},
})
},
})
);
},
});
useEffect(() => {
if (admin) {
formik.setValues({
phone: admin.phone || '',
firstName: admin.firstName || '',
lastName: admin.lastName || '',
roleId: admin.roles?.[0]?.role?.id || '',
})
phone: admin.phone || "",
firstName: admin.firstName || "",
lastName: admin.lastName || "",
roleId: admin.role?.id || "",
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [admin])
}, [admin]);
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 (!admin) {
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='mt-5'>
<div className='flex w-full justify-between items-center'>
<div className='text-lg font-light'>
ویرایش مدیر
</div>
<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' />
<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='mt-6'>
<div className='bg-white rounded-3xl p-6'>
<div className='mb-4'>
<div className="mt-6">
<div className="bg-white rounded-3xl p-6">
<div className="mb-4">
<Input
label='نام'
name='firstName'
label="نام"
name="firstName"
value={formik.values.firstName}
onChange={formik.handleChange}
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ''}
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ""}
/>
</div>
<div className='mt-6'>
<div className="mt-6">
<Input
label='نام خانوادگی'
name='lastName'
label="نام خانوادگی"
name="lastName"
value={formik.values.lastName}
onChange={formik.handleChange}
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ''}
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 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'>
<div className="mt-6">
<Select
label='نقش'
name='roleId'
label="نقش"
name="roleId"
value={formik.values.roleId}
onChange={formik.handleChange}
items={roles}
placeholder='نقش را انتخاب کنید'
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ''}
placeholder="نقش را انتخاب کنید"
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ""}
/>
</div>
</div>
</div>
</div>
)
}
);
};
export default UpdateAdmin
export default UpdateAdmin;
+1
View File
@@ -28,6 +28,7 @@ export type Admin = {
lastName: string;
phone: string;
roles: AdminRole[];
role: RoleDetail;
};
export type GetAdminsParams = {
+110
View File
@@ -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);
}}
/>
);
},
},
];
};
+10
View File
@@ -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;
};
+47
View File
@@ -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;
+18 -30
View File
@@ -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' />
<div className='flex flex-1 flex-col h-full justify-center'>
{
stepLogin === 1 ?
<LoginStep1 />
:
stepLogin === 2 && !!phone ?
<LoginStep2 />
: null
}
</div>
<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 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
export default Login;
+5 -3
View File
@@ -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}
+196
View File
@@ -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;
+4
View File
@@ -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;
};
+60
View File
@@ -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;
}
+202 -107
View File
@@ -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='mt-9'>
<div className='w-full bg-white rounded-4xl p-8'>
<div className='text-lg font-light'>
اطلاعات مشتری
<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='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-6'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<Input
label='نام'
placeholder=''
name='name'
name='firstName'
value={formik.values.firstName}
onChange={formik.handleChange}
error_text={
formik.touched.firstName && formik.errors.firstName
? formik.errors.firstName
: ''
}
/>
<Input
label='کد ملی'
label='نام خانوادگی'
placeholder=''
name='nationalCode'
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'>
<Input
label='شماره تماس'
placeholder=''
name='phone'
/>
<Input
label='ایمیل'
placeholder=''
name='email'
type='email'
/>
</div>
<div className='grid grid-cols-2 gap-4 mt-5'>
<div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<DatePicker
label='تاریخ تولد'
placeholder='انتخاب'
onChange={() => { }}
isNotRequired
defaulValue={formik.values.birthDate}
onChange={(date) => formik.setFieldValue('birthDate', date)}
/>
</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={[]}
isNotRequired
defaulValue={formik.values.marriageDate}
onChange={(date) => formik.setFieldValue('marriageDate', date)}
/>
</div>
<div className='mt-5'>
<Input
label='عنوان آدرس'
placeholder=''
name='addressTitle'
<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'>
<Textarea
label='آدرس'
placeholder=''
name='address'
<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 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>
+1
View File
@@ -0,0 +1 @@
export { default } from "./groups/List";
+109 -118
View File
@@ -1,152 +1,141 @@
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()
const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => {
const { user, orderCount, totalOrderAmount } = userRestaurant;
const fullName = [user.firstName, user.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 ? 'فعال' : 'غیرفعال',
}
}
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[]>(() => [
const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange } = useCustomerFilters();
const { data: usersData, isLoading } = useGetUsers(apiParams);
const filterFields = useMemo<FieldType[]>(
() => [
{
type: 'input',
name: 'search',
placeholder: 'نام یا شماره تماس',
type: "input",
name: "search",
placeholder: "نام یا شماره تماس",
},
{
type: 'select',
name: 'status',
placeholder: 'انتخاب وضعیت',
defaultValue: 'all',
options: [
{ label: 'همه وضعیت‌ها', value: 'all' },
{ label: 'فعال', value: 'active' },
{ label: 'غیرفعال', value: 'inactive' },
],
},
], [])
[],
);
const customers = useMemo(() => usersData?.data ?? [], [usersData])
const userRestaurants = usersData?.data ?? [];
const tableData = useMemo<CustomerListRow[]>(() => userRestaurants.map(mapUserRestaurantToRow), [userRestaurants]);
const filteredCustomers = useMemo(() => {
const searchValue = (filters.search as string || '').toLowerCase().trim()
const statusFilter = filters.status || 'all'
const totalPages = usersData?.meta?.totalPages || 1;
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 matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'active' && customer.isActive) ||
(statusFilter === 'inactive' && !customer.isActive)
return matchesSearch && matchesStatus
})
}, [customers, filters])
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(() => [
const columns = useMemo(
() => [
{
key: 'fullName',
title: 'نام و نام خانوادگی',
key: "fullName",
title: "نام و نام خانوادگی",
},
{
key: 'phone',
title: 'شماره تماس',
key: "phone",
title: "شماره تماس",
},
{
key: 'birthDate',
title: 'تاریخ تولد',
key: "birthDate",
title: "تاریخ تولد",
},
{
key: 'marriageDate',
title: 'تاریخ ازدواج',
key: "marriageDate",
title: "تاریخ ازدواج",
},
{
key: 'wallet',
title: 'کیف پول (تومان)',
key: "orderCount",
title: "تعداد سفارش",
},
{
key: 'points',
title: 'امتیاز',
key: "totalOrderAmount",
title: "مجموع سفارش (تومان)",
},
{
key: 'status',
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>
<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: 'جزئیات',
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>
),
},
], [])
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handleFiltersChange = useCallback((newFilters: FilterValues) => {
setFilters(newFilters)
setCurrentPage(1)
}, [])
],
[navigate],
);
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 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>
</Button>
</Link> */}
<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>
</Button>
</div>
</div>
<div className='mt-8'>
<Filters
fields={filterFields}
onChange={handleFiltersChange}
initialValues={filters}
searchField="search"
/>
<div className="mt-8">
<Filters fields={filterFields} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
</div>
<Table
@@ -160,8 +149,10 @@ const CustomersList: FC = () => {
onPageChange: handlePageChange,
}}
/>
</div>
)
}
export default CustomersList
<ImportCustomersModal open={isImportModalOpen} onClose={() => setIsImportModalOpen(false)} />
</div>
);
};
export default CustomersList;
+258
View File
@@ -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
+152
View File
@@ -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;
+54
View File
@@ -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;
+60
View File
@@ -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;
+75
View File
@@ -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;
+98
View File
@@ -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;
};
+29
View File
@@ -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,
};
};
+68 -4
View File
@@ -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"] });
},
});
};
+73 -3
View File
@@ -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;
};
+135 -14
View File
@@ -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');
};
+74 -234
View File
@@ -1,67 +1,66 @@
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 categories = data?.data?.map(category => ({
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
})) || []
value: category.id,
})) || [];
const formik = useFormik<CreateFoodType>({
initialValues: {
title: '',
desc: '',
title: "",
desc: "",
content: [],
categoryId: '',
categoryId: "",
price: 0,
discount: 0,
prepareTime: 0,
weekDays: [0, 1, 2, 3, 4, 5, 6],
mealTypes: ['breakfast', 'lunch', 'dinner'],
mealTypes: ["breakfast", "lunch", "dinner"],
pickupServe: true,
inPlaceServe: true,
isActive: true,
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) => {
const submitFood = (imageUrls: string[] = []) => {
@@ -70,226 +69,67 @@ const CreateFood: FC = () => {
isActive,
isSpecialOffer: isSpecial,
images: imageUrls,
content: values.content.filter(item => item && item.trim().length > 0)
}
content: values.content.filter((item) => item && item.trim().length > 0),
};
createFood(foodData, {
onSuccess: () => {
toast.success('غذا با موفقیت ایجاد شد')
window.location.href = Pages.foods.list
toast.success("غذا با موفقیت ایجاد شد");
navigate(-1);
},
onError: (error: ErrorType) => {
toast.error(error?.response?.data?.error?.message[0] || 'خطا در ایجاد غذا')
}
})
}
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)
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();
}
}
})
},
});
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'>
<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>
<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} />
<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>
<ServiceOptionsSection formik={formik} />
</div>
</div>
<div className='w-full lg:w-auto'>
<CreateFoodSidebar
isActive={isActive}
isSpecial={isSpecial}
onChangeActive={setIsActive}
onChangeSpecial={setIsSpecial}
onChangeImages={setImageFiles}
/>
<div className="w-full lg:w-auto">
<CreateFoodSidebar isActive={isActive} isSpecial={isSpecial} onChangeActive={setIsActive} onChangeSpecial={setIsSpecial} onChangeImages={setImageFiles} />
</div>
</div>
</div>
)
}
);
};
export default CreateFood
export default CreateFood;
+107 -9
View File
@@ -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,12 +135,37 @@ 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>
)
}
+98 -98
View File
@@ -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 => ({
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
})) || []
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 }, {
updateFood(
{ id, params: foodData },
{
onSuccess: () => {
toast.success('غذا با موفقیت به‌روزرسانی شد')
navigate(Pages.foods.list)
toast.success("غذا با موفقیت به‌روزرسانی شد");
navigate(-1);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error))
}
})
}
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;
+63 -3
View File
@@ -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
+72 -2
View File
@@ -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'>
+1 -27
View File
@@ -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
+34
View File
@@ -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,
});
};
+73 -7
View File
@@ -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) => {
const handleFiltersChange = useCallback(
(newFilters: FilterValues) => {
setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE);
};
updateUrlParams(newFilters, DEFAULT_PAGE);
},
[updateUrlParams],
);
const handlePageChange = (page: number) => {
const handlePageChange = useCallback(
(page: number) => {
setCurrentPage(page);
};
updateUrlParams(filters, page);
},
[filters, updateUrlParams],
);
return {
filters,
+37
View File
@@ -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;
};
+21
View File
@@ -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>;
+94
View File
@@ -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
+97
View File
@@ -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;
+304 -119
View File
@@ -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, {
const handleVerifyPayment = (paymentId: string) => {
setVerifyingPaymentId(paymentId)
verifyPayment(paymentId, {
onSuccess: () => {
// Success handled by query invalidation
setVerifyingPaymentId(null)
},
onError: (error) => {
setVerifyingPaymentId(null)
toast.error(extractErrorMessage(error))
}
},
})
}
}
const handleSendToKitchen = () => {
changeOrderStatus({
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.PREPARING
}, {
status: OrderStatus.PREPARING,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleDeliverToCourier = () => {
changeOrderStatus({
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.SHIPPED
}, {
status: OrderStatus.SHIPPED,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleDeliverToWaiter = () => {
changeOrderStatus({
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.DELIVERED_TO_WAITER
}, {
status: OrderStatus.DELIVERED_TO_WAITER,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleDeliverToReceptionist = () => {
changeOrderStatus({
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.DELIVERED_TO_RECEPTIONIST
}, {
status: OrderStatus.DELIVERED_TO_RECEPTIONIST,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleCancelOrder = (description?: string) => {
changeOrderStatus({
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.CANCELED,
params: description ? { description } : undefined
}, {
params: description ? { description } : undefined,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
setShowCancelModal(false)
}
const handleRefund = (text?: string) => {
if (!text) return
refundPayment({
const handleCompleteOrder = () => {
changeOrderStatus(
{
orderId: order.id,
params: { description: text }
}, {
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,14 +425,38 @@ 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>
)
+97
View File
@@ -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;
+47 -5
View File
@@ -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,6 +86,15 @@ const OrdersList: FC = () => {
onPageChange: handlePageChange,
}}
/>
<SendPleaseComeSmsModal
isOpen={!!smsOrder}
onClose={() => setSmsOrder(null)}
isLoading={isSendingSms}
customerName={smsOrder?.user?.firstName || ''}
restaurantName={smsOrder?.restaurant?.name || ''}
onConfirm={handleConfirmSendSms}
/>
</div>
)
}
@@ -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

Some files were not shown because too many files have changed in this diff Show More