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 ? ( -
- no slots -

- برای این روز هیچ ساعتی تعریف نشده. -

- {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 ? ( +
+ no slots +

+ برای این روز هیچ ساعتی تعریف نشده. +

+ {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 && (
@@ -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) => ( - - ))} -
- - {/* time picker */} - - - {!isValidDuration(time) && time !== "" && ( -

انتخاب مدت زمان معتبر نیست

- )} -
-
- -
-

نوع فعالیت

-
- {activityTypes.map((a) => ( - - ))} -
- - {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) => ( - - ))} -
-
-
-
- -
- - -
- - - + + 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) => ( + + ))} +
+ + {/* time picker */} + + + {!isValidDuration(time) && time !== "" && ( +

انتخاب مدت زمان معتبر نیست

+ )} +
+
+ +
+

نوع فعالیت

+
+ {activityTypes.map((a) => ( + + ))} +
+ + {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) => ( + + ))} +
+
+
+
+ +
+ + +
+ + + +
+
-
-
- ); + ); }; 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 ( - - ); - })} -
-
-
- +
+

مدت اعتبار بازگشت وجه

+
+ {EXPIRE_OPTIONS.map((opt) => { + const selected = String(expireDaysInput) === opt.value; + return ( + + ); + })} +
+
+
+ - {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" - > - table icon -

ایجاد جدول زمانی

- + 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" + > + table icon +

ایجاد جدول زمانی

+ - 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" - > - table icon -

مشاهده جدول زمانی

- -
-
-
- 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" - > - service icon -

ثبت خدمات

- - - 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" - > - flash icon -

نوبت سریع

- - - 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" - > - option icon -

نوبت پیشرفته

- - - 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" + > + table icon +

مشاهده جدول زمانی

+
- ) - : !!reservedCount && - reservedCount > 0 && ( -
-
-
-
- {reservedCount} -
-
+
+
+ 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" + > + service icon +

ثبت خدمات

+ - {/*
+ 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" + > + flash icon +

نوبت سریع

+ + + 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" + > + option icon +

نوبت پیشرفته

+ + + 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 && ( +
+
+
+
+ {reservedCount} +
+
+ + {/*
*/} -
-
- )} +
+
+ )} - request icon -

درخواست‌ها

- -
-
- ); + request icon +

درخواست‌ها

+ +
+
+ ); }; 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 ? ( -
- - - -
- ) : undefined - } - > -
- {imgSrc ? ( - setCrop(percentCrop)} - onComplete={(c) => setCompletedCrop(c)} - aspect={aspect} - circularCrop={circular} - minWidth={100} - minHeight={100} - className="w-full" - > - Crop Me - - ) : ( -
- -
- )} -
-
- ); + // 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 ? ( +
+ + +
+ ) : undefined + } + > +
+ {imgSrc ? ( + setCrop(percentCrop)} + onComplete={(c) => setCompletedCrop(c)} + aspect={aspect} + circularCrop={circular} + minWidth={100} + minHeight={100} + className="w-full" + > + Crop Me + + ) : ( +
+ +
+ )} +
+
+ ); } - - -// 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 ? ( -//
-// -// -// -//
-// ) : undefined -// } -// > -//
-// {imgSrc ? ( -// setCrop(percentCrop)} -// onComplete={(c) => setCompletedCrop(c)} -// aspect={aspect} -// minWidth={100} -// minHeight={100} -// circularCrop -// className="w-full" -// > -// Crop Me -// -// ) : ( -//
-// -//
-// )} -//
-//
-// ); -// } \ No newline at end of file