572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import DatePicker from "../date-picker/DatePicker";
|
||
import { Grid } from "../Grid/Grid";
|
||
import Textfield from "../Textfeild/Textfeild";
|
||
import Typography from "../Typography/Typography";
|
||
import {
|
||
CircleStackIcon,
|
||
DocumentArrowDownIcon,
|
||
} from "@heroicons/react/24/outline";
|
||
import { checkIsMobile } from "../../utils/checkIsMobile";
|
||
import AutoComplete from "../AutoComplete/AutoComplete";
|
||
import { Tooltip } from "../Tooltip/Tooltip";
|
||
import api from "../../utils/axios";
|
||
import { useBackdropStore } from "../../context/zustand-store/appStore";
|
||
import { useToast } from "../../hooks/useToast";
|
||
import { useApiRequest } from "../../utils/useApiRequest";
|
||
import { getNestedValue } from "../../utils/getNestedValue";
|
||
import { getFaPermissions } from "../../utils/getFaPermissions";
|
||
|
||
interface FilterItem {
|
||
key: number | string;
|
||
value: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
interface FilterConfig {
|
||
key?: string;
|
||
data?: FilterItem[];
|
||
api?: string;
|
||
selectedKeys: (number | string)[];
|
||
onChange: (keys: (number | string)[]) => void;
|
||
title: string;
|
||
size?: "small" | "medium" | "large";
|
||
inPage?: boolean;
|
||
selectField?: boolean;
|
||
keyField?: string;
|
||
valueField?: string | string[];
|
||
valueField2?: string | string[];
|
||
valueField3?: string | string[];
|
||
filterAddress?: string[];
|
||
filterValue?: string[];
|
||
valueTemplate?: string;
|
||
valueTemplateProps?: Array<{ [key: string]: "string" | "number" }>;
|
||
groupBy?: string | string[];
|
||
groupFunction?: (item: any) => string;
|
||
}
|
||
|
||
type ExcelProps = {
|
||
title?: string;
|
||
link?: string;
|
||
};
|
||
|
||
interface PaginationParametersProps {
|
||
noCustomDate?: boolean;
|
||
defaultActive?: boolean;
|
||
justOne?: boolean;
|
||
noSearch?: boolean;
|
||
startLabel?: string;
|
||
endLabel?: string;
|
||
title?: string;
|
||
onChange?: (data: {
|
||
date1?: string | null;
|
||
date2?: string | null;
|
||
search?: string | null;
|
||
[key: string]: any;
|
||
}) => void;
|
||
getData?: () => void;
|
||
children?: React.ReactNode;
|
||
filters?: FilterConfig[];
|
||
excelInfo?: ExcelProps;
|
||
}
|
||
|
||
const ApiBasedFilter: React.FC<{
|
||
filter: FilterConfig;
|
||
}> = ({ filter }) => {
|
||
const [filterData, setFilterData] = useState<FilterItem[]>([]);
|
||
|
||
if (!filter.api) {
|
||
return null;
|
||
}
|
||
|
||
const formatValue = (value: any, fieldKey: string) => {
|
||
if (!filter.valueTemplate || !filter.valueTemplateProps) return value;
|
||
|
||
const templateProp = filter.valueTemplateProps.find(
|
||
(prop) => prop[fieldKey]
|
||
);
|
||
if (!templateProp) return value;
|
||
|
||
const fieldType = templateProp[fieldKey];
|
||
|
||
if (fieldType === "number") {
|
||
const numValue = typeof value === "number" ? value : Number(value);
|
||
return !isNaN(numValue) ? numValue.toLocaleString() : String(value);
|
||
} else if (fieldType === "string") {
|
||
let result = String(value);
|
||
if (typeof value === "string" && value.includes(",")) {
|
||
result = value.replace(/,/g, "");
|
||
}
|
||
return result;
|
||
}
|
||
|
||
return value;
|
||
};
|
||
|
||
const { data: apiData } = useApiRequest({
|
||
api: filter.api,
|
||
params: { page: 1, page_size: 1000 },
|
||
queryKey: [filter.api, filter.key],
|
||
disableBackdrop: true,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (apiData?.results && filter.api) {
|
||
let data;
|
||
|
||
if (filter.filterAddress && filter.filterValue) {
|
||
data = apiData.results?.filter(
|
||
(item: any) =>
|
||
!filter.filterValue!.includes(
|
||
getNestedValue(item, filter.filterAddress!)
|
||
)
|
||
);
|
||
} else {
|
||
data = apiData.results;
|
||
}
|
||
|
||
const keyField = filter.keyField || "id";
|
||
const valueField = filter.valueField || "name";
|
||
|
||
const formattedData = data?.map((option: any) => ({
|
||
key: option[keyField],
|
||
value: filter.valueTemplate
|
||
? filter.valueTemplate
|
||
.replace(
|
||
/v1/g,
|
||
formatValue(
|
||
valueField === "page"
|
||
? getFaPermissions(option[valueField])
|
||
: typeof valueField === "string"
|
||
? option[valueField]
|
||
: getNestedValue(option, valueField),
|
||
"v1"
|
||
)
|
||
)
|
||
.replace(
|
||
/v2/g,
|
||
formatValue(
|
||
filter.valueField2
|
||
? typeof filter.valueField2 === "string"
|
||
? option[filter.valueField2]
|
||
: getNestedValue(option, filter.valueField2)
|
||
: "",
|
||
"v2"
|
||
)
|
||
)
|
||
.replace(
|
||
/v3/g,
|
||
formatValue(
|
||
filter.valueField3
|
||
? typeof filter.valueField3 === "string"
|
||
? option[filter.valueField3]
|
||
: getNestedValue(option, filter.valueField3)
|
||
: "",
|
||
"v3"
|
||
)
|
||
)
|
||
: `${
|
||
valueField === "page"
|
||
? getFaPermissions(option[valueField])
|
||
: typeof valueField === "string"
|
||
? option[valueField]
|
||
: getNestedValue(option, valueField)
|
||
} ${
|
||
filter.valueField2
|
||
? " - " +
|
||
(typeof filter.valueField2 === "string"
|
||
? option[filter.valueField2]
|
||
: getNestedValue(option, filter.valueField2))
|
||
: ""
|
||
} ${
|
||
filter.valueField3
|
||
? "(" +
|
||
(typeof filter.valueField3 === "string"
|
||
? option[filter.valueField3]
|
||
: getNestedValue(option, filter.valueField3)) +
|
||
")"
|
||
: ""
|
||
}`,
|
||
}));
|
||
|
||
setFilterData(formattedData || []);
|
||
}
|
||
}, [apiData, filter]);
|
||
|
||
return (
|
||
<AutoComplete
|
||
key={filter.key}
|
||
inPage={filter.inPage ?? true}
|
||
size={filter.size ?? "small"}
|
||
data={filterData}
|
||
selectedKeys={filter.selectedKeys}
|
||
onChange={filter.onChange}
|
||
title={filter.title}
|
||
selectField={filter.selectField}
|
||
/>
|
||
);
|
||
};
|
||
|
||
export const PaginationParameters: React.FC<PaginationParametersProps> = ({
|
||
noCustomDate = false,
|
||
defaultActive = false,
|
||
justOne = false,
|
||
noSearch = false,
|
||
startLabel = "",
|
||
endLabel = "تا",
|
||
title,
|
||
onChange,
|
||
getData,
|
||
children,
|
||
filters = [],
|
||
excelInfo,
|
||
}) => {
|
||
const [selectedDate1, setSelectedDate1] = useState<string>();
|
||
const [selectedDate2, setSelectedDate2] = useState<string>();
|
||
const [keyword, setKeyword] = useState<string>("");
|
||
const [enableDates, setEnableDates] = useState<boolean>(defaultActive);
|
||
const { openBackdrop, closeBackdrop } = useBackdropStore();
|
||
const showToast = useToast();
|
||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||
e.preventDefault();
|
||
const data = {
|
||
start: enableDates ? selectedDate1 : null,
|
||
end: enableDates ? selectedDate2 : null,
|
||
search: noSearch ? null : keyword,
|
||
};
|
||
await onChange?.(data);
|
||
getData?.();
|
||
};
|
||
|
||
const handleExcelDownload = () => {
|
||
openBackdrop();
|
||
if (!excelInfo?.link) return;
|
||
|
||
const urlParts = excelInfo.link.split("?");
|
||
const baseUrl = urlParts[0];
|
||
const existingParams: Record<string, string> = {};
|
||
|
||
if (urlParts[1]) {
|
||
const queryString = urlParts[1];
|
||
const params = new URLSearchParams(queryString);
|
||
params.forEach((value, key) => {
|
||
if (value) {
|
||
existingParams[key] = value;
|
||
}
|
||
});
|
||
}
|
||
|
||
const mergedParams: Record<string, string | undefined> = {
|
||
...existingParams,
|
||
...(keyword ? { search: keyword } : {}),
|
||
...(enableDates && selectedDate1 && selectedDate2
|
||
? { start: selectedDate1, end: selectedDate2 }
|
||
: enableDates && selectedDate1
|
||
? { start: selectedDate1 }
|
||
: enableDates && selectedDate2
|
||
? { end: selectedDate2 }
|
||
: {}),
|
||
};
|
||
|
||
const finalParams: Record<string, string> = {};
|
||
Object.keys(mergedParams).forEach((key) => {
|
||
const value = mergedParams[key];
|
||
if (value !== undefined && value !== null && value !== "") {
|
||
finalParams[key] = value;
|
||
}
|
||
});
|
||
|
||
api
|
||
.get(baseUrl, {
|
||
params: finalParams,
|
||
responseType: "blob",
|
||
})
|
||
.then((response) => {
|
||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.setAttribute(
|
||
"download",
|
||
`${excelInfo?.title || title || "خروجی"}.xlsx`
|
||
);
|
||
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
|
||
window.URL.revokeObjectURL(url);
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error downloading file:", error);
|
||
showToast("در حال حاضر امکان دانلود خروجی اکسل وجود ندارد", "error");
|
||
})
|
||
.finally(() => {
|
||
closeBackdrop();
|
||
});
|
||
};
|
||
|
||
const renderDate1 = (
|
||
<DatePicker
|
||
size="small"
|
||
disabled={!noCustomDate && !enableDates}
|
||
value={selectedDate1}
|
||
onChange={(r) => setSelectedDate1(r)}
|
||
label={startLabel}
|
||
/>
|
||
);
|
||
|
||
const renderDate2 = !justOne ? (
|
||
<DatePicker
|
||
size="small"
|
||
disabled={!noCustomDate && !enableDates}
|
||
value={selectedDate2}
|
||
onChange={(r) => setSelectedDate2(r)}
|
||
label={endLabel}
|
||
/>
|
||
) : (
|
||
""
|
||
);
|
||
|
||
const isMobile = checkIsMobile();
|
||
|
||
if (isMobile) {
|
||
return (
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="flex flex-col bg-gray-100 dark:bg-dark-800 rounded-4xl m-4">
|
||
<div className="flex-1 px-4 pt-6 pb-2 space-y-4">
|
||
{(title || excelInfo) && (
|
||
<div className="flex items-center gap-2">
|
||
<CircleStackIcon className="h-6 w-6 text-primary-600 dark:text-white" />
|
||
<Typography className="text-lg font-semibold text-gray-800 dark:text-white">
|
||
{title}
|
||
</Typography>
|
||
{excelInfo && (
|
||
<div
|
||
className="cursor-pointer rounded-sm px-1 border-red-400 shadow-b-xl shadow-sky-50"
|
||
onClick={handleExcelDownload}
|
||
>
|
||
<Tooltip
|
||
position="top"
|
||
title={
|
||
checkIsMobile() ? "" : excelInfo?.title || "خروجی اکسل"
|
||
}
|
||
>
|
||
<DocumentArrowDownIcon className="text-primary-600 w-5 animate-pulse" />
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!noCustomDate && (
|
||
<div className="bg-white dark:bg-dark-600 shadow rounded-2xl p-4 flex items-center justify-between">
|
||
<span className="text-sm text-gray-700 dark:text-gray-200">
|
||
فعالسازی بازه تاریخی
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setEnableDates((prev) => !prev)}
|
||
className={`relative w-12 h-7 rounded-full transition-colors duration-300 ${
|
||
enableDates ? "bg-green-500" : "bg-gray-300"
|
||
}`}
|
||
>
|
||
<motion.div
|
||
layout
|
||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||
className="absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow"
|
||
animate={{ x: enableDates ? 20 : 0 }}
|
||
/>
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white dark:bg-dark-600 shadow rounded-2xl p-4 space-y-2">
|
||
{renderDate1}
|
||
{renderDate2}
|
||
</div>
|
||
|
||
{filters.length > 0 && (
|
||
<div className="bg-white dark:bg-dark-600 shadow rounded-2xl p-4 space-y-2">
|
||
{filters.map((filter) =>
|
||
filter.api ? (
|
||
<ApiBasedFilter key={filter.key} filter={filter} />
|
||
) : (
|
||
<AutoComplete
|
||
key={filter.key}
|
||
inPage={filter.inPage ?? true}
|
||
size={filter.size ?? "small"}
|
||
data={filter.data || []}
|
||
selectedKeys={filter.selectedKeys}
|
||
onChange={filter.onChange}
|
||
title={filter.title}
|
||
selectField={filter.selectField}
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white dark:bg-dark-600 shadow rounded-2xl p-4">
|
||
{!noSearch && (
|
||
<Textfield
|
||
inputSize="small"
|
||
fullWidth
|
||
placeholder="عبارت جستجو را وارد کنید"
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
/>
|
||
)}
|
||
<motion.button
|
||
whileHover={{ scale: 1.03 }}
|
||
whileTap={{ scale: 0.97 }}
|
||
type="submit"
|
||
className="w-full mt-2 bg-primary-600 hover:bg-primary-700 text-white text-base font-semibold py-3 rounded-2xl shadow-lg transition-all"
|
||
>
|
||
جستجو
|
||
</motion.button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit}>
|
||
<Grid className="flex flex-wrap gap-2 justify-center items-center p-4 w-full bg-gradient-to-r from-transparent to-transparent dark:via-gray-900 via-gray-100">
|
||
{title && (
|
||
<Grid className="bg-primary-100 items-center dark:bg-dark-700 ml-2 p-1 px-4 rounded-xl flex justify-center gap-2 w-full md:w-auto">
|
||
<Grid>
|
||
<CircleStackIcon className="h-6 w-6 text-primary-800 dark:text-white" />
|
||
</Grid>
|
||
<Grid>
|
||
<Typography
|
||
variant="body2"
|
||
color="text-gray-600 dark:text-gray-100"
|
||
>
|
||
{title}
|
||
</Typography>
|
||
</Grid>
|
||
</Grid>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2">
|
||
{filters.map((filter) => (
|
||
<Grid
|
||
container
|
||
className="items-center gap-2 max-w-40"
|
||
key={filter.key}
|
||
>
|
||
{filter.api ? (
|
||
<ApiBasedFilter filter={filter} />
|
||
) : (
|
||
<AutoComplete
|
||
inPage={filter.inPage ?? true}
|
||
size={filter.size ?? "small"}
|
||
data={filter.data || []}
|
||
selectedKeys={filter.selectedKeys}
|
||
onChange={filter.onChange}
|
||
title={filter.title}
|
||
selectField={filter.selectField}
|
||
/>
|
||
)}
|
||
</Grid>
|
||
))}
|
||
{children}
|
||
</div>
|
||
|
||
{!noCustomDate && (
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEnableDates((prev) => !prev)}
|
||
className={`relative w-10 h-5 rounded-full transition-colors duration-300 cursor-pointer ${
|
||
enableDates ? "bg-green-500" : "bg-gray-300"
|
||
}`}
|
||
>
|
||
<motion.div
|
||
layout
|
||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||
className="absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full shadow-md"
|
||
animate={{ x: enableDates ? 20 : 0 }}
|
||
/>
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<AnimatePresence>
|
||
<>
|
||
<motion.div
|
||
key="date1"
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: 10 }}
|
||
transition={{ duration: 0.3 }}
|
||
className="w-full sm:w-auto flex-grow sm:flex-grow-0 min-w-[150px]"
|
||
>
|
||
{renderDate1}
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
key="date2"
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: 10 }}
|
||
transition={{ duration: 0.3 }}
|
||
className="w-full sm:w-auto flex-grow sm:flex-grow-0"
|
||
>
|
||
{renderDate2}
|
||
</motion.div>
|
||
</>
|
||
</AnimatePresence>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ duration: 1 }}
|
||
className="w-full sm:w-auto flex-grow sm:flex-grow-0"
|
||
>
|
||
{!noSearch && (
|
||
<Textfield
|
||
inputSize="small"
|
||
fullWidth
|
||
placeholder="جستجو"
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
/>
|
||
)}
|
||
</motion.div>
|
||
|
||
<motion.button
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
type="submit"
|
||
className="
|
||
bg-primary-600 text-white rounded-xl px-6 py-1.5 text-sm font-medium shadow cursor-pointer
|
||
hover:bg-primary-700 transition-colors
|
||
w-full sm:w-auto min-w-[100px]
|
||
"
|
||
>
|
||
جستجو
|
||
</motion.button>
|
||
|
||
{excelInfo && (
|
||
<div
|
||
className="cursor-pointer rounded-sm border-red-400 flex items-center justify-center"
|
||
onClick={handleExcelDownload}
|
||
>
|
||
<Tooltip
|
||
position="top"
|
||
title={checkIsMobile() ? "" : excelInfo?.title || "خروجی اکسل"}
|
||
>
|
||
<DocumentArrowDownIcon className="text-primary-600 w-8 animate-pulse" />
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</Grid>
|
||
</form>
|
||
);
|
||
};
|