Vitron-Front/app/routes/store.$storeId.financial-dashboard.transactions.tsx
Arda Samadi 16389fdd85 fix(desktop): lay out dashboard sub-pages + form pages for wide screens
- HeaderWithSupport is now lg:hidden (all its callers are desktop-full-width
  store/profile pages), removing the stray mobile back+support bar on desktop.
- Constrain + title the previously mobile-only pages so their content doesn't
  stretch edge-to-edge on desktop: store edit/create form (StoreForm), add
  product, shipping method, financial sub-pages (cash-funds/reports/
  transactions), order detail, instagram auth; and profile add/edit address,
  set detail, and payment result. Forms use a narrow centered column, lists/
  details a wider one. Transactions keeps its filter button (header restyled,
  not hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:06:13 +03:30

426 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { MetaFunction } from "@remix-run/node";
import { ArrowRight, Check, X, Loader2 } from "lucide-react";
import { formatDate, formatPrice, formatTime, safeGoBack } from "../utils/helpers";
import { useNavigate } from "@remix-run/react";
import { useState } from "react";
import { Button } from "../components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "../components/ui/drawer";
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group";
import PersianDatePicker from "../components/PersianDatePicker";
import { useSearchParams, useParams } from "react-router-dom";
import { useServiceGetPayoutRequests } from "../requestHandler/use-wallet-hooks";
import { useRequireOwner } from "../hooks/useStoreOwnership";
import jMoment from "moment-jalaali";
import filterIcon from "~/assets/icons/filter.png";
import filterIconDark from "~/assets/icons/filter-dark.png";
import { useRootData } from "~/hooks/use-root-data";
export const meta: MetaFunction = () => {
return [
{ title: "لیست تراکنش‌ها - ویترون" },
{
name: "description",
content:
"مشاهده و پیگیری تمام تراکنش‌های مالی فروشگاه شما در ویترون. فیلتر زمانی و جزئیات تراکنش‌ها.",
},
{
name: "keywords",
content: "تراکنش، تراکنش مالی، برداشت وجه، تاریخچه مالی، فروشگاه، ویترون",
},
{ name: "robots", content: "noindex, follow" },
{ property: "og:title", content: "لیست تراکنش‌ها - ویترون" },
{
property: "og:description",
content:
"مشاهده و پیگیری تمام تراکنش‌های مالی فروشگاه شما در ویترون. فیلتر زمانی و جزئیات تراکنش‌ها.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: "لیست تراکنش‌ها - ویترون" },
{
name: "twitter:description",
content:
"مشاهده و پیگیری تمام تراکنش‌های مالی فروشگاه شما در ویترون. فیلتر زمانی و جزئیات تراکنش‌ها.",
},
];
};
export default function Transactions() {
const navigate = useNavigate();
const { storeId } = useParams();
useRequireOwner(storeId); // financial is owner-only
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [searchParams] = useSearchParams();
const { theme } = useRootData();
// Convert Persian date to Gregorian ISO string
const convertPersianToGregorian = (persianDate: string): string => {
// Persian date format is expected to be YYYY/MM/DD
const [year, month, day] = persianDate.split("/");
const jMomentDate = jMoment(`${year}/${month}/${day}`, "jYYYY/jM/jD");
return jMomentDate.format("YYYY-MM-DD");
};
// Map URL params to API filters
const getFilters = () => {
const order = searchParams.get("order") as
| "all"
| "today"
| "lastWeek"
| "lastMonth"
| "other";
const fromDate = searchParams.get("fromDate");
const toDate = searchParams.get("toDate");
if (!order || order === "all") {
return undefined;
}
// Map URL params to API params
const apiFilters: {
dateFilter?: "today" | "last_7" | "last_30" | "custom";
fromDate?: string;
toDate?: string;
} = {};
switch (order) {
case "today":
apiFilters.dateFilter = "today";
break;
case "lastWeek":
apiFilters.dateFilter = "last_7";
break;
case "lastMonth":
apiFilters.dateFilter = "last_30";
break;
case "other":
// Allow custom filter even with only one date (fromDate OR toDate)
if (fromDate || toDate) {
apiFilters.dateFilter = "custom";
if (fromDate) {
try {
apiFilters.fromDate = convertPersianToGregorian(fromDate);
} catch (error) {
console.error("Error converting fromDate:", error);
apiFilters.fromDate = fromDate; // fallback to original
}
}
if (toDate) {
try {
apiFilters.toDate = convertPersianToGregorian(toDate);
} catch (error) {
console.error("Error converting toDate:", error);
apiFilters.toDate = toDate; // fallback to original
}
}
}
break;
}
return Object.keys(apiFilters).length > 0 ? apiFilters : undefined;
};
const { data: payoutRequests, isLoading } =
useServiceGetPayoutRequests(getFilters());
const handleBack = () => {
safeGoBack(navigate);
};
const filterIconClicked = () => {
setIsFilterDrawerOpen(true);
};
return (
<div className="bg-WHITE lg:max-w-[900px] lg:mx-auto lg:w-full lg:pt-6">
{/* Header (keeps the filter button; just constrained on desktop) */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border lg:static lg:justify-start lg:gap-3">
<ArrowRight onClick={handleBack} className="absolute top-5 right-5 lg:hidden" />
<p className={"text-B16 lg:text-[28px] font-bold lg:font-extrabold"}>لیست تراکنشها</p>
<div
onClick={() => {
filterIconClicked();
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") {
filterIconClicked();
}
}}
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center"
>
<img
src={theme === "dark" ? filterIconDark : filterIcon}
alt="filter"
className="w-6 h-6"
/>
</div>
</div>
<div className="flex flex-col w-full">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : payoutRequests && payoutRequests.length > 0 ? (
payoutRequests.map((request) => (
<div key={request.id} className="flex flex-col gap-2 p-4 border-b">
<div className="flex justify-between">
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">
{request.requestedAt
? formatDate(request.requestedAt)
: "تاریخ نامشخص"}
</p>
<p className="text-B12 font-bold">
{request.requestedAt ? formatTime(request.requestedAt) : ""}
</p>
</div>
{request.status === "completed" ? (
<div className="flex flex-row gap-1 text-green-500">
<Check size={16} />
<p className="text-B12 font-bold">موفق</p>
</div>
) : request.status === "rejected" ? (
<div className="flex flex-row gap-1 text-red-500">
<X size={16} />
<p className="text-B12 font-bold">ناموفق</p>
</div>
) : (
<div className="flex flex-row gap-1 text-yellow-500">
<Loader2 size={16} className="animate-spin" />
<p className="text-B12 font-bold">در انتظار</p>
</div>
)}
</div>
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">مبلغ</p>
<p className="text-R12">{formatPrice(request.amount)}</p>
</div>
{request.bankAccountCardNumber && (
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">شماره کارت:</p>
<p className="text-R12">{request.bankAccountCardNumber}</p>
</div>
)}
{request.bankAccountIban && (
<div className="flex flex-row gap-2">
<p className="text-B12 font-bold">شماره شبا:</p>
<p className="text-R12">{request.bankAccountIban}</p>
</div>
)}
{request.shomareMarja && (
<div className="flex flex-row gap-2 text-GRAY">
<p className="text-B12 font-bold">شماره مرجع بانک:</p>
<p className="text-R12">{request.shomareMarja}</p>
</div>
)}
{request.shomareErja && (
<div className="flex flex-row gap-2 text-GRAY">
<p className="text-B12 font-bold">شماره ارجاع:</p>
<p className="text-R12">{request.shomareErja}</p>
</div>
)}
{request.processedAt && (
<div className="flex flex-row gap-2 text-GRAY">
<p className="text-B12 font-bold">تاریخ پردازش:</p>
<p className="text-R12">
{formatDate(request.processedAt)}{" "}
{formatTime(request.processedAt)}
</p>
</div>
)}
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-8 text-GRAY">
<p className="text-B16">هیچ تراکنشی یافت نشد</p>
<p className="text-R12 mt-2">هنوز درخواست برداشتی ثبت نکردهاید</p>
</div>
)}
</div>
<FilterDrawer
isOpen={isFilterDrawerOpen}
onClose={() => setIsFilterDrawerOpen(false)}
/>
</div>
);
}
type dateRangeTypes = "all" | "today" | "lastWeek" | "lastMonth" | "other";
const FilterDrawer = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
const [searchParams, setSearchParams] = useSearchParams();
// Date states for custom range
const [order, setOrder] = useState<dateRangeTypes>(
(searchParams.get("order") as dateRangeTypes) || "all"
);
const [fromDate, setFromDate] = useState<string>(
searchParams.get("fromDate") || ""
);
const [toDate, setToDate] = useState<string>(
searchParams.get("toDate") || ""
);
const filterTransactions = () => {
const params: Record<string, string> = {};
if (order !== "all") {
params.order = order;
}
// Only include date params if order is "other" (custom)
if (order === "other") {
if (fromDate) {
params.fromDate = fromDate;
}
if (toDate) {
params.toDate = toDate;
}
}
// For non-custom filters, fromDate and toDate are intentionally not included
// This will remove them from the URL params
setSearchParams(params, { replace: true });
onClose();
};
return (
<Drawer open={isOpen} onOpenChange={onClose}>
<DrawerContent className="w-full pb-6">
<DrawerHeader className="flex flex-col py-3">
<div className="flex relative pt-2 text-B12 font-bold items-center justify-center w-full">
<X
className="absolute right-0 cursor-pointer"
size={24}
onClick={() => onClose()}
/>
<DrawerTitle className="text-B12 font-bold">
فیلتر نتایج
</DrawerTitle>
</div>
</DrawerHeader>
<div className="w-full p-4">
<div className="flex flex-col gap-4 w-full pb-4">
<RadioGroup
className="flex flex-col gap-4 w-full"
onValueChange={(value) => setOrder(value as dateRangeTypes)}
value={order}
>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"all"}
id="all"
/>
<label htmlFor="all" className="text-R16 w-full text-right">
همه نتایج
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"today"}
id="today"
/>
<label htmlFor="today" className="text-R16 w-full text-right">
امروز
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"lastWeek"}
id="lastWeek"
/>
<label
htmlFor="lastWeek"
className="text-R16 w-full text-right"
>
هفته اخیر
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"lastMonth"}
id="lastMonth"
/>
<label
htmlFor="lastMonth"
className="text-R16 w-full text-right"
>
ماه اخیر
</label>
</div>
<div className="flex w-full justify-between">
<RadioGroupItem
circleClass="h-4 w-4"
className="h-6 w-6"
value={"other"}
id="other"
/>
<label htmlFor="other" className="text-R16 w-full text-right">
بازه دلخواه
</label>
</div>
</RadioGroup>
</div>
{/* Custom Date Range Picker */}
{order === "other" && (
<div className="flex gap-2 w-full pb-4">
{/* From Date */}
<div className="w-full">
<PersianDatePicker
value={fromDate}
onChange={setFromDate}
placeholder="از تاریخ"
label=""
/>
</div>
{/* To Date */}
<div className="w-full">
<PersianDatePicker
value={toDate}
onChange={setToDate}
placeholder="تا تاریخ"
label=""
/>
</div>
</div>
)}
</div>
<div className="flex flex-row gap-6 w-full px-4 pt-6 pb-2">
<Button
onClick={() => filterTransactions()}
className="w-[100%]"
size={"xl"}
variant="dark"
disabled={!order || (order === "other" && !fromDate && !toDate)}
>
مشاهده نتایج
</Button>
</div>
</DrawerContent>
</Drawer>
);
};