This commit is contained in:
@@ -27,6 +27,7 @@ export const Pages = {
|
|||||||
detail: "/orders/detail/",
|
detail: "/orders/detail/",
|
||||||
foodReport: "/orders/report/by-food",
|
foodReport: "/orders/report/by-food",
|
||||||
dailyReport: "/orders/report/by-day",
|
dailyReport: "/orders/report/by-day",
|
||||||
|
cashShifts: "/orders/cash-shifts",
|
||||||
},
|
},
|
||||||
customers: {
|
customers: {
|
||||||
list: "/customers/list",
|
list: "/customers/list",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"orders_list": "لیست سفارشات",
|
"orders_list": "لیست سفارشات",
|
||||||
"orders_food_report": "گزارش فروش به تفکیک غذا",
|
"orders_food_report": "گزارش فروش به تفکیک غذا",
|
||||||
"orders_daily_report": "گزارش فروش روزانه",
|
"orders_daily_report": "گزارش فروش روزانه",
|
||||||
|
"cash_shifts": "شیفتهای صندوق",
|
||||||
"add_order": "افزودن سفارش",
|
"add_order": "افزودن سفارش",
|
||||||
"discount_list": "لیست تخفیف",
|
"discount_list": "لیست تخفیف",
|
||||||
"add_discount": "افزودن تخفیف",
|
"add_discount": "افزودن تخفیف",
|
||||||
@@ -929,5 +930,42 @@
|
|||||||
},
|
},
|
||||||
"change_status": "تغییر وضعیت",
|
"change_status": "تغییر وضعیت",
|
||||||
"save": "ذخیره"
|
"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": "بسته"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<CashShift | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
} = useCashShiftFilters();
|
||||||
|
|
||||||
|
const { data: shiftsData, isLoading } = useGetCashShifts(apiParams);
|
||||||
|
const { data: currentShiftData, isLoading: isCurrentShiftLoading } =
|
||||||
|
useGetCurrentCashShift();
|
||||||
|
|
||||||
|
const currentShift = currentShiftData?.data ?? null;
|
||||||
|
const hasOpenShift = currentShift?.status === CashShiftStatus.OPEN;
|
||||||
|
|
||||||
|
const { data: currentSummaryData, isLoading: isCurrentSummaryLoading } =
|
||||||
|
useGetCurrentCashShiftSummary(hasOpenShift);
|
||||||
|
|
||||||
|
const shifts = shiftsData?.data || [];
|
||||||
|
const totalPages = shiftsData?.meta?.totalPages || 1;
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
getCashShiftTableColumns({
|
||||||
|
t,
|
||||||
|
onView: (shift) => {
|
||||||
|
setSelectedShift(shift);
|
||||||
|
setIsDetailModalVisible(true);
|
||||||
|
},
|
||||||
|
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 (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-lg font-light">{t("cash_shift.list_title")}</h1>
|
||||||
|
{!hasOpenShift && (
|
||||||
|
<Button className="w-fit px-6" onClick={() => setIsOpenModalVisible(true)}>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Add color="#fff" size={20} />
|
||||||
|
<span>{t("cash_shift.open_shift")}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCurrentShiftLoading ? (
|
||||||
|
<div className="mt-6 flex items-center gap-2">
|
||||||
|
<MoonLoader size={16} color="#000" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hasOpenShift &&
|
||||||
|
currentShift && (
|
||||||
|
<div className="mt-6 bg-[#F5F5F5] rounded-2xl p-5 space-y-4">
|
||||||
|
<div className="flex flex-wrap justify-between items-start gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{t("cash_shift.current_shift")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-description mt-1">
|
||||||
|
{t("cash_shift.operator")}: {getAdminName(currentShift)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-description mt-1">
|
||||||
|
{t("cash_shift.opened_at")}:{" "}
|
||||||
|
{formatOptionalDate(currentShift.openedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Status
|
||||||
|
variant="warning"
|
||||||
|
label={t(`cash_shift.status_values.${CashShiftStatus.OPEN}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-fit px-5 bg-gray-100 text-gray-700"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedShift(currentShift);
|
||||||
|
setIsEditModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Edit color="#374151" size={18} />
|
||||||
|
<span>{t("cash_shift.edit_shift")}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-fit px-5"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedShift(currentShift);
|
||||||
|
setIsCloseModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Lock1 color="#fff" size={18} />
|
||||||
|
<span>{t("cash_shift.close_shift")}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CashShiftSummaryCard
|
||||||
|
summary={currentSummaryData?.data}
|
||||||
|
openingAmount={currentShift.openingAmount}
|
||||||
|
isLoading={isCurrentSummaryLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Filters
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
initialValues={filters}
|
||||||
|
showClearButton
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<CashShift & RowDataType>
|
||||||
|
columns={columns}
|
||||||
|
data={shifts as (CashShift & RowDataType)[]}
|
||||||
|
isloading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OpenCashShiftModal
|
||||||
|
open={isOpenModalVisible}
|
||||||
|
onClose={() => setIsOpenModalVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CloseCashShiftModal
|
||||||
|
open={isCloseModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCloseModalVisible(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
shift={selectedShift}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditCashShiftModal
|
||||||
|
open={isEditModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
shift={selectedShift}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CashShiftDetailModal
|
||||||
|
open={isDetailModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDetailModalVisible(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
shift={selectedShift}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashShiftsList;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { type FC } from "react";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import Status from "@/components/Status";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
formatFaNumber,
|
||||||
|
formatOptionalDate,
|
||||||
|
formatPrice,
|
||||||
|
} from "@/helpers/func";
|
||||||
|
import { useGetCashShiftSummary } from "../hooks/useCashShiftData";
|
||||||
|
import CashShiftSummaryCard from "./CashShiftSummaryCard";
|
||||||
|
import type { CashShift } from "../types/Types";
|
||||||
|
import { CashShiftStatus } from "../enum/Enum";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
shift: CashShift | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdminName = (shift: CashShift) => {
|
||||||
|
const name = [shift.admin?.firstName, shift.admin?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return name || shift.admin?.phone || "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
const CashShiftDetailModal: FC<Props> = ({ open, onClose, shift }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
|
||||||
|
const { data: summaryData, isLoading: isSummaryLoading } =
|
||||||
|
useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id);
|
||||||
|
|
||||||
|
if (!shift) return null;
|
||||||
|
|
||||||
|
const summary = summaryData?.data;
|
||||||
|
const isOpen = shift.status === CashShiftStatus.OPEN;
|
||||||
|
const statusVariant = isOpen ? "warning" : "success";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={onClose}
|
||||||
|
isHeader
|
||||||
|
title_header={t("cash_shift.detail_title")}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.operator")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">{getAdminName(shift)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.status_label")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Status
|
||||||
|
variant={statusVariant}
|
||||||
|
label={t(`cash_shift.status_values.${shift.status}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.opened_at")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatOptionalDate(shift.openedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.closed_at")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatOptionalDate(shift.closedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.opening_amount")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatPrice(shift.openingAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.orders_count")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatFaNumber(shift.ordersCount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen && shift.expectedAmount !== null && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.expected_amount")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatPrice(shift.expectedAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOpen && shift.countedAmount !== null && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.counted_amount")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatPrice(shift.countedAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOpen && shift.differenceAmount !== null && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.difference_amount")}
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
shift.differenceAmount > 0
|
||||||
|
? "text-green-600"
|
||||||
|
: shift.differenceAmount < 0
|
||||||
|
? "text-red-600"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPrice(shift.differenceAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shift.notes && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.notes")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium bg-gray-50 p-3 rounded-lg whitespace-pre-wrap break-words">
|
||||||
|
{shift.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-[#F5F5F5] rounded-2xl p-4">
|
||||||
|
<div className="text-sm font-medium mb-3">
|
||||||
|
{t("cash_shift.summary_title")}
|
||||||
|
</div>
|
||||||
|
<CashShiftSummaryCard
|
||||||
|
summary={summary}
|
||||||
|
openingAmount={shift.openingAmount}
|
||||||
|
isLoading={isSummaryLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashShiftDetailModal;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { FieldType } from "@/components/Filters";
|
||||||
|
import { CashShiftStatus } from "../enum/Enum";
|
||||||
|
|
||||||
|
export const useCashShiftFiltersFields = (): FieldType[] => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
name: "status",
|
||||||
|
placeholder: "وضعیت شیفت",
|
||||||
|
defaultValue: "",
|
||||||
|
options: [
|
||||||
|
{ label: "همه", value: "" },
|
||||||
|
{ label: "باز", value: CashShiftStatus.OPEN },
|
||||||
|
{ label: "بسته", value: CashShiftStatus.CLOSED },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "datetime",
|
||||||
|
name: "startDate",
|
||||||
|
placeholder: "از تاریخ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "datetime",
|
||||||
|
name: "endDate",
|
||||||
|
placeholder: "تا تاریخ",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { type FC } from "react";
|
||||||
|
import MoonLoader from "react-spinners/MoonLoader";
|
||||||
|
import { formatFaNumber, formatPrice } from "@/helpers/func";
|
||||||
|
import type { CashShiftSummary } from "../types/Types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
summary?: CashShiftSummary | null;
|
||||||
|
openingAmount?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SummaryItem: FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||||
|
<div className="bg-white rounded-xl px-4 py-3 min-w-[140px]">
|
||||||
|
<div className="text-xs text-description">{label}</div>
|
||||||
|
<div className="text-sm font-medium mt-1">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CashShiftSummaryCard: FC<Props> = ({ summary, openingAmount = 0, isLoading }) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<MoonLoader size={20} color="#000" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<SummaryItem label="موجودی اولیه" value={formatPrice(openingAmount)} />
|
||||||
|
<SummaryItem label="نقدی" value={formatPrice(summary.cashReceived)} />
|
||||||
|
<SummaryItem label="آنلاین" value={formatPrice(summary.onlineReceived)} />
|
||||||
|
<SummaryItem label="کارت به کارت" value={formatPrice(summary.creditCardReceived)} />
|
||||||
|
<SummaryItem label="مجموع دریافتی" value={formatPrice(summary.totalReceived)} />
|
||||||
|
<SummaryItem label="مبلغ مورد انتظار" value={formatPrice(summary.expectedAmount)} />
|
||||||
|
<SummaryItem
|
||||||
|
label="تعداد سفارشات"
|
||||||
|
value={formatFaNumber(summary.ordersCount)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashShiftSummaryCard;
|
||||||
@@ -0,0 +1,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<CashShift>[] => [
|
||||||
|
{
|
||||||
|
key: "openedAt",
|
||||||
|
title: t("cash_shift.opened_at"),
|
||||||
|
render: (item) =>
|
||||||
|
formatOptionalDate(item.openedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "closedAt",
|
||||||
|
title: t("cash_shift.closed_at"),
|
||||||
|
render: (item) =>
|
||||||
|
formatOptionalDate(item.closedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin",
|
||||||
|
title: t("cash_shift.operator"),
|
||||||
|
render: (item) => getAdminName(item),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "openingAmount",
|
||||||
|
title: t("cash_shift.opening_amount"),
|
||||||
|
render: (item) => formatPrice(item.openingAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "expectedAmount",
|
||||||
|
title: t("cash_shift.expected_amount"),
|
||||||
|
render: (item) =>
|
||||||
|
item.expectedAmount !== null ? formatPrice(item.expectedAmount) : "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "differenceAmount",
|
||||||
|
title: t("cash_shift.difference_amount"),
|
||||||
|
render: (item) => {
|
||||||
|
if (item.differenceAmount === null) return "-";
|
||||||
|
const value = formatPrice(item.differenceAmount);
|
||||||
|
if (item.differenceAmount > 0) {
|
||||||
|
return <span className="text-green-600">{value}</span>;
|
||||||
|
}
|
||||||
|
if (item.differenceAmount < 0) {
|
||||||
|
return <span className="text-red-600">{value}</span>;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ordersCount",
|
||||||
|
title: t("cash_shift.orders_count"),
|
||||||
|
render: (item) => formatFaNumber(item.ordersCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
title: t("cash_shift.status_label"),
|
||||||
|
render: (item) => (
|
||||||
|
<Status
|
||||||
|
variant={item.status === CashShiftStatus.OPEN ? "warning" : "success"}
|
||||||
|
label={t(`cash_shift.status_values.${item.status}`)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
title: "",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onView(item)}
|
||||||
|
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
|
title={t("cash_shift.view_details")}
|
||||||
|
>
|
||||||
|
<Eye size={20} color="#8C90A3" />
|
||||||
|
</button>
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
|
title={t("cash_shift.edit_shift")}
|
||||||
|
>
|
||||||
|
<Edit size={20} color="#8C90A3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.status === CashShiftStatus.OPEN && onClose && (
|
||||||
|
<button
|
||||||
|
onClick={() => onClose(item)}
|
||||||
|
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
|
title={t("cash_shift.close_shift")}
|
||||||
|
>
|
||||||
|
<Lock1 size={20} color="#8C90A3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import Textarea from "@/components/Textarea";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import {
|
||||||
|
useCloseCashShift,
|
||||||
|
useGetCashShiftSummary,
|
||||||
|
} from "../hooks/useCashShiftData";
|
||||||
|
import CashShiftSummaryCard from "./CashShiftSummaryCard";
|
||||||
|
import type { CashShift } from "../types/Types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
shift: CashShift | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CloseCashShiftModal: FC<Props> = ({ open, onClose, shift }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const [countedAmount, setCountedAmount] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const closeShift = useCloseCashShift();
|
||||||
|
|
||||||
|
const { data: summaryData, isLoading: isSummaryLoading } =
|
||||||
|
useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id);
|
||||||
|
|
||||||
|
const summary = summaryData?.data;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCountedAmount("");
|
||||||
|
setNotes("");
|
||||||
|
setError("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!shift) return;
|
||||||
|
|
||||||
|
const numericAmount = Number(countedAmount);
|
||||||
|
|
||||||
|
if (Number.isNaN(numericAmount) || numericAmount < 0) {
|
||||||
|
setError(t("cash_shift.counted_amount_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeShift.mutate(
|
||||||
|
{
|
||||||
|
shiftId: shift.id,
|
||||||
|
payload: {
|
||||||
|
countedAmount: numericAmount,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("cash_shift.closed_success"));
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(extractErrorMessage(err));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shift) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={handleClose}
|
||||||
|
isHeader
|
||||||
|
title_header={t("cash_shift.close_title")}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<div className="bg-[#F5F5F5] rounded-2xl p-4">
|
||||||
|
<div className="text-sm font-medium mb-3">
|
||||||
|
{t("cash_shift.summary_title")}
|
||||||
|
</div>
|
||||||
|
<CashShiftSummaryCard
|
||||||
|
summary={summary}
|
||||||
|
openingAmount={shift.openingAmount}
|
||||||
|
isLoading={isSummaryLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="countedAmount"
|
||||||
|
label={t("cash_shift.counted_amount")}
|
||||||
|
placeholder={t("cash_shift.counted_amount_placeholder")}
|
||||||
|
type="number"
|
||||||
|
seprator
|
||||||
|
value={countedAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCountedAmount(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
error_text={error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="notes"
|
||||||
|
label={t("cash_shift.notes")}
|
||||||
|
placeholder={t("cash_shift.notes_placeholder")}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.cancel")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="bg-gray-100 text-gray-700"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.close_shift")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={closeShift.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CloseCashShiftModal;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { type FC, useEffect, 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 { useUpdateCashShift } from "../hooks/useCashShiftData";
|
||||||
|
import type { CashShift } from "../types/Types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
shift: CashShift | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditCashShiftModal: FC<Props> = ({ open, onClose, shift }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const [openingAmount, setOpeningAmount] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const updateShift = useUpdateCashShift();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && shift) {
|
||||||
|
setOpeningAmount(String(shift.openingAmount));
|
||||||
|
setNotes(shift.notes ?? "");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [open, shift]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpeningAmount("");
|
||||||
|
setNotes("");
|
||||||
|
setError("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!shift) return;
|
||||||
|
|
||||||
|
const numericAmount = Number(openingAmount);
|
||||||
|
|
||||||
|
if (Number.isNaN(numericAmount) || numericAmount < 0) {
|
||||||
|
setError(t("cash_shift.opening_amount_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShift.mutate(
|
||||||
|
{
|
||||||
|
shiftId: shift.id,
|
||||||
|
payload: {
|
||||||
|
openingAmount: numericAmount,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("cash_shift.updated_success"));
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(extractErrorMessage(err));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shift) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={handleClose}
|
||||||
|
isHeader
|
||||||
|
title_header={t("cash_shift.edit_title")}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Input
|
||||||
|
name="openingAmount"
|
||||||
|
label={t("cash_shift.opening_amount")}
|
||||||
|
placeholder={t("cash_shift.opening_amount_placeholder")}
|
||||||
|
type="number"
|
||||||
|
seprator
|
||||||
|
value={openingAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOpeningAmount(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
error_text={error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Textarea
|
||||||
|
name="notes"
|
||||||
|
label={t("cash_shift.notes")}
|
||||||
|
placeholder={t("cash_shift.notes_placeholder")}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.cancel")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="bg-gray-100 text-gray-700"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.save_changes")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={updateShift.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditCashShiftModal;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import Textarea from "@/components/Textarea";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import { useOpenCashShift } from "../hooks/useCashShiftData";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpenCashShiftModal: FC<Props> = ({ open, onClose }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const [openingAmount, setOpeningAmount] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const openShift = useOpenCashShift();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpeningAmount("");
|
||||||
|
setNotes("");
|
||||||
|
setError("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const numericAmount = Number(openingAmount);
|
||||||
|
|
||||||
|
if (Number.isNaN(numericAmount) || numericAmount < 0) {
|
||||||
|
setError(t("cash_shift.opening_amount_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openShift.mutate(
|
||||||
|
{
|
||||||
|
openingAmount: numericAmount,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("cash_shift.opened_success"));
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(extractErrorMessage(err));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={handleClose}
|
||||||
|
isHeader
|
||||||
|
title_header={t("cash_shift.open_title")}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Input
|
||||||
|
name="openingAmount"
|
||||||
|
label={t("cash_shift.opening_amount")}
|
||||||
|
placeholder={t("cash_shift.opening_amount_placeholder")}
|
||||||
|
type="number"
|
||||||
|
seprator
|
||||||
|
value={openingAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOpeningAmount(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
error_text={error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Textarea
|
||||||
|
name="notes"
|
||||||
|
label={t("cash_shift.notes")}
|
||||||
|
placeholder={t("cash_shift.notes_placeholder")}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.cancel")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="bg-gray-100 text-gray-700"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.open_shift")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={openShift.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpenCashShiftModal;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum CashShiftStatus {
|
||||||
|
OPEN = "open",
|
||||||
|
CLOSED = "closed",
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/CashShiftService";
|
||||||
|
import type {
|
||||||
|
CloseCashShiftPayload,
|
||||||
|
OpenCashShiftPayload,
|
||||||
|
UpdateCashShiftPayload,
|
||||||
|
} from "../types/Types";
|
||||||
|
import type { GetCashShiftsParams } from "../service/CashShiftService";
|
||||||
|
|
||||||
|
const invalidateCashShiftQueries = (queryClient: ReturnType<typeof useQueryClient>) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shifts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shift-current"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shift-current-summary"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shift-summary"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCashShifts = (params?: GetCashShiftsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shifts", params],
|
||||||
|
queryFn: () => api.getCashShifts(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCurrentCashShift = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shift-current"],
|
||||||
|
queryFn: () => api.getCurrentCashShift(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCurrentCashShiftSummary = (enabled = true) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shift-current-summary"],
|
||||||
|
queryFn: () => api.getCurrentCashShiftSummary(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCashShiftSummary = (shiftId: string, enabled = true) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shift-summary", shiftId],
|
||||||
|
queryFn: () => api.getCashShiftSummary(shiftId),
|
||||||
|
enabled: enabled && !!shiftId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOpenCashShift = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: OpenCashShiftPayload) => api.openCashShift(payload),
|
||||||
|
onSuccess: () => invalidateCashShiftQueries(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCloseCashShift = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
shiftId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
shiftId: string;
|
||||||
|
payload: CloseCashShiftPayload;
|
||||||
|
}) => api.closeCashShift(shiftId, payload),
|
||||||
|
onSuccess: () => invalidateCashShiftQueries(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateCashShift = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
shiftId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
shiftId: string;
|
||||||
|
payload: UpdateCashShiftPayload;
|
||||||
|
}) => api.updateCashShift(shiftId, payload),
|
||||||
|
onSuccess: () => invalidateCashShiftQueries(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import type { FilterValues } from "@/components/Filters";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
const DEFAULT_FILTERS: FilterValues = {
|
||||||
|
status: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFiltersFromParams = (searchParams: URLSearchParams): FilterValues => {
|
||||||
|
const filters: FilterValues = { ...DEFAULT_FILTERS };
|
||||||
|
const status = searchParams.get("status");
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
|
if (status) filters.status = status;
|
||||||
|
if (startDate) filters.startDate = startDate;
|
||||||
|
if (endDate) filters.endDate = endDate;
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageFromParams = (searchParams: URLSearchParams): number => {
|
||||||
|
const page = searchParams.get("page");
|
||||||
|
const parsed = page ? parseInt(page, 10) : DEFAULT_PAGE;
|
||||||
|
return Number.isNaN(parsed) || parsed < 1 ? DEFAULT_PAGE : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSearchParams = (filters: FilterValues, page: number): URLSearchParams => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (page > DEFAULT_PAGE) {
|
||||||
|
params.set("page", String(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
params.set("status", filters.status as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
params.set("startDate", filters.startDate as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
params.set("endDate", filters.endDate as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCashShiftFilters = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<FilterValues>(() =>
|
||||||
|
getFiltersFromParams(searchParams)
|
||||||
|
);
|
||||||
|
const [currentPage, setCurrentPage] = useState(() =>
|
||||||
|
getPageFromParams(searchParams)
|
||||||
|
);
|
||||||
|
const [limit] = useState(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilters(getFiltersFromParams(searchParams));
|
||||||
|
setCurrentPage(getPageFromParams(searchParams));
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const updateUrlParams = useCallback(
|
||||||
|
(newFilters: FilterValues, page: number) => {
|
||||||
|
setSearchParams(buildSearchParams(newFilters, page), { replace: true });
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiParams = useMemo(() => {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: currentPage,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
params.status = filters.status as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
params.startDate = filters.startDate as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
params.endDate = filters.endDate as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [filters, currentPage, limit]);
|
||||||
|
|
||||||
|
const handleFiltersChange = useCallback(
|
||||||
|
(newFilters: FilterValues) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(DEFAULT_PAGE);
|
||||||
|
updateUrlParams(newFilters, DEFAULT_PAGE);
|
||||||
|
},
|
||||||
|
[updateUrlParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
updateUrlParams(filters, page);
|
||||||
|
},
|
||||||
|
[filters, updateUrlParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import type {
|
||||||
|
CloseCashShiftPayload,
|
||||||
|
GetCashShiftResponse,
|
||||||
|
GetCashShiftsResponse,
|
||||||
|
GetCashShiftSummaryResponse,
|
||||||
|
OpenCashShiftPayload,
|
||||||
|
UpdateCashShiftPayload,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
|
export interface GetCashShiftsParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
status?: string;
|
||||||
|
adminId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orderBy?: string;
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCashShifts = async (
|
||||||
|
params?: GetCashShiftsParams
|
||||||
|
): Promise<GetCashShiftsResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftsResponse>("/admin/cash-shifts", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentCashShift = async (): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftResponse>(
|
||||||
|
"/admin/cash-shifts/current"
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentCashShiftSummary =
|
||||||
|
async (): Promise<GetCashShiftSummaryResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftSummaryResponse>(
|
||||||
|
"/admin/cash-shifts/current/summary"
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCashShiftSummary = async (
|
||||||
|
shiftId: string
|
||||||
|
): Promise<GetCashShiftSummaryResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftSummaryResponse>(
|
||||||
|
`/admin/cash-shifts/${shiftId}/summary`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCashShiftById = async (
|
||||||
|
shiftId: string
|
||||||
|
): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftResponse>(
|
||||||
|
`/admin/cash-shifts/${shiftId}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openCashShift = async (
|
||||||
|
payload: OpenCashShiftPayload
|
||||||
|
): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.post<GetCashShiftResponse>(
|
||||||
|
"/admin/cash-shifts/open",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeCashShift = async (
|
||||||
|
shiftId: string,
|
||||||
|
payload: CloseCashShiftPayload
|
||||||
|
): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.patch<GetCashShiftResponse>(
|
||||||
|
`/admin/cash-shifts/${shiftId}/close`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCashShift = async (
|
||||||
|
shiftId: string,
|
||||||
|
payload: UpdateCashShiftPayload
|
||||||
|
): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.patch<GetCashShiftResponse>(
|
||||||
|
`/admin/cash-shifts/${shiftId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { IResponse } from "@/types/response.types";
|
||||||
|
import type { RowDataType } from "@/components/types/TableTypes";
|
||||||
|
import type { CashShiftStatus } from "../enum/Enum";
|
||||||
|
|
||||||
|
export interface CashShiftAdmin {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashShift extends RowDataType {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
openedAt: string;
|
||||||
|
closedAt: string | null;
|
||||||
|
openingAmount: number;
|
||||||
|
expectedAmount: number | null;
|
||||||
|
countedAmount: number | null;
|
||||||
|
differenceAmount: number | null;
|
||||||
|
ordersCount: number;
|
||||||
|
status: CashShiftStatus;
|
||||||
|
notes: string | null;
|
||||||
|
admin: CashShiftAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashShiftSummary {
|
||||||
|
ordersCount: number;
|
||||||
|
cashReceived: number;
|
||||||
|
onlineReceived: number;
|
||||||
|
creditCardReceived: number;
|
||||||
|
totalReceived: number;
|
||||||
|
expectedAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationMeta {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetCashShiftsResponse = IResponse<CashShift[]> & {
|
||||||
|
meta: PaginationMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetCashShiftResponse = IResponse<CashShift>;
|
||||||
|
export type GetCashShiftSummaryResponse = IResponse<CashShiftSummary | null>;
|
||||||
|
|
||||||
|
export interface OpenCashShiftPayload {
|
||||||
|
openingAmount: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloseCashShiftPayload {
|
||||||
|
countedAmount: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCashShiftPayload {
|
||||||
|
openingAmount?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -120,7 +120,9 @@ const OrderDetails: FC = () => {
|
|||||||
const hasPaymentDescription = Boolean(payment?.description)
|
const hasPaymentDescription = Boolean(payment?.description)
|
||||||
const hasPaymentAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
|
const hasPaymentAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
|
||||||
const showPaymentReceiptBox = isCreditCardPayment && (hasPaymentDescription || hasPaymentAttachments)
|
const showPaymentReceiptBox = isCreditCardPayment && (hasPaymentDescription || hasPaymentAttachments)
|
||||||
|
const showVerifyCashPaymentButton = isCashPayment && isPaymentPending && !isCanceled
|
||||||
const showVerifyPaymentButton = (isCashPayment || isCreditCardPayment) && isPaymentPending && !isCanceled
|
const showVerifyPaymentButton = (isCashPayment || isCreditCardPayment) && isPaymentPending && !isCanceled
|
||||||
|
const showPaymentVerificationBox = showPaymentReceiptBox || showVerifyCashPaymentButton
|
||||||
|
|
||||||
// نمایش دکمه ارسال به آشپزخانه
|
// نمایش دکمه ارسال به آشپزخانه
|
||||||
// برای pendingPayment با پرداخت نقدی یا برای paid
|
// برای pendingPayment با پرداخت نقدی یا برای paid
|
||||||
@@ -277,7 +279,7 @@ const OrderDetails: FC = () => {
|
|||||||
getPaymentStatusTextColor={getPaymentStatusTextColor}
|
getPaymentStatusTextColor={getPaymentStatusTextColor}
|
||||||
getPaymentStatusText={getPaymentStatusText}
|
getPaymentStatusText={getPaymentStatusText}
|
||||||
/>
|
/>
|
||||||
{showPaymentReceiptBox && (
|
{showPaymentVerificationBox && (
|
||||||
<PaymentVerificationBox
|
<PaymentVerificationBox
|
||||||
order={order}
|
order={order}
|
||||||
isVerifyingPayment={isVerifyingPayment}
|
isVerifyingPayment={isVerifyingPayment}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
|
|||||||
showVerifyButton,
|
showVerifyButton,
|
||||||
}) => {
|
}) => {
|
||||||
const payment = order.payments?.[0]
|
const payment = order.payments?.[0]
|
||||||
|
const isCash = order.paymentMethod?.method === PaymentMethodEnum.Cash
|
||||||
const isCreditCard = order.paymentMethod?.method === PaymentMethodEnum.CreditCard
|
const isCreditCard = order.paymentMethod?.method === PaymentMethodEnum.CreditCard
|
||||||
const hasDescription = Boolean(payment?.description)
|
const hasDescription = Boolean(payment?.description)
|
||||||
const hasAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
|
const hasAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
|
||||||
@@ -57,7 +58,7 @@ const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
|
|||||||
|
|
||||||
{showVerifyButton && (
|
{showVerifyButton && (
|
||||||
<Button
|
<Button
|
||||||
label='تایید پرداخت'
|
label={isCash ? 'تایید پرداخت نقدی' : 'تایید پرداخت'}
|
||||||
onClick={onVerifyPayment}
|
onClick={onVerifyPayment}
|
||||||
isloading={isVerifyingPayment}
|
isloading={isVerifyingPayment}
|
||||||
className='bg-green-600 hover:bg-green-700 w-full'
|
className='bg-green-600 hover:bg-green-700 w-full'
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import CreateOrder from '@/pages/orders/Create'
|
|||||||
import OrderDetails from '@/pages/orders/Details'
|
import OrderDetails from '@/pages/orders/Details'
|
||||||
import FoodOrderReport from '@/pages/orders/FoodOrderReport'
|
import FoodOrderReport from '@/pages/orders/FoodOrderReport'
|
||||||
import DailyOrderReport from '@/pages/orders/DailyOrderReport'
|
import DailyOrderReport from '@/pages/orders/DailyOrderReport'
|
||||||
|
import CashShiftsList from '@/pages/cashShifts/List'
|
||||||
import CommentsList from '@/pages/comments/List'
|
import CommentsList from '@/pages/comments/List'
|
||||||
import CommentsDetails from '@/pages/comments/Details'
|
import CommentsDetails from '@/pages/comments/Details'
|
||||||
import ScheduleList from '@/pages/schedule/List'
|
import ScheduleList from '@/pages/schedule/List'
|
||||||
@@ -93,6 +94,7 @@ const MainRouter: FC = () => {
|
|||||||
<Route path={Pages.orders.add} element={<CreateOrder />} />
|
<Route path={Pages.orders.add} element={<CreateOrder />} />
|
||||||
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
|
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
|
||||||
<Route path={Pages.orders.dailyReport} element={<DailyOrderReport />} />
|
<Route path={Pages.orders.dailyReport} element={<DailyOrderReport />} />
|
||||||
|
<Route path={Pages.orders.cashShifts} element={<CashShiftsList />} />
|
||||||
<Route path={Pages.orders.detail + ':id'} element={<OrderDetails />} />
|
<Route path={Pages.orders.detail + ':id'} element={<OrderDetails />} />
|
||||||
<Route path={Pages.comments.list} element={<CommentsList />} />
|
<Route path={Pages.comments.list} element={<CommentsList />} />
|
||||||
<Route path={Pages.comments.detail + ':id'} element={<CommentsDetails />} />
|
<Route path={Pages.comments.detail + ':id'} element={<CommentsDetails />} />
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ const OrdersSubMenu: FC = () => {
|
|||||||
isActive={isActive('report/by-day')}
|
isActive={isActive('report/by-day')}
|
||||||
link={Pages.orders.dailyReport}
|
link={Pages.orders.dailyReport}
|
||||||
/>
|
/>
|
||||||
|
<SubMenuItem
|
||||||
|
title={t('submenu.cash_shifts')}
|
||||||
|
isActive={isActive('cash-shifts')}
|
||||||
|
link={Pages.orders.cashShifts}
|
||||||
|
/>
|
||||||
<SubMenuItem
|
<SubMenuItem
|
||||||
title={t('submenu.add_order')}
|
title={t('submenu.add_order')}
|
||||||
isActive={isActive('add')}
|
isActive={isActive('add')}
|
||||||
|
|||||||
Reference in New Issue
Block a user