diff --git a/.gradle/8.11.1/checksums/checksums.lock b/.gradle/8.11.1/checksums/checksums.lock
new file mode 100644
index 00000000..d0c70f6a
Binary files /dev/null and b/.gradle/8.11.1/checksums/checksums.lock differ
diff --git a/.gradle/8.11.1/checksums/md5-checksums.bin b/.gradle/8.11.1/checksums/md5-checksums.bin
new file mode 100644
index 00000000..dab37ffa
Binary files /dev/null and b/.gradle/8.11.1/checksums/md5-checksums.bin differ
diff --git a/.gradle/8.11.1/checksums/sha1-checksums.bin b/.gradle/8.11.1/checksums/sha1-checksums.bin
new file mode 100644
index 00000000..5b6c84fe
Binary files /dev/null and b/.gradle/8.11.1/checksums/sha1-checksums.bin differ
diff --git a/.gradle/8.11.1/fileChanges/last-build.bin b/.gradle/8.11.1/fileChanges/last-build.bin
new file mode 100644
index 00000000..f76dd238
Binary files /dev/null and b/.gradle/8.11.1/fileChanges/last-build.bin differ
diff --git a/.gradle/8.11.1/fileHashes/fileHashes.lock b/.gradle/8.11.1/fileHashes/fileHashes.lock
new file mode 100644
index 00000000..23fad6df
Binary files /dev/null and b/.gradle/8.11.1/fileHashes/fileHashes.lock differ
diff --git a/.gradle/8.11.1/gc.properties b/.gradle/8.11.1/gc.properties
new file mode 100644
index 00000000..e69de29b
diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 00000000..818ee69e
Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 00000000..d1deb0c7
--- /dev/null
+++ b/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Sun Feb 22 17:15:27 GMT+03:30 2026
+gradle.version=8.11.1
diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties
new file mode 100644
index 00000000..e69de29b
diff --git a/app/build.gradle b/app/build.gradle
index 3332c14e..4da967ad 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,50 +1,27 @@
-/*
- * Copyright 2019 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
import groovy.xml.MarkupBuilder
plugins {
id 'com.android.application'
+ id 'org.jetbrains.kotlin.android' // فقط در صورتی که پروژهات از Kotlin استفاده میکند
}
def twaManifest = [
applicationId: 'ir.mysalona.testapp.twa',
- hostName: 'testapp.mysalona.ir', // The domain being opened in the TWA.
- launchUrl: '/', // The start path for the TWA. Must be relative to the domain.
- name: 'Salona', // The application name.
- launcherName: 'Salona', // The name shown on the Android Launcher.
- themeColor: '#AB25C0', // The color used for the status bar.
- themeColorDark: '#000000', // The color used for the dark status bar.
- navigationColor: '#000000', // The color used for the navigation bar.
- navigationColorDark: '#000000', // The color used for the dark navbar.
- navigationDividerColor: '#000000', // The navbar divider color.
- navigationDividerColorDark: '#000000', // The dark navbar divider color.
- backgroundColor: '#AB25C0', // The color used for the splash screen background.
- enableNotifications: true, // Set to true to enable notification delegation.
- // Every shortcut must include the following fields:
- // - name: String that will show up in the shortcut.
- // - short_name: Shorter string used if |name| is too long.
- // - url: Absolute path of the URL to launch the app with (e.g '/create').
- // - icon: Name of the resource in the drawable folder to use as an icon.
+ hostName: 'testapp.mysalona.ir',
+ launchUrl: '/',
+ name: 'Salona',
+ launcherName: 'Salona',
+ themeColor: '#AB25C0',
+ themeColorDark: '#000000',
+ navigationColor: '#000000',
+ navigationColorDark: '#000000',
+ navigationDividerColor: '#000000',
+ navigationDividerColorDark: '#000000',
+ backgroundColor: '#AB25C0',
+ enableNotifications: true,
shortcuts: [],
- // The duration of fade out animation in milliseconds to be played when removing splash screen.
splashScreenFadeOutDuration: 300,
- generatorApp: 'bubblewrap-cli', // Application that generated the Android Project
- // The fallback strategy for when Trusted Web Activity is not available. Possible values are
- // 'customtabs' and 'webview'.
+ generatorApp: 'bubblewrap-cli',
fallbackType: 'customtabs',
enableSiteSettingsShortcut: 'true',
orientation: 'default',
@@ -53,6 +30,7 @@ def twaManifest = [
android {
compileSdkVersion 36
namespace "ir.mysalona.testapp.twa"
+
defaultConfig {
applicationId "ir.mysalona.testapp.twa"
minSdkVersion 21
@@ -60,76 +38,26 @@ android {
versionCode 1
versionName "1"
- // The name for the application
resValue "string", "appName", twaManifest.name
-
- // The name for the application on the Android Launcher
resValue "string", "launcherName", twaManifest.launcherName
- // The URL that will be used when launching the TWA from the Android Launcher
def launchUrl = "https://" + twaManifest.hostName + twaManifest.launchUrl
resValue "string", "launchUrl", launchUrl
-
+ resValue "string", "webManifestUrl", 'https://testapp.mysalona.ir/manifest.webmanifest'
+ resValue "string", "fullScopeUrl", 'https://testapp.mysalona.ir/'
-
- // The URL the Web Manifest for the Progressive Web App that the TWA points to. This
- // is used by Chrome OS and Meta Quest to open the Web version of the PWA instead of
- // the TWA, as it will probably give a better user experience for non-mobile devices.
- resValue "string", "webManifestUrl", 'https://testapp.mysalona.ir/manifest.webmanifest'
-
-
-
- // This is used by Meta Quest.
- resValue "string", "fullScopeUrl", 'https://testapp.mysalona.ir/'
-
-
-
-
- // The hostname is used when building the intent-filter, so the TWA is able to
- // handle Intents to open host url of the application.
resValue "string", "hostName", twaManifest.hostName
- // This attribute sets the status bar color for the TWA. It can be either set here or in
- // `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the status bar color defaults to #FFFFFF - white.
resValue "color", "colorPrimary", twaManifest.themeColor
-
- // This attribute sets the dark status bar color for the TWA. It can be either set here or in
- // `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the status bar color defaults to #000000 - white.
resValue "color", "colorPrimaryDark", twaManifest.themeColorDark
-
- // This attribute sets the navigation bar color for the TWA. It can be either set here or
- // in `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the navigation bar color defaults to #FFFFFF - white.
resValue "color", "navigationColor", twaManifest.navigationColor
-
- // This attribute sets the dark navigation bar color for the TWA. It can be either set here
- // or in `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the navigation bar color defaults to #000000 - black.
resValue "color", "navigationColorDark", twaManifest.navigationColorDark
-
- // This attribute sets the navbar divider color for the TWA. It can be either
- // set here or in `res/values/colors.xml`. Setting in both places is an error and the app
- // will not compile. If not set, the divider color defaults to #00000000 - transparent.
resValue "color", "navigationDividerColor", twaManifest.navigationDividerColor
-
- // This attribute sets the dark navbar divider color for the TWA. It can be either
- // set here or in `res/values/colors.xml`. Setting in both places is an error and the
- //app will not compile. If not set, the divider color defaults to #000000 - black.
resValue "color", "navigationDividerColorDark", twaManifest.navigationDividerColorDark
-
- // Sets the color for the background used for the splash screen when launching the
- // Trusted Web Activity.
resValue "color", "backgroundColor", twaManifest.backgroundColor
- // Defines a provider authority for the Splash Screen
resValue "string", "providerAuthority", twaManifest.applicationId + '.fileprovider'
-
- // The enableNotification resource is used to enable or disable the
- // TrustedWebActivityService, by changing the android:enabled and android:exported
- // attributes
resValue "bool", "enableNotification", twaManifest.enableNotifications.toString()
twaManifest.shortcuts.eachWithIndex { shortcut, index ->
@@ -137,28 +65,26 @@ android {
resValue "string", "shortcut_short_name_$index", "$shortcut.short_name"
}
- // The splashScreenFadeOutDuration resource is used to set the duration of fade out animation in milliseconds
- // to be played when removing splash screen. The default is 0 (no animation).
resValue "integer", "splashScreenFadeOutDuration", twaManifest.splashScreenFadeOutDuration.toString()
-
resValue "string", "generatorApp", twaManifest.generatorApp
-
resValue "string", "fallbackType", twaManifest.fallbackType
-
resValue "bool", "enableSiteSettingsShortcut", twaManifest.enableSiteSettingsShortcut
resValue "string", "orientation", twaManifest.orientation
-
-
}
+
buildTypes {
release {
minifyEnabled true
+ // اگر از ProGuard/R8 استفاده میکنی، فایلهای proguard را اینجا اضافه کن
+ // proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
+
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
+
lintOptions {
checkReleaseBuilds false
}
@@ -169,8 +95,8 @@ task generateShorcutsFile {
twaManifest.shortcuts.eachWithIndex { s, i ->
assert s.name != null, 'Missing `name` in shortcut #' + i
assert s.short_name != null, 'Missing `short_name` in shortcut #' + i
- assert s.url != null, 'Missing `icon` in shortcut #' + i
- assert s.icon != null, 'Missing `url` in shortcut #' + i
+ assert s.url != null, 'Missing `url` in shortcut #' + i
+ assert s.icon != null, 'Missing `icon` in shortcut #' + i
}
def shortcutsFile = new File("$projectDir/src/main/res/xml", "shortcuts.xml")
@@ -178,36 +104,31 @@ task generateShorcutsFile {
def xmlWriter = new StringWriter()
def xmlMarkup = new MarkupBuilder(new IndentPrinter(xmlWriter, " ", true))
- xmlMarkup
- .'shortcuts'('xmlns:android': 'http://schemas.android.com/apk/res/android') {
- twaManifest.shortcuts.eachWithIndex { s, i ->
- 'shortcut'(
- 'android:shortcutId': 'shortcut' + i,
- 'android:enabled': 'true',
- 'android:icon': '@drawable/' + s.icon,
- 'android:shortcutShortLabel': '@string/shortcut_short_name_' + i,
- 'android:shortcutLongLabel': '@string/shortcut_name_' + i) {
- 'intent'(
- 'android:action': 'android.intent.action.MAIN',
- 'android:targetPackage': twaManifest.applicationId,
- 'android:targetClass': twaManifest.applicationId + '.LauncherActivity',
- 'android:data': s.url)
- 'categories'('android:name': 'android.intent.category.LAUNCHER')
- }
+ xmlMarkup.'shortcuts'('xmlns:android': 'http://schemas.android.com/apk/res/android') {
+ twaManifest.shortcuts.eachWithIndex { s, i ->
+ 'shortcut'(
+ 'android:shortcutId': 'shortcut' + i,
+ 'android:enabled': 'true',
+ 'android:icon': '@drawable/' + s.icon,
+ 'android:shortcutShortLabel': '@string/shortcut_short_name_' + i,
+ 'android:shortcutLongLabel': '@string/shortcut_name_' + i
+ ) {
+ 'intent'(
+ 'android:action': 'android.intent.action.MAIN',
+ 'android:targetPackage': twaManifest.applicationId,
+ 'android:targetClass': twaManifest.applicationId + '.LauncherActivity',
+ 'android:data': s.url
+ )
+ 'categories'('android:name': 'android.intent.category.LAUNCHER')
}
}
+ }
shortcutsFile.text = xmlWriter.toString() + '\n'
}
preBuild.dependsOn(generateShorcutsFile)
-repositories {
-
-}
-
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
-
- implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.6.2'
-
-}
+ implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.6.2'
+}
\ No newline at end of file
diff --git a/app/build/intermediates/incremental/mergeReleaseJniLibFolders/merger.xml b/app/build/intermediates/incremental/mergeReleaseJniLibFolders/merger.xml
new file mode 100644
index 00000000..50d7a9d6
--- /dev/null
+++ b/app/build/intermediates/incremental/mergeReleaseJniLibFolders/merger.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
index 7f3e9c19..5effdb1d 100644
--- a/app/src/main/res/xml/shortcuts.xml
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -1,16 +1 @@
-
diff --git a/build.gradle b/build.gradle
index a3d57ce0..bab6b5f2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,42 +1,19 @@
-/*
- * Copyright 2019 Google Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+// Root project build.gradle (Groovy DSL)
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-
-buildscript {
-
- repositories {
- google()
- jcenter()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:8.9.1'
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
+plugins {
+ // نسخهها را در سطح ریشه تعریف میکنیم و از apply false استفاده میکنیم
+ id 'com.android.application' version '8.13.2' apply false
+ id 'com.android.library' version '8.13.2' apply false
+ id 'org.jetbrains.kotlin.android' version '1.9.10' apply false
}
allprojects {
repositories {
google()
- jcenter()
+ mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
-}
+}
\ No newline at end of file
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
new file mode 100644
index 00000000..f93137e6
--- /dev/null
+++ b/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/gradle.properties b/gradle.properties
index 784a1d43..9edfc6ee 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,14 +1,13 @@
# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx1536m
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
+# Increase heap for Gradle daemon:
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+
+# Use AndroidX (پروژهات اگر قبلاً اینو داشت نگهش دار)
android.useAndroidX=true
+android.enableJetifier=true
+
+# Optional: proxy settings (در صورت نیاز در شبکهی شرکت/پروکسی)
+# systemProp.http.proxyHost=proxy.example.com
+# systemProp.http.proxyPort=8080
+# systemProp.https.proxyHost=proxy.example.com
+# systemProp.https.proxyPort=8080
\ No newline at end of file
diff --git a/package.json b/package.json
index 97b0b25b..3a84d33b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "app.salona",
"private": true,
- "version": "3.122.470",
+ "version": "3.122.475",
"type": "module",
"scripts": {
"dev": "vite --host",
diff --git a/settings.gradle b/settings.gradle
index e7b4def4..68e8fc81 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
include ':app'
+rootProject.name = "Salona-new-version"
\ No newline at end of file
diff --git a/src/apps/new-ui/components/AppointmentComponents/AppointmentTime.tsx b/src/apps/new-ui/components/AppointmentComponents/AppointmentTime.tsx
index 7aaeadc6..5c1fccd1 100644
--- a/src/apps/new-ui/components/AppointmentComponents/AppointmentTime.tsx
+++ b/src/apps/new-ui/components/AppointmentComponents/AppointmentTime.tsx
@@ -1,4 +1,3 @@
-
import { useEffect, useMemo, useState } from "react";
import reserveIcon from "../../assets/appointment/reserveIcon.svg";
import PersianDate from "../JalaliToPersianCalender";
@@ -8,202 +7,195 @@ import { useGetPersonnelDayTimeslots } from "../../services/PersonnelNewTest";
import { AlarmCheck } from "lucide-react";
interface Props {
- selectedDateIso?: string | null;
- addedTimes?: string[];
- onAddTime?: (time: string) => boolean | void;
- onRemoveAddedTime?: (time: string) => void;
- setAppointmentToSelectedCard?: boolean;
- dayScheduleData?: any; // fallback
- isShowNewTime?: boolean;
- hasBottomGap?: boolean;
- isShowAddServices?: boolean;
- onGotoSchedule?: (jalaliDate?: string) => void;
- showNoSlotsHelper?: boolean;
- // personel_service_id که از PersonnelScroller میاد
- personelServiceId?: number | null;
+ selectedDateIso?: string | null;
+ addedTimes?: string[];
+ onAddTime?: (time: string) => boolean | void;
+ onRemoveAddedTime?: (time: string) => void;
+ setAppointmentToSelectedCard?: boolean;
+ dayScheduleData?: any; // fallback
+ isShowNewTime?: boolean;
+ hasBottomGap?: boolean;
+ isShowAddServices?: boolean;
+ onGotoSchedule?: (jalaliDate?: string) => void;
+ showNoSlotsHelper?: boolean;
+ // personel_service_id که از PersonnelScroller میاد
+ personelServiceId?: number | null;
}
const AppointmentTime: React.FC = ({
- selectedDateIso = null,
- dayScheduleData,
- hasBottomGap = true,
- showNoSlotsHelper = false,
- isShowAddServices = true,
- personelServiceId, // این personel_service_id هست
+ selectedDateIso = null,
+ dayScheduleData,
+ hasBottomGap = true,
+ showNoSlotsHelper = false,
+ isShowAddServices = true,
+ personelServiceId, // این personel_service_id هست
}) => {
+ const appointmentDateJalali = localStorage.getItem("appointmentDate");
+ const appointmentWeekday = localStorage.getItem("appointmentWeekday");
+ const [selectedTime, setSelectedTime] = useState("");
- const appointmentDateJalali = localStorage.getItem("appointmentDate");
- const appointmentWeekday = localStorage.getItem("appointmentWeekday");
- const [selectedTime, setSelectedTime] = useState("");
+ const date = appointmentDateJalali ?? "";
+ const [jy, jm, jd] = date.split("/").map(Number);
-
+ // ============== تغییر اصلی اینجا ==============
+ // برای استفاده از API روزانه، نیاز به personel_id داریم
+ // از localStorage میتونیم personel_id رو دریافت کنیم یا از props دیگه
- const date = appointmentDateJalali ?? "";
- const [jy, jm, jd] = date.split("/").map(Number);
+ // استخراج personel_id از localStorage یا از طریق یه prop جدید
+ // فعلاً فرض میکنیم که personelServiceId همون personel_id هست
+ // اگه نیست، باید از یه راه دیگه personel_id رو بدست بیاریم
- // ============== تغییر اصلی اینجا ==============
- // برای استفاده از API روزانه، نیاز به personel_id داریم
- // از localStorage میتونیم personel_id رو دریافت کنیم یا از props دیگه
+ const { data: personnelDayData, isLoading } = useGetPersonnelDayTimeslots(
+ personelServiceId ?? undefined, // اینجا باید personel_id باشه نه personel_service_id
+ jy,
+ jm,
+ jd,
+ );
- // استخراج personel_id از localStorage یا از طریق یه prop جدید
- // فعلاً فرض میکنیم که personelServiceId همون personel_id هست
- // اگه نیست، باید از یه راه دیگه personel_id رو بدست بیاریم
+ // فیلتر کردن بر اساس personel_service_id دریافتی
+ const availableSlots = useMemo(() => {
+ // اگه personelServiceId وجود نداشت، از dayScheduleData استفاده کن
+ if (!personelServiceId) {
+ if (dayScheduleData) {
+ return dayScheduleData.timeslots ?? dayScheduleData ?? [];
+ }
+ return [];
+ }
- const { data: personnelDayData, isLoading } = useGetPersonnelDayTimeslots(
- personelServiceId ?? undefined, // اینجا باید personel_id باشه نه personel_service_id
- jy,
- jm,
- jd,
- );
+ // اولویت با دیتای دریافتی از API جدید
+ if (personnelDayData?.services && Array.isArray(personnelDayData.services)) {
+ // پیدا کردن سرویسی که personel_service_id match داره
+ const matchedService = personnelDayData.services.find(
+ (service: any) => service.personel_service_id === personelServiceId,
+ );
- // فیلتر کردن بر اساس personel_service_id دریافتی
- const availableSlots = useMemo(() => {
- // اگه personelServiceId وجود نداشت، از dayScheduleData استفاده کن
- if (!personelServiceId) {
- if (dayScheduleData) {
- return dayScheduleData.timeslots ?? dayScheduleData ?? [];
- }
- return [];
- }
+ if (matchedService?.time_slots) {
+ return matchedService.time_slots;
+ }
- // اولویت با دیتای دریافتی از API جدید
- if (
- personnelDayData?.services &&
- Array.isArray(personnelDayData.services)
- ) {
- // پیدا کردن سرویسی که personel_service_id match داره
- const matchedService = personnelDayData.services.find(
- (service: any) => service.personel_service_id === personelServiceId,
- );
+ // اگه سرویس match نشد، هیچ اسلاتی نشون نده
+ return [];
+ }
+
+ console.log(dayScheduleData);
- if (matchedService?.time_slots) {
- return matchedService.time_slots;
- }
+ // fallback به dayScheduleData قدیمی
+ if (dayScheduleData) {
+ return dayScheduleData.timeslots ?? dayScheduleData ?? [];
+ }
- // اگه سرویس match نشد، هیچ اسلاتی نشون نده
- return [];
- }
+ return [];
+ }, [personnelDayData, dayScheduleData, personelServiceId]);
- // fallback به dayScheduleData قدیمی
- if (dayScheduleData) {
- return dayScheduleData.timeslots ?? dayScheduleData ?? [];
- }
- return [];
- }, [personnelDayData, dayScheduleData, personelServiceId]);
+ useEffect(() => {
+ const t = localStorage.getItem("appointmentTime");
+ setSelectedTime(t || "");
+ }, [selectedDateIso]);
- useEffect(() => {
- const t = localStorage.getItem("appointmentTime");
- setSelectedTime(t || "");
- }, [selectedDateIso]);
+ useEffect(() => {
+ setSelectedTime("");
+ }, [localStorage.getItem("appointmentDate")]);
- useEffect(() => {
- setSelectedTime("");
- }, [localStorage.getItem("appointmentDate")]);
+ useEffect(() => {
+ setSelectedTime("");
+ }, [personelServiceId]);
- useEffect(() => {
- setSelectedTime("");
- }, [personelServiceId]);
-
- return (
-
-
-
-
{localStorage.getItem("appointmentTime")}
-
-
{appointmentWeekday}
-
-
- {appointmentDateJalali ? (
-
- ) : (
- ""
- )}
-
-
-
-
-
-
- ساعت
-
-
- {isLoading ? (
-
- ) : availableSlots.length === 0 && showNoSlotsHelper ? (
-
-
-
- برای این روز هیچ ساعتی تعریف نشده.
-
- {isShowAddServices && (
- <>
-
- برای ثبت نوبت و ساعات کاری، به جدول نوبتها برو و برای این
- روز زمان تعریف کن.
-
-
-
-
- رفتن به جدول نوبتدهی
-
+ return (
+
+
+
+
{localStorage.getItem("appointmentTime")}
+
+
{appointmentWeekday}
+
-
+ {appointmentDateJalali ?
: ""}
- >
- )}
-
- ) : (
-
- {availableSlots.map((slot: any, idx: number) => {
- const isReserved =
- slot.is_free === undefined ? false : !slot.is_free;
- const isSelected = selectedTime === slot.start_time;
- const isPendingSomeone = slot.conflicts?.length > 0;
+
- // const totalReserved = slot.conflicts?.length || 0;
-
- // const allCustomers =
- // slot.conflicts?.map((id: any) =>
- // appointmentsData?.appointments.find(
- // (apt: any) => Number(apt.id) === id,
- // ),
- // ) || [];
-
- // const firstFourCustomers = allCustomers.slice(0, 4);
-
- return (
+
{
- // if (isPendingSomeone || isReserved) return;
+ className={`time-step-target w-full mt-2 border-y-2 px-4 relative ${
+ !(availableSlots.length === 0 && showNoSlotsHelper) && "py-8"
+ }`}
+ >
+
ساعت
- // validateAndSelectTime({
- // date: appointmentDateGregorian,
- // time: slot.start_time,
- // onSuccess: () => {
- // setSelectedTime(slot.start_time);
- // localStorage.setItem("appointmentTime", slot.start_time);
- // },
- // });
- // }}
- className={`relative py-4 h-32 w-[95px] mx-auto rounded-xl flex flex-col shadow-[0_2px_8px_0_#2D0A4829] cursor-pointer items-center justify-center gap-1 ${
- idx === 0 ? "time-slot-step" : ""
- }
+ {isLoading ? (
+
+ ) : availableSlots.length === 0 && showNoSlotsHelper ? (
+
+
+
+ برای این روز هیچ ساعتی تعریف نشده.
+
+ {isShowAddServices && (
+ <>
+
+ برای ثبت نوبت و ساعات کاری، به جدول نوبتها برو و برای این روز زمان تعریف
+ کن.
+
+
+
+
+ رفتن به جدول نوبتدهی
+
+
+ >
+ )}
+
+ ) : (
+
+ {availableSlots.map((slot: any, idx: number) => {
+ console.log(selectedTime,slot.is_free);
+ const isReserved = slot.is_free === undefined ? false : !slot.is_free;
+ const isSelected = selectedTime === slot.start_time;
+ const isPendingSomeone = slot.conflicts?.length > 0;
+
+ // const isReserved = !slot.is_free;
+ // const isSelected = time === slot.start_time;
+ // const isPendingSomeone = slot.conflicts?.length > 0;
+
+ // const totalReserved = slot.conflicts?.length || 0;
+
+ // const allCustomers =
+ // slot.conflicts?.map((id: any) =>
+ // appointmentsData?.appointments.find(
+ // (apt: any) => Number(apt.id) === id,
+ // ),
+ // ) || [];
+
+ // const firstFourCustomers = allCustomers.slice(0, 4);
+
+ return (
+
{
+ // if (isPendingSomeone || isReserved) return;
+
+ // validateAndSelectTime({
+ // date: appointmentDateGregorian,
+ // time: slot.start_time,
+ // onSuccess: () => {
+ // setSelectedTime(slot.start_time);
+ // localStorage.setItem("appointmentTime", slot.start_time);
+ // },
+ // });
+ // }}
+ className={`relative py-4 h-32 w-[95px] mx-auto rounded-xl flex flex-col shadow-[0_2px_8px_0_#2D0A4829] cursor-pointer items-center justify-center gap-1 ${
+ idx === 0 ? "time-slot-step" : ""
+ }
${isSelected ? "text-[#AA00FF] border-2 border-[#AA00FF66]" : ""}
${isReserved ? "opacity-50 pointer-events-none" : "bg-white "}
${isPendingSomeone ? "opacity-50 pointer-events-none" : ""}
`}
- >
- {/* {isReserved && (
+ >
+ {/* {isReserved && (
= ({
)} */}
-
-
-
-
-
- {slot.start_time}
-
-
تا
-
- {slot.end_time}
-
-
+
+
+
+
+
+ {slot.start_time}
+
+
تا
+
+ {slot.end_time}
+
+
- {/* {isReserved ? (
+ {/* {isReserved ? (
totalReserved === 1 ? (
رزرو شده
@@ -270,11 +262,11 @@ const AppointmentTime: React.FC = ({
خالی
)} */}
-
- );
- })}
+
+ );
+ })}
- {/*
- )}
-
+
+ )}
+
+
+
-
-
- );
+ );
};
export default AppointmentTime;
diff --git a/src/apps/new-ui/pages/AdvancedTurn/index.tsx b/src/apps/new-ui/pages/AdvancedTurn/index.tsx
index 8662c429..ee4facca 100644
--- a/src/apps/new-ui/pages/AdvancedTurn/index.tsx
+++ b/src/apps/new-ui/pages/AdvancedTurn/index.tsx
@@ -35,6 +35,7 @@ const ClearKeysList = [
"btnTrigger",
"reservedId",
"editAppointmentName",
+ "appointmentDateGregorian",
"editAppointmentPhone",
"editAppointmentService",
"editAppointmentServiceCost",
@@ -84,6 +85,38 @@ const AdvancedTurn = () => {
}
};
}, []);
+
+ useEffect(() => {
+ return () => {
+ const path = window.location.pathname || "";
+ const keepRoutes = ["/appointments/advance", "/purchase-summary"];
+
+ const shouldClear = !keepRoutes.some((r) => path.includes(r));
+
+ if (shouldClear) {
+ // حذف کلیدهای ثابت
+ ClearKeysList.forEach((key) => localStorage.removeItem(key));
+
+ // حذف کلیدهای دینامیک که با 'appointmentTime:' شروع میشوند
+ const dynamicPrefix = "appointmentTime:";
+ const keysToRemove = [];
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (!k) continue;
+ if (k.startsWith(dynamicPrefix)) keysToRemove.push(k);
+
+ // اگر فقط فرمت تاریخ مشخصی میخواهی (مثلاً YYYY-MM-DD) از regex استفاده کن:
+ // if (/^appointmentTime:\d{4}-\d{2}-\d{2}$/.test(k)) keysToRemove.push(k);
+ }
+
+ keysToRemove.forEach((k) => localStorage.removeItem(k));
+
+ // برای دیباگ (اختیاری)
+ // console.log("Removed dynamic keys:", keysToRemove);
+ }
+ };
+ }, []);
return (
diff --git a/src/apps/new-ui/pages/BusinessCard/card-config/+components/EditPhoto.tsx b/src/apps/new-ui/pages/BusinessCard/card-config/+components/EditPhoto.tsx
index 3e088965..a5c639ad 100644
--- a/src/apps/new-ui/pages/BusinessCard/card-config/+components/EditPhoto.tsx
+++ b/src/apps/new-ui/pages/BusinessCard/card-config/+components/EditPhoto.tsx
@@ -6,8 +6,8 @@ import toast from "react-hot-toast";
import { Trash2 } from "lucide-react";
interface EditPhotoProps {
- profilePhoto: string | File;
- setProfilePhoto: (photo: string | File) => void;
+ profilePhoto: string | File | null;
+ setProfilePhoto: (photo: string | File | null) => void;
apiBaseUrl?: string;
}
@@ -16,37 +16,56 @@ const EditBusinessPhoto = ({
setProfilePhoto,
apiBaseUrl = import.meta.env.VITE_CARD_API_URL,
}: EditPhotoProps) => {
- const [selectedFileForCrop, setSelectedFileForCrop] = useState<
- File | undefined
- >();
+ const [selectedFileForCrop, setSelectedFileForCrop] = useState
();
const [isCropperOpen, setIsCropperOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef(null);
- // تابع کمکی برای تبدیل profilePhoto به URL قابل نمایش
- const getPhotoUrl = () => {
- if (!profilePhoto) return profileDefault;
+ // مدیریت URL برای File ها (تا createObjectURL فقط یکبار ساخته و revoke شود)
+ const [localPhotoUrl, setLocalPhotoUrl] = useState(null);
+ const prevObjectUrlRef = useRef(null);
+
+ useEffect(() => {
+ // clean previous object url
+ if (prevObjectUrlRef.current) {
+ try {
+ URL.revokeObjectURL(prevObjectUrlRef.current);
+ } catch {}
+ prevObjectUrlRef.current = null;
+ }
+
+ if (!profilePhoto) {
+ setLocalPhotoUrl(null);
+ return;
+ }
if (typeof profilePhoto === "string") {
- if (profilePhoto && profilePhoto !== "default") {
- return `${apiBaseUrl}/${profilePhoto}`;
+ // اگر string و default یا آدرس backend هست
+ if (profilePhoto === "default" || profilePhoto === "") {
+ setLocalPhotoUrl(null);
+ } else {
+ setLocalPhotoUrl(`${apiBaseUrl}/${profilePhoto}`);
}
- return profileDefault;
+ return;
}
// اگر File باشد
- return URL.createObjectURL(profilePhoto);
- };
+ const obj = URL.createObjectURL(profilePhoto);
+ prevObjectUrlRef.current = obj;
+ setLocalPhotoUrl(obj);
- // cleanup object URL هنگام unmount
- useEffect(() => {
return () => {
- if (profilePhoto && typeof profilePhoto !== "string") {
- URL.revokeObjectURL(URL.createObjectURL(profilePhoto));
+ if (prevObjectUrlRef.current) {
+ try {
+ URL.revokeObjectURL(prevObjectUrlRef.current);
+ } catch {}
+ prevObjectUrlRef.current = null;
}
};
- }, [profilePhoto]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [profilePhoto, apiBaseUrl]);
+ // validate & open cropper
const handleFileSelect = (file: File) => {
const allowedTypes = ["image/jpeg", "image/png", "image/jpg"];
if (!allowedTypes.includes(file.type)) {
@@ -65,30 +84,19 @@ const EditBusinessPhoto = ({
};
const uploadProfilePictureHandler = async (file: File) => {
- const MAX_FILE_SIZE = 5 * 1024 * 1024;
- if (file.size > MAX_FILE_SIZE) {
- toast.error("حجم فایل باید کمتر از ۵ مگابایت باشد");
- return;
- }
-
- const allowedTypes = ["image/jpeg", "image/png", "image/jpg"];
- if (!allowedTypes.includes(file.type)) {
- toast.error("فقط فایلهای JPEG ،JPG و PNG مجاز هستند");
- return;
- }
-
+ // در حالت واقعی: آپلود به سرور انجام شود و سپس setProfilePhoto با path سرور
+ // اینجا برای نمونه مستقیم ست میکنیم
setIsUploading(true);
try {
- // شبیهسازی آپلود با کمی تاخیر (در صورت نبود API واقعی)
- await new Promise((resolve) => setTimeout(resolve, 1000));
+ // simulate upload delay
+ await new Promise((r) => setTimeout(r, 800));
- // ست کردن فایل مستقیم (مشابه کامپوننت قبلی)
setProfilePhoto(file);
toast.success("تصویر پروفایل با موفقیت به روز رسانی شد");
setSelectedFileForCrop(undefined);
setIsCropperOpen(false);
- } catch {
+ } catch (err) {
toast.error("خطا در آپلود تصویر");
} finally {
setIsUploading(false);
@@ -97,7 +105,7 @@ const EditBusinessPhoto = ({
const deleteProfilePictureHandler = () => {
try {
- setProfilePhoto("");
+ setProfilePhoto("default");
toast.success("تصویر پروفایل با موفقیت حذف گردید");
} catch {
toast.error("خطا در حذف تصویر");
@@ -108,16 +116,13 @@ const EditBusinessPhoto = ({
fileInputRef.current?.click();
};
- const currentPhotoUrl = getPhotoUrl();
- const isDefaultPhoto =
- !profilePhoto || profilePhoto === "default" || profilePhoto === "";
+ const currentPhotoUrl = localPhotoUrl || profileDefault;
+ const isDefaultPhoto = !profilePhoto || profilePhoto === "default" || profilePhoto === "";
return (
تصویر پروفایل
- {/* عکس پروفایل */}
-
- {/* دکمه حذف - فقط وقتی عکس پیشفرض نباشه نمایش داده میشه */}
{!isDefaultPhoto && (
- {/* توضیحات */}
-
- برای تغییر عکس روی آن کلیک کنید
-
+
برای تغییر عکس روی آن کلیک کنید
فرمتهای مجاز: JPEG ،JPG و PNG (حداکثر ۵ مگابایت)
- {/* input مخفی برای انتخاب فایل */}
0) {
handleFileSelect(e.target.files[0]);
}
- e.target.value = "";
+ // reset so same file can be selected again
+ e.currentTarget.value = "";
}}
/>
- {/* مودال کراپ عکس */}
{isCropperOpen && selectedFileForCrop && (
)}
@@ -189,4 +190,4 @@ const EditBusinessPhoto = ({
);
};
-export default EditBusinessPhoto;
+export default EditBusinessPhoto;
\ No newline at end of file
diff --git a/src/apps/new-ui/pages/Personnel/+components/ManageServicesModal.tsx b/src/apps/new-ui/pages/Personnel/+components/ManageServicesModal.tsx
index e3ddf941..858386cd 100644
--- a/src/apps/new-ui/pages/Personnel/+components/ManageServicesModal.tsx
+++ b/src/apps/new-ui/pages/Personnel/+components/ManageServicesModal.tsx
@@ -1,488 +1,102 @@
-import { useEffect, useMemo, useState, useRef } from "react";
+import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import ServicesQA from "./ServicesQA";
import ServicePrice from "./ServicePrice";
import ServiceRent from "./ServiceRent";
import PercentInput from "./PercentInput";
import { showWarningAlert } from "@/apps/new-ui/components/CustomAlert";
import {
- useGetPersonel,
- useAddServiceToPersonel,
- useDeletePersonelService,
+ useGetPersonel,
+ useAddServiceToPersonel,
+ useDeletePersonelService,
} from "@/apps/new-ui/services/PersonnelServices";
import { notifyOnce } from "@/lib/toastManager-2";
import MobileTimePickerCustomize from "@/apps/new-ui/components/MobilePicker/MobileTimePickerCustomize";
interface Props {
- personelId: number;
- onClose: () => void;
+ personelId: number;
+ onClose: () => void;
}
const activityTypes = [
- { id: "percentage", label: "درصدی", apiValue: "درصدی" },
- { id: "rent", label: "اجارهای", apiValue: "اجارهای" },
+ { id: "percentage", label: "درصدی", apiValue: "درصدی" },
+ { id: "rent", label: "اجارهای", apiValue: "اجارهای" },
];
const categories = [
- { id: "کارآموز", label: "کارآموز" },
- { id: "حرفهای", label: "حرفهای" },
- { id: "استادکار", label: "استادکار" },
+ { id: "کارآموز", label: "کارآموز" },
+ { id: "حرفهای", label: "حرفهای" },
+ { id: "استادکار", label: "استادکار" },
];
const LS = {
- SALON_PERCENTAGE: "salonPercentage",
- SERVICE_COST: "serviceCost",
- SERVICE_RENT: "serviceRent",
- SERVICE_TITLE: "serviceTitle",
- SERVICE_PRICE: "servicePrice",
- SERVICE_DURATION: "serviceDuration",
+ SALON_PERCENTAGE: "salonPercentage",
+ SERVICE_COST: "serviceCost",
+ SERVICE_RENT: "serviceRent",
+ SERVICE_TITLE: "serviceTitle",
+ SERVICE_PRICE: "servicePrice",
+ SERVICE_DURATION: "serviceDuration",
};
const durationDefaults = ["00:45", "01:00", "01:30", "02:00"];
const ManageServicesModal: React.FC
= ({ personelId, onClose }) => {
- const { data: personelData, refetch } = useGetPersonel(personelId, {
- enabled: Boolean(personelId),
- });
-
- const addService = useAddServiceToPersonel();
- const deleteService = useDeletePersonelService();
-
- const [selectedService, setSelectedService] = useState("");
- const [userSelectedService, setUserSelectedService] =
- useState(false);
-
- const [servicePriceFormatted, setServicePriceFormatted] =
- useState("");
- const [servicePriceEdited, setServicePriceEdited] = useState(false);
-
- const [serviceRentFormatted, setServiceRentFormatted] = useState("");
- const [salonPercentage, setSalonPercentage] = useState("");
- const [activityType, setActivityType] = useState("");
- const [category, setCategory] = useState("");
-
- const [existingServiceId, setExistingServiceId] = useState(
- null,
- );
- const [isEditMode, setIsEditMode] = useState(false);
-
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const [initialSnapshot, setInitialSnapshot] = useState<{
- selectedService: string;
- serviceRentFormatted: string;
- servicePriceFormatted: string;
- salonPercentage: number | "";
- activityType: string;
- category: string;
- personel_service_id?: number | null;
- serviceDuration?: string;
- } | null>(null);
-
- // duration ref + state
- const durationRef = useRef(null);
- const [time, setTime] = useState("");
-
- // --------------clear-------------------------------
- const clearPersonnelLS = () => {
- Object.values(LS).forEach((key) => {
- localStorage.removeItem(key);
+ const { data: personelData, refetch } = useGetPersonel(personelId, {
+ enabled: Boolean(personelId),
});
- };
- const detectActivityTypeFromString = (s?: string | null) => {
- if (!s) return "";
- const str = String(s).toLowerCase();
- if (str.includes("درصد") || str.includes("percentage") || str.includes("%"))
- return "percentage";
- if (str.includes("اجاره") || str.includes("rent")) return "rent";
- const found = activityTypes.find(
- (a) => String(a.apiValue).toLowerCase() === str,
- );
- return found?.id ?? "";
- };
+ const addService = useAddServiceToPersonel();
+ const deleteService = useDeletePersonelService();
- // isValidDuration helper (same logic as AddService)
- const isValidDuration = (val: string) => {
- if (!val) return false;
- const [hh, mm] = val.split(":").map(Number);
- return hh * 60 + mm > 0;
- };
+ const [selectedService, setSelectedService] = useState("");
+ const [userSelectedService, setUserSelectedService] = useState(false);
- // populateForServiceName: پر کردن ورودیها بر اساس نام سرویس (از سرور یا کلیک کاربر)
- const populateForServiceName = (
- name: string | null,
- isUserAction = false,
- ) => {
- if (!personelData) return;
- const services: any[] = personelData.services ?? [];
- if (!name || name.trim() === "") {
- setExistingServiceId(null);
- setIsEditMode(false);
- setActivityType("");
- setCategory("");
- setServicePriceFormatted("");
- setServiceRentFormatted("");
- setSalonPercentage("");
- setServicePriceEdited(false);
- setTime("");
- return;
- }
+ const [servicePriceFormatted, setServicePriceFormatted] = useState("");
+ const [servicePriceEdited, setServicePriceEdited] = useState(false);
- const matched = services.find(
- (s) =>
- String(s.service_name ?? s.name ?? "")
- .trim()
- .toLowerCase() ===
- String(name ?? "")
- .trim()
- .toLowerCase(),
- );
+ const [serviceRentFormatted, setServiceRentFormatted] = useState("");
+ const [salonPercentage, setSalonPercentage] = useState("");
+ const [activityType, setActivityType] = useState("");
+ const [category, setCategory] = useState("");
- if (!matched) {
- setExistingServiceId(null);
- setIsEditMode(false);
- setActivityType("");
- setCategory("");
- setServicePriceFormatted("");
- setServiceRentFormatted("");
- setSalonPercentage("");
- setServicePriceEdited(false);
- setTime("");
- return;
- }
+ const [existingServiceId, setExistingServiceId] = useState(null);
+ const [isEditMode, setIsEditMode] = useState(false);
- // const amount = matched.amount ?? matched.price ?? matched.cost ?? "";
- const servicePrice = matched.price != null ? String(matched.price) : "";
+ const [isSubmitting, setIsSubmitting] = useState(false);
- const serviceRent = matched.amount != null ? String(matched.amount) : "";
- const matchedType = detectActivityTypeFromString(
- matched.type_of_service ?? matched.type ?? personelData.type ?? "",
- );
- const matchedCategory =
- matched.category ??
- matched.type ??
- matched.personel_type ??
- personelData.type ??
- "";
+ const [initialSnapshot, setInitialSnapshot] = useState<{
+ selectedService: string;
+ serviceRentFormatted: string;
+ servicePriceFormatted: string;
+ salonPercentage: number | "";
+ activityType: string;
+ category: string;
+ personel_service_id?: number | null;
+ serviceDuration?: string;
+ } | null>(null);
- // اگر نوع اجارهای بود مقدار باید در ServiceRent بنشیند
- if (matchedType === "percentage") {
- setServiceRentFormatted("");
+ // duration ref + state
+ const durationRef = useRef(null);
+ const [time, setTime] = useState("");
- setServicePriceFormatted(servicePrice);
- // setSalonPercentage(
- // amount === null || amount === undefined ? "" : Number(amount)
- // );
- setSalonPercentage(matched.amount == null ? "" : Number(matched.amount));
- setActivityType("percentage");
- } else {
- // setServiceRentFormatted(amount ? String(amount) : "");
- setSalonPercentage("");
- // ServicePrice را دست نخورده میگذاریم (طبق خواسته) — مگر بخواهی آن را هم پر کنیم
- // setServicePriceFormatted("");
- setActivityType("rent");
+ // ✅ Ref برای نگهداری آخرین نسخه personelData
+ const personelDataRef = useRef(personelData);
+ useEffect(() => {
+ personelDataRef.current = personelData;
+ }, [personelData]);
- // مبلغ اجاره
- setServiceRentFormatted(serviceRent);
-
- // مبلغ خدمات (نه اجاره!)
- setServicePriceFormatted(servicePrice);
- }
-
- const matchedDuration =
- matched.time ??
- matched.duration ??
- matched.service_time ??
- matched.length ??
- "";
-
- setCategory(matchedCategory ?? "");
- setExistingServiceId(matched.service_id ?? null);
- setIsEditMode(Boolean(matched.service_id));
- // اگر کاربر خودش کلیک کرده فلگ userSelectedService را بزن
- if (isUserAction) setUserSelectedService(true);
-
- // چون مقدار از سرور میآید، اینها prefill محسوب میشوند: servicePriceEdited=false
- setServicePriceEdited(false);
-
- // set duration if present
- setTime(matchedDuration ? String(matchedDuration) : "");
- };
-
- // initial snapshot from server (prefill but do not mark userSelectedService)
- useEffect(() => {
- if (!personelData) return;
- const p: any = personelData;
- if (!p.services || p.services.length === 0) {
- setInitialSnapshot(null);
- return;
- }
- const svc = p.services[0];
- // const amount = svc.amount ?? svc.price ?? svc.cost ?? "";
- const detectedType = detectActivityTypeFromString(
- svc.type_of_service ?? svc.type ?? p.type_of_service,
- );
-
- const detectedDuration =
- svc.time ?? svc.duration ?? svc.service_time ?? svc.length ?? "";
-
- const snapshot: any = {
- // selectedService: svc.service_name ?? svc.name ?? "",
- // servicePriceFormatted: "",
- // serviceRentFormatted: detectedType === "rent" ? (amount ? String(amount) : "") : "",
- // salonPercentage:
- // detectedType === "percentage" ? (amount === null || amount === undefined ? "" : Number(amount)) : "",
- // activityType: detectedType ?? "",
- // category: svc.category ?? svc.type ?? svc.personel_type ?? p.type ?? "",
- // personel_service_id: svc.personel_service_id ?? svc.id ?? null,
- selectedService: svc.service_name ?? svc.name ?? "",
- servicePriceFormatted:
- detectedType === "rent" && svc.price ? String(svc.price) : "",
- serviceRentFormatted:
- detectedType === "rent" && svc.amount ? String(svc.amount) : "",
- salonPercentage: detectedType === "percentage" ? (svc.amount ?? "") : "",
- activityType: detectedType ?? "",
- category: svc.category ?? svc.type ?? svc.personel_type ?? p.type ?? "",
- personel_service_id: svc.personel_service_id ?? svc.id ?? null,
- serviceDuration: detectedDuration ? String(detectedDuration) : "",
+ // --------------clear-------------------------------
+ const clearPersonnelLS = () => {
+ Object.values(LS).forEach((key) => {
+ localStorage.removeItem(key);
+ });
};
- setInitialSnapshot(snapshot);
-
- // ==============این قسمت باعث میشده اولین سرویسی که ثبت کرده سلکت بشه=============
- // if (!selectedService || selectedService.trim() === "") {
- // setSelectedService(snapshot.selectedService);
- // populateForServiceName(snapshot.selectedService, false);
- // }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [personelData]);
-
- // when user selects a service via UI
- const onServiceSelect = (v: string) => {
- setSelectedService(v);
- setUserSelectedService(Boolean(v && v.trim() !== ""));
- // populate immediately using the provided name (isUserAction = true)
- populateForServiceName(v, true);
- };
-
- // duration handlers
- const handleDurationSelect = (t: string) => {
- setTime(t);
- setTimeout(() => {
- durationRef.current?.scrollIntoView({
- behavior: "smooth",
- block: "start",
- });
- }, 100);
- };
-
- const handleDurationChange = (t: string) => {
- setTime(t);
- setTimeout(() => {
- durationRef.current?.scrollIntoView({
- behavior: "smooth",
- block: "start",
- });
- }, 100);
- };
-
- // validation
- const serviceSelected = Boolean(
- selectedService && selectedService.trim() !== "",
- );
- const activitySelected = Boolean(activityType && activityType.trim() !== "");
- const categorySelected = Boolean(category && category.trim() !== "");
-
- const amountValid = useMemo(() => {
- if (!servicePriceFormatted || servicePriceFormatted.trim() === "") {
- return false;
- }
- const priceNum = Number(String(servicePriceFormatted).replace(/,/g, ""));
- if (!Number.isFinite(priceNum) || priceNum <= 0) {
- return false;
- }
- if (activityType !== "rent") return true;
- const rentSource = serviceRentFormatted || servicePriceFormatted;
- if (!rentSource) return false;
- const n = Number(String(rentSource).replace(/,/g, ""));
- return Number.isFinite(n) && n > 0;
- }, [activityType, servicePriceFormatted, serviceRentFormatted]);
-
- const percentValid = useMemo(() => {
- if (activityType !== "percentage") return true;
- if (salonPercentage === "" || salonPercentage === null) return false;
- const n = Number(salonPercentage);
- return Number.isFinite(n) && n >= 0 && n <= 100;
- }, [activityType, salonPercentage]);
-
- const canSaveBase =
- serviceSelected &&
- activitySelected &&
- categorySelected &&
- amountValid &&
- percentValid &&
- servicePriceFormatted && // بررسی وجود مقدار
- servicePriceFormatted.trim() !== ""; // بررسی خالی نبودن
-
- const isDirty = useMemo(() => {
- if (!isEditMode) return true;
- if (!initialSnapshot) return true;
- const normalizeNumber = (v: string | number | "" | undefined) => {
- if (v === "" || v === undefined || v === null) return 0;
- const num = Number(String(v).replace(/,/g, ""));
- return Number.isFinite(num) ? num : 0;
- };
- const prevRent = normalizeNumber(initialSnapshot.serviceRentFormatted);
- const curRent = normalizeNumber(serviceRentFormatted);
- const prevPrice = normalizeNumber(initialSnapshot.servicePriceFormatted);
- const curPrice = normalizeNumber(servicePriceFormatted);
- const prevPercent =
- initialSnapshot.salonPercentage === ""
- ? 0
- : Number(initialSnapshot.salonPercentage);
- const curPercent = salonPercentage === "" ? 0 : Number(salonPercentage);
-
- const prevDuration = initialSnapshot.serviceDuration ?? "";
- const curDuration = time ?? "";
-
- return (
- String(selectedService ?? "").trim() !==
- String(initialSnapshot.selectedService ?? "").trim() ||
- String(activityType ?? "") !==
- String(initialSnapshot.activityType ?? "") ||
- String(category ?? "") !== String(initialSnapshot.category ?? "") ||
- prevRent !== curRent ||
- prevPrice !== curPrice ||
- prevPercent !== curPercent ||
- prevDuration !== curDuration
- );
- }, [
- isEditMode,
- initialSnapshot,
- selectedService,
- activityType,
- category,
- serviceRentFormatted,
- servicePriceFormatted,
- salonPercentage,
- time,
- ]);
-
- const canSave = useMemo(() => {
- if (!canSaveBase) return false;
- if (isEditMode) return isDirty;
- return true;
- }, [canSaveBase, isEditMode, isDirty]);
-
- const getActivityApiValue = (id: string) =>
- activityTypes.find((a) => a.id === id)?.apiValue ??
- activityTypes[0].apiValue;
-
- const buildServicePayload = (_personel_id: number | null) => {
- const rawPrice = servicePriceFormatted
- ? Number(String(servicePriceFormatted).replace(/,/g, ""))
- : 0;
- const rawRent = serviceRentFormatted
- ? Number(String(serviceRentFormatted).replace(/,/g, ""))
- : 0;
- const rawPercentage = salonPercentage === "" ? 0 : Number(salonPercentage);
- const apiType = getActivityApiValue(activityType);
- const price = rawPrice;
- let amount = 0;
- if (apiType === "درصدی") {
- amount = rawPercentage;
- } else {
- if (rawRent > 0) amount = rawRent;
- else amount = rawPrice;
- }
- return {
- service_name: selectedService,
- type_of_service: apiType,
- price,
- amount,
- type: category,
- time: time || undefined,
- };
- };
-
- const handleSave = async () => {
- if (isSubmitting) return; // ✅ جلوگیری از کلیک دوباره
- if (!personelId) {
- notifyOnce("toast-error", "شناسه پرسنل نامشخص است.", "error");
- return;
- }
- if (!canSave) {
- notifyOnce(
- "toast-error",
- "هیچ تغییری برای ذخیره وجود ندارد یا فیلدها معتبر نیستند.",
- "error",
- );
- return;
- }
- setIsSubmitting(true); // بلافاصله غیرفعال شود
- try {
- const payload = buildServicePayload(personelId);
- // @ts-ignore
- await addService.mutateAsync({ personel_id: personelId, ...payload });
- try {
- await refetch?.();
- } catch (e) {
- console.error("refetch after save failed", e);
- }
- notifyOnce(
- "toast-success",
- isEditMode
- ? "ویرایش خدمت با موفقیت انجام شد."
- : "خدمت با موفقیت اضافه شد.",
- "success",
- );
- clearPersonnelLS();
- onClose();
- } catch (err) {
- console.error("add/update service error:", err);
- } finally {
- setIsSubmitting(false); // در صورت خطا هم آزاد شود
- }
- };
-
- useEffect(() => {
- const handler = (e: Event) => {
- try {
- // event ممکنه CustomEvent باشد
- const ce = e as CustomEvent;
- const cost = ce.detail?.cost ?? "";
- // set both formatted value and mark edited if user changed it
- setServicePriceFormatted(cost);
- setServicePriceEdited(Boolean(cost && cost !== ""));
- } catch {
- // ignore
- }
- };
-
- window.addEventListener("serviceCostChanged", handler as EventListener);
-
- return () => {
- window.removeEventListener(
- "serviceCostChanged",
- handler as EventListener,
- );
- };
- }, []);
-
- const handleRemoveExistingService = async () => {
- if (!existingServiceId) return;
- showWarningAlert("آیا از حذف خدمت جاری مطمئن هستید؟", async () => {
- try {
- // @ts-ignore
- await deleteService.mutateAsync(existingServiceId);
- try {
- await refetch?.();
- } catch (e) {
- console.error("refetch after delete failed", e);
- }
+ // ✅ تابع ریست کردن همه state ها
+ const resetAllStates = useCallback(() => {
setSelectedService("");
setUserSelectedService(false);
setServicePriceFormatted("");
+ setServicePriceEdited(false);
setServiceRentFormatted("");
setSalonPercentage("");
setActivityType("");
@@ -490,228 +104,568 @@ const ManageServicesModal: React.FC = ({ personelId, onClose }) => {
setExistingServiceId(null);
setIsEditMode(false);
setInitialSnapshot(null);
- setServicePriceEdited(false);
setTime("");
- clearPersonnelLS();
- onClose();
- } catch (err) {
- console.error("delete service error", err);
- notifyOnce(
- "toast-error",
- "خطا در حذف خدمت. دوباره تلاش کنید.",
- "error",
- );
- }
- });
- };
+ }, []);
- const canDelete = Boolean(existingServiceId) && !deleteService.isPending;
+ const detectActivityTypeFromString = (s?: string | null) => {
+ if (!s) return "";
+ const str = String(s).toLowerCase();
+ if (str.includes("درصد") || str.includes("percentage") || str.includes("%")) return "percentage";
+ if (str.includes("اجاره") || str.includes("rent")) return "rent";
+ const found = activityTypes.find((a) => String(a.apiValue).toLowerCase() === str);
+ return found?.id ?? "";
+ };
- return (
-
-
مدیریت خدمات
+ // isValidDuration helper
+ const isValidDuration = (val: string) => {
+ if (!val) return false;
+ const [hh, mm] = val.split(":").map(Number);
+ return hh * 60 + mm > 0;
+ };
-
-
-
-
-
-
{
- setServicePriceFormatted(formatted);
- setServicePriceEdited(Boolean(formatted && formatted !== ""));
- }}
- />
-
- {/* ========= Duration ========= */}
-
-
-
مدت زمان خدمت
-
- {/* default buttons */}
-
- {durationDefaults.map((t) => (
- handleDurationSelect(t)}
- className={`w-full py-2 rounded-xl border-2 transition
- ${
- time === t
- ? "border-[#AA00FF66] bg-gradient-to-b from-[#F7EEFF] to-white"
- : "border-gray-200"
- }`}
- >
- {t}
-
- ))}
-
-
- {/* time picker */}
-
-
- {!isValidDuration(time) && time !== "" && (
-
انتخاب مدت زمان معتبر نیست
- )}
-
-
-
-
-
نوع فعالیت
-
- {activityTypes.map((a) => (
-
- setActivityType(a.id)}
- className="hidden"
- />
- {a.label}
-
- ))}
-
-
- {activityType === "rent" && (
-
{
- // // همیشه مقدار وارد شده در ServiceRent خودش را نگه میدارد
- // setServiceRentFormatted(formatted);
- // // در صورتی که کاربر سرویس را صریح انتخاب کرده و ServicePrice را ویرایش نکرده،
- // // مقدار واردشده را در ServicePrice نیز بنشان
- // if (userSelectedService && selectedService && !servicePriceEdited) {
- // setServicePriceFormatted(formatted);
- // }
- // }}
- onChange={(formatted: string) => {
- setServiceRentFormatted(formatted);
-
- const hasInitialPrice =
- initialSnapshot?.servicePriceFormatted &&
- initialSnapshot.servicePriceFormatted !== "";
-
- if (
- !isEditMode &&
- userSelectedService &&
- selectedService &&
- !servicePriceEdited &&
- !hasInitialPrice
- ) {
- setServicePriceFormatted(formatted);
- }
- }}
- />
- )}
-
- {activityType === "percentage" && (
- setSalonPercentage(v === "" ? "" : Number(v))}
- unit="%"
- />
- )}
-
-
-
دستهبندی
-
- {categories.map((c) => (
-
- setCategory(c.id)}
- className="hidden"
- />
- {c.label}
-
- ))}
-
-
-
-
-
-
-
handleRemoveExistingService()}
- disabled={!canDelete}
- className={`px-4 py-2 rounded-full border ${
- canDelete
- ? "text-red-500 border-red-200"
- : "opacity-50 cursor-not-allowed"
- }`}
- >
- {deleteService.isPending ? "در حال حذف..." : "حذف"}
-
-
-
-
{
- onClose();
- clearPersonnelLS();
- }}
- className="px-4 py-2 rounded-full border"
- >
- بستن
-
-
-
{
+ // استفاده از dataOverride اگر پاس داده شده، در غیر این صورت از ref استفاده کن
+ const data = dataOverride ?? personelDataRef.current;
+
+ if (!data) return;
+ const services: any[] = data.services ?? [];
+
+ if (!name || name.trim() === "") {
+ setExistingServiceId(null);
+ setIsEditMode(false);
+ setActivityType("");
+ setCategory("");
+ setServicePriceFormatted("");
+ setServiceRentFormatted("");
+ setSalonPercentage("");
+ setServicePriceEdited(false);
+ setTime("");
+ return;
}
- >
- {addService.isPending
- ? "در حال ذخیره..."
- : isEditMode
- ? "ویرایش"
- : "ثبت"}
-
+
+ const matched = services.find(
+ (s) =>
+ String(s.service_name ?? s.name ?? "")
+ .trim()
+ .toLowerCase() ===
+ String(name ?? "")
+ .trim()
+ .toLowerCase(),
+ );
+
+ if (!matched) {
+ setExistingServiceId(null);
+ setIsEditMode(false);
+ setActivityType("");
+ setCategory("");
+ setServicePriceFormatted("");
+ setServiceRentFormatted("");
+ setSalonPercentage("");
+ setServicePriceEdited(false);
+ setTime("");
+ return;
+ }
+
+ const servicePrice = matched.price != null ? String(matched.price) : "";
+ const serviceRent = matched.amount != null ? String(matched.amount) : "";
+ const matchedType = detectActivityTypeFromString(
+ matched.type_of_service ?? matched.type ?? data.type ?? "",
+ );
+ const matchedCategory = matched.category ?? matched.type ?? matched.personel_type ?? data.type ?? "";
+
+ if (matchedType === "percentage") {
+ setServiceRentFormatted("");
+ setServicePriceFormatted(servicePrice);
+ setSalonPercentage(matched.amount == null ? "" : Number(matched.amount));
+ setActivityType("percentage");
+ } else {
+ setSalonPercentage("");
+ setActivityType("rent");
+ setServiceRentFormatted(serviceRent);
+ setServicePriceFormatted(servicePrice);
+ }
+
+ const matchedDuration = matched.time ?? matched.duration ?? matched.service_time ?? matched.length ?? "";
+
+ setCategory(matchedCategory ?? "");
+ setExistingServiceId(matched.service_id ?? null);
+ setIsEditMode(Boolean(matched.service_id));
+
+ if (isUserAction) setUserSelectedService(true);
+ setServicePriceEdited(false);
+ setTime(matchedDuration ? String(matchedDuration) : "");
+ },
+ [], // بدون وابستگی چون از ref استفاده میکنیم
+ );
+
+ // ✅ اضافه شده: وقتی personelData تغییر کند و سرویسی انتخاب شده باشد، اطلاعات را بروزرسانی کن
+ useEffect(() => {
+ if (personelData && selectedService && selectedService.trim() !== "") {
+ // با data جدید populate کن
+ populateForServiceName(selectedService, false, personelData);
+ }
+ }, [personelData, selectedService, populateForServiceName]);
+
+ // initial snapshot from server
+ useEffect(() => {
+ if (!personelData) return;
+ const p: any = personelData;
+ if (!p.services || p.services.length === 0) {
+ setInitialSnapshot(null);
+ return;
+ }
+ const svc = p.services[0];
+ const detectedType = detectActivityTypeFromString(svc.type_of_service ?? svc.type ?? p.type_of_service);
+ const detectedDuration = svc.time ?? svc.duration ?? svc.service_time ?? svc.length ?? "";
+
+ const snapshot: any = {
+ selectedService: svc.service_name ?? svc.name ?? "",
+ servicePriceFormatted: detectedType === "rent" && svc.price ? String(svc.price) : "",
+ serviceRentFormatted: detectedType === "rent" && svc.amount ? String(svc.amount) : "",
+ salonPercentage: detectedType === "percentage" ? (svc.amount ?? "") : "",
+ activityType: detectedType ?? "",
+ category: svc.category ?? svc.type ?? svc.personel_type ?? p.type ?? "",
+ personel_service_id: svc.personel_service_id ?? svc.id ?? null,
+ serviceDuration: detectedDuration ? String(detectedDuration) : "",
+ };
+
+ setInitialSnapshot(snapshot);
+ }, [personelData]);
+
+ // ✅ تابع اصلاح شده برای انتخاب سرویس
+ const onServiceSelect = useCallback(
+ (v: string) => {
+ // ✅ اگر همان سرویس قبلی انتخاب شده، باز هم populate کن
+ // این حالت مهم است: وقتی کاربر همان سرویس را دوباره کلیک میکند
+ const isSameService = v === selectedService;
+
+ setSelectedService(v);
+ setUserSelectedService(Boolean(v && v.trim() !== ""));
+
+ // ✅ همیشه populate کن، حتی اگر همان سرویس باشد
+ // از آخرین نسخه personelData استفاده کن
+ populateForServiceName(v, true, personelDataRef.current);
+
+ // ✅ اگر همان سرویس است، یک force update اضافی انجام بده
+ if (isSameService && personelDataRef.current) {
+ // کمی تاخیر برای اطمینان از sync شدن state ها
+ setTimeout(() => {
+ populateForServiceName(v, true, personelDataRef.current);
+ }, 0);
+ }
+ },
+ [selectedService, populateForServiceName],
+ );
+
+ // duration handlers
+ const handleDurationSelect = (t: string) => {
+ setTime(t);
+ setTimeout(() => {
+ durationRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ }, 100);
+ };
+
+ const handleDurationChange = (t: string) => {
+ setTime(t);
+ setTimeout(() => {
+ durationRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ }, 100);
+ };
+
+ // validation
+ const serviceSelected = Boolean(selectedService && selectedService.trim() !== "");
+ const activitySelected = Boolean(activityType && activityType.trim() !== "");
+ const categorySelected = Boolean(category && category.trim() !== "");
+
+ const amountValid = useMemo(() => {
+ if (!servicePriceFormatted || servicePriceFormatted.trim() === "") {
+ return false;
+ }
+ const priceNum = Number(String(servicePriceFormatted).replace(/,/g, ""));
+ if (!Number.isFinite(priceNum) || priceNum <= 0) {
+ return false;
+ }
+ if (activityType !== "rent") return true;
+ const rentSource = serviceRentFormatted || servicePriceFormatted;
+ if (!rentSource) return false;
+ const n = Number(String(rentSource).replace(/,/g, ""));
+ return Number.isFinite(n) && n > 0;
+ }, [activityType, servicePriceFormatted, serviceRentFormatted]);
+
+ const percentValid = useMemo(() => {
+ if (activityType !== "percentage") return true;
+ if (salonPercentage === "" || salonPercentage === null) return false;
+ const n = Number(salonPercentage);
+ return Number.isFinite(n) && n >= 0 && n <= 100;
+ }, [activityType, salonPercentage]);
+
+ const canSaveBase =
+ serviceSelected &&
+ activitySelected &&
+ categorySelected &&
+ amountValid &&
+ percentValid &&
+ servicePriceFormatted &&
+ servicePriceFormatted.trim() !== "";
+
+ const isDirty = useMemo(() => {
+ if (!isEditMode) return true;
+ if (!initialSnapshot) return true;
+ const normalizeNumber = (v: string | number | "" | undefined) => {
+ if (v === "" || v === undefined || v === null) return 0;
+ const num = Number(String(v).replace(/,/g, ""));
+ return Number.isFinite(num) ? num : 0;
+ };
+ const prevRent = normalizeNumber(initialSnapshot.serviceRentFormatted);
+ const curRent = normalizeNumber(serviceRentFormatted);
+ const prevPrice = normalizeNumber(initialSnapshot.servicePriceFormatted);
+ const curPrice = normalizeNumber(servicePriceFormatted);
+ const prevPercent = initialSnapshot.salonPercentage === "" ? 0 : Number(initialSnapshot.salonPercentage);
+ const curPercent = salonPercentage === "" ? 0 : Number(salonPercentage);
+
+ const prevDuration = initialSnapshot.serviceDuration ?? "";
+ const curDuration = time ?? "";
+
+ return (
+ String(selectedService ?? "").trim() !== String(initialSnapshot.selectedService ?? "").trim() ||
+ String(activityType ?? "") !== String(initialSnapshot.activityType ?? "") ||
+ String(category ?? "") !== String(initialSnapshot.category ?? "") ||
+ prevRent !== curRent ||
+ prevPrice !== curPrice ||
+ prevPercent !== curPercent ||
+ prevDuration !== curDuration
+ );
+ }, [
+ isEditMode,
+ initialSnapshot,
+ selectedService,
+ activityType,
+ category,
+ serviceRentFormatted,
+ servicePriceFormatted,
+ salonPercentage,
+ time,
+ ]);
+
+ const canSave = useMemo(() => {
+ if (!canSaveBase) return false;
+ if (isEditMode) return isDirty;
+ return true;
+ }, [canSaveBase, isEditMode, isDirty]);
+
+ const getActivityApiValue = (id: string) =>
+ activityTypes.find((a) => a.id === id)?.apiValue ?? activityTypes[0].apiValue;
+
+ const buildServicePayload = (_personel_id: number | null) => {
+ const rawPrice = servicePriceFormatted ? Number(String(servicePriceFormatted).replace(/,/g, "")) : 0;
+ const rawRent = serviceRentFormatted ? Number(String(serviceRentFormatted).replace(/,/g, "")) : 0;
+ const rawPercentage = salonPercentage === "" ? 0 : Number(salonPercentage);
+ const apiType = getActivityApiValue(activityType);
+ const price = rawPrice;
+ let amount = 0;
+ if (apiType === "درصدی") {
+ amount = rawPercentage;
+ } else {
+ if (rawRent > 0) amount = rawRent;
+ else amount = rawPrice;
+ }
+ return {
+ service_name: selectedService,
+ type_of_service: apiType,
+ price,
+ amount,
+ type: category,
+ time: time || undefined,
+ };
+ };
+
+ // ✅ تابع handleSave اصلاح شده
+ const handleSave = async () => {
+ if (isSubmitting) return;
+ if (!personelId) {
+ notifyOnce("toast-error", "شناسه پرسنل نامشخص است.", "error");
+ return;
+ }
+ if (!canSave) {
+ notifyOnce("toast-error", "هیچ تغییری برای ذخیره وجود ندارد یا فیلدها معتبر نیستند.", "error");
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ // ✅ نام سرویس انتخاب شده را ذخیره کن برای استفاده بعد از refetch
+ const savedServiceName = selectedService;
+
+ try {
+ const payload = buildServicePayload(personelId);
+ // @ts-ignore
+ await addService.mutateAsync({ personel_id: personelId, ...payload });
+
+ // ✅ اول refetch کن و منتظر نتیجه بمان
+ let newData = null;
+ try {
+ const result = await refetch();
+ newData = result.data;
+ // ✅ ref را هم بروزرسانی کن
+ personelDataRef.current = newData;
+ } catch (e) {
+ console.error("refetch after save failed", e);
+ }
+
+ notifyOnce(
+ "toast-success",
+ isEditMode ? "ویرایش خدمت با موفقیت انجام شد." : "خدمت با موفقیت اضافه شد.",
+ "success",
+ );
+
+ // ✅ ریست کردن state ها قبل از بستن modal
+ resetAllStates();
+ clearPersonnelLS();
+ onClose();
+
+ } catch (err) {
+ console.error("add/update service error:", err);
+ notifyOnce("toast-error", "خطا در ذخیره خدمت. دوباره تلاش کنید.", "error");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ useEffect(() => {
+ const handler = (e: Event) => {
+ try {
+ const ce = e as CustomEvent;
+ const cost = ce.detail?.cost ?? "";
+ setServicePriceFormatted(cost);
+ setServicePriceEdited(Boolean(cost && cost !== ""));
+ } catch {
+ // ignore
+ }
+ };
+
+ window.addEventListener("serviceCostChanged", handler as EventListener);
+
+ return () => {
+ window.removeEventListener("serviceCostChanged", handler as EventListener);
+ };
+ }, []);
+
+ const handleRemoveExistingService = async () => {
+ if (!existingServiceId) return;
+ showWarningAlert("آیا از حذف خدمت جاری مطمئن هستید؟", async () => {
+ try {
+ // @ts-ignore
+ await deleteService.mutateAsync(existingServiceId);
+ try {
+ const result = await refetch();
+ personelDataRef.current = result.data;
+ } catch (e) {
+ console.error("refetch after delete failed", e);
+ }
+
+ // ✅ استفاده از تابع ریست
+ resetAllStates();
+ clearPersonnelLS();
+ onClose();
+ } catch (err) {
+ console.error("delete service error", err);
+ notifyOnce("toast-error", "خطا در حذف خدمت. دوباره تلاش کنید.", "error");
+ }
+ });
+ };
+
+ const canDelete = Boolean(existingServiceId) && !deleteService.isPending;
+
+ return (
+
+
مدیریت خدمات
+
+
+
+
+
+
+
{
+ setServicePriceFormatted(formatted);
+ setServicePriceEdited(Boolean(formatted && formatted !== ""));
+ }}
+ />
+
+ {/* ========= Duration ========= */}
+
+
+
مدت زمان خدمت
+
+ {/* default buttons */}
+
+ {durationDefaults.map((t) => (
+ handleDurationSelect(t)}
+ className={`w-full py-2 rounded-xl border-2 transition
+ ${
+ time === t
+ ? "border-[#AA00FF66] bg-gradient-to-b from-[#F7EEFF] to-white"
+ : "border-gray-200"
+ }`}
+ >
+ {t}
+
+ ))}
+
+
+ {/* time picker */}
+
+
+ {!isValidDuration(time) && time !== "" && (
+
انتخاب مدت زمان معتبر نیست
+ )}
+
+
+
+
+
نوع فعالیت
+
+ {activityTypes.map((a) => (
+
+ setActivityType(a.id)}
+ className="hidden"
+ />
+ {a.label}
+
+ ))}
+
+
+ {activityType === "rent" && (
+
{
+ setServiceRentFormatted(formatted);
+
+ const hasInitialPrice =
+ initialSnapshot?.servicePriceFormatted &&
+ initialSnapshot.servicePriceFormatted !== "";
+
+ if (
+ !isEditMode &&
+ userSelectedService &&
+ selectedService &&
+ !servicePriceEdited &&
+ !hasInitialPrice
+ ) {
+ setServicePriceFormatted(formatted);
+ }
+ }}
+ />
+ )}
+
+ {activityType === "percentage" && (
+ setSalonPercentage(v === "" ? "" : Number(v))}
+ unit="%"
+ />
+ )}
+
+
+
دستهبندی
+
+ {categories.map((c) => (
+
+ setCategory(c.id)}
+ className="hidden"
+ />
+ {c.label}
+
+ ))}
+
+
+
+
+
+
+
handleRemoveExistingService()}
+ disabled={!canDelete}
+ className={`px-4 py-2 rounded-full border ${
+ canDelete ? "text-red-500 border-red-200" : "opacity-50 cursor-not-allowed"
+ }`}
+ >
+ {deleteService.isPending ? "در حال حذف..." : "حذف"}
+
+
+
+ {
+ resetAllStates();
+ clearPersonnelLS();
+ onClose();
+ }}
+ className="px-4 py-2 rounded-full border"
+ >
+ بستن
+
+
+
+ {addService.isPending ? "در حال ذخیره..." : isEditMode ? "ویرایش" : "ثبت"}
+
+
+
-
-
- );
+ );
};
export default ManageServicesModal;
diff --git a/src/apps/new-ui/pages/PersonnelCashbackSettings/index.tsx b/src/apps/new-ui/pages/PersonnelCashbackSettings/index.tsx
index 847da837..609aca2e 100644
--- a/src/apps/new-ui/pages/PersonnelCashbackSettings/index.tsx
+++ b/src/apps/new-ui/pages/PersonnelCashbackSettings/index.tsx
@@ -3,15 +3,12 @@ import toast from "react-hot-toast";
import PersonnelScroller from "./+components/PersonnelScroller"; // مسیر را مطابق پروژهات تنظیم کن
import DiscountInput, { formatWithCommas } from "./+components/DiscountInput"; // مسیر را مطابق پروژهات تنظیم کن
import ServicePrice from "./+components/ServicePrice"; // مسیر را مطابق پروژهات تنظیم کن (اگر ندارید این بخش را حذف کنید)
-import {
- showErrorAlert,
- showWarningAlert,
-} from "@/apps/new-ui/components/CustomAlert"; // مسیر را مطابق پروژهات تنظیم کن
+import { showErrorAlert, showWarningAlert } from "@/apps/new-ui/components/CustomAlert"; // مسیر را مطابق پروژهات تنظیم کن
import {
- useCreateOrUpdateCashback,
- useDeleteCashback,
- useGetAllCashbacks,
+ useCreateOrUpdateCashback,
+ useDeleteCashback,
+ useGetAllCashbacks,
} from "@/apps/new-ui/services/CashbackServices"; // مسیر را مطابق پروژهات تنظیم کن
import { useGetServicesWithPersonels } from "@/apps/new-ui/services/PersonnelServices"; // مسیر را مطابق پروژهات تنظیم کن
@@ -20,63 +17,52 @@ import { BeatLoader } from "react-spinners";
/* ---------- helpers ---------- */
const parseFormattedToInt = (formatted = "") => {
- const raw = String(formatted ?? "")
- .replace(/,/g, "")
- .replace(/\s/g, "");
- if (raw === "") return NaN;
- const n = Number(raw);
- return Number.isNaN(n) ? NaN : Math.trunc(n);
+ const raw = String(formatted ?? "")
+ .replace(/,/g, "")
+ .replace(/\s/g, "");
+ if (raw === "") return NaN;
+ const n = Number(raw);
+ return Number.isNaN(n) ? NaN : Math.trunc(n);
};
const EXPIRE_OPTIONS: Array<{ label: string; value: string }> = [
- { label: "نامحدود", value: "unlimited" },
- { label: "10 روز", value: "10" },
- { label: "20 روز", value: "20" },
- { label: "30 روز", value: "30" },
+ { label: "نامحدود", value: "unlimited" },
+ { label: "10 روز", value: "10" },
+ { label: "20 روز", value: "20" },
+ { label: "30 روز", value: "30" },
];
const normalizeCashbackConfig = (raw: any) => {
- if (!raw) {
- return {
- id: undefined,
- personel_service_id: undefined,
- amount: null,
- expire_days: "",
- price: undefined,
- };
- }
+ if (!raw) {
+ return {
+ id: undefined,
+ personel_service_id: undefined,
+ amount: null,
+ expire_days: "",
+ price: undefined,
+ };
+ }
- return {
- id:
- raw.id ??
- raw.cashback_id ??
- raw.personel_service_cashback_id ??
- undefined,
- personel_service_id:
- typeof raw.personel_service_id !== "undefined"
- ? Number(raw.personel_service_id)
- : undefined,
- amount:
- raw.amount == null
- ? null
- : Number.isFinite(Number(raw.amount))
- ? Math.trunc(Number(raw.amount))
- : null,
- expire_days:
- raw.expire_days == null
- ? ""
- : String(raw.expire_days) === "unlimited"
- ? "unlimited"
- : String(Number(raw.expire_days)),
- price:
- raw.price != null
- ? (() => {
- const p = String(raw.price).replace(/,/g, "").replace(/\s/g, "");
- const n = Number(p);
- return Number.isNaN(n) ? undefined : Math.trunc(n);
- })()
- : undefined,
- };
+ return {
+ id: raw.id ?? raw.cashback_id ?? raw.personel_service_cashback_id ?? undefined,
+ personel_service_id:
+ typeof raw.personel_service_id !== "undefined" ? Number(raw.personel_service_id) : undefined,
+ amount: raw.amount == null ? null : Number.isFinite(Number(raw.amount)) ? Math.trunc(Number(raw.amount)) : null,
+ expire_days:
+ raw.expire_days == null
+ ? ""
+ : String(raw.expire_days) === "unlimited"
+ ? "unlimited"
+ : String(Number(raw.expire_days)),
+ price:
+ raw.price != null
+ ? (() => {
+ const p = String(raw.price).replace(/,/g, "").replace(/\s/g, "");
+ const n = Number(p);
+ return Number.isNaN(n) ? undefined : Math.trunc(n);
+ })()
+ : undefined,
+ };
};
// const cashbackConfigsEqual = (a: any, b: any) => {
@@ -95,355 +81,326 @@ const normalizeCashbackConfig = (raw: any) => {
/* ========== Component ========== */
const PersonnelCashbackSettingPage: React.FC = () => {
- // hooks: کشبکها
- const { data: fetched, refetch } = useGetAllCashbacks();
- const createOrUpdate = useCreateOrUpdateCashback();
- const deleteMutation = useDeleteCashback();
+ // hooks: کشبکها
+ const { data: fetched, refetch } = useGetAllCashbacks();
+ const createOrUpdate = useCreateOrUpdateCashback();
+ const deleteMutation = useDeleteCashback();
- // hooks: سرویسها/پرسنلها (برای دریافت مقدار amount از خود سرویس)
- const { data: servicesData, isLoading: isLoadingServices } =
- useGetServicesWithPersonels({ enabled: true });
- const servicesList: any[] = servicesData?.services ?? [];
+ // hooks: سرویسها/پرسنلها (برای دریافت مقدار amount از خود سرویس)
+ const { data: servicesData, isLoading: isLoadingServices } = useGetServicesWithPersonels({ enabled: true });
+ const servicesList: any[] = servicesData?.services ?? [];
- // configs array: support shapes like { cashbacks: [...] } or direct array
- const configsArray: any[] = Array.isArray(fetched)
- ? fetched
- : Array.isArray((fetched as any)?.cashbacks)
- ? (fetched as any).cashbacks
- : Array.isArray((fetched as any)?.configs)
- ? (fetched as any).configs
- : [];
+ // configs array: support shapes like { cashbacks: [...] } or direct array
+ const configsArray: any[] = Array.isArray(fetched)
+ ? fetched
+ : Array.isArray((fetched as any)?.cashbacks)
+ ? (fetched as any).cashbacks
+ : Array.isArray((fetched as any)?.configs)
+ ? (fetched as any).configs
+ : [];
- // selection & UI state
- const [selectedPersonelServiceId, setSelectedPersonelServiceId] = useState<
- number | undefined
- >(undefined);
- const [cashbackFormatted, setCashbackFormatted] = useState
(""); // formatted (e.g. "5,000")
- const [servicePriceFormatted, setServicePriceFormatted] =
- useState(""); // formatted price input
- const [expireDaysInput, setExpireDaysInput] = useState(""); // e.g. "30" or "unlimited"
- const [pending, setPending] = useState(false);
+ // selection & UI state
+ const [selectedPersonelServiceId, setSelectedPersonelServiceId] = useState(undefined);
+ const [cashbackFormatted, setCashbackFormatted] = useState(""); // formatted (e.g. "5,000")
+ const [servicePriceFormatted, setServicePriceFormatted] = useState(""); // formatted price input
+ const [expireDaysInput, setExpireDaysInput] = useState(""); // e.g. "30" or "unlimited"
+ const [pending, setPending] = useState(false);
- // baseline initial config for selected personel (normalized)
- const [initialConfig, setInitialConfig] = useState(null);
+ // baseline initial config for selected personel (normalized)
+ const [initialConfig, setInitialConfig] = useState(null);
- // Helper: find personel object in servicesList by personel_service_id
- const findPersonelInServices = (personel_service_id?: number) => {
- if (personel_service_id == null) return undefined;
- for (const svc of servicesList) {
- const ps = Array.isArray(svc?.personels) ? svc.personels : [];
- const found = ps.find(
- (p: any) =>
- Number(p?.personel_service_id) === Number(personel_service_id),
- );
- if (found) return found;
- }
- return undefined;
- };
-
- const navigate = useNavigate();
-
- useEffect(() => {
- if (isLoadingServices === false && servicesList.length === 0) {
- showErrorAlert(
- "خدماتی برای پرسنل یافت نشد.لطفا پیش از انجام عملیات برای پرسنل سرویس ثبت شده را اضافه کنید.",
- () => navigate("/personnel"),
- );
- }
- }, [servicesList]);
-
- // populate when selection or fetched configs or services change
- useEffect(() => {
- // no selection => clear
- if (typeof selectedPersonelServiceId === "undefined") {
- setInitialConfig(null);
- setCashbackFormatted("");
- setExpireDaysInput("");
- setServicePriceFormatted("");
- return;
- }
-
- // first: populate from cashbacks configs if exists
- const matched = configsArray.find(
- (c: any) =>
- Number(c?.personel_service_id) === Number(selectedPersonelServiceId),
- );
-
- if (!matched) {
- setInitialConfig(null);
- setCashbackFormatted("");
- setExpireDaysInput("");
- } else {
- const norm = normalizeCashbackConfig(matched);
- setInitialConfig(norm);
- setCashbackFormatted(
- norm.amount == null ? "" : formatWithCommas(String(norm.amount)),
- );
- setExpireDaysInput(norm.expire_days ?? "");
- }
-
- // then: try to get amount from services list (priority)
- const personel = findPersonelInServices(selectedPersonelServiceId);
- if (
- personel &&
- typeof personel.amount !== "undefined" &&
- personel.amount !== null &&
- String(personel.amount).trim() !== ""
- ) {
- // use amount from personel (priority). format with commas
- const parsed = Number(personel.amount);
- if (!Number.isNaN(parsed)) {
- setServicePriceFormatted(formatWithCommas(String(Math.trunc(parsed))));
- return;
- }
- }
-
- // fallback: if matched cashback contains a price field use it
- if (matched) {
- const norm = normalizeCashbackConfig(matched);
- if (typeof norm.price !== "undefined") {
- setServicePriceFormatted(String(norm.price));
- return;
- }
- }
-
- // otherwise clear price
- setServicePriceFormatted("");
- }, [configsArray, selectedPersonelServiceId, servicesList]);
-
- // parsed numbers
- const parsedCashbackNumber = useMemo(() => {
- const n = parseFormattedToInt(cashbackFormatted);
- return Number.isNaN(n) ? null : n;
- }, [cashbackFormatted]);
-
- const parsedPriceNumber = useMemo(() => {
- const n = parseFormattedToInt(servicePriceFormatted);
- return Number.isNaN(n) ? null : n;
- }, [servicePriceFormatted]);
-
- // existing config (normalized) for convenience
- const existingConfig = useMemo(() => {
- if (!selectedPersonelServiceId) return undefined;
- const matched = configsArray.find(
- (c: any) =>
- Number(c?.personel_service_id) === Number(selectedPersonelServiceId),
- );
- return matched ? normalizeCashbackConfig(matched) : undefined;
- }, [configsArray, selectedPersonelServiceId]);
-
- const isPersonelSelected = typeof selectedPersonelServiceId !== "undefined";
-
- // use baseInitial (already computed above)
- const baseInitial = initialConfig ?? normalizeCashbackConfig(undefined);
-
- const effectiveExpireForValidation = (() => {
- if (String(expireDaysInput ?? "") !== "") return String(expireDaysInput);
- if (String(baseInitial.expire_days ?? "") !== "")
- return String(baseInitial.expire_days);
- // user entered amount but didn't choose expire -> treat as unlimited for validation (user-friendly)
- if (parsedCashbackNumber != null) return "unlimited";
- return "";
- })();
-
- const isExpireValid =
- effectiveExpireForValidation === "unlimited" ||
- (effectiveExpireForValidation !== "" &&
- !Number.isNaN(Number(effectiveExpireForValidation)) &&
- Number(effectiveExpireForValidation) >= 1);
-
- // amount changed?
- const amountChanged =
- (baseInitial.amount === null && parsedCashbackNumber != null) ||
- (baseInitial.amount != null && parsedCashbackNumber == null) ||
- (baseInitial.amount != null &&
- parsedCashbackNumber != null &&
- baseInitial.amount !== parsedCashbackNumber);
-
- // expire changed (compare actual string values the user set vs base)
- const expireChanged =
- String(baseInitial.expire_days ?? "") !== String(expireDaysInput ?? "");
-
- // price changed (keep your existing logic)
- const priceChanged =
- (typeof baseInitial.price === "undefined" && parsedPriceNumber != null) ||
- (typeof baseInitial.price !== "undefined" &&
- parsedPriceNumber != null &&
- baseInitial.price !== parsedPriceNumber);
-
- // has any change that requires saving
- const hasChanges = amountChanged || expireChanged || priceChanged;
-
- // Final canSave: حداقل یک تغییر، پرسنل انتخابشده، درخواست در صف نباشد، و expire معتبر باشد.
- const canSave = isPersonelSelected && !pending && hasChanges && isExpireValid;
-
- // Save handler
- const handleSave = async (): Promise => {
- if (!selectedPersonelServiceId) {
- // toast.error("ابتدا یک پرسنل انتخاب کنید.");
-
- showErrorAlert("ابتدا یک پرسنل انتخاب کنید.");
- return false;
- }
-
- if (!isExpireValid) {
- // toast.error("مدت اعتبار نامعتبر است.");
- showErrorAlert("مدت اعتبار نامعتبر است.");
- return false;
- }
-
- // بررسی معتبر بودن مقادیر عددی برای مقایسه
- const cashbackAmount = parsedCashbackNumber;
- const serviceAmount = parsedPriceNumber;
-
- // فقط زمانی مقایسه کن که هر دو مقدار عددی معتبر داشته باشیم
- if (
- cashbackAmount !== null &&
- serviceAmount !== null &&
- cashbackAmount > serviceAmount
- ) {
- showErrorAlert("مبلغ بازگشت وجه نمیتواند از مبلغ خدمات بیشتر باشد.");
- return false;
- }
-
- if (!canSave) {
- // toast.error("هیچ تغییری برای ذخیره وجود ندارد.");
- showErrorAlert("هیچ تغییری برای ذخیره وجود ندارد.");
- return false;
- }
-
- const payload: any = {
- personel_service_id: selectedPersonelServiceId,
- amount:
- parsedCashbackNumber == null
- ? 0
- : Math.max(0, Math.trunc(parsedCashbackNumber)),
- expire_days:
- expireDaysInput === "unlimited"
- ? "unlimited"
- : Math.max(1, Math.trunc(Number(expireDaysInput))),
+ // Helper: find personel object in servicesList by personel_service_id
+ const findPersonelInServices = (personel_service_id?: number) => {
+ if (personel_service_id == null) return undefined;
+ for (const svc of servicesList) {
+ const ps = Array.isArray(svc?.personels) ? svc.personels : [];
+ const found = ps.find((p: any) => Number(p?.personel_service_id) === Number(personel_service_id));
+ if (found) return found;
+ }
+ return undefined;
};
- try {
- setPending(true);
- const p = createOrUpdate.mutateAsync(payload).then(() => {
- toast.promise(p, {
- loading: "در حال ذخیره تنظیمات بازگشت وجه...",
- success: "تنظیمات بازگشت وجه با موفقیت ذخیره شد.",
- error: "خطا در ذخیره تنظیمات بازگشت وجه.",
- });
- });
+ const navigate = useNavigate();
- const res = await p;
- await refetch();
+ useEffect(() => {
+ if (isLoadingServices === false && servicesList.length === 0) {
+ showErrorAlert(
+ "خدماتی برای پرسنل یافت نشد.لطفا پیش از انجام عملیات برای پرسنل سرویس ثبت شده را اضافه کنید.",
+ () => navigate("/personnel"),
+ );
+ }
+ }, [servicesList]);
- // try to find updated config in returned structure or in fetched list
- const returnedCfg = (res as any)?.data ?? res ?? {};
- const norm = normalizeCashbackConfig(returnedCfg);
- if (!norm.personel_service_id) {
- const found = configsArray.find(
- (c: any) =>
- Number(c?.personel_service_id) ===
- Number(selectedPersonelServiceId),
+ // populate when selection or fetched configs or services change
+ useEffect(() => {
+ // no selection => clear
+ if (typeof selectedPersonelServiceId === "undefined") {
+ setInitialConfig(null);
+ setCashbackFormatted("");
+ setExpireDaysInput("");
+ setServicePriceFormatted("");
+ return;
+ }
+
+ // first: populate from cashbacks configs if exists
+ const matched = configsArray.find(
+ (c: any) => Number(c?.personel_service_id) === Number(selectedPersonelServiceId),
);
- if (found) setInitialConfig(normalizeCashbackConfig(found));
- else setInitialConfig(normalizeCashbackConfig(returnedCfg));
- } else {
- setInitialConfig(norm);
- }
- return true;
- } catch (err) {
- console.error("save cashback error", err);
- return false;
- } finally {
- setPending(false);
- }
- };
+ if (!matched) {
+ setInitialConfig(null);
+ setCashbackFormatted("");
+ setExpireDaysInput("");
+ } else {
+ const norm = normalizeCashbackConfig(matched);
+ setInitialConfig(norm);
+ setCashbackFormatted(norm.amount == null ? "" : formatWithCommas(String(norm.amount)));
+ setExpireDaysInput(norm.expire_days ?? "");
+ }
- // Delete handler
- const handleDelete = async () => {
- if (!existingConfig) {
- toast.error("کانفیگی برای حذف وجود ندارد.");
- return;
- }
+ // then: try to get amount from services list (priority)
+ const personel = findPersonelInServices(selectedPersonelServiceId);
+ if (
+ personel &&
+ typeof personel.amount !== "undefined" &&
+ personel.amount !== null &&
+ String(personel.amount).trim() !== ""
+ ) {
+ // use amount from personel (priority). format with commas
+ const parsed = Number(personel.amount);
+ if (!Number.isNaN(parsed)) {
+ setServicePriceFormatted(formatWithCommas(String(Math.trunc(parsed))));
+ return;
+ }
+ }
- const cfgId = existingConfig.id;
- if (!cfgId) {
- toast.error("شناسهٔ تنظیمات برای حذف یافت نشد.");
- return;
- }
+ // fallback: if matched cashback contains a price field use it
+ if (matched) {
+ const norm = normalizeCashbackConfig(matched);
+ if (typeof norm.price !== "undefined") {
+ setServicePriceFormatted(String(norm.price));
+ return;
+ }
+ }
- showWarningAlert("آیا از حذف تنظیمات این خدمت مطمئن هستید؟", async () => {
- try {
- setPending(true);
- const p = deleteMutation.mutateAsync(cfgId);
- toast.promise(p, {
- loading: "در حال حذف تنظیمات...",
- success: "تنظیمات بازگشت وجه حذف شد.",
- error: "خطا در حذف تنظیمات.",
- });
- await p;
- await refetch();
- // reset form for selected personel (keep selection or deselect — choose to keep selected but clear values)
- setInitialConfig(null);
- setCashbackFormatted("");
- setExpireDaysInput("");
+ // otherwise clear price
setServicePriceFormatted("");
- // optionally deselect:
- // setSelectedPersonelServiceId(undefined);
- } catch (err) {
- console.error("delete cashback error", err);
- } finally {
- setPending(false);
- }
- });
- };
+ }, [configsArray, selectedPersonelServiceId, servicesList]);
- const LottieFallback = (
-
-
-
- );
+ // parsed numbers
+ const parsedCashbackNumber = useMemo(() => {
+ const n = parseFormattedToInt(cashbackFormatted);
+ return Number.isNaN(n) ? null : n;
+ }, [cashbackFormatted]);
- const lazyLoader = () =>
- Promise.all([
- import("lottie-react"),
- import("@public/lotties/personnelChoose.json"),
- ]).then(([lottieModule, json]) => {
- const LottieWrapper: React.FC = (props) => {
- const Lottie = (lottieModule as any).default;
- const animationData = (json as any).default ?? json;
- return (
-
+ const parsedPriceNumber = useMemo(() => {
+ const n = parseFormattedToInt(servicePriceFormatted);
+ return Number.isNaN(n) ? null : n;
+ }, [servicePriceFormatted]);
+
+ // existing config (normalized) for convenience
+ const existingConfig = useMemo(() => {
+ if (!selectedPersonelServiceId) return undefined;
+ const matched = configsArray.find(
+ (c: any) => Number(c?.personel_service_id) === Number(selectedPersonelServiceId),
);
- };
- return { default: LottieWrapper };
- });
+ return matched ? normalizeCashbackConfig(matched) : undefined;
+ }, [configsArray, selectedPersonelServiceId]);
- const LazyLottie = lazy(lazyLoader);
+ const isPersonelSelected = typeof selectedPersonelServiceId !== "undefined";
- return (
-
-
-
- تنظیمات بازگشت وجه پرسنل
-
-
-
-
-
setSelectedPersonelServiceId(id)}
- selectedPersonelServiceId={selectedPersonelServiceId}
- columnWidth={240}
- configs={configsArray}
- />
+ // use baseInitial (already computed above)
+ const baseInitial = initialConfig ?? normalizeCashbackConfig(undefined);
+
+ const effectiveExpireForValidation = (() => {
+ if (String(expireDaysInput ?? "") !== "") return String(expireDaysInput);
+ if (String(baseInitial.expire_days ?? "") !== "") return String(baseInitial.expire_days);
+ // user entered amount but didn't choose expire -> treat as unlimited for validation (user-friendly)
+ if (parsedCashbackNumber != null) return "unlimited";
+ return "";
+ })();
+
+ const isExpireValid =
+ effectiveExpireForValidation === "unlimited" ||
+ (effectiveExpireForValidation !== "" &&
+ !Number.isNaN(Number(effectiveExpireForValidation)) &&
+ Number(effectiveExpireForValidation) >= 1);
+
+ // amount changed?
+ const amountChanged =
+ (baseInitial.amount === null && parsedCashbackNumber != null) ||
+ (baseInitial.amount != null && parsedCashbackNumber == null) ||
+ (baseInitial.amount != null && parsedCashbackNumber != null && baseInitial.amount !== parsedCashbackNumber);
+
+ // expire changed (compare actual string values the user set vs base)
+ const expireChanged = String(baseInitial.expire_days ?? "") !== String(expireDaysInput ?? "");
+
+ // price changed (keep your existing logic)
+ const priceChanged =
+ (typeof baseInitial.price === "undefined" && parsedPriceNumber != null) ||
+ (typeof baseInitial.price !== "undefined" &&
+ parsedPriceNumber != null &&
+ baseInitial.price !== parsedPriceNumber);
+
+ // has any change that requires saving
+ const hasChanges = amountChanged || expireChanged || priceChanged;
+
+ // Final canSave: حداقل یک تغییر، پرسنل انتخابشده، درخواست در صف نباشد، و expire معتبر باشد.
+ const canSave = isPersonelSelected && !pending && hasChanges && isExpireValid;
+
+ // Save handler
+ const handleSave = async (): Promise => {
+ if (!selectedPersonelServiceId) {
+ // toast.error("ابتدا یک پرسنل انتخاب کنید.");
+
+ showErrorAlert("ابتدا یک پرسنل انتخاب کنید.");
+ return false;
+ }
+
+ if (!isExpireValid) {
+ // toast.error("مدت اعتبار نامعتبر است.");
+ showErrorAlert("مدت اعتبار نامعتبر است.");
+ return false;
+ }
+
+ // بررسی معتبر بودن مقادیر عددی برای مقایسه
+ const cashbackAmount = parsedCashbackNumber;
+ const serviceAmount = parsedPriceNumber;
+
+ // فقط زمانی مقایسه کن که هر دو مقدار عددی معتبر داشته باشیم
+ if (cashbackAmount !== null && serviceAmount !== null && cashbackAmount > serviceAmount) {
+ showErrorAlert("مبلغ بازگشت وجه نمیتواند از مبلغ خدمات بیشتر باشد.");
+ return false;
+ }
+
+ if (!canSave) {
+ // toast.error("هیچ تغییری برای ذخیره وجود ندارد.");
+ showErrorAlert("هیچ تغییری برای ذخیره وجود ندارد.");
+ return false;
+ }
+
+ const payload: any = {
+ personel_service_id: selectedPersonelServiceId,
+ amount: parsedCashbackNumber == null ? 0 : Math.max(0, Math.trunc(parsedCashbackNumber)),
+ expire_days:
+ expireDaysInput === "unlimited" ? "unlimited" : Math.max(1, Math.trunc(Number(expireDaysInput))),
+ };
+
+ try {
+ setPending(true);
+ const p = createOrUpdate.mutateAsync(payload).then(() => {
+ toast.promise(p, {
+ loading: "در حال ذخیره تنظیمات بازگشت وجه...",
+ success: "تنظیمات بازگشت وجه با موفقیت ذخیره شد.",
+ error: "خطا در ذخیره تنظیمات بازگشت وجه.",
+ });
+ });
+
+ const res = await p;
+ await refetch();
+
+ // try to find updated config in returned structure or in fetched list
+ const returnedCfg = (res as any)?.data ?? res ?? {};
+ const norm = normalizeCashbackConfig(returnedCfg);
+ if (!norm.personel_service_id) {
+ const found = configsArray.find(
+ (c: any) => Number(c?.personel_service_id) === Number(selectedPersonelServiceId),
+ );
+ if (found) setInitialConfig(normalizeCashbackConfig(found));
+ else setInitialConfig(normalizeCashbackConfig(returnedCfg));
+ } else {
+ setInitialConfig(norm);
+ }
+
+ setSelectedPersonelServiceId(undefined)
+
+ return true;
+ } catch (err) {
+ console.error("save cashback error", err);
+ return false;
+ } finally {
+ setPending(false);
+ }
+ };
+
+ // Delete handler
+ const handleDelete = async () => {
+ if (!existingConfig) {
+ toast.error("کانفیگی برای حذف وجود ندارد.");
+ return;
+ }
+
+ const cfgId = existingConfig.id;
+ if (!cfgId) {
+ toast.error("شناسهٔ تنظیمات برای حذف یافت نشد.");
+ return;
+ }
+
+ showWarningAlert("آیا از حذف تنظیمات این خدمت مطمئن هستید؟", async () => {
+ try {
+ setPending(true);
+ const p = deleteMutation.mutateAsync(cfgId);
+ toast.promise(p, {
+ loading: "در حال حذف تنظیمات...",
+ success: "تنظیمات بازگشت وجه حذف شد.",
+ error: "خطا در حذف تنظیمات.",
+ });
+ await p;
+ await refetch();
+ // reset form for selected personel (keep selection or deselect — choose to keep selected but clear values)
+ setInitialConfig(null);
+ setCashbackFormatted("");
+ setExpireDaysInput("");
+ setServicePriceFormatted("");
+ // optionally deselect:
+ // setSelectedPersonelServiceId(undefined);
+ } catch (err) {
+ console.error("delete cashback error", err);
+ } finally {
+ setPending(false);
+ }
+ });
+ };
+
+ const LottieFallback = (
+
+
-
+ );
- {selectedPersonelServiceId ? (
-
- {/*
+ const lazyLoader = () =>
+ Promise.all([import("lottie-react"), import("@public/lotties/personnelChoose.json")]).then(
+ ([lottieModule, json]) => {
+ const LottieWrapper: React.FC
= (props) => {
+ const Lottie = (lottieModule as any).default;
+ const animationData = (json as any).default ?? json;
+ return ;
+ };
+ return { default: LottieWrapper };
+ },
+ );
+
+ const LazyLottie = lazy(lazyLoader);
+
+ return (
+
+
+
تنظیمات بازگشت وجه پرسنل
+
+
+
+
setSelectedPersonelServiceId(id)}
+ selectedPersonelServiceId={selectedPersonelServiceId}
+ columnWidth={240}
+ configs={configsArray}
+ />
+
+
+
+ {selectedPersonelServiceId ? (
+
+ {/*
setSelectedPersonelServiceId(id)}
selectedPersonelServiceId={selectedPersonelServiceId}
@@ -452,92 +409,82 @@ const PersonnelCashbackSettingPage: React.FC = () => {
/>
*/}
-
-
مقدار بازگشت وجه (تومان)
-
setCashbackFormatted(v ?? "")}
- />
-
+
+
مقدار بازگشت وجه (تومان)
+
setCashbackFormatted(v ?? "")} />
+
-
-
قیمت خدمت (اختیاری)
-
setServicePriceFormatted(formatted)}
- />
-
+
+
قیمت خدمت (اختیاری)
+
setServicePriceFormatted(formatted)}
+ />
+
-
-
مدت اعتبار بازگشت وجه
-
- {EXPIRE_OPTIONS.map((opt) => {
- const selected = String(expireDaysInput) === opt.value;
- return (
- setExpireDaysInput(opt.value)}
- className={`px-3 py-1 rounded-full border ${
- selected
- ? "bg-[#AA00FF] text-white border-transparent"
- : "bg-white text-[#76558F]"
- }`}
- >
- {opt.label}
-
- );
- })}
-
-
-
-
{
- await handleSave();
- }}
- disabled={!canSave}
- className={`px-4 w-full py-2 text-nowrap rounded-full ${
- !canSave
- ? "opacity-50 cursor-not-allowed bg-gray-200 text-gray-500"
- : "bg-[#AA00FF] text-white"
- }`}
- >
- {pending
- ? "در حال ذخیره..."
- : existingConfig
- ? "ویرایش"
- : "ذخیره"}
-
+
+
مدت اعتبار بازگشت وجه
+
+ {EXPIRE_OPTIONS.map((opt) => {
+ const selected = String(expireDaysInput) === opt.value;
+ return (
+ setExpireDaysInput(opt.value)}
+ className={`px-3 py-1 rounded-full border ${
+ selected
+ ? "bg-[#AA00FF] text-white border-transparent"
+ : "bg-white text-[#76558F]"
+ }`}
+ >
+ {opt.label}
+
+ );
+ })}
+
+
+
+ {
+ await handleSave();
+ }}
+ disabled={!canSave}
+ className={`px-4 w-full py-2 text-nowrap rounded-full ${
+ !canSave
+ ? "opacity-50 cursor-not-allowed bg-gray-200 text-gray-500"
+ : "bg-[#AA00FF] text-white"
+ }`}
+ >
+ {pending ? "در حال ذخیره..." : existingConfig ? "ویرایش" : "ذخیره"}
+
- {existingConfig && (
-
- حذف
-
+ {existingConfig && (
+
+ حذف
+
+ )}
+
+
+ ) : (
+
+
+
+
+
+
برای اعمال تنظیمات بازگشت وجه هر پرسنل، ابتدا پرسنل مورد نظر خود را انتخاب کنید.
+
+
)}
-
- ) : (
-
-
-
-
-
-
- برای اعمال تنظیمات بازگشت وجه هر پرسنل، ابتدا پرسنل مورد نظر خود
- را انتخاب کنید.
-
-
-
- )}
-
- );
+ );
};
export default PersonnelCashbackSettingPage;
diff --git a/src/apps/new-ui/pages/PersonnelShowTimeTable/index.tsx b/src/apps/new-ui/pages/PersonnelShowTimeTable/index.tsx
index 404eca8f..c1b34e0d 100644
--- a/src/apps/new-ui/pages/PersonnelShowTimeTable/index.tsx
+++ b/src/apps/new-ui/pages/PersonnelShowTimeTable/index.tsx
@@ -1,20 +1,12 @@
-import {
- useEffect,
- useCallback,
- useState,
- useRef,
- useMemo,
- Suspense,
- lazy,
-} from "react";
+import { useEffect, useCallback, useState, useRef, useMemo, Suspense, lazy } from "react";
import AppointmentTime from "@/apps/new-ui/components/AppointmentComponents/AppointmentTime";
import toast from "react-hot-toast";
import {
- getJalaliDaysInMonth,
- gregorianToJalaliString,
- jalaliToGregorianString,
- jalaliToIso,
- pad2,
+ getJalaliDaysInMonth,
+ gregorianToJalaliString,
+ jalaliToGregorianString,
+ jalaliToIso,
+ pad2,
} from "@/apps/new-ui/Utils/Jalali";
import moment from "jalali-moment";
import MobileCalendarSingleMonth from "../AdvancedTurn/+components/Calendar/MobileCalendarSingleMonth";
@@ -28,16 +20,16 @@ import { Link } from "react-router-dom";
type AppMap = Record
;
const STORAGE_KEY = "appointmentTimesMap";
const KEYS_TO_CLEAR = [
- "appointmentDateGregorian",
- "appointmentWeekday",
- "appointmentDate",
- "appointmentTime",
- "appointmentTimesMap",
- "reserve_description",
- "reserve_captcha",
- "reserve_mobile",
- "artist_id",
- "reserve_name",
+ "appointmentDateGregorian",
+ "appointmentWeekday",
+ "appointmentDate",
+ "appointmentTime",
+ "appointmentTimesMap",
+ "reserve_description",
+ "reserve_captcha",
+ "reserve_mobile",
+ "artist_id",
+ "reserve_name",
];
const jalaliMoment = moment().locale("fa");
@@ -46,636 +38,580 @@ const default_month = pad2(Number(jalaliMoment.format("jMM")));
const default_day = pad2(Number(jalaliMoment.format("jDD")));
const looksLikeJson = (v: any) => {
- try {
- if (typeof v !== "string") return false;
- JSON.parse(v);
- return true;
- } catch {
- return false;
- }
+ try {
+ if (typeof v !== "string") return false;
+ JSON.parse(v);
+ return true;
+ } catch {
+ return false;
+ }
};
export default function PersonnelShowTimeTable() {
- const [selectedDateIso, setSelectedDateIso] = useState(() => {
- try {
- if (typeof window === "undefined") return null;
- const fromGreg = localStorage.getItem("appointmentDateGregorian");
- if (fromGreg) return fromGreg;
- const fromJalali = localStorage.getItem("appointmentDate");
- if (fromJalali) {
- const g = jalaliToGregorianString(fromJalali);
- if (g) {
- localStorage.setItem("appointmentDateGregorian", g);
- return g;
- }
- }
- const now = new Date();
- const gNow = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
- const jal = gregorianToJalaliString(now);
- localStorage.setItem("appointmentDate", jal);
- localStorage.setItem("appointmentDateGregorian", gNow);
- const weekdays = [
- "یکشنبه",
- "دوشنبه",
- "سهشنبه",
- "چهارشنبه",
- "پنجشنبه",
- "جمعه",
- "شنبه",
- ];
- localStorage.setItem("appointmentWeekday", weekdays[now.getDay()]);
- return gNow;
- } catch {
- return null;
- }
- });
-
- const [daySchedule, setDaySchedule] = useState([]);
- const [appointmentsMap, setAppointmentsMap] = useState(() => {
- try {
- if (typeof window === "undefined") return {};
- const raw = localStorage.getItem(STORAGE_KEY);
- return raw && looksLikeJson(raw) ? JSON.parse(raw) : {};
- } catch {
- return {};
- }
- });
-
- useEffect(() => {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(appointmentsMap));
- } catch {}
- }, [appointmentsMap]);
-
- const clearStored = useCallback(() => {
- try {
- KEYS_TO_CLEAR.forEach((k) => {
+ const [selectedDateIso, setSelectedDateIso] = useState(() => {
try {
- localStorage.removeItem(k);
+ if (typeof window === "undefined") return null;
+ const fromGreg = localStorage.getItem("appointmentDateGregorian");
+ if (fromGreg) return fromGreg;
+ const fromJalali = localStorage.getItem("appointmentDate");
+ if (fromJalali) {
+ const g = jalaliToGregorianString(fromJalali);
+ if (g) {
+ localStorage.setItem("appointmentDateGregorian", g);
+ return g;
+ }
+ }
+ const now = new Date();
+ const gNow = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
+ const jal = gregorianToJalaliString(now);
+ localStorage.setItem("appointmentDate", jal);
+ localStorage.setItem("appointmentDateGregorian", gNow);
+ const weekdays = ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"];
+ localStorage.setItem("appointmentWeekday", weekdays[now.getDay()]);
+ return gNow;
+ } catch {
+ return null;
+ }
+ });
+
+ const [daySchedule, setDaySchedule] = useState([]);
+ const [appointmentsMap, setAppointmentsMap] = useState(() => {
+ try {
+ if (typeof window === "undefined") return {};
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw && looksLikeJson(raw) ? JSON.parse(raw) : {};
+ } catch {
+ return {};
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(appointmentsMap));
} catch {}
- });
- } catch {}
- }, []);
+ }, [appointmentsMap]);
- useEffect(() => {
- const handleBeforeUnload = () => {
- try {
- clearStored();
- } catch {}
- };
+ const clearStored = useCallback(() => {
+ try {
+ KEYS_TO_CLEAR.forEach((k) => {
+ try {
+ localStorage.removeItem(k);
+ } catch {}
+ });
+ } catch {}
+ }, []);
- if (typeof window !== "undefined") {
- window.addEventListener("beforeunload", handleBeforeUnload);
- }
-
- return () => {
- try {
- if (typeof window !== "undefined") {
- window.removeEventListener("beforeunload", handleBeforeUnload);
- }
- } catch {}
- clearStored();
- };
- }, [clearStored]);
-
- useEffect(() => {
- const checkLocalStorage = () => {};
- const iv = setInterval(checkLocalStorage, 500);
- return () => clearInterval(iv);
- }, []);
-
- const getCountsForMonth = useCallback(
- (year: number, monthIndex: number) => {
- const map: Record = {};
- Object.keys(appointmentsMap).forEach((iso) => {
- const [y, m] = iso.split("-").map(Number);
- if (y === year && m - 1 === monthIndex) {
- map[iso] = appointmentsMap[iso]?.length || 0;
- }
- });
- return map;
- },
- [appointmentsMap],
- );
-
- const [selectedDateInfo, setSelectedDateInfo] = useState({
- jalali: `${default_year}/${default_month}/${default_day}`,
- weekday: moment(
- `${default_year}/${default_month}/${default_day}`,
- "jYYYY/jMM/jDD",
- )
- .locale("fa")
- .format("dddd"),
- });
-
- const [service, setService] = useState("");
-
- const [selYear, selMonth, selDay] = selectedDateInfo.jalali
- .split("/")
- .map((s) => Number(s));
-
- const [selectedPersonelServiceId, setSelectedPersonelServiceId] = useState<
- number | undefined
- >(() => {
- try {
- if (typeof window === "undefined") return undefined;
- const savedId = localStorage.getItem("selectedPersonelServiceId");
- return savedId ? Number(savedId) : undefined;
- } catch {
- return undefined;
- }
- });
-
- const { data: monthlyData, isLoading } = useGetPersonnelMonthlyTimeslots(
- selectedPersonelServiceId,
- selYear,
- selMonth,
- );
-
- const Holidays = monthlyData?.holidays;
- const IranHolidays = monthlyData?.iran_holidays;
-
- // ==================== ساختار Days دقیقاً مثل قبل ====================
- const rawDays = useMemo(() => {
- if (!monthlyData?.services) return [];
-
- // تبدیل به فرمت روز مبنا
- const daysMap = new Map();
-
- monthlyData.services.forEach((serviceData: any) => {
- serviceData.days?.forEach((day: any) => {
- if (!daysMap.has(day.day)) {
- daysMap.set(day.day, {
- day: day.day,
- is_holiday: day.is_holiday,
- services: [],
- });
- }
-
- const dayObj = daysMap.get(day.day);
- dayObj.services.push({
- personel_service_id: serviceData.personel_service_id,
- service_name: serviceData.service_name,
- free_slots: day.timeslots || [], // اینجا timeslots از API میاد
- });
- });
- });
-
- return Array.from(daysMap.values()).sort((a, b) => a.day - b.day);
- }, [monthlyData]);
-
- // ==================== فیلتر بر اساس سرویس ====================
- const filteredDays = useMemo(() => {
- if (!service || service.trim().length === 0) return rawDays;
-
- return rawDays.map((day: any) => ({
- ...day,
- services:
- day.services?.filter(
- (s: any) => s.service_name?.toString().trim() === service.trim(),
- ) || [],
- }));
- }, [rawDays, service]);
-
- // ==================== ساخت calendarDays ====================
- const calendarDays = useMemo(() => {
- try {
- if (!Array.isArray(filteredDays) || filteredDays.length === 0) return [];
-
- const yearJ = selYear;
- const monthJ = selMonth;
-
- return filteredDays.map((d: any) => {
- let slots: Array<{
- start_time: string;
- end_time: string;
- service_name?: string;
- personel_service_id?: number;
- }> = [];
-
- if (Array.isArray(d.services) && d.services.length > 0) {
- // اگه سرویس خاصی انتخاب شده
- if (service && service.trim().length > 0) {
- for (const s of d.services) {
- if (!s) continue;
- if ((s.service_name ?? "").toString().trim() === service.trim()) {
- if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
- slots = slots.concat(
- s.free_slots.map((f: any) => ({
- start_time: f.start_time,
- end_time: f.end_time,
- service_name: s.service_name,
- personel_service_id: s.personel_service_id,
- })),
- );
- }
- }
- }
- }
- // اگه personel_service_id خاصی انتخاب شده
- else if (selectedPersonelServiceId) {
- for (const s of d.services) {
- if (!s) continue;
- if (s.personel_service_id === selectedPersonelServiceId) {
- if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
- slots = slots.concat(
- s.free_slots.map((f: any) => ({
- start_time: f.start_time,
- end_time: f.end_time,
- service_name: s.service_name,
- personel_service_id: s.personel_service_id,
- })),
- );
- }
- }
- }
- }
- // همه سرویسها
- else {
- for (const s of d.services) {
- if (!s) continue;
- if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
- slots = slots.concat(
- s.free_slots.map((f: any) => ({
- start_time: f.start_time,
- end_time: f.end_time,
- service_name: s.service_name,
- personel_service_id: s.personel_service_id,
- })),
- );
- }
- }
- }
- }
-
- return {
- ...d,
- __year: yearJ,
- __month: monthJ,
- timeslots: slots,
+ useEffect(() => {
+ const handleBeforeUnload = () => {
+ try {
+ clearStored();
+ } catch {}
};
- });
- } catch (e) {
- console.error("calendarDays build error:", e);
- return [];
- }
- }, [filteredDays, service, selectedPersonelServiceId, selYear, selMonth]);
- const handleRemoveTime = (time: string) => {
- const dateIso =
- selectedDateIso ||
- (typeof window !== "undefined"
- ? localStorage.getItem("appointmentDateGregorian")
- : null);
- if (!dateIso) return;
- setAppointmentsMap((prev) => {
- const arr = prev[dateIso] ? prev[dateIso].filter((t) => t !== time) : [];
- const copy = { ...prev };
- if (arr.length === 0) delete copy[dateIso];
- else copy[dateIso] = arr;
- return copy;
- });
- toast.success("نوبت حذف شد.");
- };
+ if (typeof window !== "undefined") {
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ }
- const searchAttemptRef = useRef(0);
- const lastActionRef = useRef<"user" | "auto" | null>(null);
+ return () => {
+ try {
+ if (typeof window !== "undefined") {
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ }
+ } catch {}
+ clearStored();
+ };
+ }, [clearStored]);
- // ==================== فقط یک useEffect برای انتخاب اولیه ====================
- useEffect(() => {
- // اگه کاربر خودش انتخاب کرده بود، هیچ کاری نکن
- if (lastActionRef.current === "user") {
- lastActionRef.current = null;
- return;
- }
+ useEffect(() => {
+ const checkLocalStorage = () => {};
+ const iv = setInterval(checkLocalStorage, 500);
+ return () => clearInterval(iv);
+ }, []);
- // اگه تاریخ قبلاً انتخاب شده، هیچ کاری نکن
- if (selectedDateIso) {
- return;
- }
-
- if (typeof window === "undefined") return;
- if (!Array.isArray(calendarDays) || calendarDays.length === 0 || isLoading)
- return;
-
- // اولین روزی که اسلات داره رو پیدا کن
- const firstWithSlots = calendarDays.find(
- (d: any) => Array.isArray(d.timeslots) && d.timeslots.length > 0,
+ const getCountsForMonth = useCallback(
+ (year: number, monthIndex: number) => {
+ const map: Record = {};
+ Object.keys(appointmentsMap).forEach((iso) => {
+ const [y, m] = iso.split("-").map(Number);
+ if (y === year && m - 1 === monthIndex) {
+ map[iso] = appointmentsMap[iso]?.length || 0;
+ }
+ });
+ return map;
+ },
+ [appointmentsMap],
);
- if (firstWithSlots) {
- const y = String(firstWithSlots.__year ?? selYear);
- const m = pad2(Number(firstWithSlots.__month ?? selMonth));
- const d = pad2(Number(firstWithSlots.day));
-
- try {
- const iso = jalaliToIso(y, m, d);
- const jalali = `${y}/${m}/${d}`;
- const weekday = moment(jalali, "jYYYY/jMM/jDD")
- .locale("fa")
- .format("dddd");
-
- setSelectedDateIso(iso);
- setSelectedDateInfo({ jalali, weekday });
-
- localStorage.setItem("appointmentDateGregorian", iso);
- localStorage.setItem("appointmentDate", jalali);
- localStorage.setItem("appointmentWeekday", weekday);
-
- lastActionRef.current = "auto";
- searchAttemptRef.current = 0;
- } catch (e) {}
- }
- }, [calendarDays, isLoading, selectedDateIso, selYear, selMonth]);
-
- // ==================== when selectedDateIso changes -> set daySchedule ====================
- useEffect(() => {
- if (!selectedDateIso) return;
-
- try {
- const [gy, gm, gd] = selectedDateIso.split("-").map(Number);
- const dateObj = new Date(gy, gm - 1, gd);
- const jalali = gregorianToJalaliString(dateObj);
- const weekday = moment(jalali, "jYYYY/jMM/jDD")
- .locale("fa")
- .format("dddd");
-
- try {
- localStorage.setItem("appointmentDateGregorian", selectedDateIso);
- localStorage.setItem("appointmentDate", jalali);
- localStorage.setItem("appointmentWeekday", weekday);
- } catch {}
-
- let found: any = undefined;
- if (Array.isArray(calendarDays) && calendarDays.length > 0) {
- const dayNum = Number(jalali.split("/")[2]);
- found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
- console.log("Found day:", dayNum, found); // برای دیباگ
- }
-
- setDaySchedule(found?.timeslots || []);
- setSelectedDateInfo({ jalali, weekday });
- } catch (e) {
- console.error("Error setting daySchedule:", e);
- setDaySchedule([]);
- }
- }, [selectedDateIso, calendarDays]);
-
- const handleDateSelect = (isoDateGregorian: string) => {
- try {
- const [gy, gm, gd] = isoDateGregorian.split("-").map(Number);
- const dateObj = new Date(gy, gm - 1, gd);
- const jalali = gregorianToJalaliString(dateObj);
- localStorage.setItem("appointmentDate", jalali);
- localStorage.setItem("appointmentDateGregorian", isoDateGregorian);
- const weekdays = [
- "یکشنبه",
- "دوشنبه",
- "سهشنبه",
- "چهارشنبه",
- "پنجشنبه",
- "جمعه",
- "شنبه",
- ];
- localStorage.setItem("appointmentWeekday", weekdays[dateObj.getDay()]);
- lastActionRef.current = "user";
- searchAttemptRef.current = 0;
- setSelectedDateIso(isoDateGregorian);
- } catch {}
- };
-
- const addedTimesForSelected = selectedDateIso
- ? appointmentsMap[selectedDateIso] || []
- : [];
-
- const navigateMonth = (navigate: "next" | "prev") => {
- try {
- const y = Number(selYear);
- const m = Number(selMonth);
- const d = Number(selDay);
-
- let newYear = y;
- let newMonth = m;
-
- if (navigate === "next") {
- newMonth = m + 1;
- if (newMonth > 12) {
- newMonth = 1;
- newYear = y + 1;
- }
- } else {
- newMonth = m - 1;
- if (newMonth < 1) {
- newMonth = 12;
- newYear = y - 1;
- }
- }
-
- const daysInTarget = getJalaliDaysInMonth(newYear, newMonth);
- const newDay = Math.min(d, daysInTarget);
-
- const newYearStr = String(newYear);
- const newMonthStr = pad2(newMonth);
- const newDayStr = pad2(newDay);
-
- const newIso = jalaliToIso(newYearStr, newMonthStr, newDayStr);
-
- const newWeekday = moment(
- `${newYearStr}/${newMonthStr}/${newDayStr}`,
- "jYYYY/jMM/jDD",
- )
- .locale("fa")
- .format("dddd");
-
- lastActionRef.current = "user";
- searchAttemptRef.current = 0;
-
- setSelectedDateInfo({
- jalali: `${newYearStr}/${newMonthStr}/${newDayStr}`,
- weekday: newWeekday,
- });
-
- try {
- localStorage.setItem("appointmentDateGregorian", newIso);
- localStorage.setItem(
- "appointmentDate",
- `${newYearStr}/${newMonthStr}/${newDayStr}`,
- );
- localStorage.setItem("appointmentWeekday", newWeekday);
- } catch {}
-
- setSelectedDateIso(newIso);
- } catch (e) {}
- };
-
- const handleAddTime = (time: string): boolean => {
- const dateIso =
- selectedDateIso ||
- (typeof window !== "undefined"
- ? localStorage.getItem("appointmentDateGregorian")
- : null);
- if (!dateIso) {
- toast.error("ابتدا یک تاریخ انتخاب کنید.");
- return false;
- }
- const normalized = time.trim();
- if (!normalized) return false;
- let added = false;
- setAppointmentsMap((prev) => {
- const arr = prev[dateIso] ? [...prev[dateIso]] : [];
- if (arr.includes(normalized)) return prev;
- const next = { ...prev, [dateIso]: [...arr, normalized].sort() };
- added = true;
- return next;
+ const [selectedDateInfo, setSelectedDateInfo] = useState({
+ jalali: `${default_year}/${default_month}/${default_day}`,
+ weekday: moment(`${default_year}/${default_month}/${default_day}`, "jYYYY/jMM/jDD").locale("fa").format("dddd"),
});
- if (added) {
- toast.success("ساعت با موفقیت اضافه شد.");
- return true;
- } else {
- toast.error("این ساعت قبلاً اضافه شده است.");
- return false;
- }
- };
- useEffect(() => {
- return () => {
- if (!window.location.pathname.includes("/time-table")) {
- localStorage.removeItem("selectedPersonelServiceId");
- // localStorage.removeItem("selectedPersonelId");
- }
+ const [service, setService] = useState("");
+
+ const [selYear, selMonth, selDay] = selectedDateInfo.jalali.split("/").map((s) => Number(s));
+
+ const [selectedPersonelServiceId, setSelectedPersonelServiceId] = useState(() => {
+ try {
+ if (typeof window === "undefined") return undefined;
+ const savedId = localStorage.getItem("selectedPersonelServiceId");
+ return savedId ? Number(savedId) : undefined;
+ } catch {
+ return undefined;
+ }
+ });
+
+ const { data: monthlyData, isLoading } = useGetPersonnelMonthlyTimeslots(
+ selectedPersonelServiceId,
+ selYear,
+ selMonth,
+ );
+
+ const Holidays = monthlyData?.holidays;
+ const IranHolidays = monthlyData?.iran_holidays;
+
+ // ==================== ساختار Days دقیقاً مثل قبل ====================
+ const rawDays = useMemo(() => {
+ if (!monthlyData?.services) return [];
+
+ // تبدیل به فرمت روز مبنا
+ const daysMap = new Map();
+
+ monthlyData.services.forEach((serviceData: any) => {
+ serviceData.days?.forEach((day: any) => {
+ if (!daysMap.has(day.day)) {
+ daysMap.set(day.day, {
+ day: day.day,
+ is_holiday: day.is_holiday,
+ services: [],
+ });
+ }
+
+ const dayObj = daysMap.get(day.day);
+ dayObj.services.push({
+ personel_service_id: serviceData.personel_service_id,
+ service_name: serviceData.service_name,
+ free_slots: day.timeslots || [], // اینجا timeslots از API میاد
+ });
+ });
+ });
+
+ return Array.from(daysMap.values()).sort((a, b) => a.day - b.day);
+ }, [monthlyData]);
+
+ // ==================== فیلتر بر اساس سرویس ====================
+ const filteredDays = useMemo(() => {
+ if (!service || service.trim().length === 0) return rawDays;
+
+ return rawDays.map((day: any) => ({
+ ...day,
+ services: day.services?.filter((s: any) => s.service_name?.toString().trim() === service.trim()) || [],
+ }));
+ }, [rawDays, service]);
+
+ // ==================== ساخت calendarDays ====================
+ const calendarDays = useMemo(() => {
+ try {
+ if (!Array.isArray(filteredDays) || filteredDays.length === 0) return [];
+
+ const yearJ = selYear;
+ const monthJ = selMonth;
+
+ return filteredDays.map((d: any) => {
+ let slots: Array<{
+ start_time: string;
+ end_time: string;
+ is_free: boolean;
+ conflicts: any;
+ service_name?: string;
+ personel_service_id?: number;
+ }> = [];
+
+ if (Array.isArray(d.services) && d.services.length > 0) {
+ // اگه سرویس خاصی انتخاب شده
+ if (service && service.trim().length > 0) {
+ for (const s of d.services) {
+ if (!s) continue;
+ if ((s.service_name ?? "").toString().trim() === service.trim()) {
+ if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
+ slots = slots.concat(
+ s.free_slots.map((f: any) => ({
+ start_time: f.start_time,
+ conflicts: f.conflicts,
+ is_free: f.is_free,
+ end_time: f.end_time,
+ service_name: s.service_name,
+ personel_service_id: s.personel_service_id,
+ })),
+ );
+ }
+ }
+ }
+ }
+ // اگه personel_service_id خاصی انتخاب شده
+ else if (selectedPersonelServiceId) {
+ for (const s of d.services) {
+ if (!s) continue;
+ if (s.personel_service_id === selectedPersonelServiceId) {
+ if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
+ slots = slots.concat(
+ s.free_slots.map((f: any) => ({
+ start_time: f.start_time,
+ end_time: f.end_time,
+ service_name: s.service_name,
+ personel_service_id: s.personel_service_id,
+ })),
+ );
+ }
+ }
+ }
+ }
+ // همه سرویسها
+ else {
+ for (const s of d.services) {
+ if (!s) continue;
+ if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
+ slots = slots.concat(
+ s.free_slots.map((f: any) => ({
+ start_time: f.start_time,
+ end_time: f.end_time,
+ service_name: s.service_name,
+ personel_service_id: s.personel_service_id,
+ })),
+ );
+ }
+ }
+ }
+ }
+
+ return {
+ ...d,
+ __year: yearJ,
+ __month: monthJ,
+ timeslots: slots,
+ };
+ });
+ } catch (e) {
+ console.error("calendarDays build error:", e);
+ return [];
+ }
+ }, [filteredDays, service, selectedPersonelServiceId, selYear, selMonth]);
+
+ const handleRemoveTime = (time: string) => {
+ const dateIso =
+ selectedDateIso ||
+ (typeof window !== "undefined" ? localStorage.getItem("appointmentDateGregorian") : null);
+ if (!dateIso) return;
+ setAppointmentsMap((prev) => {
+ const arr = prev[dateIso] ? prev[dateIso].filter((t) => t !== time) : [];
+ const copy = { ...prev };
+ if (arr.length === 0) delete copy[dateIso];
+ else copy[dateIso] = arr;
+ return copy;
+ });
+ toast.success("نوبت حذف شد.");
};
- }, []);
- const handlePersonnelSelect = (
- personel_service_id?: number,
- service_name?: string,
- ) => {
- setSelectedPersonelServiceId(personel_service_id);
- setService(service_name ?? "");
+ const searchAttemptRef = useRef(0);
+ const lastActionRef = useRef<"user" | "auto" | null>(null);
- try {
- if (personel_service_id) {
- localStorage.setItem(
- "selectedPersonelServiceId",
- String(personel_service_id),
- );
- } else {
- localStorage.removeItem("selectedPersonelServiceId");
- }
- } catch {}
+ // ==================== فقط یک useEffect برای انتخاب اولیه ====================
+ useEffect(() => {
+ // اگه کاربر خودش انتخاب کرده بود، هیچ کاری نکن
+ if (lastActionRef.current === "user") {
+ lastActionRef.current = null;
+ return;
+ }
- try {
- const url = new URL(window.location.href);
- if (personel_service_id == null) url.searchParams.delete("id");
- else url.searchParams.set("id", String(personel_service_id));
- window.history.replaceState({}, "", url.toString());
- } catch {}
- };
- // =================================================
- const { data: serviceData, isLoading: serviceLoading } =
- useGetServicesWithPersonels({
- enabled: true,
+ // اگه تاریخ قبلاً انتخاب شده، هیچ کاری نکن
+ if (selectedDateIso) {
+ return;
+ }
+
+ if (typeof window === "undefined") return;
+ if (!Array.isArray(calendarDays) || calendarDays.length === 0 || isLoading) return;
+
+ // اولین روزی که اسلات داره رو پیدا کن
+ const firstWithSlots = calendarDays.find((d: any) => Array.isArray(d.timeslots) && d.timeslots.length > 0);
+
+ if (firstWithSlots) {
+ const y = String(firstWithSlots.__year ?? selYear);
+ const m = pad2(Number(firstWithSlots.__month ?? selMonth));
+ const d = pad2(Number(firstWithSlots.day));
+
+ try {
+ const iso = jalaliToIso(y, m, d);
+ const jalali = `${y}/${m}/${d}`;
+ const weekday = moment(jalali, "jYYYY/jMM/jDD").locale("fa").format("dddd");
+
+ setSelectedDateIso(iso);
+ setSelectedDateInfo({ jalali, weekday });
+
+ localStorage.setItem("appointmentDateGregorian", iso);
+ localStorage.setItem("appointmentDate", jalali);
+ localStorage.setItem("appointmentWeekday", weekday);
+
+ lastActionRef.current = "auto";
+ searchAttemptRef.current = 0;
+ } catch (e) {}
+ }
+ }, [calendarDays, isLoading, selectedDateIso, selYear, selMonth]);
+
+ // ==================== when selectedDateIso changes -> set daySchedule ====================
+ useEffect(() => {
+ if (!selectedDateIso) return;
+
+ try {
+ const [gy, gm, gd] = selectedDateIso.split("-").map(Number);
+ const dateObj = new Date(gy, gm - 1, gd);
+ const jalali = gregorianToJalaliString(dateObj);
+ const weekday = moment(jalali, "jYYYY/jMM/jDD").locale("fa").format("dddd");
+
+ try {
+ localStorage.setItem("appointmentDateGregorian", selectedDateIso);
+ localStorage.setItem("appointmentDate", jalali);
+ localStorage.setItem("appointmentWeekday", weekday);
+ } catch {}
+
+ let found: any = undefined;
+ if (Array.isArray(calendarDays) && calendarDays.length > 0) {
+ const dayNum = Number(jalali.split("/")[2]);
+ found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
+ }
+
+ setDaySchedule(found?.timeslots || []);
+ setSelectedDateInfo({ jalali, weekday });
+ } catch (e) {
+ console.error("Error setting daySchedule:", e);
+ setDaySchedule([]);
+ }
+ }, [selectedDateIso, calendarDays]);
+
+ const handleDateSelect = (isoDateGregorian: string) => {
+ try {
+ const [gy, gm, gd] = isoDateGregorian.split("-").map(Number);
+ const dateObj = new Date(gy, gm - 1, gd);
+ const jalali = gregorianToJalaliString(dateObj);
+ localStorage.setItem("appointmentDate", jalali);
+ localStorage.setItem("appointmentDateGregorian", isoDateGregorian);
+ const weekdays = ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"];
+ localStorage.setItem("appointmentWeekday", weekdays[dateObj.getDay()]);
+ lastActionRef.current = "user";
+ searchAttemptRef.current = 0;
+ setSelectedDateIso(isoDateGregorian);
+ } catch {}
+ };
+
+ const addedTimesForSelected = selectedDateIso ? appointmentsMap[selectedDateIso] || [] : [];
+
+ const navigateMonth = (navigate: "next" | "prev") => {
+ try {
+ const y = Number(selYear);
+ const m = Number(selMonth);
+ const d = Number(selDay);
+
+ let newYear = y;
+ let newMonth = m;
+
+ if (navigate === "next") {
+ newMonth = m + 1;
+ if (newMonth > 12) {
+ newMonth = 1;
+ newYear = y + 1;
+ }
+ } else {
+ newMonth = m - 1;
+ if (newMonth < 1) {
+ newMonth = 12;
+ newYear = y - 1;
+ }
+ }
+
+ const daysInTarget = getJalaliDaysInMonth(newYear, newMonth);
+ const newDay = Math.min(d, daysInTarget);
+
+ const newYearStr = String(newYear);
+ const newMonthStr = pad2(newMonth);
+ const newDayStr = pad2(newDay);
+
+ const newIso = jalaliToIso(newYearStr, newMonthStr, newDayStr);
+
+ const newWeekday = moment(`${newYearStr}/${newMonthStr}/${newDayStr}`, "jYYYY/jMM/jDD")
+ .locale("fa")
+ .format("dddd");
+
+ lastActionRef.current = "user";
+ searchAttemptRef.current = 0;
+
+ setSelectedDateInfo({
+ jalali: `${newYearStr}/${newMonthStr}/${newDayStr}`,
+ weekday: newWeekday,
+ });
+
+ try {
+ localStorage.setItem("appointmentDateGregorian", newIso);
+ localStorage.setItem("appointmentDate", `${newYearStr}/${newMonthStr}/${newDayStr}`);
+ localStorage.setItem("appointmentWeekday", newWeekday);
+ } catch {}
+
+ setSelectedDateIso(newIso);
+ } catch (e) {}
+ };
+
+ const handleAddTime = (time: string): boolean => {
+ const dateIso =
+ selectedDateIso ||
+ (typeof window !== "undefined" ? localStorage.getItem("appointmentDateGregorian") : null);
+ if (!dateIso) {
+ toast.error("ابتدا یک تاریخ انتخاب کنید.");
+ return false;
+ }
+ const normalized = time.trim();
+ if (!normalized) return false;
+ let added = false;
+ setAppointmentsMap((prev) => {
+ const arr = prev[dateIso] ? [...prev[dateIso]] : [];
+ if (arr.includes(normalized)) return prev;
+ const next = { ...prev, [dateIso]: [...arr, normalized].sort() };
+ added = true;
+ return next;
+ });
+ if (added) {
+ toast.success("ساعت با موفقیت اضافه شد.");
+ return true;
+ } else {
+ toast.error("این ساعت قبلاً اضافه شده است.");
+ return false;
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ if (!window.location.pathname.includes("/time-table")) {
+ localStorage.removeItem("selectedPersonelServiceId");
+ // localStorage.removeItem("selectedPersonelId");
+ }
+ };
+ }, []);
+
+ const handlePersonnelSelect = (personel_service_id?: number, service_name?: string) => {
+ setSelectedPersonelServiceId(personel_service_id);
+ setService(service_name ?? "");
+
+ try {
+ if (personel_service_id) {
+ localStorage.setItem("selectedPersonelServiceId", String(personel_service_id));
+ } else {
+ localStorage.removeItem("selectedPersonelServiceId");
+ }
+ } catch {}
+
+ try {
+ const url = new URL(window.location.href);
+ if (personel_service_id == null) url.searchParams.delete("id");
+ else url.searchParams.set("id", String(personel_service_id));
+ window.history.replaceState({}, "", url.toString());
+ } catch {}
+ };
+ // =================================================
+ const { data: serviceData, isLoading: serviceLoading } = useGetServicesWithPersonels({
+ enabled: true,
});
- const LottieFallback = (
-
-
-
- );
- const lazyLoader = () =>
- Promise.all([
- import("lottie-react"),
- import("@public/lotties/personnelChoose.json"),
- ]).then(([lottieModule, json]) => {
- const LottieWrapper: React.FC = (props) => {
- const Lottie = (lottieModule as any).default;
- const animationData = (json as any).default ?? json;
- return (
-
- );
- };
- return { default: LottieWrapper };
- });
-
- const LazyLottie = lazy(lazyLoader);
-
- return (
- <>
-
-
- <>مشاهده جدول زمانی>
-
- {serviceLoading ? (
-
+ const LottieFallback = (
+
-
- ) : !serviceData?.services ||
- serviceData.services.length === 0 ||
- !serviceData.services.some(
- (service) => service.personels && service.personels.length > 0,
- ) ? (
-
-
-
-
-
-
- برای مشاهده جدول نوبتدهی هر پرسنل، ابتدا پرسنل و خدمات پرسنل
- مورد نظر خود را اضافه کنید.
-
-
- ثبت پرسنل جدید
-
-
-
- ) : (
-
+ );
+ const lazyLoader = () =>
+ Promise.all([import("lottie-react"), import("@public/lotties/personnelChoose.json")]).then(
+ ([lottieModule, json]) => {
+ const LottieWrapper: React.FC
= (props) => {
+ const Lottie = (lottieModule as any).default;
+ const animationData = (json as any).default ?? json;
+ return ;
+ };
+ return { default: LottieWrapper };
+ },
+ );
-
-
-
+ const LazyLottie = lazy(lazyLoader);
-
-
handleAddTime(time)}
- onRemoveAddedTime={(time: any) => handleRemoveTime(time)}
- />
+ return (
+ <>
+
+
+ <>مشاهده جدول زمانی>
+
+ {serviceLoading ? (
+
+
+
+ ) : !serviceData?.services ||
+ serviceData.services.length === 0 ||
+ !serviceData.services.some((service) => service.personels && service.personels.length > 0) ? (
+
+
+
+
+
+
+ برای مشاهده جدول نوبتدهی هر پرسنل، ابتدا پرسنل و خدمات پرسنل مورد نظر خود را اضافه
+ کنید.
+
+
+ ثبت پرسنل جدید
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
handleAddTime(time)}
+ onRemoveAddedTime={(time: any) => handleRemoveTime(time)}
+ />
+
+
+ )}
-
- )}
-
- >
- );
+ >
+ );
}
diff --git a/src/apps/new-ui/pages/ShowTimeTable/index.tsx b/src/apps/new-ui/pages/ShowTimeTable/index.tsx
index 7d1c9979..85a8ef5d 100644
--- a/src/apps/new-ui/pages/ShowTimeTable/index.tsx
+++ b/src/apps/new-ui/pages/ShowTimeTable/index.tsx
@@ -1,20 +1,12 @@
-import {
- useEffect,
- useCallback,
- useState,
- useRef,
- useMemo,
- lazy,
- Suspense,
-} from "react";
+import { useEffect, useCallback, useState, useRef, useMemo, lazy, Suspense } from "react";
import AppointmentTime from "@/apps/new-ui/components/AppointmentComponents/AppointmentTime";
import toast from "react-hot-toast";
import {
- getJalaliDaysInMonth,
- gregorianToJalaliString,
- jalaliToGregorianString,
- jalaliToIso,
- pad2,
+ getJalaliDaysInMonth,
+ gregorianToJalaliString,
+ jalaliToGregorianString,
+ jalaliToIso,
+ pad2,
} from "@/apps/new-ui/Utils/Jalali";
import moment from "jalali-moment";
import MobileCalendarSingleMonth from "../AdvancedTurn/+components/Calendar/MobileCalendarSingleMonth";
@@ -28,16 +20,16 @@ import { useGetUserInfo } from "../../services/User";
type AppMap = Record
;
const STORAGE_KEY = "appointmentTimesMap";
const KEYS_TO_CLEAR = [
- "appointmentDateGregorian",
- "appointmentWeekday",
- "appointmentDate",
- "appointmentTime",
- "appointmentTimesMap",
- "reserve_description",
- "reserve_captcha",
- "reserve_mobile",
- "artist_id",
- "reserve_name",
+ "appointmentDateGregorian",
+ "appointmentWeekday",
+ "appointmentDate",
+ "appointmentTime",
+ "appointmentTimesMap",
+ "reserve_description",
+ "reserve_captcha",
+ "reserve_mobile",
+ "artist_id",
+ "reserve_name",
];
const jalaliMoment = moment().locale("fa");
@@ -46,690 +38,623 @@ const default_month = pad2(Number(jalaliMoment.format("jMM")));
const default_day = pad2(Number(jalaliMoment.format("jDD")));
const looksLikeJson = (v: any) => {
- try {
- if (typeof v !== "string") return false;
- JSON.parse(v);
- return true;
- } catch {
- return false;
- }
+ try {
+ if (typeof v !== "string") return false;
+ JSON.parse(v);
+ return true;
+ } catch {
+ return false;
+ }
};
export default function ShowTimeTable() {
- const [selectedDateIso, setSelectedDateIso] = useState(() => {
- try {
- if (typeof window === "undefined") return null;
- const fromGreg = localStorage.getItem("appointmentDateGregorian");
- if (fromGreg) return fromGreg;
- const fromJalali = localStorage.getItem("appointmentDate");
- if (fromJalali) {
- const g = jalaliToGregorianString(fromJalali);
- if (g) {
- localStorage.setItem("appointmentDateGregorian", g);
- return g;
- }
- }
- const now = new Date();
- const gNow = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
- const jal = gregorianToJalaliString(now);
- localStorage.setItem("appointmentDate", jal);
- localStorage.setItem("appointmentDateGregorian", gNow);
- const weekdays = [
- "یکشنبه",
- "دوشنبه",
- "سهشنبه",
- "چهارشنبه",
- "پنجشنبه",
- "جمعه",
- "شنبه",
- ];
- localStorage.setItem("appointmentWeekday", weekdays[now.getDay()]);
- return gNow;
- } catch {
- return null;
- }
- });
-
- const [daySchedule, setDaySchedule] = useState([]);
- const [appointmentsMap, setAppointmentsMap] = useState(() => {
- try {
- if (typeof window === "undefined") return {};
- const raw = localStorage.getItem(STORAGE_KEY);
- return raw && looksLikeJson(raw) ? JSON.parse(raw) : {};
- } catch {
- return {};
- }
- });
-
- useEffect(() => {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(appointmentsMap));
- } catch {}
- }, [appointmentsMap]);
-
- const clearStored = useCallback(() => {
- try {
- KEYS_TO_CLEAR.forEach((k) => {
+ const [selectedDateIso, setSelectedDateIso] = useState(() => {
try {
- localStorage.removeItem(k);
- } catch {}
- });
- } catch {}
- }, []);
-
- // cleanup on unmount and page unload
- useEffect(() => {
- const handleBeforeUnload = () => {
- try {
- clearStored();
- } catch {}
- };
-
- if (typeof window !== "undefined") {
- window.addEventListener("beforeunload", handleBeforeUnload);
- }
-
- return () => {
- try {
- if (typeof window !== "undefined") {
- window.removeEventListener("beforeunload", handleBeforeUnload);
- }
- } catch {}
- clearStored();
- };
- }, [clearStored]);
-
- useEffect(() => {
- const checkLocalStorage = () => {};
- const iv = setInterval(checkLocalStorage, 500);
- return () => clearInterval(iv);
- }, []);
-
- const getCountsForMonth = useCallback(
- (year: number, monthIndex: number) => {
- const map: Record = {};
- Object.keys(appointmentsMap).forEach((iso) => {
- const [y, m] = iso.split("-").map(Number);
- if (y === year && m - 1 === monthIndex) {
- map[iso] = appointmentsMap[iso]?.length || 0;
- }
- });
- return map;
- },
- [appointmentsMap],
- );
-
- const [selectedDateInfo, setSelectedDateInfo] = useState({
- jalali: `${default_year}/${default_month}/${default_day}`,
- weekday: moment(
- `${default_year}/${default_month}/${default_day}`,
- "jYYYY/jMM/jDD",
- )
- .locale("fa")
- .format("dddd"),
- });
-
- // service selection state (selector above calendar)
- const [service, setService] = useState("");
-
- const [selYear, selMonth, selDay] = selectedDateInfo.jalali
- .split("/")
- .map((s) => Number(s));
-
- const { data } = useGetMonthScheduleForPublic(selYear, selMonth);
- const Holidays = data?.holidays;
- const IranHolidays = data?.iran_holidays;
-
- // ------------- RAW DAYS: filter by selected service (fixed shadowing bug) -------------
- const rawDays = useMemo(() => {
- if (!Array.isArray(data?.days)) return [];
- // normalize selected service name (null => no filter)
- const selectedServiceName =
- service && typeof service === "string" && service.trim().length > 0
- ? service.trim()
- : null;
-
- return data.days.map((day: any) => {
- if (!selectedServiceName || !day.services || day.services.length === 0) {
- return day;
- }
- // avoid shadowing variable names; compare trimmed strings
- const filteredServices = day.services.filter((s: any) => {
- try {
- const name = (s?.service_name ?? "").toString().trim();
- return name === selectedServiceName;
- } catch {
- return false;
- }
- });
- return {
- ...day,
- services: filteredServices,
- };
- });
- }, [data?.days, service]);
-
- // ------------- DAYS with timeslots aggregated from services.free_slots -------------
- const Days = useMemo(() => {
- return rawDays.map((d: any) => {
- if (Array.isArray(d.timeslots)) {
- return { ...d, timeslots: d.timeslots };
- }
- if (Array.isArray(d.services)) {
- const aggregated = d.services.flatMap((s: any) =>
- Array.isArray(s.free_slots)
- ? s.free_slots.map((fs: any) => ({
- ...fs,
- service_name: s.service_name,
- }))
- : [],
- );
- return { ...d, timeslots: aggregated };
- }
- return { ...d, timeslots: [] };
- });
- }, [rawDays]);
-
- const handleRemoveTime = (time: string) => {
- const dateIso =
- selectedDateIso ||
- (typeof window !== "undefined"
- ? localStorage.getItem("appointmentDateGregorian")
- : null);
- if (!dateIso) return;
- setAppointmentsMap((prev) => {
- const arr = prev[dateIso] ? prev[dateIso].filter((t) => t !== time) : [];
- const copy = { ...prev };
- if (arr.length === 0) delete copy[dateIso];
- else copy[dateIso] = arr;
- return copy;
- });
- toast.success("نوبت حذف شد.");
- };
-
- const searchAttemptRef = useRef(0);
- const lastActionRef = useRef<"user" | "auto" | null>(null);
-
- // ---------------- build calendarDays from Days + free_slots (filter by service) ----------------
- const calendarDays = useMemo(() => {
- try {
- if (!Array.isArray(Days) || Days.length === 0) return [];
- const yearJ = Number(data?.year ?? selYear ?? default_year);
- const monthJ = Number(data?.month ?? selMonth ?? default_month);
-
- return Days.map((d: any) => {
- let slots: Array<{
- start_time: string;
- end_time: string;
- service_name?: string;
- }> = [];
-
- if (Array.isArray(d.services) && d.services.length > 0) {
- if (service && service.trim().length > 0) {
- // فقط سرویس انتخابشده
- for (const s of d.services) {
- if (!s) continue;
- if ((s.service_name ?? "").toString().trim() === service.trim()) {
- if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
- slots = slots.concat(
- s.free_slots.map((f: any) => ({
- start_time: f.start_time ?? f.start,
- end_time: f.end_time ?? f.end,
- service_name: s.service_name,
- })),
- );
+ if (typeof window === "undefined") return null;
+ const fromGreg = localStorage.getItem("appointmentDateGregorian");
+ if (fromGreg) return fromGreg;
+ const fromJalali = localStorage.getItem("appointmentDate");
+ if (fromJalali) {
+ const g = jalaliToGregorianString(fromJalali);
+ if (g) {
+ localStorage.setItem("appointmentDateGregorian", g);
+ return g;
}
- }
}
- } else {
- // هیچ سرویسی انتخاب نشده: ترکیب همه free_slots
- for (const s of d.services) {
- if (!s) continue;
- if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
- slots = slots.concat(
- s.free_slots.map((f: any) => ({
- start_time: f.start_time ?? f.start,
- end_time: f.end_time ?? f.end,
- service_name: s.service_name,
- })),
- );
- }
- }
- }
- } else if (Array.isArray(d.timeslots) && d.timeslots.length > 0) {
- // fallback: if timeslots already present on day (from Days), use them
- slots = d.timeslots.map((t: any) => ({
- start_time: t.start_time ?? t.start,
- end_time: t.end_time ?? t.end,
- service_name: t.service_name ?? undefined,
- }));
+ const now = new Date();
+ const gNow = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
+ const jal = gregorianToJalaliString(now);
+ localStorage.setItem("appointmentDate", jal);
+ localStorage.setItem("appointmentDateGregorian", gNow);
+ const weekdays = ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"];
+ localStorage.setItem("appointmentWeekday", weekdays[now.getDay()]);
+ return gNow;
+ } catch {
+ return null;
+ }
+ });
+
+ const [daySchedule, setDaySchedule] = useState([]);
+ const [appointmentsMap, setAppointmentsMap] = useState(() => {
+ try {
+ if (typeof window === "undefined") return {};
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw && looksLikeJson(raw) ? JSON.parse(raw) : {};
+ } catch {
+ return {};
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(appointmentsMap));
+ } catch {}
+ }, [appointmentsMap]);
+
+ const clearStored = useCallback(() => {
+ try {
+ KEYS_TO_CLEAR.forEach((k) => {
+ try {
+ localStorage.removeItem(k);
+ } catch {}
+ });
+ } catch {}
+ }, []);
+
+ // cleanup on unmount and page unload
+ useEffect(() => {
+ const handleBeforeUnload = () => {
+ try {
+ clearStored();
+ } catch {}
+ };
+
+ if (typeof window !== "undefined") {
+ window.addEventListener("beforeunload", handleBeforeUnload);
}
- return {
- ...d,
- __year: yearJ,
- __month: monthJ,
- // مهم: timeslots باید داخل calendarDays موجود باشد
- timeslots: slots,
+ return () => {
+ try {
+ if (typeof window !== "undefined") {
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ }
+ } catch {}
+ clearStored();
};
- });
- } catch (e) {
- console.error("calendarDays build error:", e);
- return [];
- }
- }, [Days, service, data?.year, data?.month, selYear, selMonth]);
+ }, [clearStored]);
- // ---------- initial pick logic (auto-select first valid day) using calendarDays ----------
- useEffect(() => {
- if (lastActionRef.current === "user") {
- lastActionRef.current = null;
- return;
- }
+ useEffect(() => {
+ const checkLocalStorage = () => {};
+ const iv = setInterval(checkLocalStorage, 500);
+ return () => clearInterval(iv);
+ }, []);
- if (typeof window === "undefined") return;
- if (!Array.isArray(calendarDays) || calendarDays.length === 0) return;
+ const getCountsForMonth = useCallback(
+ (year: number, monthIndex: number) => {
+ const map: Record = {};
+ Object.keys(appointmentsMap).forEach((iso) => {
+ const [y, m] = iso.split("-").map(Number);
+ if (y === year && m - 1 === monthIndex) {
+ map[iso] = appointmentsMap[iso]?.length || 0;
+ }
+ });
+ return map;
+ },
+ [appointmentsMap],
+ );
- const holidaySet = new Set();
- (Holidays || []).forEach((n: any) => {
- const v = Number(n);
- if (Number.isFinite(v)) holidaySet.add(v);
- });
- (IranHolidays || []).forEach((n: any) => {
- const v = Number(n);
- if (Number.isFinite(v)) holidaySet.add(v);
+ const [selectedDateInfo, setSelectedDateInfo] = useState({
+ jalali: `${default_year}/${default_month}/${default_day}`,
+ weekday: moment(`${default_year}/${default_month}/${default_day}`, "jYYYY/jMM/jDD").locale("fa").format("dddd"),
});
- const hasTimeslotsForJalaliDay = (jalaliDateStr: string) => {
- try {
- const dayNum = Number(jalaliDateStr.split("/")[2]);
- const found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
- return !!(
- found &&
- Array.isArray(found.timeslots) &&
- found.timeslots.length > 0
- );
- } catch {
- return false;
- }
+ // service selection state (selector above calendar)
+ const [service, setService] = useState("");
+
+ const [selYear, selMonth, selDay] = selectedDateInfo.jalali.split("/").map((s) => Number(s));
+
+ const { data } = useGetMonthScheduleForPublic(selYear, selMonth);
+ const Holidays = data?.holidays;
+ const IranHolidays = data?.iran_holidays;
+
+ // ------------- RAW DAYS: filter by selected service (fixed shadowing bug) -------------
+ const rawDays = useMemo(() => {
+ if (!Array.isArray(data?.days)) return [];
+ // normalize selected service name (null => no filter)
+ const selectedServiceName =
+ service && typeof service === "string" && service.trim().length > 0 ? service.trim() : null;
+
+ return data.days.map((day: any) => {
+ if (!selectedServiceName || !day.services || day.services.length === 0) {
+ return day;
+ }
+ // avoid shadowing variable names; compare trimmed strings
+ const filteredServices = day.services.filter((s: any) => {
+ try {
+ const name = (s?.service_name ?? "").toString().trim();
+ return name === selectedServiceName;
+ } catch {
+ return false;
+ }
+ });
+ return {
+ ...day,
+ services: filteredServices,
+ };
+ });
+ }, [data?.days, service]);
+
+ // ------------- DAYS with timeslots aggregated from services.free_slots -------------
+ const Days = useMemo(() => {
+ return rawDays.map((d: any) => {
+ if (Array.isArray(d.timeslots)) {
+ return { ...d, timeslots: d.timeslots };
+ }
+ if (Array.isArray(d.services)) {
+ const aggregated = d.services.flatMap((s: any) =>
+ Array.isArray(s.free_slots)
+ ? s.free_slots.map((fs: any) => ({
+ ...fs,
+ service_name: s.service_name,
+ }))
+ : [],
+ );
+ return { ...d, timeslots: aggregated };
+ }
+ return { ...d, timeslots: [] };
+ });
+ }, [rawDays]);
+
+ const handleRemoveTime = (time: string) => {
+ const dateIso =
+ selectedDateIso ||
+ (typeof window !== "undefined" ? localStorage.getItem("appointmentDateGregorian") : null);
+ if (!dateIso) return;
+ setAppointmentsMap((prev) => {
+ const arr = prev[dateIso] ? prev[dateIso].filter((t) => t !== time) : [];
+ const copy = { ...prev };
+ if (arr.length === 0) delete copy[dateIso];
+ else copy[dateIso] = arr;
+ return copy;
+ });
+ toast.success("نوبت حذف شد.");
};
- try {
- const stored = localStorage.getItem("appointmentDateGregorian");
- if (stored) {
- const pd = new Date(stored);
- if (!isNaN(pd.getTime())) {
- const jal = gregorianToJalaliString(pd);
- const dayNum = Number(jal.split("/")[2]);
- if (!holidaySet.has(dayNum) && hasTimeslotsForJalaliDay(jal)) {
+ const searchAttemptRef = useRef(0);
+ const lastActionRef = useRef<"user" | "auto" | null>(null);
+
+ // ---------------- build calendarDays from Days + free_slots (filter by service) ----------------
+ const calendarDays = useMemo(() => {
+ try {
+ if (!Array.isArray(Days) || Days.length === 0) return [];
+ const yearJ = Number(data?.year ?? selYear ?? default_year);
+ const monthJ = Number(data?.month ?? selMonth ?? default_month);
+
+ return Days.map((d: any) => {
+ let slots: Array<{
+ start_time: string;
+ end_time: string;
+ service_name?: string;
+ is_free: boolean;
+ conflicts: any;
+ }> = [];
+
+ if (Array.isArray(d.services) && d.services.length > 0) {
+ if (service && service.trim().length > 0) {
+ // فقط سرویس انتخابشده
+ for (const s of d.services) {
+ if (!s) continue;
+ if ((s.service_name ?? "").toString().trim() === service.trim()) {
+ if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
+ slots = slots.concat(
+ s.free_slots.map((f: any) => ({
+ start_time: f.start_time ?? f.start,
+ end_time: f.end_time ?? f.end,
+ conflicts: f.conflicts,
+ is_free: f.is_free,
+ service_name: s.service_name,
+ })),
+ );
+ }
+ }
+ }
+ } else {
+ // هیچ سرویسی انتخاب نشده: ترکیب همه free_slots
+ for (const s of d.services) {
+ if (!s) continue;
+ if (Array.isArray(s.free_slots) && s.free_slots.length > 0) {
+ slots = slots.concat(
+ s.free_slots.map((f: any) => ({
+ start_time: f.start_time ?? f.start,
+ end_time: f.end_time ?? f.end,
+ service_name: s.service_name,
+ })),
+ );
+ }
+ }
+ }
+ } else if (Array.isArray(d.timeslots) && d.timeslots.length > 0) {
+ // fallback: if timeslots already present on day (from Days), use them
+ slots = d.timeslots.map((t: any) => ({
+ start_time: t.start_time ?? t.start,
+ end_time: t.end_time ?? t.end,
+ service_name: t.service_name ?? undefined,
+ }));
+ }
+
+ return {
+ ...d,
+ __year: yearJ,
+ __month: monthJ,
+ // مهم: timeslots باید داخل calendarDays موجود باشد
+ timeslots: slots,
+ };
+ });
+ } catch (e) {
+ console.error("calendarDays build error:", e);
+ return [];
+ }
+ }, [Days, service, data?.year, data?.month, selYear, selMonth]);
+
+ // ---------- initial pick logic (auto-select first valid day) using calendarDays ----------
+ useEffect(() => {
+ if (lastActionRef.current === "user") {
+ lastActionRef.current = null;
return;
- }
}
- }
- } catch {}
- const today = new Date();
- const start = new Date(
- today.getFullYear(),
- today.getMonth(),
- today.getDate(),
- );
+ if (typeof window === "undefined") return;
+ if (!Array.isArray(calendarDays) || calendarDays.length === 0) return;
- if (searchAttemptRef.current >= 3) return;
+ const holidaySet = new Set();
+ (Holidays || []).forEach((n: any) => {
+ const v = Number(n);
+ if (Number.isFinite(v)) holidaySet.add(v);
+ });
+ (IranHolidays || []).forEach((n: any) => {
+ const v = Number(n);
+ if (Number.isFinite(v)) holidaySet.add(v);
+ });
- for (let i = 0; i < 365; i++) {
- const d = new Date(start);
- d.setDate(start.getDate() + i);
- const jalali = gregorianToJalaliString(d);
- const dayNum = Number(jalali.split("/")[2]);
+ const hasTimeslotsForJalaliDay = (jalaliDateStr: string) => {
+ try {
+ const dayNum = Number(jalaliDateStr.split("/")[2]);
+ const found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
+ return !!(found && Array.isArray(found.timeslots) && found.timeslots.length > 0);
+ } catch {
+ return false;
+ }
+ };
- if (holidaySet.has(dayNum)) continue;
- if (!hasTimeslotsForJalaliDay(jalali)) continue;
-
- const iso = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
-
- setSelectedDateIso((current) => {
- if (current === iso) return current;
try {
- const weekday = moment(jalali, "jYYYY/jMM/jDD")
- .locale("fa")
- .format("dddd");
- setSelectedDateInfo({ jalali, weekday });
- localStorage.setItem("appointmentDateGregorian", iso);
- localStorage.setItem("appointmentDate", jalali);
- localStorage.setItem("appointmentWeekday", weekday);
+ const stored = localStorage.getItem("appointmentDateGregorian");
+ if (stored) {
+ const pd = new Date(stored);
+ if (!isNaN(pd.getTime())) {
+ const jal = gregorianToJalaliString(pd);
+ const dayNum = Number(jal.split("/")[2]);
+ if (!holidaySet.has(dayNum) && hasTimeslotsForJalaliDay(jal)) {
+ return;
+ }
+ }
+ }
} catch {}
- return iso;
- });
- lastActionRef.current = "auto";
- searchAttemptRef.current = 0;
- break;
- }
- }, [calendarDays, Holidays, IranHolidays]);
+ const today = new Date();
+ const start = new Date(today.getFullYear(), today.getMonth(), today.getDate());
- // when selectedDateIso changes -> set daySchedule from calendarDays (which contains timeslots)
- useEffect(() => {
- if (!selectedDateIso) return;
+ if (searchAttemptRef.current >= 3) return;
- try {
- const [gy, gm, gd] = selectedDateIso.split("-").map(Number);
- const dateObj = new Date(gy, gm - 1, gd);
- const jalali = gregorianToJalaliString(dateObj);
- const weekday = moment(jalali, "jYYYY/jMM/jDD")
- .locale("fa")
- .format("dddd");
+ for (let i = 0; i < 365; i++) {
+ const d = new Date(start);
+ d.setDate(start.getDate() + i);
+ const jalali = gregorianToJalaliString(d);
+ const dayNum = Number(jalali.split("/")[2]);
- try {
- localStorage.setItem("appointmentDateGregorian", selectedDateIso);
- localStorage.setItem("appointmentDate", jalali);
- localStorage.setItem("appointmentWeekday", weekday);
- } catch {}
+ if (holidaySet.has(dayNum)) continue;
+ if (!hasTimeslotsForJalaliDay(jalali)) continue;
- let found: any = undefined;
- if (Array.isArray(calendarDays) && calendarDays.length > 0) {
- const dayNum = Number(jalali.split("/")[2]);
- if (
- calendarDays[dayNum - 1] &&
- Number(calendarDays[dayNum - 1]?.day) === dayNum
- ) {
- found = calendarDays[dayNum - 1];
+ const iso = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+
+ setSelectedDateIso((current) => {
+ if (current === iso) return current;
+ try {
+ const weekday = moment(jalali, "jYYYY/jMM/jDD").locale("fa").format("dddd");
+ setSelectedDateInfo({ jalali, weekday });
+ localStorage.setItem("appointmentDateGregorian", iso);
+ localStorage.setItem("appointmentDate", jalali);
+ localStorage.setItem("appointmentWeekday", weekday);
+ } catch {}
+ return iso;
+ });
+
+ lastActionRef.current = "auto";
+ searchAttemptRef.current = 0;
+ break;
+ }
+ }, [calendarDays, Holidays, IranHolidays]);
+
+ // when selectedDateIso changes -> set daySchedule from calendarDays (which contains timeslots)
+ useEffect(() => {
+ if (!selectedDateIso) return;
+
+ try {
+ const [gy, gm, gd] = selectedDateIso.split("-").map(Number);
+ const dateObj = new Date(gy, gm - 1, gd);
+ const jalali = gregorianToJalaliString(dateObj);
+ const weekday = moment(jalali, "jYYYY/jMM/jDD").locale("fa").format("dddd");
+
+ try {
+ localStorage.setItem("appointmentDateGregorian", selectedDateIso);
+ localStorage.setItem("appointmentDate", jalali);
+ localStorage.setItem("appointmentWeekday", weekday);
+ } catch {}
+
+ let found: any = undefined;
+ if (Array.isArray(calendarDays) && calendarDays.length > 0) {
+ const dayNum = Number(jalali.split("/")[2]);
+ if (calendarDays[dayNum - 1] && Number(calendarDays[dayNum - 1]?.day) === dayNum) {
+ found = calendarDays[dayNum - 1];
+ } else {
+ found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
+ }
+ }
+
+ // اگر روز پیدا شد، فقط timeslots آن روز را به daySchedule بدهیم
+ if (found) {
+ // استاندارد: daySchedule همیشه آرایه timeslots است (AppointmentTime احتمالاً با این قالب کار میکند)
+ setDaySchedule(found ?? []);
+ } else {
+ setDaySchedule([]);
+ }
+
+ setSelectedDateInfo({ jalali, weekday });
+ } catch (e) {
+ setDaySchedule([]);
+ }
+ }, [selectedDateIso, calendarDays]);
+
+ const handleDateSelect = (isoDateGregorian: string) => {
+ try {
+ const [gy, gm, gd] = isoDateGregorian.split("-").map(Number);
+ const dateObj = new Date(gy, gm - 1, gd);
+ const jalali = gregorianToJalaliString(dateObj);
+ localStorage.setItem("appointmentDate", jalali);
+ localStorage.setItem("appointmentDateGregorian", isoDateGregorian);
+ const weekdays = ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"];
+ localStorage.setItem("appointmentWeekday", weekdays[dateObj.getDay()]);
+ lastActionRef.current = "user";
+ searchAttemptRef.current = 0;
+ setSelectedDateIso(isoDateGregorian);
+ } catch {}
+ };
+
+ const addedTimesForSelected = selectedDateIso ? appointmentsMap[selectedDateIso] || [] : [];
+
+ const navigateMonth = (navigate: "next" | "prev") => {
+ try {
+ const y = Number(selYear);
+ const m = Number(selMonth);
+ const d = Number(selDay);
+
+ let newYear = y;
+ let newMonth = m;
+
+ if (navigate === "next") {
+ newMonth = m + 1;
+ if (newMonth > 12) {
+ newMonth = 1;
+ newYear = y + 1;
+ }
+ } else {
+ newMonth = m - 1;
+ if (newMonth < 1) {
+ newMonth = 12;
+ newYear = y - 1;
+ }
+ }
+
+ const daysInTarget = getJalaliDaysInMonth(newYear, newMonth);
+ const newDay = Math.min(d, daysInTarget);
+
+ const newYearStr = String(newYear);
+ const newMonthStr = pad2(newMonth);
+ const newDayStr = pad2(newDay);
+
+ const newIso = jalaliToIso(newYearStr, newMonthStr, newDayStr);
+
+ const newWeekday = moment(`${newYearStr}/${newMonthStr}/${newDayStr}`, "jYYYY/jMM/jDD")
+ .locale("fa")
+ .format("dddd");
+
+ lastActionRef.current = "user";
+ searchAttemptRef.current = 0;
+
+ setSelectedDateInfo({
+ jalali: `${newYearStr}/${newMonthStr}/${newDayStr}`,
+ weekday: newWeekday,
+ });
+
+ try {
+ localStorage.setItem("appointmentDateGregorian", newIso);
+ localStorage.setItem("appointmentDate", `${newYearStr}/${newMonthStr}/${newDayStr}`);
+ localStorage.setItem("appointmentWeekday", newWeekday);
+ } catch {}
+
+ setSelectedDateIso(newIso);
+ } catch (e) {}
+ };
+
+ // جایگزین useEffect فعلی که روی [service, calendarDays] اجرا میشد
+ useEffect(() => {
+ if (!Array.isArray(calendarDays) || calendarDays.length === 0) return;
+
+ // 1) اگر تاریخ فعلی (selectedDateIso) هنوز اسلات داره پس کاری نکن
+ const currentHasSlots = (() => {
+ try {
+ if (!selectedDateIso) return false;
+ const [gy, gm, gd] = selectedDateIso.split("-").map(Number);
+ if (![gy, gm, gd].every(Number.isFinite)) return false;
+ const dateObj = new Date(gy, gm - 1, gd);
+ const jalali = gregorianToJalaliString(dateObj);
+ const dayNum = Number(jalali.split("/")[2]);
+ const found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
+ return !!(found && Array.isArray(found.timeslots) && found.timeslots.length > 0);
+ } catch {
+ return false;
+ }
+ })();
+
+ if (currentHasSlots) {
+ // تاریخ انتخابشده پس از فیلتر هم اسلات دارد — اجازه بده کاربر سر جایش بماند
+ return;
+ }
+
+ // 2) اگر تاریخ فعلی اسلات نداشت، به دنبال اولین روزی بگرد که اسلات دارد و آن را انتخاب کن
+ const firstWithSlots = calendarDays.find((d: any) => Array.isArray(d.timeslots) && d.timeslots.length > 0);
+ if (!firstWithSlots) {
+ // هیچ روزی با اسلات پیدا نشد: پاک کن یا نشان بده که چیزی نیست
+ setDaySchedule([]);
+ return;
+ }
+
+ const y = String(firstWithSlots.__year ?? selYear ?? default_year);
+ const m = pad2(Number(firstWithSlots.__month ?? selMonth ?? default_month));
+ const d = pad2(Number(firstWithSlots.day));
+
+ try {
+ const iso = jalaliToIso(y, m, d);
+ lastActionRef.current = "auto";
+ setSelectedDateIso((current) => {
+ if (current === iso) return current;
+ try {
+ const weekday = moment(`${y}/${m}/${d}`, "jYYYY/jMM/jDD").locale("fa").format("dddd");
+ setSelectedDateInfo({ jalali: `${y}/${m}/${d}`, weekday });
+ localStorage.setItem("appointmentDateGregorian", iso);
+ localStorage.setItem("appointmentDate", `${y}/${m}/${d}`);
+ localStorage.setItem("appointmentWeekday", weekday);
+ } catch {}
+ return iso;
+ });
+ searchAttemptRef.current = 0;
+ } catch (e) {
+ // ignore
+ }
+ }, [service, calendarDays]); // اجرا وقتی service یا calendarDays تغییر کند
+
+ const handleAddTime = (time: string): boolean => {
+ const dateIso =
+ selectedDateIso ||
+ (typeof window !== "undefined" ? localStorage.getItem("appointmentDateGregorian") : null);
+ if (!dateIso) {
+ toast.error("ابتدا یک تاریخ انتخاب کنید.");
+ return false;
+ }
+ const normalized = time.trim();
+ if (!normalized) return false;
+ let added = false;
+ setAppointmentsMap((prev) => {
+ const arr = prev[dateIso] ? [...prev[dateIso]] : [];
+ if (arr.includes(normalized)) return prev;
+ const next = { ...prev, [dateIso]: [...arr, normalized].sort() };
+ added = true;
+ return next;
+ });
+ if (added) {
+ toast.success("ساعت با موفقیت اضافه شد.");
+ return true;
} else {
- found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
+ toast.error("این ساعت قبلاً اضافه شده است.");
+ return false;
}
- }
+ };
- // اگر روز پیدا شد، فقط timeslots آن روز را به daySchedule بدهیم
- if (found) {
- // استاندارد: daySchedule همیشه آرایه timeslots است (AppointmentTime احتمالاً با این قالب کار میکند)
- setDaySchedule(found ?? []);
- } else {
- setDaySchedule([]);
- }
+ // ======================================
+ const { data: userInfo } = useGetUserInfo();
- setSelectedDateInfo({ jalali, weekday });
- } catch (e) {
- setDaySchedule([]);
- }
- }, [selectedDateIso, calendarDays]);
-
- const handleDateSelect = (isoDateGregorian: string) => {
- try {
- const [gy, gm, gd] = isoDateGregorian.split("-").map(Number);
- const dateObj = new Date(gy, gm - 1, gd);
- const jalali = gregorianToJalaliString(dateObj);
- localStorage.setItem("appointmentDate", jalali);
- localStorage.setItem("appointmentDateGregorian", isoDateGregorian);
- const weekdays = [
- "یکشنبه",
- "دوشنبه",
- "سهشنبه",
- "چهارشنبه",
- "پنجشنبه",
- "جمعه",
- "شنبه",
- ];
- localStorage.setItem("appointmentWeekday", weekdays[dateObj.getDay()]);
- lastActionRef.current = "user";
- searchAttemptRef.current = 0;
- setSelectedDateIso(isoDateGregorian);
- } catch {}
- };
-
- const addedTimesForSelected = selectedDateIso
- ? appointmentsMap[selectedDateIso] || []
- : [];
-
- const navigateMonth = (navigate: "next" | "prev") => {
- try {
- const y = Number(selYear);
- const m = Number(selMonth);
- const d = Number(selDay);
-
- let newYear = y;
- let newMonth = m;
-
- if (navigate === "next") {
- newMonth = m + 1;
- if (newMonth > 12) {
- newMonth = 1;
- newYear = y + 1;
- }
- } else {
- newMonth = m - 1;
- if (newMonth < 1) {
- newMonth = 12;
- newYear = y - 1;
- }
- }
-
- const daysInTarget = getJalaliDaysInMonth(newYear, newMonth);
- const newDay = Math.min(d, daysInTarget);
-
- const newYearStr = String(newYear);
- const newMonthStr = pad2(newMonth);
- const newDayStr = pad2(newDay);
-
- const newIso = jalaliToIso(newYearStr, newMonthStr, newDayStr);
-
- const newWeekday = moment(
- `${newYearStr}/${newMonthStr}/${newDayStr}`,
- "jYYYY/jMM/jDD",
- )
- .locale("fa")
- .format("dddd");
-
- lastActionRef.current = "user";
- searchAttemptRef.current = 0;
-
- setSelectedDateInfo({
- jalali: `${newYearStr}/${newMonthStr}/${newDayStr}`,
- weekday: newWeekday,
- });
-
- try {
- localStorage.setItem("appointmentDateGregorian", newIso);
- localStorage.setItem(
- "appointmentDate",
- `${newYearStr}/${newMonthStr}/${newDayStr}`,
- );
- localStorage.setItem("appointmentWeekday", newWeekday);
- } catch {}
-
- setSelectedDateIso(newIso);
- } catch (e) {}
- };
-
- // جایگزین useEffect فعلی که روی [service, calendarDays] اجرا میشد
- useEffect(() => {
- if (!Array.isArray(calendarDays) || calendarDays.length === 0) return;
-
- // 1) اگر تاریخ فعلی (selectedDateIso) هنوز اسلات داره پس کاری نکن
- const currentHasSlots = (() => {
- try {
- if (!selectedDateIso) return false;
- const [gy, gm, gd] = selectedDateIso.split("-").map(Number);
- if (![gy, gm, gd].every(Number.isFinite)) return false;
- const dateObj = new Date(gy, gm - 1, gd);
- const jalali = gregorianToJalaliString(dateObj);
- const dayNum = Number(jalali.split("/")[2]);
- const found = calendarDays.find((d: any) => Number(d?.day) === dayNum);
- return !!(
- found &&
- Array.isArray(found.timeslots) &&
- found.timeslots.length > 0
- );
- } catch {
- return false;
- }
- })();
-
- if (currentHasSlots) {
- // تاریخ انتخابشده پس از فیلتر هم اسلات دارد — اجازه بده کاربر سر جایش بماند
- return;
- }
-
- // 2) اگر تاریخ فعلی اسلات نداشت، به دنبال اولین روزی بگرد که اسلات دارد و آن را انتخاب کن
- const firstWithSlots = calendarDays.find(
- (d: any) => Array.isArray(d.timeslots) && d.timeslots.length > 0,
- );
- if (!firstWithSlots) {
- // هیچ روزی با اسلات پیدا نشد: پاک کن یا نشان بده که چیزی نیست
- setDaySchedule([]);
- return;
- }
-
- const y = String(firstWithSlots.__year ?? selYear ?? default_year);
- const m = pad2(Number(firstWithSlots.__month ?? selMonth ?? default_month));
- const d = pad2(Number(firstWithSlots.day));
-
- try {
- const iso = jalaliToIso(y, m, d);
- lastActionRef.current = "auto";
- setSelectedDateIso((current) => {
- if (current === iso) return current;
- try {
- const weekday = moment(`${y}/${m}/${d}`, "jYYYY/jMM/jDD")
- .locale("fa")
- .format("dddd");
- setSelectedDateInfo({ jalali: `${y}/${m}/${d}`, weekday });
- localStorage.setItem("appointmentDateGregorian", iso);
- localStorage.setItem("appointmentDate", `${y}/${m}/${d}`);
- localStorage.setItem("appointmentWeekday", weekday);
- } catch {}
- return iso;
- });
- searchAttemptRef.current = 0;
- } catch (e) {
- // ignore
- }
- }, [service, calendarDays]); // اجرا وقتی service یا calendarDays تغییر کند
-
- const handleAddTime = (time: string): boolean => {
- const dateIso =
- selectedDateIso ||
- (typeof window !== "undefined"
- ? localStorage.getItem("appointmentDateGregorian")
- : null);
- if (!dateIso) {
- toast.error("ابتدا یک تاریخ انتخاب کنید.");
- return false;
- }
- const normalized = time.trim();
- if (!normalized) return false;
- let added = false;
- setAppointmentsMap((prev) => {
- const arr = prev[dateIso] ? [...prev[dateIso]] : [];
- if (arr.includes(normalized)) return prev;
- const next = { ...prev, [dateIso]: [...arr, normalized].sort() };
- added = true;
- return next;
- });
- if (added) {
- toast.success("ساعت با موفقیت اضافه شد.");
- return true;
- } else {
- toast.error("این ساعت قبلاً اضافه شده است.");
- return false;
- }
- };
-
- // ======================================
- const { data: userInfo } = useGetUserInfo();
-
- const LottieFallback = (
-
-
-
- );
- const lazyLoader = () =>
- Promise.all([
- import("lottie-react"),
- import("@public/lotties/puzzle.json"),
- ]).then(([lottieModule, json]) => {
- const LottieWrapper: React.FC = (props) => {
- const Lottie = (lottieModule as any).default;
- const animationData = (json as any).default ?? json;
- return (
-
- );
- };
- return { default: LottieWrapper };
- });
-
- const LazyLottie = lazy(lazyLoader);
-
- return (
- <>
-
-
- <>مشاهده جدول زمانی>
+ const LottieFallback = (
+
+
+ );
+ const lazyLoader = () =>
+ Promise.all([import("lottie-react"), import("@public/lotties/puzzle.json")]).then(([lottieModule, json]) => {
+ const LottieWrapper: React.FC
= (props) => {
+ const Lottie = (lottieModule as any).default;
+ const animationData = (json as any).default ?? json;
+ return ;
+ };
+ return { default: LottieWrapper };
+ });
- {userInfo?.current_user.services &&
- userInfo?.current_user.services.length === 0 ? (
-
-
-
-
-
-
- برای مشاهده جدول زمانی هر خدمت، ابتدا خدمات مورد نظر خود را اضافه کنید.
-
-
- ثبت خدمات جدید
-
-
-
- ) : (
-
- {/* ==== SERVICE SELECTOR: show above calendar ==== */}
-
-
- {/* ServicesQA باید یک string سرویس برگرداند (یا "" برای همه) */}
- setService(s ? String(s) : "")}
- />
-
-
+ const LazyLottie = lazy(lazyLoader);
-
-
-
+ return (
+ <>
+
+
+ <>مشاهده جدول زمانی>
+
-
-
handleAddTime(time)}
- onRemoveAddedTime={(time: any) => handleRemoveTime(time)}
- />
+ {userInfo?.current_user.services && userInfo?.current_user.services.length === 0 ? (
+
+
+
+
+
+
برای مشاهده جدول زمانی هر خدمت، ابتدا خدمات مورد نظر خود را اضافه کنید.
+
+ ثبت خدمات جدید
+
+
+
+ ) : (
+
+ {/* ==== SERVICE SELECTOR: show above calendar ==== */}
+
+
+ {/* ServicesQA باید یک string سرویس برگرداند (یا "" برای همه) */}
+ setService(s ? String(s) : "")} />
+
+
+
+
+
+
+
+
+
handleAddTime(time)}
+ onRemoveAddedTime={(time: any) => handleRemoveTime(time)}
+ />
+
+
+ )}
-
- )}
-
- >
- );
+ >
+ );
}
diff --git a/src/apps/new-ui/pages/appointments/+components/TakeAppointment.tsx b/src/apps/new-ui/pages/appointments/+components/TakeAppointment.tsx
index 784873fe..7fb64e60 100644
--- a/src/apps/new-ui/pages/appointments/+components/TakeAppointment.tsx
+++ b/src/apps/new-ui/pages/appointments/+components/TakeAppointment.tsx
@@ -1,7 +1,7 @@
import { Link } from "react-router-dom";
import {
- useGetUnConfirmedPersonnelReservedAppointmentsCount,
- useGetUnConfirmedReservedAppointmentsCount,
+ useGetUnConfirmedPersonnelReservedAppointmentsCount,
+ useGetUnConfirmedReservedAppointmentsCount,
} from "@/apps/new-ui/services/ReservedAppointment";
import { newAppointmentRoutes } from "@new-ui/routes";
import quickAppointment from "../../../assets/homePage/timer.gif";
@@ -14,113 +14,108 @@ import { useEffect, useState } from "react";
import { useGetUserInfo } from "@/apps/new-ui/services/User";
const TakeAppointment = () => {
- const { data: reservedCount } = useGetUnConfirmedReservedAppointmentsCount();
- const { data: reservedPersonnelCount } =
- useGetUnConfirmedPersonnelReservedAppointmentsCount();
+ const { data: reservedCount } = useGetUnConfirmedReservedAppointmentsCount();
+ const { data: reservedPersonnelCount } = useGetUnConfirmedPersonnelReservedAppointmentsCount();
- const [isUserAbilitiesPersonnel, setIsUserAbilitiesPersonnel] = useState<
- boolean | undefined
- >(false);
- const { data: userInformation } = useGetUserInfo();
+ const [isUserAbilitiesPersonnel, setIsUserAbilitiesPersonnel] = useState(false);
+ const { data: userInformation } = useGetUserInfo();
- useEffect(() => {
- setIsUserAbilitiesPersonnel(
- userInformation?.user_abilities_list?.[6]?.active,
- );
- }, [userInformation?.user_abilities_list?.[6]?.active]);
+ useEffect(() => {
+ setIsUserAbilitiesPersonnel(userInformation?.user_abilities_list?.[6]?.active);
+ }, [userInformation?.user_abilities_list?.[6]?.active]);
- return (
-
-
-
-
e.preventDefault()}
- className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
- >
-
-
ایجاد جدول زمانی
-
+ return (
+
+
+
+
e.preventDefault()}
+ className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
+ >
+
+
ایجاد جدول زمانی
+
-
e.preventDefault()}
- className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
- >
-
-
مشاهده جدول زمانی
-
-
-
-
-
e.preventDefault()}
- className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
- >
-
-
ثبت خدمات
-
-
-
e.preventDefault()}
- className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
- >
-
-
نوبت سریع
-
-
-
e.preventDefault()}
- className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
- >
-
-
نوبت پیشرفته
-
-
-
e.preventDefault()}
- className="relative w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
- >
- {isUserAbilitiesPersonnel
- ? !!reservedPersonnelCount &&
- reservedPersonnelCount > 0 && (
-
-
-
-
- {reservedPersonnelCount}
-
-
-
+
e.preventDefault()}
+ className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
+ >
+
+
مشاهده جدول زمانی
+
- )
- : !!reservedCount &&
- reservedCount > 0 && (
-
-
+
+
e.preventDefault()}
+ className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
+ >
+
+
ثبت خدمات
+
- {/*
+
e.preventDefault()}
+ className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
+ >
+
+
نوبت سریع
+
+
+
e.preventDefault()}
+ className="w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
+ >
+
+
نوبت پیشرفته
+
+
+
e.preventDefault()}
+ className="relative w-full flex flex-col items-center justify-center gap-1 py-1.5 rounded-xl shadow-[0_2px_8px_0_#2D0A4829] border-2 border-gray-200 bg-white"
+ >
+ {isUserAbilitiesPersonnel
+ ? !!reservedPersonnelCount &&
+ reservedPersonnelCount > 0 && (
+
+
+
+
+ {reservedPersonnelCount}
+
+
+
+
+ )
+ : !!reservedCount &&
+ reservedCount > 0 && (
+
- )}
+
+
+ )}
-
-
درخواستها
-
-
-
- );
+
+
درخواستها
+
+
+
+ );
};
export default TakeAppointment;
diff --git a/src/components/ImageCropperModal/index.tsx b/src/components/ImageCropperModal/index.tsx
index ee0bdf4c..b7a66aad 100644
--- a/src/components/ImageCropperModal/index.tsx
+++ b/src/components/ImageCropperModal/index.tsx
@@ -1,414 +1,236 @@
import { useState, useRef, useEffect, DependencyList } from "react";
import "react-image-crop/dist/ReactCrop.css";
-import ReactCrop, {
- centerCrop,
- makeAspectCrop,
- Crop,
- PixelCrop,
-} from "react-image-crop";
-import { canvasPreview } from "./canvasPreview";
+import ReactCrop, { centerCrop, makeAspectCrop, Crop, PixelCrop } from "react-image-crop";
import { toast } from "react-hot-toast";
import { Modal } from "../ui/modal";
import { Button } from "../ui/button";
import { BeatLoader } from "react-spinners";
-const useDebounceEffect = (
- fn: () => void,
- waitTime: number,
- deps: DependencyList
-) => {
- useEffect(() => {
- const t = setTimeout(fn, waitTime);
- return () => clearTimeout(t);
- }, deps);
+const useDebounceEffect = (fn: () => void, waitTime: number, deps: DependencyList) => {
+ useEffect(() => {
+ const t = setTimeout(() => fn(), waitTime);
+ return () => clearTimeout(t);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
};
-const centerAspectCrop = (
- mediaWidth: number,
- mediaHeight: number,
- aspect: number
-) => {
- return centerCrop(
- makeAspectCrop({ unit: "%", width: 90 }, aspect, mediaWidth, mediaHeight),
- mediaWidth,
- mediaHeight
- );
+const centerAspectCrop = (mediaWidth: number, mediaHeight: number, aspect: number) => {
+ return centerCrop(
+ makeAspectCrop({ unit: "%", width: 90 }, aspect, mediaWidth, mediaHeight),
+ mediaWidth,
+ mediaHeight,
+ );
};
-const rotate = 0;
-const scale = 1;
-
interface Props {
- selectedFileForCrop: File;
- setSelectedFileForCrop: React.Dispatch<
- React.SetStateAction
- >;
- onSubmit: (file: File) => void;
- isLoading?: boolean;
- aspect?: number;
- circular?: boolean;
+ selectedFileForCrop: File;
+ setSelectedFileForCrop: React.Dispatch>;
+ onSubmit: (file: File) => void;
+ isLoading?: boolean;
+ aspect?: number;
+ circular?: boolean;
}
export default function ImageCropperModal({
- selectedFileForCrop,
- setSelectedFileForCrop,
- onSubmit,
- isLoading,
- aspect = 1,
- circular = false,
+ selectedFileForCrop,
+ setSelectedFileForCrop,
+ onSubmit,
+ isLoading,
+ aspect = 1,
+ circular = false,
}: Props) {
- const [imgSrc, setImgSrc] = useState("");
- const previewCanvasRef = useRef(null);
- const imgRef = useRef(null);
- const blobUrlRef = useRef("");
- const [crop, setCrop] = useState();
- const [completedCrop, setCompletedCrop] = useState();
+ const [imgSrc, setImgSrc] = useState("");
+ const imgRef = useRef(null);
+ const [crop, setCrop] = useState();
+ const [completedCrop, setCompletedCrop] = useState();
+ const [processing, setProcessing] = useState(false);
- const onImageLoad = (e: React.SyntheticEvent) => {
- const { width, height } = e.currentTarget;
- setCrop(centerAspectCrop(width, height, aspect));
- };
+ useEffect(() => {
+ if (!selectedFileForCrop) {
+ setImgSrc("");
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => setImgSrc(reader.result?.toString() || "");
+ reader.readAsDataURL(selectedFileForCrop);
- const onSubmitCrop = async () => {
- const image = imgRef.current;
- const previewCanvas = previewCanvasRef.current;
- if (!image || !previewCanvas || !completedCrop) {
- return toast.error("خطا در برش تصویر");
- }
- const scaleX = image.naturalWidth / image.width;
- const scaleY = image.naturalHeight / image.height;
- const offscreen = new OffscreenCanvas(
- completedCrop.width * scaleX,
- completedCrop.height * scaleY
- );
- const ctx = offscreen.getContext("2d");
- if (!ctx) {
- return toast.error("خطا در تبدیل تصویر 2 بعدی");
- }
- ctx.drawImage(
- previewCanvas,
- 0,
- 0,
- previewCanvas.width,
- previewCanvas.height,
- 0,
- 0,
- offscreen.width,
- offscreen.height
- );
- const blob = await offscreen.convertToBlob({
- type: "image/jpeg",
- quality: 0.8,
- });
- if (blobUrlRef.current) {
- URL.revokeObjectURL(blobUrlRef.current);
- }
- const fileName = "cropped_" + selectedFileForCrop.name;
- const file = new File([blob], fileName, { type: blob.type });
- onSubmit(file);
- };
+ // validate size
+ if (selectedFileForCrop.size > 4 * 1024 * 1024) {
+ toast.error("حجم تصویر نباید بیشتر از ۴ مگابایت باشد.");
+ setSelectedFileForCrop(undefined);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedFileForCrop]);
- useDebounceEffect(
- () => {
- if (
- completedCrop?.width &&
- completedCrop?.height &&
- imgRef.current &&
- previewCanvasRef.current
- ) {
- canvasPreview(
- imgRef.current,
- previewCanvasRef.current,
- completedCrop,
- scale,
- rotate
+ const onImageLoad = (e: React.SyntheticEvent) => {
+ const { width, height } = e.currentTarget;
+ setCrop(centerAspectCrop(width, height, aspect));
+ };
+
+ // تابع اصلی برای برش -> بازگشت File
+ const getCroppedImgFile = async (
+ image: HTMLImageElement,
+ crop: PixelCrop,
+ fileName: string,
+ isCircular: boolean,
+ ): Promise => {
+ // محاسبه مقیاسها برای تبدیل درصد به پیکسل واقعی تصویر منبع
+ const scaleX = image.naturalWidth / image.width;
+ const scaleY = image.naturalHeight / image.height;
+
+ const pixelCropX = Math.round(crop.x * scaleX);
+ const pixelCropY = Math.round(crop.y * scaleY);
+ const pixelCropWidth = Math.round(crop.width * scaleX);
+ const pixelCropHeight = Math.round(crop.height * scaleY);
+
+ const canvas = document.createElement("canvas");
+ canvas.width = pixelCropWidth;
+ canvas.height = pixelCropHeight;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) throw new Error("Cannot get canvas context");
+
+ // اگر میخواهیم پسزمینه سفید داشته باشیم برای JPEG (غیر circular)
+ // اما برای circular ما PNG با شفافیت خروجی میدهیم.
+ if (!isCircular) {
+ // optional: fill white background so JPEG corners سفید شوند (نه سیاه)
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ }
+
+ // draw the cropped area from the source image to canvas
+ ctx.drawImage(
+ image,
+ pixelCropX,
+ pixelCropY,
+ pixelCropWidth,
+ pixelCropHeight,
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
);
- }
- },
- 100,
- [completedCrop, scale, rotate]
- );
- useEffect(() => {
- if (selectedFileForCrop) {
- const reader = new FileReader();
- reader.onload = () => setImgSrc(reader.result?.toString() || "");
- reader.readAsDataURL(selectedFileForCrop);
- }
- }, [selectedFileForCrop]);
+ if (isCircular) {
+ // create circular mask so corners become transparent (and export PNG)
+ const maskCanvas = document.createElement("canvas");
+ maskCanvas.width = canvas.width;
+ maskCanvas.height = canvas.height;
+ const mctx = maskCanvas.getContext("2d");
+ if (!mctx) throw new Error("Cannot get mask context");
- useEffect(() => {
- if (selectedFileForCrop) {
- if (selectedFileForCrop.size > 4 * 1024 * 1024) {
- toast.error("حجم تصویر نباید بیشتر از ۴ مگابایت باشد.");
- setSelectedFileForCrop(undefined);
- return;
- }
- const reader = new FileReader();
- reader.onload = () => setImgSrc(reader.result?.toString() || "");
- reader.readAsDataURL(selectedFileForCrop);
- }
- }, [selectedFileForCrop]);
+ mctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
+ mctx.beginPath();
+ const cx = maskCanvas.width / 2;
+ const cy = maskCanvas.height / 2;
+ const r = Math.min(cx, cy);
+ mctx.arc(cx, cy, r, 0, Math.PI * 2, true);
+ mctx.closePath();
+ mctx.fillStyle = "#000";
+ mctx.fill();
- return (
- setSelectedFileForCrop(undefined)}
- footer={
- completedCrop ? (
-
-
- setSelectedFileForCrop(undefined)}
- >
- لغو
-
-
- آپلود
-
-
- ) : undefined
- }
- >
-
- {imgSrc ? (
-
setCrop(percentCrop)}
- onComplete={(c) => setCompletedCrop(c)}
- aspect={aspect}
- circularCrop={circular}
- minWidth={100}
- minHeight={100}
- className="w-full"
- >
-
-
- ) : (
-
-
-
- )}
-
-
- );
+ // use mask: destination-in
+ mctx.globalCompositeOperation = "source-in";
+ mctx.drawImage(canvas, 0, 0);
+
+ // تبدیل maskCanvas (حاوی تصویر دایرهای و شفافیت) به blob (png)
+ const blob = await new Promise((resolve) =>
+ maskCanvas.toBlob((b) => resolve(b), "image/png", 0.92),
+ );
+ if (!blob) throw new Error("Failed to create blob");
+
+ return new File([blob], fileName.replace(/\.[^.]+$/, ".png"), { type: blob.type });
+ } else {
+ // JPEG خروجی با کیفیت قابل قبول
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob((b) => resolve(b), "image/jpeg", 0.88),
+ );
+ if (!blob) throw new Error("Failed to create blob");
+ return new File([blob], fileName, { type: blob.type });
+ }
+ };
+
+ const onSubmitCrop = async () => {
+ if (!imgRef.current || !completedCrop) {
+ return toast.error("ابتدا برش را کامل کنید");
+ }
+ setProcessing(true);
+ try {
+ const fileName = "cropped_" + selectedFileForCrop.name;
+ const resultFile = await getCroppedImgFile(imgRef.current, completedCrop, fileName, circular);
+ onSubmit(resultFile);
+ // مودال بسته خواهد شد توسط والد (onSubmit واقعی)
+ } catch (err) {
+ console.error(err);
+ toast.error("خطا در ساخت تصویر برش خورده");
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ // هر بار که completedCrop تغییر کرد میتوانیم پیشنمایش داخلی بسازیم (اختیاری)
+ useDebounceEffect(
+ () => {
+ // اینجا کاری خاص لازم نداریم چون هنگام ارسال فایل جدید را ایجاد میکنیم
+ },
+ 150,
+ [completedCrop],
+ );
+
+ return (
+ setSelectedFileForCrop(undefined)}
+ footer={
+ completedCrop ? (
+
+ setSelectedFileForCrop(undefined)}
+ disabled={processing || isLoading}
+ >
+ لغو
+
+
+ آپلود
+
+
+ ) : undefined
+ }
+ >
+
+ {imgSrc ? (
+
setCrop(percentCrop)}
+ onComplete={(c) => setCompletedCrop(c)}
+ aspect={aspect}
+ circularCrop={circular}
+ minWidth={100}
+ minHeight={100}
+ className="w-full"
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
}
-
-
-// import { useState, useRef, useEffect, DependencyList } from 'react';
-// import 'react-image-crop/dist/ReactCrop.css';
-// import ReactCrop, {
-// centerCrop,
-// makeAspectCrop,
-// Crop,
-// PixelCrop,
-// } from 'react-image-crop';
-// import { canvasPreview } from './canvasPreview';
-// import { toast } from 'react-hot-toast';
-// import { Modal } from '../ui/modal';
-// import { Button } from '../ui/button';
-// import { BeatLoader } from 'react-spinners';
-
-// const useDebounceEffect = (
-// fn: () => void,
-// waitTime: number,
-// deps: DependencyList,
-// ) => {
-// useEffect(() => {
-// const t = setTimeout(() => {
-// // eslint-disable-next-line prefer-spread
-// fn.apply(undefined, deps as []);
-// }, waitTime);
-// return () => {
-// clearTimeout(t);
-// };
-// }, deps);
-// };
-// const centerAspectCrop = (
-// mediaWidth: number,
-// mediaHeight: number,
-// aspect: number,
-// ) => {
-// return centerCrop(
-// makeAspectCrop(
-// {
-// unit: '%',
-// width: 90,
-// },
-// aspect,
-// mediaWidth,
-// mediaHeight,
-// ),
-// mediaWidth,
-// mediaHeight,
-// );
-// };
-
-// const rotate = 0;
-// const scale = 1;
-// const aspect = 1;
-
-// interface Props {
-// selectedFileForCrop: File;
-// setSelectedFileForCrop: React.Dispatch<
-// React.SetStateAction
-// >;
-// onSubmit: (file: File) => void;
-// isLoading?: boolean;
-// }
-// export default function ImageCropperModal({
-// selectedFileForCrop,
-// setSelectedFileForCrop,
-// onSubmit,
-// isLoading,
-// }: Props) {
-// const [imgSrc, setImgSrc] = useState('');
-// const previewCanvasRef = useRef(null);
-// const imgRef = useRef(null);
-// const blobUrlRef = useRef('');
-// const [crop, setCrop] = useState();
-// const [completedCrop, setCompletedCrop] = useState();
-// const onImageLoad = (e: React.SyntheticEvent) => {
-// if (aspect) {
-// const { width, height } = e.currentTarget;
-// setCrop(centerAspectCrop(width, height, aspect));
-// }
-// };
-// const onSubmitCrop = async () => {
-// const image = imgRef.current;
-// const previewCanvas = previewCanvasRef.current;
-// if (!image || !previewCanvas || !completedCrop) {
-// return toast.error('خطا در برش تصویر');
-// }
-// const scaleX = image.naturalWidth / image.width;
-// const scaleY = image.naturalHeight / image.height;
-// const offscreen = new OffscreenCanvas(
-// completedCrop.width * scaleX,
-// completedCrop.height * scaleY,
-// );
-// const ctx = offscreen.getContext('2d');
-// if (!ctx) {
-// return toast.error('خطا در تبدیل تصویر 2 بعدی');
-// }
-// ctx.drawImage(
-// previewCanvas,
-// 0,
-// 0,
-// previewCanvas.width,
-// previewCanvas.height,
-// 0,
-// 0,
-// offscreen.width,
-// offscreen.height,
-// );
-// const blob = await offscreen.convertToBlob({
-// type: 'image/jpeg',
-// quality: 0.8,
-// });
-// if (blobUrlRef.current) {
-// URL.revokeObjectURL(blobUrlRef.current);
-// }
-// const fileName = 'cropped_' + selectedFileForCrop.name;
-// const file = new File([blob], fileName, { type: blob.type });
-// onSubmit(file);
-// };
-// useDebounceEffect(
-// async () => {
-// if (
-// completedCrop?.width &&
-// completedCrop?.height &&
-// imgRef.current &&
-// previewCanvasRef.current
-// ) {
-// canvasPreview(
-// imgRef.current,
-// previewCanvasRef.current,
-// completedCrop,
-// scale,
-// rotate,
-// );
-// }
-// },
-// 100,
-// [completedCrop, scale, rotate],
-// );
-// useEffect(() => {
-// if (selectedFileForCrop) {
-// const reader = new FileReader();
-// reader.onload = () => setImgSrc(reader.result?.toString() || '');
-// reader.readAsDataURL(selectedFileForCrop);
-// }
-// }, [selectedFileForCrop]);
-
-// return (
-// setSelectedFileForCrop(undefined)}
-// footer={
-// completedCrop ? (
-//
-//
-// setSelectedFileForCrop(undefined)}
-// >
-// لغو
-//
-//
-// آپلود
-//
-//
-// ) : undefined
-// }
-// >
-//
-// {imgSrc ? (
-//
setCrop(percentCrop)}
-// onComplete={(c) => setCompletedCrop(c)}
-// aspect={aspect}
-// minWidth={100}
-// minHeight={100}
-// circularCrop
-// className="w-full"
-// >
-//
-//
-// ) : (
-//
-//
-//
-// )}
-//
-//
-// );
-// }
\ No newline at end of file