From 1682651e29bcc7a7b363c982cac1cc3e450f9925 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh <95388378+srt207-reza@users.noreply.github.com> Date: Tue, 14 Oct 2025 23:18:35 +0330 Subject: [PATCH] feat: add api to oponinion sms and accounting pages --- .env | 3 +- src/apps/appointment/routes/index.ts | 1 + src/apps/appointment/routes/structure.tsx | 4 + .../+components/CreatePanel/index.tsx | 168 ++++++++++-- .../+components/CreatedSurveyCard.tsx | 41 +-- .../OpinionSMS/+components/SurveyModal.tsx | 33 +-- .../pages/OpinionSMS/+components/UserCard.tsx | 17 +- src/apps/new-ui/pages/OpinionSMS/index.tsx | 255 ++++++++++-------- src/apps/new-ui/pages/OpinionSMS/types.ts | 55 ++-- .../pages/ReportAll/Accounting/index.tsx | 149 ++++------ .../ChartReport/+components/CostPieChart.tsx | 18 +- .../pages/ReportAll/ChartReport/index.tsx | 85 +++--- .../Income&Cost/+components/BottomActions.tsx | 13 +- .../Income&Cost/+components/EntryCard.tsx | 15 +- .../Create/+components/AmountInput.tsx | 187 +++++++++++-- .../Create/+components/BottomActions.tsx | 8 +- .../ReportAll/Income&Cost/Create/index.tsx | 164 +++++++---- .../pages/ReportAll/Income&Cost/index.tsx | 219 +++++++++++---- .../pages/ReportAll/Income&Cost/types.ts | 3 +- .../factor/+components/BottomActions.tsx | 7 +- .../ReportAll/factor/+components/CardList.tsx | 4 +- .../factor/+components/EmptyState.tsx | 4 +- .../new-ui/pages/ReportAll/factor/index.tsx | 188 ++++++++++--- src/apps/new-ui/services/ChartReport.tsx | 34 +++ src/apps/new-ui/services/Factor.tsx | 98 +++++++ src/apps/new-ui/services/IncomeAndCost.tsx | 154 +++++++++++ src/apps/new-ui/services/OpinionSMS.tsx | 95 +++++++ 27 files changed, 1515 insertions(+), 507 deletions(-) create mode 100644 src/apps/new-ui/services/ChartReport.tsx create mode 100644 src/apps/new-ui/services/Factor.tsx create mode 100644 src/apps/new-ui/services/IncomeAndCost.tsx create mode 100644 src/apps/new-ui/services/OpinionSMS.tsx diff --git a/.env b/.env index af5bd9f8..55a3be77 100644 --- a/.env +++ b/.env @@ -1,5 +1,6 @@ -VITE_APP_API_URL= https://aptest.mysalona.ir +# VITE_APP_API_URL= https://aptest.mysalona.ir +VITE_APP_API_URL= https://api.salonaapp.ir VITE_APP_SITE_URL= https://app.mysalona.ir diff --git a/src/apps/appointment/routes/index.ts b/src/apps/appointment/routes/index.ts index a145daa3..9ca137cd 100644 --- a/src/apps/appointment/routes/index.ts +++ b/src/apps/appointment/routes/index.ts @@ -10,6 +10,7 @@ export const appointmentRoutes = Object.freeze({ DiscountEvent: "/discount-event", IncomeAndCost: "/income-and-cost", CreateIncomeAndCost: "/income-and-cost/create", + EditIncomeAndCost: "/income-and-cost/edit/:id", ChartReport: "/chart-report", Factor: "/factor", diff --git a/src/apps/appointment/routes/structure.tsx b/src/apps/appointment/routes/structure.tsx index 0f7c663d..369a64ad 100644 --- a/src/apps/appointment/routes/structure.tsx +++ b/src/apps/appointment/routes/structure.tsx @@ -210,6 +210,10 @@ export const appointmentRoutesStructure = [ path: appointmentRoutes.CreateIncomeAndCost, element: , }, + { + path: appointmentRoutes.EditIncomeAndCost, + element: , + }, { path: appointmentRoutes.ChartReport, element: , diff --git a/src/apps/new-ui/pages/OpinionSMS/+components/CreatePanel/index.tsx b/src/apps/new-ui/pages/OpinionSMS/+components/CreatePanel/index.tsx index ab4c5539..d39b96f8 100644 --- a/src/apps/new-ui/pages/OpinionSMS/+components/CreatePanel/index.tsx +++ b/src/apps/new-ui/pages/OpinionSMS/+components/CreatePanel/index.tsx @@ -1,14 +1,19 @@ -import { useMemo } from "react"; +// CreatePanel.tsx +import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { CustomerItem, FormItem } from "../../types"; import UserCard from "../UserCard"; import searchIcon from "@new-ui/assets/PurchaseSubscription/Search.svg"; import microphoneIcon from "@new-ui/assets/PurchaseSubscription/microphone.svg"; import arrowWhiteIcon from "@new-ui/assets/smsKade/ArrowWhite.svg"; import arrowGrayIcon from "@new-ui/assets/smsKade/ArrowGray.svg"; +import SurveyModal from "../SurveyModal"; + +// سرویس‌ها +import { useGetAllCustomers, useGetSurveyForms, useGetSurveyFormsQuestions } from "@/apps/new-ui/services/OpinionSMS"; +import { BeatLoader } from "react-spinners"; type Props = { - customers: CustomerItem[]; - forms: FormItem[]; + // دیگر customCustomers حذف شد — حالا از customersResp استفاده می‌کنیم selectedCustomerId: number | null; selectedFormId: number | null; onSelectCustomer: (id: number) => void; @@ -20,8 +25,6 @@ type Props = { }; const CreatePanel: React.FC = ({ - customers, - forms, selectedCustomerId, selectedFormId, onSelectCustomer, @@ -31,13 +34,77 @@ const CreatePanel: React.FC = ({ customerSearch, setCustomerSearch, }) => { + // داده‌های عمومی از سرویس‌ها + const { data: customersResp, isLoading: customersLoading } = useGetAllCustomers(); + const { data: surveyFormsResp, isLoading: surveyFormsLoading } = useGetSurveyForms(); + + // modalFormId: id فرمی که میخوایم questions ش رو بیاریم + const [modalFormId, setModalFormId] = useState(null); + const [showModal, setShowModal] = useState(false); + const [questionsConfig, setQuestionsConfig] = useState<{ questions: any[]; choices: any[] }>({ + questions: [], + choices: [], + }); + + // فراخوانی سوالات — hook در بالای کامپوننت و بدون شرط فراخوانی میشه + const { + data: formData, + isLoading: questionsLoading, + error: questionsError, + } = useGetSurveyFormsQuestions(modalFormId ?? undefined); + + // sync کردن questions وقتی داده رسید + useEffect(() => { + if (formData) { + setQuestionsConfig({ + questions: Array.isArray(formData.questions) ? formData.questions : [], + choices: Array.isArray(formData.choices) ? formData.choices : [], + }); + } else { + setQuestionsConfig({ questions: [], choices: [] }); + } + }, [formData]); + + // customers از پاسخ سرویس گرفته میشه — fallback به آرایهٔ خالی + const customers: CustomerItem[] = useMemo(() => { + // ساختار customersResp ممکنه متفاوت باشه؛ اگر شکل دیگری داره، این بخش رو مطابق ساختارش تنظیم کن + // معمول‌ترین الگو: customersResp.customers یا customersResp.data + if (!customersResp) return []; + if (Array.isArray((customersResp as any).customers)) return (customersResp as any).customers; + if (Array.isArray((customersResp as any).data)) return (customersResp as any).data; + // اگر خود response خودش آرایه ست + if (Array.isArray(customersResp as any)) return customersResp as any; + return []; + }, [customersResp]); + + // فیلتر کردن مشتری‌ها بر مبنای جستجو — useMemo برای جلوگیری از محاسبات غیرضروری const filteredCustomers = useMemo(() => { - const term = customerSearch.trim(); + const term = customerSearch?.trim(); if (!term) return customers; - return customers.filter((c) => c.title.includes(term) || c.description?.includes(term)); + const lowered = term.toLowerCase(); + return customers.filter((c) => { + const title = c.title?.toLowerCase() ?? ""; + const desc = c.description?.toLowerCase() ?? ""; + return title.includes(lowered) || desc.includes(lowered); + }); }, [customers, customerSearch]); - const canAdd = !!(selectedCustomerId && selectedFormId); + const canAdd = Boolean(selectedCustomerId !== null && selectedFormId !== null); + + // استفاده از useCallback برای event handlers + const handleViewModal = useCallback((id: number) => { + setModalFormId(id); + setShowModal(true); + }, []); + + const handleCloseModal = useCallback(() => { + setShowModal(false); + setModalFormId(null); + setQuestionsConfig({ questions: [], choices: [] }); + }, []); + + // ترکیب loadingها برای نمایش کلی + const anyLoading = customersLoading || surveyFormsLoading; return ( <> @@ -45,6 +112,7 @@ const CreatePanel: React.FC = ({ انتخاب مشتری +
search icon = ({ className="pointer-events-none absolute inset-x-0 bottom-0 h-10 z-10 bg-gradient-to-t from-white/95 to-transparent" />
- {filteredCustomers.map((user) => ( - onSelectCustomer(user.id)} - /> - ))} + {/* از Suspense برای بخش لیست استفاده شده — fallback هم BeatLoader هست */} + + +
+ } + > + {anyLoading ? ( +
+ +
+ ) : filteredCustomers.length === 0 ? ( +
مشتری‌ای یافت نشد.
+ ) : ( + filteredCustomers.map((user: any) => ( + onSelectCustomer(user)} + /> + )) + )} +
+ {/* بخش فرم‌ها */}
انتخاب فرم سوالات @@ -99,22 +185,46 @@ const CreatePanel: React.FC = ({ className="pointer-events-none absolute inset-x-0 bottom-0 h-10 z-10 bg-gradient-to-t from-white/95 to-transparent" />
- {forms.map((form) => ( - onSelectForm(form.id)} - onView={() => {}} + + +
+ } + > + {surveyFormsLoading ? ( +
+ +
+ ) : surveyFormsResp?.forms?.length ? ( + surveyFormsResp.forms.map((form: any) => ( + onSelectForm(form)} + onView={() => handleViewModal(form?.id)} + /> + )) + ) : ( +
فرمی یافت نشد.
+ )} + + - ))} +
+ {/* دکمه‌ها (فیکس در پایین) */}
- -
diff --git a/src/apps/new-ui/pages/OpinionSMS/+components/UserCard.tsx b/src/apps/new-ui/pages/OpinionSMS/+components/UserCard.tsx index 20097422..95d643cc 100644 --- a/src/apps/new-ui/pages/OpinionSMS/+components/UserCard.tsx +++ b/src/apps/new-ui/pages/OpinionSMS/+components/UserCard.tsx @@ -1,21 +1,18 @@ -import { useState } from "react"; import tick from "@new-ui/assets/appointment/tick.svg"; import emptyTick from "@new-ui/assets/appointment/emptyTick.svg"; import { Eye } from "lucide-react"; -import SurveyModal from "./SurveyModal"; +import waitingIcon from "@new-ui/assets/waiting-img.svg"; interface Props { - img: string | undefined; - title: string; + img?: string | undefined; + title: string | undefined; description: string | undefined; checked: boolean; - questions?: any; onClick?: () => void; onView?: () => void; } -const UserCard: React.FC = ({ img, title, questions, checked, description, onClick, onView }) => { - const [showModal, setShowModal] = useState(false); +const UserCard: React.FC = ({ img, title, checked, description, onClick, onView }) => { return (
= ({ img, title, questions, checked, description }} >
- {title + {title

{title}

= ({ img, title, questions, checked, description

- {title.includes("فرم") && ( + {title?.includes("فرم") && ( )} {checked ? tick : emptyTick} diff --git a/src/apps/new-ui/pages/OpinionSMS/index.tsx b/src/apps/new-ui/pages/OpinionSMS/index.tsx index d802769e..000b31bc 100644 --- a/src/apps/new-ui/pages/OpinionSMS/index.tsx +++ b/src/apps/new-ui/pages/OpinionSMS/index.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from "react"; -import { CustomerItem, FormItem, CreatedSurvey } from "./types"; +// OpinionSMS.tsx +import React, { Suspense, useEffect, useMemo, useState } from "react"; import Header from "./+components/Header"; import SearchBar from "./+components/SearchBar"; import GaugeCard from "./+components/GaugeCard"; @@ -7,166 +7,209 @@ import EmptyState from "./+components/EmptyState"; import CreatedSurveyCard from "./+components/CreatedSurveyCard"; import CreatePanel from "./+components/CreatePanel"; import BottomActions from "./+components/BottomActions"; -import profilePicture from "@new-ui/assets/Profile pic.png"; -import waitingIcon from "@new-ui/assets/waiting-img.svg"; +import { BeatLoader } from "react-spinners"; import "./OpinionSMS.css"; -const opinionSMSListItemsTest: CustomerItem[] = [ - { id: 1, img: profilePicture, checked: false, title: "1 پریسا آذری", description: "09121234567" }, - { id: 2, img: profilePicture, checked: false, title: "2 پریسا آذری", description: "09121234567" }, - { id: 3, img: profilePicture, checked: false, title: "پریسا آذری", description: "09121234567" }, - { id: 4, img: profilePicture, checked: false, title: "پریسا آذری", description: "09121234567" }, - { id: 5, img: profilePicture, checked: false, title: "پریسا آذری", description: "09121234567" }, - { id: 6, img: profilePicture, checked: false, title: "پریسا آذری", description: "09121234567" }, - { id: 7, img: profilePicture, checked: false, title: "پریسا آذری", description: "09121234567" }, -]; - -const opinionSMSFormsListItemsTest: FormItem[] = [ - { - id: 1, - img: waitingIcon, - checked: false, - title: "فرم شماره 1", - description: "سوالات استاندارد", - questions: [ - { id: 1, title: "رفتار و برخورد آرایشگر چگونه بود ؟", choices: ["بسیار خوب", "خوب", "معمولی", "بد"] }, - { id: 2, title: "میزان تمیزی و بهداشت وسایل چقدر بود تا خط دوم برسد ؟" }, - { id: 3, title: "آیا وقت دهی به موقع انجام شد ؟" }, - ], - }, - { id: 2, img: waitingIcon, checked: false, title: "فرم شماره 2", description: "سوالات تخصصی ترمیم" }, - { id: 3, img: waitingIcon, checked: false, title: "فرم شماره 3", description: "سوالات تخصصی ترمیم" }, - { id: 4, img: waitingIcon, checked: false, title: "فرم شماره 4", description: "سوالات تخصصی ترمیم" }, -]; +import { useGetAllSurveyForms, useCreateSurvey, useDeleteSurveyForm } from "../../services/OpinionSMS"; +import { useNavigate } from "react-router-dom"; const OpinionSMS: React.FC = () => { - const [customers] = useState(opinionSMSListItemsTest); - const [forms] = useState(opinionSMSFormsListItemsTest); + const { data, isLoading, refetch } = useGetAllSurveyForms(); + const { mutate: deleteSurveyForm, isPending: deleteLoyaltyCardPending } = useDeleteSurveyForm(); + // نگهداری داده‌ی خامِ سرور (بدون نرمالایز) + const [localSurveys, setLocalSurveys] = useState(null); + const navigate = useNavigate(); - // selection states + // انتخاب‌ها و UI state const [selectedCustomerId, setSelectedCustomerId] = useState(null); + const [selectedCustomer, setSelectedCustomer] = useState(); const [selectedFormId, setSelectedFormId] = useState(null); + const [selectedForm, setSelectedForm] = useState(); const [isShowCreateList, setIsShowCreateList] = useState(false); - - // created surveys - const [createdSurveys, setCreatedSurveys] = useState([]); const [showCreatedPreviewId, setShowCreatedPreviewId] = useState(null); - // searches + // جستجوها const [searchMain, setSearchMain] = useState(""); const [customerSearch, setCustomerSearch] = useState(""); - const selectedCustomer = useMemo( - () => customers.find((c) => c.id === selectedCustomerId) || null, - [customers, selectedCustomerId] - ); - const selectedForm = useMemo(() => forms.find((f) => f.id === selectedFormId) || null, [forms, selectedFormId]); + // hook ایجاد (فرض react-query style) + const { mutateAsync: createSurveyAsync } = useCreateSurvey(); - const totalSurveys = createdSurveys.length; - const averagePercent = 0; // placeholder + // وقتی داده از hook آمد، مستقیم آن آرایهٔ surveys را در localSurveys قرار بده (بدون تبدیل) + useEffect(() => { + if (!data) { + setLocalSurveys([]); + return; + } - const submitSurvey = () => { - if (!(selectedCustomer && selectedForm)) return; + console.log(data); - const payload: CreatedSurvey = { - id: `${Date.now()}`, - customer: selectedCustomer, - form: selectedForm, - createdAt: new Date().toISOString(), + if (Array.isArray((data as any).surveys)) { + setLocalSurveys((data as any).surveys); + return; + } + + // اگر data خودش آرایه بود + if (Array.isArray(data as any)) { + setLocalSurveys(data as any); + return; + } + + // fallback: اگر ساختار متفاوت بود، خالی بمونه (شما خواستی نرمالایز نشه) + setLocalSurveys([]); + }, [data]); + + const totalSurveys = (localSurveys ?? []).length; + + // فیلتر ساده روی فیلدهای خامِ سرور — بدون نرمالایز یا map + const filteredCreatedSurveys = useMemo(() => { + const list = localSurveys ?? []; + const t = searchMain.trim().toLowerCase(); + if (!t) return list; + return list.filter((s: any) => { + const name = (s.customer_name ?? "").toString().toLowerCase(); + const phone = (s.customer_phone_number ?? "").toString().toLowerCase(); + const formId = (s.question_form ?? "").toString().toLowerCase(); + const link = (s.link ?? "").toString().toLowerCase(); + const created = (s.created_at ?? "").toString().toLowerCase(); + // const img = + return ( + name.includes(t) || phone.includes(t) || formId.includes(t) || link.includes(t) || created.includes(t) + ); + }); + }, [localSurveys, searchMain]); + + // submitSurvey حالا از useCreateSurvey استفاده می‌کند. + // payload باید حداقل شامل: { customer_name, customer_phone_number, question_form } + const submitSurvey = async (e?: React.FormEvent | any) => { + if (e?.preventDefault) e.preventDefault(); + + if (selectedCustomer == null || selectedFormId == null) return; + + // ساخت payload نهایی از state‌ها + const payloadToSend = { + customer_name: selectedCustomer.name, + customer_phone_number: selectedCustomer.phone_number, + question_form: selectedFormId, }; - // reset selection and hide panel first - setSelectedCustomerId(null); - setSelectedFormId(null); - setIsShowCreateList(false); + try { + const res = await createSurveyAsync(payloadToSend); + await refetch(); - setTimeout(() => { - setCreatedSurveys((p) => [payload, ...p]); - setShowCreatedPreviewId(payload.id); - }, 0); - }; + // پاک کردن stateها بعد از ایجاد موفق + setSelectedCustomerId(null); + setSelectedFormId(null); + setSelectedCustomer(null); + setSelectedForm(null); + setIsShowCreateList(false); - const handleDeleteCreatedSurvey = (id: string) => { - setCreatedSurveys((p) => p.filter((s) => s.id !== id)); - if (showCreatedPreviewId === id) setShowCreatedPreviewId(null); - }; - - const handleShareCreatedSurvey = (s: CreatedSurvey) => { - const text = `فرم نظرسنجی برای ${s.customer.title} - ${s.form.title}`; - if ((navigator as any).share) { - (navigator as any).share({ title: "فرم نظرسنجی", text }).catch(() => {}); - } else { - console.log("share:", text); + // در صورت نیاز نمایش پیش‌نمایش + const createdId = res && (res as any).id ? String((res as any).id) : String(Date.now()); + setShowCreatedPreviewId(createdId); + } catch (error) { + console.error("❌ خطا در ایجاد نظرسنجی:", error); } }; - const filteredCreatedSurveys = useMemo(() => { - const t = searchMain.trim(); - if (!t) return createdSurveys; - return createdSurveys.filter( - (s) => - s.form.title.includes(t) || - s.form.description?.includes(t) || - s.customer.title.includes(t) || - s.customer.description?.includes(t) - ); - }, [createdSurveys, searchMain]); + const handleDeleteCreatedSurvey = async (id: number) => { + setLocalSurveys((p) => (p ? p.filter((s) => String(s.id) !== String(id)) : p)); + if (showCreatedPreviewId && String(showCreatedPreviewId) === String(id)) setShowCreatedPreviewId(null); + // اگر لازم باشه می‌تونی اینجا درخواست حذف سرور بزنی و بعد refetch() + + deleteSurveyForm(id); + await refetch(); + }; + + const handleShareCreatedSurvey = (s: any) => { + const text = `فرم نظرسنجی برای ${s.customer_name ?? "مشتری"} - فرم: ${s.question_form ?? ""}`; + if ((navigator as any).share) { + (navigator as any).share({ title: "فرم نظرسنجی", text }).catch(() => {}); + } else { + navigator.clipboard?.writeText(text).catch(() => {}); + console.log("share (fallback):", text); + } + }; + + const anyLoading = isLoading && (localSurveys == null || localSurveys.length === 0); return ( <>
+ {/* Gauge + Main Search */} {!isShowCreateList && totalSurveys > 0 && (
- +
)} {/* Created Surveys */} - {!isShowCreateList && filteredCreatedSurveys.length > 0 ? ( + {!isShowCreateList ? (
- {filteredCreatedSurveys.map((s) => ( -
- -
- ))} + + +
+ } + > + {anyLoading ? ( +
+ +
+ ) : filteredCreatedSurveys.length > 0 ? ( + filteredCreatedSurveys.map((s: any) => ( +
+ handleDeleteCreatedSurvey(s.id)} + onShare={() => handleShareCreatedSurvey(s)} + /> +
+ )) + ) : ( + setIsShowCreateList(true)} /> + )} +
- ) : ( - !isShowCreateList && setIsShowCreateList(true)} /> - )} + ) : null} {/* Create panel */} {isShowCreateList && ( setSelectedCustomerId((p) => (p === id ? null : id))} - onSelectForm={(id) => setSelectedFormId((p) => (p === id ? null : id))} + onSelectCustomer={(user: any) => { + setSelectedCustomerId((p) => (p === user.id ? null : user.id)); + setSelectedCustomer(user); + }} + onSelectForm={(form: any) => { + setSelectedFormId((p) => (p === form.id ? null : form.id)); + setSelectedForm(form); + }} onCancel={() => { setIsShowCreateList(false); setSelectedCustomerId(null); + setSelectedCustomer(null); setSelectedFormId(null); + setSelectedForm(null); }} - onSubmit={submitSurvey} + // پنل می‌تونه payload خام (customer_name, customer_phone_number, question_form) رو پاس بده + onSubmit={(payload?: any) => submitSurvey(payload)} customerSearch={customerSearch} setCustomerSearch={setCustomerSearch} /> )} {/* Bottom actions */} - {filteredCreatedSurveys.length != 0 && ( + {(localSurveys ?? []).length !== 0 && ( setIsShowCreateList(true)} - onClose={() => console.log("close")} + onClose={() => navigate(-1)} hidden={isShowCreateList} /> )} diff --git a/src/apps/new-ui/pages/OpinionSMS/types.ts b/src/apps/new-ui/pages/OpinionSMS/types.ts index 457378b0..c5a69393 100644 --- a/src/apps/new-ui/pages/OpinionSMS/types.ts +++ b/src/apps/new-ui/pages/OpinionSMS/types.ts @@ -1,23 +1,48 @@ export type CustomerItem = { - id: number; - img?: string; - checked?: boolean; - title: string; - description?: string; + id: number; + img?: string; + checked?: boolean; + title: string; + description?: string; + + customer_type?: string; + date_of_birth?: string; + name?: string | undefined; + phone_number?: string; + profile_picture?: string; }; export type FormItem = { - id: number; - img?: string; - checked?: boolean; - title: string; - description?: string; - questions?: { id: number; title: string; choices?: string[] }[]; + id: number; + img?: string; + checked?: boolean; + title: string; + description?: string; + questions?: { id: number; title: string; choices?: string[] }[]; }; +// export type CreatedSurvey = { +// id: string; +// customer: CustomerItem; +// form: FormItem; +// createdAt: string; +// }; + export type CreatedSurvey = { - id: string; - customer: CustomerItem; - form: FormItem; - createdAt: string; + id: number; + user_id: number | null; + question_form: number | null; + + customer_name: string | null; + customer_phone_number?: string | null; + profile_picture: string | null; + + created_at: string; // "1404-07-21 11:54:53" + average_score_percent: number; + complete: boolean; + final_comment?: string | null; + link?: string | null; + + // اگر API ممکنه فیلدهای اضافی برگردونه: + [key: string]: any; }; diff --git a/src/apps/new-ui/pages/ReportAll/Accounting/index.tsx b/src/apps/new-ui/pages/ReportAll/Accounting/index.tsx index f4bae79d..36ed26ac 100644 --- a/src/apps/new-ui/pages/ReportAll/Accounting/index.tsx +++ b/src/apps/new-ui/pages/ReportAll/Accounting/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import FrameIcon from "@new-ui/assets/Frame.svg"; -import FrameIcon2 from "@new-ui/assets/Frame2.svg"; +import FrameIcon from "@new-ui/assets/Frame2.svg"; +import FrameIcon2 from "@new-ui/assets/Frame.svg"; import FactorIcon from "@new-ui/assets/factor.svg"; import ChartIcon from "@new-ui/assets/chart.svg"; import ExelIcon from "@new-ui/assets/exel.svg"; @@ -12,103 +12,29 @@ import { useForm } from "react-hook-form"; import { DateTimeInput } from "@/components/DateTimeInput"; import moment from "jalali-moment"; import LockIcon from "@new-ui/assets/lock.svg"; -import Card from "./+components/Card"; +import Card from "@/apps/new-ui/pages/ReportAll/Accounting/+components/Card"; const ReportActionsList = [ - { - id: 1, - icon: FrameIcon, - title: "درآمد ها", - fallback: "/income-and-cost?title=income", - disabled: false, - }, - { - id: 2, - icon: FrameIcon2, - title: "هزینه ها", - fallback: "/income-and-cost?title=cost", - disabled: false, - }, - { - id: 3, - icon: FactorIcon, - title: "صورتحساب", - fallback: "/factor", - disabled: false, - }, - { - id: 4, - icon: LockIcon, - title: "مالیات", - fallback: "/explore", - disabled: true, - }, - { - id: 5, - icon: LockIcon, - title: "حقوق", - fallback: "/explore", - disabled: true, - }, + { id: 1, icon: FrameIcon, title: "درآمد ها", fallback: "/income-and-cost?title=income", disabled: false }, + { id: 2, icon: FrameIcon2, title: "هزینه ها", fallback: "/income-and-cost?title=cost", disabled: false }, + { id: 3, icon: FactorIcon, title: "صورتحساب", fallback: "/factor", disabled: false }, + { id: 4, icon: LockIcon, title: "مالیات", fallback: "/explore", disabled: true }, + { id: 5, icon: LockIcon, title: "حقوق", fallback: "/explore", disabled: true }, ]; const ReportActionsTypeList = [ - { - id: 1, - icon: ChartIcon, - title: "نمودار", - fallback: "/chart-report", - disabled: false, - }, - { - id: 2, - icon: ExelIcon, - title: "اکسل", - fallback: "exel", - disabled: false, - }, - { - id: 3, - icon: ListIcon, - title: "لیست", - fallback: "list", - disabled: false, - }, + { id: 1, icon: ChartIcon, title: "نمودار", fallback: "/chart-report", disabled: false }, + { id: 2, icon: ExelIcon, title: "اکسل", fallback: "exel", disabled: false }, + { id: 3, icon: ListIcon, title: "لیست", fallback: "list", disabled: false }, ]; const ReportDateRangeList = [ - { - id: 1, - range: "1", - title: "سال", - fallback: "year", - disabled: false, - }, - { - id: 2, - range: "1", - title: "ماه", - fallback: "mounth", - disabled: false, - }, - { - id: 3, - icon: UnionIcon, - title: "بازه", - fallback: "range", - disabled: false, - }, - { - id: 4, - icon: LockIcon, - title: "قفل", - disabled: true, - }, + { id: 1, range: "1", title: "سال", fallback: "year", disabled: false }, + { id: 2, range: "1", title: "ماه", fallback: "mounth", disabled: false }, + { id: 3, icon: UnionIcon, title: "بازه", fallback: "range", disabled: false }, + { id: 4, icon: LockIcon, title: "قفل", disabled: true }, ]; - - - const Accounting: React.FC = () => { const navigate = useNavigate(); const [reportActions, setReportActions] = useState(null); @@ -118,19 +44,20 @@ const Accounting: React.FC = () => { const [fallbackConfig, setFallbackConfig] = useState({}); - const { control, getValues } = useForm({ + const { control, getValues, watch } = useForm({ defaultValues: { startDate: "", endDate: "", }, }); + // watch start/end to update button state when in range mode + const [watchedStart, watchedEnd] = watch(["startDate", "endDate"]); + useEffect(() => { console.log(reportActions, reportActionsType, reportDateRange, fallbackConfig); }, [reportActions, reportActionsType, reportDateRange, fallbackConfig]); - const btnTrigger = !!(reportActions && reportActionsType && reportDateRange); - const handleSelectReportActions = (id: number, fallback: string) => { setReportActions(id); setFallbackConfig((p: any) => ({ ...p, actionFallback: fallback })); @@ -148,6 +75,19 @@ const Accounting: React.FC = () => { else setIsShowDateRange(false); }; + // helper: compute start/end for current jalali month/year + const computeCurrentJalaliMonthRange = () => { + const start = moment().startOf("jMonth").format("jYYYY-jMM-jDD"); + const end = moment().endOf("jMonth").format("jYYYY-jMM-jDD"); + return { start, end }; + }; + const computeCurrentJalaliYearRange = () => { + const start = moment().startOf("jYear").format("jYYYY-jMM-jDD"); + const end = moment().endOf("jYear").format("jYYYY-jMM-jDD"); + return { start, end }; + }; + + // build url with date handling for year/month/range const buildNavigateUrl = () => { const actionItem = ReportActionsList.find((i) => i.id === reportActions); const typeItem = ReportActionsTypeList.find((i) => i.id === reportActionsType); @@ -166,10 +106,9 @@ const Accounting: React.FC = () => { } if (typeFallback) initialParams.set("type", typeFallback); - + if (typeFallback && typeFallback.startsWith("/")) { initialParams.delete("type"); - let tfBase = typeFallback; if (typeFallback.includes("?")) { const [path, qs] = typeFallback.split("?"); @@ -180,22 +119,42 @@ const Accounting: React.FC = () => { basePath = tfBase; } + // date handling if (dateFallback === "range") { const start = getValues("startDate"); const end = getValues("endDate"); if (start) initialParams.set("start", start); if (end) initialParams.set("end", end); initialParams.set("range", "range"); + } else if (dateFallback === "mounth" || dateFallback === "month") { + const { start, end } = computeCurrentJalaliMonthRange(); + initialParams.set("start", start); + initialParams.set("end", end); + initialParams.set("range", "mounth"); + } else if (dateFallback === "year") { + const { start, end } = computeCurrentJalaliYearRange(); + initialParams.set("start", start); + initialParams.set("end", end); + initialParams.set("range", "year"); } else if (dateFallback) { + // fallback generic initialParams.set("range", dateFallback); } - // final url const finalQs = initialParams.toString(); const finalUrl = finalQs ? `${basePath}?${finalQs}` : basePath; return finalUrl; }; + // enable button only when required pieces are present + const selectedDateItem = ReportDateRangeList.find((i) => i.id === reportDateRange); + const selectedDateFallback = selectedDateItem?.fallback ?? ""; + // if range selected, require both start and end + const isRangeSelected = selectedDateFallback === "range"; + const btnTrigger = + !!(reportActions && reportActionsType && reportDateRange) && + (!isRangeSelected || (watchedStart && watchedEnd)); + return (
{/* =====report actions======= */} diff --git a/src/apps/new-ui/pages/ReportAll/ChartReport/+components/CostPieChart.tsx b/src/apps/new-ui/pages/ReportAll/ChartReport/+components/CostPieChart.tsx index d69a4e62..53df0214 100644 --- a/src/apps/new-ui/pages/ReportAll/ChartReport/+components/CostPieChart.tsx +++ b/src/apps/new-ui/pages/ReportAll/ChartReport/+components/CostPieChart.tsx @@ -68,16 +68,20 @@ const CostPieChart: React.FC = ({ entries, height = 280, renderKey }) =>
{/* center label over chart */} -
= cost ? "text-[#1AC792]" : "text-[##FF8484]"}`}> -
سود
-
- {hasData ? profit.toLocaleString("fa-IR") : "—"} + {hasData && ( +
= cost ? "text-[#1AC792]" : "text-[##FF8484]" + }`} + > +
سود
+
{profit.toLocaleString("fa-IR")}
+
تومان
-
تومان
-
+ )} {/* summary boxes */} -
+
درآمد :
{income.toLocaleString("fa-IR")} تومان
diff --git a/src/apps/new-ui/pages/ReportAll/ChartReport/index.tsx b/src/apps/new-ui/pages/ReportAll/ChartReport/index.tsx index 10cdc73f..b3a76656 100644 --- a/src/apps/new-ui/pages/ReportAll/ChartReport/index.tsx +++ b/src/apps/new-ui/pages/ReportAll/ChartReport/index.tsx @@ -5,29 +5,19 @@ import CostPieChart, { AccountingEntry as PieEntry } from "./+components/CostPie import MonthlyChart from "./+components/MonthlyChart"; import { CalendarDays } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { appointmentClient } from "@/utils/axios-interceptor"; +import { useGetTotalProfitBetweenDates } from "@/apps/new-ui/services/ChartReport"; + export type AccountingEntry = { id: number; type: "income" | "cost"; amount: number; - date: string; // yyyy-mm-dd + date: string; // yyyy-mm-dd or jYYYY-jMM-jDD depending on backend category?: string; note?: string; }; -const STORAGE_KEY = "accountingEntries"; - -function readEntries(): AccountingEntry[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) return parsed as AccountingEntry[]; - return []; - } catch { - return []; - } -} - function filterByRange(entries: AccountingEntry[], start: string, end: string) { if (!start && !end) return entries; return entries.filter((e) => { @@ -39,14 +29,6 @@ function filterByRange(entries: AccountingEntry[], start: string, end: string) { } export default function ChartReport() { - localStorage.setItem( - "accountingEntries", - JSON.stringify([ - { id: 1, type: "income", amount: 60000000, date: "1404-07-20" }, - { id: 2, type: "cost", amount: 40000000, date: "1404-07-21" }, - ]) - ); - const navigate = useNavigate(); const [searchParams, _setSearchParams] = useSearchParams(); @@ -58,16 +40,59 @@ export default function ChartReport() { const [tab, setTab] = useState<0 | 1>(0); - const allEntries = useMemo(() => readEntries(), []); + // --- totals hook (server) --- + // use the provided hook (net_profit, total_expence, total_income) + const defaultStart = startParam || ""; // keep exact query params; if empty backend hook may be disabled + const defaultEnd = endParam || ""; + + const totalsQuery = useGetTotalProfitBetweenDates( + defaultStart && defaultEnd ? { startDate: defaultStart, endDate: defaultEnd } : undefined + ) as any; + + const totalsData = totalsQuery?.data; + const totalsLoading = totalsQuery?.isLoading; + const totalsError = totalsQuery?.isError; + + // --- transactions query (for charts) --- + const page = searchParams.get("page") ?? "1"; + const txsQuery = useQuery({ + queryKey: ["ACCOUNTING_TRANSACTIONS", defaultStart, defaultEnd, page], + enabled: Boolean(defaultStart && defaultEnd), + queryFn: async () => { + // call backend endpoint directly (same format as earlier) + const s = encodeURIComponent(defaultStart); + const e = encodeURIComponent(defaultEnd); + const { data } = await appointmentClient.get(`/compare_incomes_expenses/${s}/${e}/${page}`); + // data.transactions expected + return data?.transactions ?? []; + }, + retry: 1, + }); + + const serverTxs: AccountingEntry[] = useMemo(() => { + const txs = Array.isArray(txsQuery.data) ? txsQuery.data : []; + return txs.map((t: any, idx: number) => ({ + id: t.id ?? idx, + type: (t.type as "income" | "cost") ?? "income", + amount: Number(t.amount ?? 0), + date: t.date ?? "", + category: t.category ?? "", + note: t.note ?? "", + })); + }, [txsQuery.data]); + + // keep same filtering behaviour (though server already filtered by dates) const entriesInRange = useMemo( - () => filterByRange(allEntries, startParam, endParam), - [allEntries, startParam, endParam] + () => filterByRange(serverTxs, startParam, endParam), + [serverTxs, startParam, endParam] ); - // totals - // const incomeTotal = entriesInRange.filter((e) => e.type === "income").reduce((s, e) => s + e.amount, 0); - // const costTotal = entriesInRange.filter((e) => e.type === "cost").reduce((s, e) => s + e.amount, 0); - // const profitTotal = incomeTotal - costTotal; + // totals values (prefer server totals; fallback to computed from transactions) + const incomeTotal = + totalsData?.total_income ?? entriesInRange.filter((e) => e.type === "income").reduce((s, e) => s + e.amount, 0); + const costTotal = + totalsData?.total_expence ?? entriesInRange.filter((e) => e.type === "cost").reduce((s, e) => s + e.amount, 0); + const profitTotal = totalsData?.net_profit ?? incomeTotal - costTotal; const renderKey = `${tab}-${startParam}-${endParam}`; diff --git a/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/BottomActions.tsx b/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/BottomActions.tsx index a06f12e1..be799553 100644 --- a/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/BottomActions.tsx +++ b/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/BottomActions.tsx @@ -1,7 +1,7 @@ -type Props = { onCreate: () => void; onClose: () => void; label: string }; +type Props = { onCreate: () => void; onClose: () => void; label?: string; disabled?: boolean }; + +const BottomActions: React.FC = ({ onCreate, onClose, label, disabled = true }) => { -const BottomActions: React.FC = ({ onCreate, onClose, label }) => { - return (
diff --git a/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/EntryCard.tsx b/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/EntryCard.tsx index 925913d5..9048375a 100644 --- a/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/EntryCard.tsx +++ b/src/apps/new-ui/pages/ReportAll/Income&Cost/+components/EntryCard.tsx @@ -3,6 +3,7 @@ import FrameIcon from "@new-ui/assets/IncomeFrameLarge.jpg"; import FrameIcon2 from "@new-ui/assets/CostFrameLarge.jpg"; import PlusIcon from "@new-ui/assets/plusCard.svg"; import { Pen, X } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; export default function EntryCard({ item, @@ -21,7 +22,11 @@ export default function EntryCard({ } }; - const textMode = (item.type === "income" ? "درآمد" : "هزینه"); + const [searchParams] = useSearchParams(); + + const rawMode = searchParams.get("title"); + const mode = rawMode ? rawMode.toLowerCase() : null; // 'income' | 'cost' | null + const textMode = mode === "income" ? "درآمد" : mode === "cost" ? "هزینه" : "درآمد/هزینه"; return (
مبلغ: {currencyFormat(item.amount)} @@ -54,19 +59,19 @@ export default function EntryCard({
- ترمیم متن طولانی به قدری متن ادامه ترمیم متن طولانی به قدری متن ادامه پیدا کند تا به خط بعدی برسد + {item.description}
diff --git a/src/apps/new-ui/pages/ReportAll/Income&Cost/Create/index.tsx b/src/apps/new-ui/pages/ReportAll/Income&Cost/Create/index.tsx index 354019fb..d81ebf46 100644 --- a/src/apps/new-ui/pages/ReportAll/Income&Cost/Create/index.tsx +++ b/src/apps/new-ui/pages/ReportAll/Income&Cost/Create/index.tsx @@ -1,69 +1,137 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Header from "../../+components/Header"; -import { useSearchParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import FrameIcon from "@new-ui/assets/IncomeFrameLarge.jpg"; import FrameIcon2 from "@new-ui/assets/CostFrameLarge.jpg"; import AmountInput from "./+components/AmountInput"; import SimpleDatePicker from "@/apps/new-ui/components/MobilePicker/MobileScrollDatePicker"; import Card from "../../Accounting/+components/Card"; import BottomActions from "./+components/BottomActions"; +import { + useUpsertIncome, + useUpsertExpense, + useGetExpenseById, + useGetIncomeById, +} from "@/apps/new-ui/services/IncomeAndCost"; +import { IAccounting } from "@/apps/appointment/utils/types"; + +type Mode = "income" | "cost" | null; const IncomeList = [ - { - id: 1, - title: "آرایش", - disabled: false, - }, - { - id: 2, - title: "ترمیم", - disabled: false, - }, - { - id: 3, - title: "اصلاح", - disabled: false, - }, + { id: 1, title: "آرایش", disabled: false }, + { id: 2, title: "ترمیم", disabled: false }, + { id: 3, title: "اصلاح", disabled: false }, ]; const CostList = [ - { - id: 1, - title: "مواد", - disabled: false, - }, - { - id: 2, - title: "کرایه", - disabled: false, - }, - { - id: 3, - title: "اجاره", - disabled: false, - }, + { id: 1, title: "مواد", disabled: false }, + { id: 2, title: "کرایه", disabled: false }, + { id: 3, title: "اجاره", disabled: false }, ]; + const CreateIncomeAndCost: React.FC = () => { + const { id } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); - const mode: string | null | undefined = searchParams.get("title")?.toLocaleLowerCase(); + const rawMode = searchParams.get("title"); + const mode: Mode = rawMode ? (rawMode.toLowerCase() as Mode) : null; + const textMode = mode === "income" ? "درآمد" : "هزینه"; const iconMode = mode === "income" ? FrameIcon : FrameIcon2; const pageMode = mode === "income" ? IncomeList : CostList; - const [reportActionsType, setReportActionsType] = useState(null); + const navigate = useNavigate(); - const [date, setDate] = useState(""); - const [details, setDetails] = useState(""); + const [reportActionsType, setReportActionsType] = useState(null); + const [amount, setAmount] = useState(null); + const [date, setDate] = useState(""); + const [description, setDescription] = useState(""); + + const [initialValues, setInitialValues] = useState({ + amount: null as number | null, + date: "", + description: "", + reportActionsType: null as string | null, + }); + + // Call both hooks but only pass id when relevant (prevents unnecessary fetches). + // Many data fetching hooks treat `undefined` as "do not fetch". + const expenseIdParam = id && mode === "cost" ? id : undefined; + const incomeIdParam = id && mode === "income" ? id : undefined; + + const { data: expenseData } = useGetExpenseById(expenseIdParam as any); + const { data: incomeData } = useGetIncomeById(incomeIdParam as any); + + // Mutations + const upsertIncomeHook = useUpsertIncome(); + const upsertExpenseHook = useUpsertExpense(); + const upsertIncome = upsertIncomeHook.mutate; + const upsertExpense = upsertExpenseHook.mutate; + + // When editing, populate fields from the appropriate source + useEffect(() => { + const source = mode === "cost" ? expenseData : mode === "income" ? incomeData : undefined; + if (!id || !source) return; + + const parsedAmount = + typeof (source as any).amount === "string" + ? Number((source as any).amount) + : (source as any).amount ?? null; + + const srcDate = (source as any).date ?? ""; + const srcDescription = (source as any).description ?? ""; + const srcReportActionsType = (source as any).reportActionsType ?? null; + + setAmount(parsedAmount); + setDate(srcDate); + setDescription(srcDescription); + setReportActionsType(srcReportActionsType); + + setInitialValues({ + amount: parsedAmount, + date: srcDate, + description: srcDescription, + reportActionsType: srcReportActionsType, + }); + }, [id, mode, expenseData, incomeData]); + + const trimmedDescription = description.trim(); + + const isChanged = + amount !== initialValues.amount || + date !== initialValues.date || + trimmedDescription !== initialValues.description || + reportActionsType !== initialValues.reportActionsType; + + const canSubmit = + Boolean(amount && date && trimmedDescription !== "" && reportActionsType) && (id ? isChanged : true); + + const submitHandler = () => { + if (!canSubmit) return; + + const payload: Partial = { + id: id ? Number(id) : undefined, + amount: Number(amount ?? 0), + date, + description: trimmedDescription, + }; + + if (mode === "cost") { + upsertExpense(payload as IAccounting); + } else if (mode === "income") { + upsertIncome(payload as IAccounting); + } + }; return ( <> -
+
icon - {}} /> - setDate(newDate)} /> + setAmount(value)} /> + + setDate(newDate)} />
@@ -82,18 +150,18 @@ const CreateIncomeAndCost: React.FC = () => {