shift in admin
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-14 16:28:26 +03:30
parent 435fe3e947
commit 9aed33d73b
19 changed files with 1410 additions and 2 deletions
+1
View File
@@ -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",
+38
View File
@@ -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": "بسته"
}
}
}
+223
View File
@@ -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;
+4
View File
@@ -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;
};
+65
View File
@@ -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;
}
+3 -1
View File
@@ -120,7 +120,9 @@ const OrderDetails: FC = () => {
const hasPaymentDescription = Boolean(payment?.description)
const hasPaymentAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
const showPaymentReceiptBox = isCreditCardPayment && (hasPaymentDescription || hasPaymentAttachments)
const showVerifyCashPaymentButton = isCashPayment && isPaymentPending && !isCanceled
const showVerifyPaymentButton = (isCashPayment || isCreditCardPayment) && isPaymentPending && !isCanceled
const showPaymentVerificationBox = showPaymentReceiptBox || showVerifyCashPaymentButton
// نمایش دکمه ارسال به آشپزخانه
// برای pendingPayment با پرداخت نقدی یا برای paid
@@ -277,7 +279,7 @@ const OrderDetails: FC = () => {
getPaymentStatusTextColor={getPaymentStatusTextColor}
getPaymentStatusText={getPaymentStatusText}
/>
{showPaymentReceiptBox && (
{showPaymentVerificationBox && (
<PaymentVerificationBox
order={order}
isVerifyingPayment={isVerifyingPayment}
@@ -17,6 +17,7 @@ const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
showVerifyButton,
}) => {
const payment = order.payments?.[0]
const isCash = order.paymentMethod?.method === PaymentMethodEnum.Cash
const isCreditCard = order.paymentMethod?.method === PaymentMethodEnum.CreditCard
const hasDescription = Boolean(payment?.description)
const hasAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
@@ -57,7 +58,7 @@ const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
{showVerifyButton && (
<Button
label='تایید پرداخت'
label={isCash ? 'تایید پرداخت نقدی' : 'تایید پرداخت'}
onClick={onVerifyPayment}
isloading={isVerifyingPayment}
className='bg-green-600 hover:bg-green-700 w-full'
+2
View File
@@ -19,6 +19,7 @@ import CreateOrder from '@/pages/orders/Create'
import OrderDetails from '@/pages/orders/Details'
import FoodOrderReport from '@/pages/orders/FoodOrderReport'
import DailyOrderReport from '@/pages/orders/DailyOrderReport'
import CashShiftsList from '@/pages/cashShifts/List'
import CommentsList from '@/pages/comments/List'
import CommentsDetails from '@/pages/comments/Details'
import ScheduleList from '@/pages/schedule/List'
@@ -93,6 +94,7 @@ const MainRouter: FC = () => {
<Route path={Pages.orders.add} element={<CreateOrder />} />
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
<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.comments.list} element={<CommentsList />} />
<Route path={Pages.comments.detail + ':id'} element={<CommentsDetails />} />
+5
View File
@@ -43,6 +43,11 @@ const OrdersSubMenu: FC = () => {
isActive={isActive('report/by-day')}
link={Pages.orders.dailyReport}
/>
<SubMenuItem
title={t('submenu.cash_shifts')}
isActive={isActive('cash-shifts')}
link={Pages.orders.cashShifts}
/>
<SubMenuItem
title={t('submenu.add_order')}
isActive={isActive('add')}