diff --git a/src/config/Pages.ts b/src/config/Pages.ts index ec5c6e7..b58510d 100644 --- a/src/config/Pages.ts +++ b/src/config/Pages.ts @@ -27,6 +27,7 @@ export const Pages = { detail: "/orders/detail/", foodReport: "/orders/report/by-food", dailyReport: "/orders/report/by-day", + cashShifts: "/orders/cash-shifts", }, customers: { list: "/customers/list", diff --git a/src/langs/fa.json b/src/langs/fa.json index ed2ad67..aa4f28f 100644 --- a/src/langs/fa.json +++ b/src/langs/fa.json @@ -100,6 +100,7 @@ "orders_list": "لیست سفارشات", "orders_food_report": "گزارش فروش به تفکیک غذا", "orders_daily_report": "گزارش فروش روزانه", + "cash_shifts": "شیفت‌های صندوق", "add_order": "افزودن سفارش", "discount_list": "لیست تخفیف", "add_discount": "افزودن تخفیف", @@ -929,5 +930,42 @@ }, "change_status": "تغییر وضعیت", "save": "ذخیره" + }, + "cash_shift": { + "list_title": "شیفت‌های صندوق", + "current_shift": "شیفت فعال", + "open_shift": "باز کردن شیفت", + "close_shift": "بستن شیفت", + "open_title": "باز کردن شیفت صندوق", + "close_title": "بستن شیفت صندوق", + "edit_title": "ویرایش شیفت صندوق", + "edit_shift": "ویرایش شیفت", + "save_changes": "ذخیره تغییرات", + "detail_title": "جزئیات شیفت صندوق", + "summary_title": "خلاصه شیفت", + "operator": "اپراتور", + "status_label": "وضعیت", + "opened_at": "شروع شیفت", + "closed_at": "پایان شیفت", + "opening_amount": "موجودی اولیه", + "opening_amount_placeholder": "مبلغ موجودی اولیه صندوق", + "opening_amount_required": "موجودی اولیه الزامی است", + "expected_amount": "مبلغ مورد انتظار", + "counted_amount": "مبلغ شمارش‌شده", + "counted_amount_placeholder": "مبلغ شمارش‌شده در صندوق", + "counted_amount_required": "مبلغ شمارش‌شده الزامی است", + "difference_amount": "اختلاف", + "orders_count": "تعداد سفارشات", + "notes": "یادداشت", + "notes_placeholder": "یادداشت (اختیاری)", + "cancel": "انصراف", + "view_details": "مشاهده جزئیات", + "opened_success": "شیفت صندوق با موفقیت باز شد", + "closed_success": "شیفت صندوق با موفقیت بسته شد", + "updated_success": "شیفت صندوق با موفقیت ویرایش شد", + "status_values": { + "open": "باز", + "closed": "بسته" + } } } diff --git a/src/pages/cashShifts/List.tsx b/src/pages/cashShifts/List.tsx new file mode 100644 index 0000000..7295298 --- /dev/null +++ b/src/pages/cashShifts/List.tsx @@ -0,0 +1,223 @@ +import { type FC, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Add, Edit, Lock1 } from "iconsax-react"; +import Table from "@/components/Table"; +import Filters from "@/components/Filters"; +import Button from "@/components/Button"; +import type { RowDataType } from "@/components/types/TableTypes"; +import { + useGetCashShifts, + useGetCurrentCashShift, + useGetCurrentCashShiftSummary, +} from "./hooks/useCashShiftData"; +import { useCashShiftFilters } from "./hooks/useCashShiftFilters"; +import { getCashShiftTableColumns } from "./components/CashShiftTableColumns"; +import { useCashShiftFiltersFields } from "./components/CashShiftFiltersFields"; +import CashShiftSummaryCard from "./components/CashShiftSummaryCard"; +import OpenCashShiftModal from "./components/OpenCashShiftModal"; +import CloseCashShiftModal from "./components/CloseCashShiftModal"; +import EditCashShiftModal from "./components/EditCashShiftModal"; +import CashShiftDetailModal from "./components/CashShiftDetailModal"; +import type { CashShift } from "./types/Types"; +import { CashShiftStatus } from "./enum/Enum"; +import { formatOptionalDate } from "@/helpers/func"; +import Status from "@/components/Status"; +import MoonLoader from "react-spinners/MoonLoader"; + +const CashShiftsList: FC = () => { + const { t } = useTranslation("global"); + const [isOpenModalVisible, setIsOpenModalVisible] = useState(false); + const [isCloseModalVisible, setIsCloseModalVisible] = useState(false); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const [isDetailModalVisible, setIsDetailModalVisible] = useState(false); + const [selectedShift, setSelectedShift] = useState(null); + + const { + filters, + currentPage, + apiParams, + handleFiltersChange, + handlePageChange, + } = useCashShiftFilters(); + + const { data: shiftsData, isLoading } = useGetCashShifts(apiParams); + const { data: currentShiftData, isLoading: isCurrentShiftLoading } = + useGetCurrentCashShift(); + + const currentShift = currentShiftData?.data ?? null; + const hasOpenShift = currentShift?.status === CashShiftStatus.OPEN; + + const { data: currentSummaryData, isLoading: isCurrentSummaryLoading } = + useGetCurrentCashShiftSummary(hasOpenShift); + + const shifts = shiftsData?.data || []; + const totalPages = shiftsData?.meta?.totalPages || 1; + + const columns = useMemo( + () => + getCashShiftTableColumns({ + t, + onView: (shift) => { + setSelectedShift(shift); + setIsDetailModalVisible(true); + }, + onEdit: (shift) => { + setSelectedShift(shift); + setIsEditModalVisible(true); + }, + onClose: (shift) => { + setSelectedShift(shift); + setIsCloseModalVisible(true); + }, + }), + [t] + ); + + const filterFields = useCashShiftFiltersFields(); + + const getAdminName = (shift: CashShift) => { + const name = [shift.admin?.firstName, shift.admin?.lastName] + .filter(Boolean) + .join(" "); + return name || shift.admin?.phone || "-"; + }; + + return ( +
+
+

{t("cash_shift.list_title")}

+ {!hasOpenShift && ( + + )} +
+ + {isCurrentShiftLoading ? ( +
+ +
+ ) : ( + hasOpenShift && + currentShift && ( +
+
+
+
+ {t("cash_shift.current_shift")} +
+
+ {t("cash_shift.operator")}: {getAdminName(currentShift)} +
+
+ {t("cash_shift.opened_at")}:{" "} + {formatOptionalDate(currentShift.openedAt, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} +
+
+ +
+ + + +
+
+ + +
+ ) + )} + +
+ +
+ + + columns={columns} + data={shifts as (CashShift & RowDataType)[]} + isloading={isLoading} + pagination={{ + currentPage, + totalPages, + onPageChange: handlePageChange, + }} + /> + + setIsOpenModalVisible(false)} + /> + + { + setIsCloseModalVisible(false); + setSelectedShift(null); + }} + shift={selectedShift} + /> + + { + setIsEditModalVisible(false); + setSelectedShift(null); + }} + shift={selectedShift} + /> + + { + setIsDetailModalVisible(false); + setSelectedShift(null); + }} + shift={selectedShift} + /> +
+ ); +}; + +export default CashShiftsList; diff --git a/src/pages/cashShifts/components/CashShiftDetailModal.tsx b/src/pages/cashShifts/components/CashShiftDetailModal.tsx new file mode 100644 index 0000000..3a64203 --- /dev/null +++ b/src/pages/cashShifts/components/CashShiftDetailModal.tsx @@ -0,0 +1,185 @@ +import { type FC } from "react"; +import DefaulModal from "@/components/DefaulModal"; +import Status from "@/components/Status"; +import { useTranslation } from "react-i18next"; +import { + formatFaNumber, + formatOptionalDate, + formatPrice, +} from "@/helpers/func"; +import { useGetCashShiftSummary } from "../hooks/useCashShiftData"; +import CashShiftSummaryCard from "./CashShiftSummaryCard"; +import type { CashShift } from "../types/Types"; +import { CashShiftStatus } from "../enum/Enum"; + +type Props = { + open: boolean; + onClose: () => void; + shift: CashShift | null; +}; + +const getAdminName = (shift: CashShift) => { + const name = [shift.admin?.firstName, shift.admin?.lastName] + .filter(Boolean) + .join(" "); + return name || shift.admin?.phone || "-"; +}; + +const CashShiftDetailModal: FC = ({ open, onClose, shift }) => { + const { t } = useTranslation("global"); + + const { data: summaryData, isLoading: isSummaryLoading } = + useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id); + + if (!shift) return null; + + const summary = summaryData?.data; + const isOpen = shift.status === CashShiftStatus.OPEN; + const statusVariant = isOpen ? "warning" : "success"; + + return ( + +
+
+
+ +

{getAdminName(shift)}

+
+ +
+ +
+ +
+
+ +
+ +

+ {formatOptionalDate(shift.openedAt, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} +

+
+ +
+ +

+ {formatOptionalDate(shift.closedAt, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} +

+
+ +
+ +

+ {formatPrice(shift.openingAmount)} +

+
+ +
+ +

+ {formatFaNumber(shift.ordersCount)} +

+
+ + {!isOpen && shift.expectedAmount !== null && ( +
+ +

+ {formatPrice(shift.expectedAmount)} +

+
+ )} + + {!isOpen && shift.countedAmount !== null && ( +
+ +

+ {formatPrice(shift.countedAmount)} +

+
+ )} + + {!isOpen && shift.differenceAmount !== null && ( +
+ +

0 + ? "text-green-600" + : shift.differenceAmount < 0 + ? "text-red-600" + : "" + }`} + > + {formatPrice(shift.differenceAmount)} +

+
+ )} +
+ + {shift.notes && ( +
+ +

+ {shift.notes} +

+
+ )} + +
+
+ {t("cash_shift.summary_title")} +
+ +
+
+
+ ); +}; + +export default CashShiftDetailModal; diff --git a/src/pages/cashShifts/components/CashShiftFiltersFields.tsx b/src/pages/cashShifts/components/CashShiftFiltersFields.tsx new file mode 100644 index 0000000..7d63f65 --- /dev/null +++ b/src/pages/cashShifts/components/CashShiftFiltersFields.tsx @@ -0,0 +1,32 @@ +import { useMemo } from "react"; +import type { FieldType } from "@/components/Filters"; +import { CashShiftStatus } from "../enum/Enum"; + +export const useCashShiftFiltersFields = (): FieldType[] => { + return useMemo( + () => [ + { + type: "select", + name: "status", + placeholder: "وضعیت شیفت", + defaultValue: "", + options: [ + { label: "همه", value: "" }, + { label: "باز", value: CashShiftStatus.OPEN }, + { label: "بسته", value: CashShiftStatus.CLOSED }, + ], + }, + { + type: "datetime", + name: "startDate", + placeholder: "از تاریخ", + }, + { + type: "datetime", + name: "endDate", + placeholder: "تا تاریخ", + }, + ], + [] + ); +}; diff --git a/src/pages/cashShifts/components/CashShiftSummaryCard.tsx b/src/pages/cashShifts/components/CashShiftSummaryCard.tsx new file mode 100644 index 0000000..ac21d3c --- /dev/null +++ b/src/pages/cashShifts/components/CashShiftSummaryCard.tsx @@ -0,0 +1,48 @@ +import { type FC } from "react"; +import MoonLoader from "react-spinners/MoonLoader"; +import { formatFaNumber, formatPrice } from "@/helpers/func"; +import type { CashShiftSummary } from "../types/Types"; + +type Props = { + summary?: CashShiftSummary | null; + openingAmount?: number; + isLoading?: boolean; +}; + +const SummaryItem: FC<{ label: string; value: string }> = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); + +const CashShiftSummaryCard: FC = ({ summary, openingAmount = 0, isLoading }) => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!summary) { + return null; + } + + return ( +
+ + + + + + + +
+ ); +}; + +export default CashShiftSummaryCard; diff --git a/src/pages/cashShifts/components/CashShiftTableColumns.tsx b/src/pages/cashShifts/components/CashShiftTableColumns.tsx new file mode 100644 index 0000000..9fff711 --- /dev/null +++ b/src/pages/cashShifts/components/CashShiftTableColumns.tsx @@ -0,0 +1,136 @@ +import { Edit, Eye, Lock1 } from "iconsax-react"; +import type { ColumnType } from "@/components/types/TableTypes"; +import type { CashShift } from "../types/Types"; +import { + formatFaNumber, + formatOptionalDate, + formatPrice, +} from "@/helpers/func"; +import Status from "@/components/Status"; +import { CashShiftStatus } from "../enum/Enum"; +import type { TFunction } from "i18next"; + +interface GetCashShiftTableColumnsParams { + t: TFunction; + onView: (shift: CashShift) => void; + onEdit?: (shift: CashShift) => void; + onClose?: (shift: CashShift) => void; +} + +const getAdminName = (shift: CashShift) => { + const name = [shift.admin?.firstName, shift.admin?.lastName] + .filter(Boolean) + .join(" "); + return name || shift.admin?.phone || "-"; +}; + +export const getCashShiftTableColumns = ({ + t, + onView, + onEdit, + onClose, +}: GetCashShiftTableColumnsParams): ColumnType[] => [ + { + key: "openedAt", + title: t("cash_shift.opened_at"), + render: (item) => + formatOptionalDate(item.openedAt, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }), + }, + { + key: "closedAt", + title: t("cash_shift.closed_at"), + render: (item) => + formatOptionalDate(item.closedAt, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }), + }, + { + key: "admin", + title: t("cash_shift.operator"), + render: (item) => getAdminName(item), + }, + { + key: "openingAmount", + title: t("cash_shift.opening_amount"), + render: (item) => formatPrice(item.openingAmount), + }, + { + key: "expectedAmount", + title: t("cash_shift.expected_amount"), + render: (item) => + item.expectedAmount !== null ? formatPrice(item.expectedAmount) : "-", + }, + { + key: "differenceAmount", + title: t("cash_shift.difference_amount"), + render: (item) => { + if (item.differenceAmount === null) return "-"; + const value = formatPrice(item.differenceAmount); + if (item.differenceAmount > 0) { + return {value}; + } + if (item.differenceAmount < 0) { + return {value}; + } + return value; + }, + }, + { + key: "ordersCount", + title: t("cash_shift.orders_count"), + render: (item) => formatFaNumber(item.ordersCount), + }, + { + key: "status", + title: t("cash_shift.status_label"), + render: (item) => ( + + ), + }, + { + key: "actions", + title: "", + render: (item) => ( +
+ + {onEdit && ( + + )} + {item.status === CashShiftStatus.OPEN && onClose && ( + + )} +
+ ), + }, +]; diff --git a/src/pages/cashShifts/components/CloseCashShiftModal.tsx b/src/pages/cashShifts/components/CloseCashShiftModal.tsx new file mode 100644 index 0000000..6436490 --- /dev/null +++ b/src/pages/cashShifts/components/CloseCashShiftModal.tsx @@ -0,0 +1,135 @@ +import { type FC, useState } from "react"; +import DefaulModal from "@/components/DefaulModal"; +import Input from "@/components/Input"; +import Textarea from "@/components/Textarea"; +import Button from "@/components/Button"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; +import { extractErrorMessage } from "@/config/func"; +import { + useCloseCashShift, + useGetCashShiftSummary, +} from "../hooks/useCashShiftData"; +import CashShiftSummaryCard from "./CashShiftSummaryCard"; +import type { CashShift } from "../types/Types"; + +type Props = { + open: boolean; + onClose: () => void; + shift: CashShift | null; +}; + +const CloseCashShiftModal: FC = ({ open, onClose, shift }) => { + const { t } = useTranslation("global"); + const [countedAmount, setCountedAmount] = useState(""); + const [notes, setNotes] = useState(""); + const [error, setError] = useState(""); + const closeShift = useCloseCashShift(); + + const { data: summaryData, isLoading: isSummaryLoading } = + useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id); + + const summary = summaryData?.data; + + const handleClose = () => { + setCountedAmount(""); + setNotes(""); + setError(""); + onClose(); + }; + + const handleSubmit = () => { + if (!shift) return; + + const numericAmount = Number(countedAmount); + + if (Number.isNaN(numericAmount) || numericAmount < 0) { + setError(t("cash_shift.counted_amount_required")); + return; + } + + closeShift.mutate( + { + shiftId: shift.id, + payload: { + countedAmount: numericAmount, + notes: notes.trim() || undefined, + }, + }, + { + onSuccess: () => { + toast.success(t("cash_shift.closed_success")); + handleClose(); + }, + onError: (err) => { + toast.error(extractErrorMessage(err)); + }, + } + ); + }; + + if (!shift) return null; + + return ( + +
+
+
+ {t("cash_shift.summary_title")} +
+ +
+ + { + setCountedAmount(e.target.value); + setError(""); + }} + error_text={error} + /> + +