first commit

This commit is contained in:
2026-01-19 13:08:58 +03:30
commit 850b4a3f1e
293 changed files with 51775 additions and 0 deletions

View File

@@ -0,0 +1,653 @@
import React, {
useState,
useEffect,
useRef,
useId,
useMemo,
useCallback,
} from "react";
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/24/outline";
import { getSizeStyles } from "../../data/getInputSizes";
import Textfield from "../Textfeild/Textfeild";
import { motion } from "framer-motion";
import { Tooltip } from "../Tooltip/Tooltip";
import { createPortal } from "react-dom";
import { checkIsMobile } from "../../utils/checkIsMobile";
interface DataItem {
key: number | string;
value: string;
disabled?: boolean;
isGroupHeader?: boolean;
originalGroupKey?: string | number;
}
interface AutoCompleteProps {
data: DataItem[];
multiselect?: boolean;
inPage?: boolean;
disabled?: boolean;
selectedKeys: (number | string)[];
onChange: (keys: (number | string)[]) => void | [];
width?: string;
buttonHeight?: number | string;
title?: string;
error?: boolean;
size?: "small" | "medium" | "large";
helperText?: string;
onChangeValue?: (data: { value: string; key: number | string }) => void;
onGroupHeaderClick?: (groupKey: string | number) => void;
selectField?: boolean;
}
const AutoComplete: React.FC<AutoCompleteProps> = ({
data,
multiselect = false,
selectedKeys,
onChange,
disabled = false,
inPage = false,
title = "",
error = false,
size = "medium",
helperText,
onChangeValue,
onGroupHeaderClick,
selectField = false,
}) => {
const [filteredData, setFilteredData] = useState<DataItem[]>(data);
const [showOptions, setShowOptions] = useState<boolean>(false);
const [dropdownWidth, setDropdownWidth] = useState<number>(0);
const [dropdownPosition, setDropdownPosition] = useState<{
top: number;
left: number;
}>({ top: 0, left: 0 });
const [maxHeight, setMaxHeight] = useState<number>(240);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLUListElement>(null);
const uniqueId = useId();
const selectedKeysRef = useRef<(number | string)[]>(selectedKeys);
const isInternalChangeRef = useRef<boolean>(false);
useEffect(() => {
const updateDropdownDimensions = () => {
if (inputRef.current) {
const rect = inputRef.current.getBoundingClientRect();
const defaultMaxHeight = 240;
const spaceBelow = window.innerHeight - rect.bottom;
const availableHeight = Math.max(100, spaceBelow - 10);
const calculatedMaxHeight =
spaceBelow < defaultMaxHeight ? availableHeight : defaultMaxHeight;
setDropdownWidth(rect.width);
setMaxHeight(calculatedMaxHeight);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
});
}
};
updateDropdownDimensions();
const resizeObserver = new ResizeObserver(updateDropdownDimensions);
if (inputRef.current) {
resizeObserver.observe(inputRef.current);
}
window.addEventListener("resize", updateDropdownDimensions);
window.addEventListener("scroll", updateDropdownDimensions);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateDropdownDimensions);
window.removeEventListener("scroll", updateDropdownDimensions);
};
}, []);
useEffect(() => {
if (!showOptions) return;
let animationFrameId: number;
let isActive = true;
let lastTop = 0;
let lastLeft = 0;
let lastWidth = 0;
let lastMaxHeight = 0;
const updatePosition = (force = false) => {
if (!isActive || !inputRef.current) return;
const rect = inputRef.current.getBoundingClientRect();
const defaultMaxHeight = 240;
const viewportHeight =
window.visualViewport?.height || window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const availableHeight = Math.max(100, spaceBelow - 10);
const calculatedMaxHeight =
spaceBelow < defaultMaxHeight ? availableHeight : defaultMaxHeight;
const newTop = rect.bottom + window.scrollY;
const newLeft = rect.left + window.scrollX;
const newWidth = rect.width;
if (
force ||
Math.abs(newTop - lastTop) > 0.5 ||
Math.abs(newLeft - lastLeft) > 0.5 ||
Math.abs(newWidth - lastWidth) > 0.5 ||
Math.abs(calculatedMaxHeight - lastMaxHeight) > 1
) {
setDropdownWidth(newWidth);
setMaxHeight(calculatedMaxHeight - 30);
setDropdownPosition({
top: newTop,
left: newLeft,
});
lastTop = newTop;
lastLeft = newLeft;
lastWidth = newWidth;
lastMaxHeight = calculatedMaxHeight;
}
if (isActive && showOptions) {
animationFrameId = requestAnimationFrame(() => updatePosition(false));
}
};
updatePosition();
const handleResize = () => updatePosition(true);
const handleScroll = () => updatePosition();
let lastViewportHeight =
window.visualViewport?.height || window.innerHeight;
const handleVisualViewportResize = () => {
const currentHeight = window.visualViewport?.height || window.innerHeight;
const heightDiff = Math.abs(currentHeight - lastViewportHeight);
lastViewportHeight = currentHeight;
if (heightDiff > 50) {
setTimeout(() => {
updatePosition(true);
setTimeout(() => updatePosition(true), 200);
setTimeout(() => updatePosition(true), 400);
}, 50);
} else {
setTimeout(() => updatePosition(true), 50);
}
};
const handleVisualViewportScroll = () => updatePosition();
const handleFocus = () => {
setTimeout(() => updatePosition(true), 300);
};
const handleBlur = () => {
setTimeout(() => {
updatePosition(true);
setTimeout(() => updatePosition(true), 200);
setTimeout(() => updatePosition(true), 400);
}, 100);
};
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleScroll, true);
if (checkIsMobile()) {
if (window.visualViewport) {
window.visualViewport.addEventListener(
"resize",
handleVisualViewportResize
);
window.visualViewport.addEventListener(
"scroll",
handleVisualViewportScroll
);
}
const inputElement = inputRef.current;
if (inputElement) {
inputElement.addEventListener("focus", handleFocus);
inputElement.addEventListener("blur", handleBlur);
inputElement.addEventListener("touchstart", handleFocus);
}
}
return () => {
isActive = false;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleScroll, true);
if (checkIsMobile()) {
if (window.visualViewport) {
window.visualViewport.removeEventListener(
"resize",
handleVisualViewportResize
);
window.visualViewport.removeEventListener(
"scroll",
handleVisualViewportScroll
);
}
const inputElement = inputRef.current;
if (inputElement) {
inputElement.removeEventListener("focus", handleFocus);
inputElement.removeEventListener("blur", handleBlur);
inputElement.removeEventListener("touchstart", handleFocus);
}
}
};
}, [showOptions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const clickedInsideCurrent = target.closest(`.select-group-${uniqueId}`);
const clickedOnAnotherAutocomplete =
target.closest(".select-group") && !clickedInsideCurrent;
const clickedOnPortalDropdown = target.closest(
`[data-autocomplete-portal="${uniqueId}"]`
);
if (clickedOnAnotherAutocomplete) {
setShowOptions(false);
return;
}
if (!clickedInsideCurrent && !clickedOnPortalDropdown) {
setTimeout(() => {
const isInputFocused = document.activeElement === inputRef.current;
if (!isInputFocused) {
setShowOptions(false);
}
}, 0);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [uniqueId]);
useEffect(() => {
setFilteredData(data);
}, [data]);
useEffect(() => {
selectedKeysRef.current = selectedKeys;
}, [selectedKeys]);
useEffect(() => {
if (isInternalChangeRef.current) {
isInternalChangeRef.current = false;
return;
}
if (selectedKeys?.length > 0 && onChangeValue) {
const selectedItem = data.find((item) => item.key === selectedKeys[0]);
if (selectedItem) {
onChangeValue({
value: selectedItem.value.trim(),
key: selectedItem.key,
});
}
}
}, [selectedKeys, data]);
useEffect(() => {
if (!showOptions) {
setIsTyping(false);
}
}, [showOptions]);
useEffect(() => {
if (!showOptions || !checkIsMobile()) return;
const originalOverflow = window.getComputedStyle(document.body).overflow;
const originalPosition = window.getComputedStyle(document.body).position;
const originalTop = document.body.style.top;
const scrollY = window.scrollY;
document.body.style.overflow = "hidden";
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
const preventTouchMove = (e: TouchEvent) => {
const target = e.target as HTMLElement;
const dropdown = document.querySelector(
`[data-autocomplete-portal="${uniqueId}"]`
);
if (dropdown) {
const touch = e.touches[0] || e.changedTouches[0];
if (touch) {
const elementAtPoint = document.elementFromPoint(
touch.clientX,
touch.clientY
);
if (
elementAtPoint &&
(dropdown.contains(elementAtPoint) || dropdown.contains(target))
) {
return;
}
} else if (dropdown.contains(target)) {
return;
}
}
e.preventDefault();
};
document.addEventListener("touchmove", preventTouchMove, {
passive: false,
});
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.position = originalPosition;
document.body.style.top = originalTop;
document.body.style.width = "";
window.scrollTo(0, scrollY);
document.removeEventListener("touchmove", preventTouchMove);
};
}, [showOptions, uniqueId]);
const inputValue = useMemo(() => {
if (selectedKeys?.length > 0) {
const selectedValues = data
.filter((item) => selectedKeys?.includes(item.key))
.map((item) => item.value);
return selectedValues?.join(", ");
}
return "";
}, [selectedKeys, data]);
const [localInputValue, setLocalInputValue] = useState<string>("");
const [isTyping, setIsTyping] = useState<boolean>(false);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setLocalInputValue(value);
setIsTyping(true);
const filtered = data.filter((item) =>
item.value.toLowerCase().includes(value.toLowerCase())
);
setFilteredData(filtered);
setShowOptions(true);
},
[data]
);
const handleChange = useCallback(
(newSelectedKeys: (number | string)[]) => {
isInternalChangeRef.current = true;
onChange(newSelectedKeys);
if (onChangeValue && newSelectedKeys.length > 0) {
const selectedItem = data.find(
(item) => item.key === newSelectedKeys[0]
);
if (selectedItem) {
onChangeValue({
value: selectedItem.value.trim(),
key: selectedItem.key,
});
}
}
},
[onChange, onChangeValue, data]
);
const handleOptionClick = useCallback(
(key: number | string) => {
const currentSelectedKeys = selectedKeysRef.current;
let newSelectedKeys: (number | string)[];
if (multiselect) {
if (currentSelectedKeys.includes(key)) {
newSelectedKeys = currentSelectedKeys.filter((item) => item !== key);
} else {
newSelectedKeys = [...currentSelectedKeys, key];
}
} else {
if (currentSelectedKeys.includes(key)) {
newSelectedKeys = currentSelectedKeys.filter((item) => item !== key);
} else {
newSelectedKeys = [key];
}
}
handleChange(newSelectedKeys);
setIsTyping(false);
if (!multiselect) {
setLocalInputValue("");
setShowOptions(false);
}
},
[multiselect, handleChange]
);
const handleInputClick = useCallback(() => {
document.querySelectorAll(".select-group").forEach((el) => {
if (!el.classList.contains(`select-group-${uniqueId}`)) {
const input = el.querySelector("input");
if (input) {
input.blur();
}
}
});
setShowOptions(true);
setFilteredData(data);
setLocalInputValue("");
setIsTyping(false);
}, [uniqueId, data]);
const handleCloseInput = useCallback(() => {
setShowOptions(false);
setIsTyping(false);
}, []);
const selectedKeysSet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
const handleSelectAll = useCallback(() => {
const enabledItems = filteredData.filter((item) => !item.disabled);
const allEnabledKeys = enabledItems.map((item) => item.key);
handleChange(allEnabledKeys);
}, [filteredData, handleChange]);
const handleDeselectAll = useCallback(() => {
handleChange([]);
}, [handleChange]);
const areAllSelected = useMemo(() => {
const enabledItems = filteredData.filter((item) => !item.disabled);
return (
enabledItems.length > 0 &&
enabledItems.every((item) => selectedKeysSet.has(item.key))
);
}, [filteredData, selectedKeysSet]);
const dropdownOptions = useMemo(() => {
if (filteredData.length === 0) {
return (
<li className="px-4 py-3 text-gray-500 dark:text-dark-400 text-center">
نتیجهای یافت نشد
</li>
);
}
const selectAllHeader = multiselect ? (
<li
key="select-all-header"
onClick={areAllSelected ? handleDeselectAll : handleSelectAll}
className="flex items-center my-1 justify-start gap-2 px-4 py-2 cursor-pointer transition-colors duration-150 rounded-md border border-gray-200 dark:border-dark-600 hover:bg-primary-100 text-dark-800 dark:text-dark-100 dark:hover:bg-dark-700 bg-gray-50 dark:bg-dark-700 font-semibold"
>
<span className="text-sm">
{areAllSelected ? "عدم انتخاب همه" : "انتخاب همه"}
</span>
{areAllSelected && (
<CheckIcon className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
)}
</li>
) : null;
const options = filteredData.map((item) => {
const isSelected = selectedKeysSet.has(item.key);
const isGroupHeader = item.isGroupHeader;
const handleClick = () => {
if (isGroupHeader && onGroupHeaderClick) {
const groupKey =
item.originalGroupKey !== undefined
? item.originalGroupKey
: String(item.key).startsWith("__group__")
? String(item.key).slice(11)
: item.key;
onGroupHeaderClick(groupKey);
} else if (!item.disabled) {
handleOptionClick(item.key);
}
};
return (
<li
key={`${item.key}`}
onClick={handleClick}
className={`flex items-center justify-between px-4 py-2 transition-colors duration-150 rounded-md
${
isGroupHeader && onGroupHeaderClick
? "cursor-pointer opacity-55 hover:bg-gray-100 text-dark-800 dark:text-dark-100 dark:hover:bg-primary-900/90 font-semibold bg-gray-200 dark:bg-primary-900"
: item.disabled
? "text-gray-400 dark:text-dark-500 cursor-not-allowed"
: "cursor-pointer hover:bg-primary-100 text-dark-800 dark:text-dark-100 dark:hover:bg-dark-700"
}
${
isSelected && !isGroupHeader
? "bg-primary-50 dark:bg-dark-700 font-semibold"
: ""
}
`}
aria-disabled={item?.disabled && !isGroupHeader}
>
{checkIsMobile() ? (
<span
className={`truncate ${
item?.value.length > 55 ? "text-xs" : "text-sm"
}`}
>
{item.value}
</span>
) : (
<Tooltip
title={item.value}
hidden={item?.value?.length < 55}
position="right"
>
<span
className={`truncate ${
item?.value.length > 55 ? "text-xs" : "text-sm"
}`}
>
{item.value}
</span>
</Tooltip>
)}
{isSelected && (
<CheckIcon className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
)}
</li>
);
});
return selectAllHeader ? [selectAllHeader, ...options] : options;
}, [
filteredData,
selectedKeysSet,
handleOptionClick,
multiselect,
areAllSelected,
handleSelectAll,
handleDeselectAll,
onGroupHeaderClick,
]);
const dropdownPortalContent = useMemo(() => {
if (!showOptions) return null;
return createPortal(
<motion.ul
ref={dropdownRef}
data-autocomplete-portal={`${uniqueId}`}
initial={{ opacity: 0, scaleY: 0.95, y: -5 }}
animate={{ opacity: 1, scaleY: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{
position: "fixed",
top: dropdownPosition.top,
left: dropdownPosition.left,
width: `${dropdownWidth}px`,
maxHeight: `${maxHeight}px`,
zIndex: 9999,
transformOrigin: "top center",
scrollbarWidth: "thin",
scrollbarColor: "#cbd5e1 transparent",
boxSizing: "border-box",
}}
className={`overflow-y-auto border border-gray-200 dark:border-dark-500 bg-white dark:bg-dark-800 divide-y divide-gray-100 dark:divide-dark-600 text-sm backdrop-blur-lg rounded-xl shadow-2xl modern-scrollbar`}
>
{dropdownOptions}
</motion.ul>,
document.body
);
}, [
showOptions,
dropdownPosition,
dropdownWidth,
uniqueId,
dropdownOptions,
maxHeight,
]);
return (
<div
className={`select-group select-group-${uniqueId} ${
inPage ? "w-auto" : "w-full"
}`}
>
<div className="relative w-full">
<div className="relative">
<Textfield
disabled={disabled}
readOnly={selectField}
inputMode={selectField ? "none" : undefined}
handleCloseInput={handleCloseInput}
error={error}
helperText={helperText}
ref={inputRef}
isAutoComplete
inputSize={size}
value={isTyping ? localInputValue : inputValue}
onChange={handleInputChange}
onClick={handleInputClick}
className="selected-value w-full p-3 pl-10 outline-0 rounded-lg border border-black-100 transition-all duration-200 text-right"
placeholder={title || "انتخاب کنید..."}
/>
<ChevronDownIcon
className={`absolute left-3 text-dark-400 dark:text-dark-100 transition-transform duration-200 ${
showOptions ? "transform rotate-180" : ""
} ${getSizeStyles(size).icon}`}
/>
</div>
{dropdownPortalContent}
</div>
</div>
);
};
export default AutoComplete;

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef } from "react";
import { useBackdropStore } from "../../context/zustand-store/appStore";
import Lottie from "lottie-react";
import { motion, AnimatePresence } from "framer-motion";
import waiting from "../../assets/animations/waiting.json";
const Backdrop: React.FC = () => {
const { isOpen, closeBackdrop } = useBackdropStore();
const backdropRef = useRef<HTMLDivElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeBackdrop();
return;
}
e.preventDefault();
e.stopPropagation();
};
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
setTimeout(() => {
backdropRef.current?.focus();
}, 100);
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
ref={backdropRef}
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onKeyDown={handleKeyDown}
tabIndex={-1}
autoFocus
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="flex flex-col items-center justify-center"
>
<div className="w-32 h-32">
<Lottie animationData={waiting} loop={true} />
</div>
<p className="mt-4 text-white text-lg font-medium select-none">
لطفا صبر کنید ...
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
export default Backdrop;

View File

@@ -0,0 +1,124 @@
import React from "react";
import { motion } from "framer-motion";
interface BarChartProps {
data: {
x: string;
y: number;
label?: string;
}[];
className?: string;
xAxisLabel?: string;
yAxisLabel?: string;
barColor?: string;
hoverColor?: string;
showGuidelines?: boolean;
}
const BarChart: React.FC<BarChartProps> = ({
data,
className = "",
xAxisLabel = "",
yAxisLabel = "",
barColor = "#3b82f6",
hoverColor = "#93c5fd",
showGuidelines = true,
}) => {
// Find the maximum value for scaling
const maxValue = Math.max(...data.map((item) => item.y), 0);
return (
<div
className={`flex flex-col ${className} overflow-x-auto lg:overflow-x-hidden`}
>
<div className="flex flex-1">
{/* Y-axis label */}
{yAxisLabel && (
<div className={`flex w-10 items-center justify-end`}>
<div
className={`text-sm font-medium text-gray-600 dark:text-gray-400 rotate-90 whitespace-nowrap`}
>
{yAxisLabel}
</div>
</div>
)}
{/* Chart area */}
<div className="flex-1 flex flex-col">
{/* Y-axis ticks and bars */}
<div className="flex-1 flex relative">
{/* Y-axis ticks */}
<div className="relative h-full" style={{ width: "30px" }}>
{[100, 75, 50, 25, 0].map((tick) => (
<div
key={tick}
className="absolute w-full flex items-end"
style={{ bottom: `${tick}%` }}
>
<span
className={`text-xs text-gray-500 dark:text-gray-400
`}
>
{Math.round((tick / 100) * maxValue)}
</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
))}
</div>
{/* Guidelines */}
{showGuidelines && (
<div className="absolute inset-0 pointer-events-none">
{[100, 75, 50, 25, 0].map((tick) => (
<div
key={`guide-${tick}`}
className="absolute w-full border-t border-gray-200 dark:border-gray-700 border-solid"
style={{ bottom: `${tick}%` }}
/>
))}
</div>
)}
{/* Bars */}
<div className="flex-1 flex items-end gap-2 justify-between h-full ">
{data.map((item, index) => (
<div
key={index}
className="flex flex-col items-center h-full"
style={{ flex: 1 }}
>
<div className="flex-1 w-full flex flex-col justify-end">
<motion.div
initial={{ height: 0 }}
animate={{ height: `${(item.y / maxValue) * 100}%` }}
transition={{ duration: 0.8, type: "spring" }}
className={`w-full rounded-t-md relative group bg-linear-to-t from-gray-100 to-[${barColor}]`}
style={{ backgroundColor: barColor }}
whileHover={{ backgroundColor: hoverColor }}
>
<div className="absolute z-50 text-wrap left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-800 text-white text-xs px-2 py-1 rounded whitespace-nowrap">
{item.label}: {` ${item.y}`}
</div>
</motion.div>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 whitespace-nowrap">
{item.x}
</div>
</div>
))}
</div>
</div>
{/* X-axis label */}
{xAxisLabel && (
<div className="text-sm font-medium text-gray-600 dark:text-gray-300 mt-2 text-center whitespace-nowrap">
{xAxisLabel}
</div>
)}
</div>
</div>
</div>
);
};
export default BarChart;

View File

@@ -0,0 +1,104 @@
import { motion } from "framer-motion";
import { useModalStore } from "../../context/zustand-store/appStore";
import { Grid } from "../Grid/Grid";
import Button from "../Button/Button";
import { useToast } from "../../hooks/useToast";
import { useApiMutation } from "../../utils/useApiRequest";
import Typography from "../Typography/Typography";
type Props = {
api?: string;
getData?: () => void;
method?: "post" | "put" | "delete" | "patch";
title?: string;
isAlert?: boolean;
payload?: any;
};
export const BooleanQuestion = ({
api = "",
getData,
method = "post",
title = "",
isAlert = false,
payload = {},
}: Props) => {
const { closeModal } = useModalStore();
const showToast = useToast();
const mutation = useApiMutation({
api: api || "",
method: method || "post",
});
const onSubmit = async () => {
try {
await mutation.mutateAsync(payload);
showToast("عملیات با موفقیت انجام شد", "success");
closeModal();
if (getData) {
getData();
}
} catch (error: any) {
if (error?.response?.data?.detail || error?.response?.data?.message) {
showToast(
error?.response?.data?.detail ||
error?.response?.data?.message + " !",
"error"
);
} else {
showToast("مشکلی پیش آمده است!", "error");
}
closeModal();
}
};
return (
<Grid
container
xs="full"
column
className="flex justify-center items-start"
>
{title && (
<Typography variant="body2" className="text-start mb-2">
{title}
</Typography>
)}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md p-2"
>
<Grid container className="flex-row space-y-0 space-x-4">
<Button
onClick={() => {
onSubmit();
}}
fullWidth
className={`${
isAlert
? "bg-[#eb5757] hover:bg-[#d44e4e]"
: "bg-primary-600 hover:bg-primary-500"
} text-white py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md`}
>
بله
</Button>
<Button
onClick={() => closeModal()}
fullWidth
className={`${
isAlert
? "bg-gray-200 text-gray-700 hover:bg-gray-100"
: "bg-gray-200 text-gray-700 hover:bg-gray-100"
} py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md`}
>
خیر
</Button>
</Grid>
</motion.div>
</Grid>
);
};

View File

@@ -0,0 +1,300 @@
import React, {
ReactNode,
ReactElement,
ButtonHTMLAttributes,
useState,
useEffect,
} from "react";
import clsx from "clsx";
import {
ChartBarIcon,
DocumentChartBarIcon,
EyeIcon,
FolderPlusIcon,
PencilIcon,
PencilSquareIcon,
TrashIcon,
ViewfinderCircleIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
import {
bgPrimaryColor,
mobileBorders,
textColorOnPrimary,
} from "../../data/getColorBasedOnMode";
import { checkIsMobile } from "../../utils/checkIsMobile";
import { inputWidths } from "../../data/getItemsWidth";
import { PlusIcon } from "@heroicons/react/24/solid";
import { useUserProfileStore } from "../../context/zustand-store/userStore";
import excel from "../../assets/images/svg/excel.svg?react";
import SVGImage from "../SvgImage/SvgImage";
import api from "../../utils/axios";
import { useBackdropStore } from "../../context/zustand-store/appStore";
import { useToast } from "../../hooks/useToast";
import { RolesContextMenu } from "./RolesContextMenu";
type ExcelProps = {
link: string;
title: string;
};
type ButtonProps = {
children?: ReactNode | string;
icon?: ReactElement;
direction?: "row" | "row-reverse" | "col" | "col-reverse";
iconColor?: string;
iconBgColor?: string;
iconSize?: number | string;
className?: string;
variant?:
| "submit"
| "secondary-submit"
| "edit"
| "secondary-edit"
| "detail"
| "delete"
| "view"
| "info"
| "chart"
| "set";
page?: string;
access?: string;
height?: string | number;
fullWidth?: boolean;
excelInfo?: ExcelProps;
rounded?: boolean;
size?: "small" | "medium" | "large";
disabled?: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>;
const Button: React.FC<ButtonProps> = ({
children,
icon,
direction = "row",
iconSize,
className = "",
variant = "",
page = "",
access = "",
height,
fullWidth = false,
excelInfo,
rounded = false,
size = "medium",
disabled = false,
...props
}) => {
const directionClass = {
row: "flex-row",
"row-reverse": "flex-row-reverse",
col: "flex-col",
"col-reverse": "flex-col-reverse",
}[direction];
const sizeStyles = {
small: {
padding: "h-[32px] px-2",
text: "text-xs",
icon: iconSize ?? 14,
},
medium: {
padding: "h-[40px] px-2",
text: "text-sm",
icon: iconSize ?? 18,
},
large: {
padding: "h-[48px] px-2",
text: "text-base",
icon: iconSize ?? 20,
},
}[size] ?? {
padding: "px-4 py-2",
text: "text-sm",
icon: iconSize ?? 18,
};
const getVariantIcon = () => {
switch (variant) {
case "submit":
return (
<PlusIcon
className={`w-5 h-5 ${
children ? "text-white" : "text-purple-400 dark:text-white"
}`}
/>
);
case "secondary-submit":
return (
<FolderPlusIcon
className={`w-5 h-5 ${
children ? "text-white" : "text-purple-400 dark:text-white"
}`}
/>
);
case "edit":
return (
<PencilIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
);
case "secondary-edit":
return (
<PencilSquareIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
);
case "detail":
return (
<EyeIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
);
case "view":
return (
<ViewfinderCircleIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
);
case "delete":
return <TrashIcon className="w-5 h-5 text-red-500" />;
case "info":
return (
<DocumentChartBarIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
);
case "chart":
return (
<ChartBarIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
);
case "set":
return (
<WrenchIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
);
default:
return null;
}
};
const { profile } = useUserProfileStore();
const { openBackdrop, closeBackdrop } = useBackdropStore();
const showToast = useToast();
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const isAdmin = profile?.role?.type?.key === "ADM";
const ableToSeeButton = () => {
if (!access || !page) {
return true;
} else {
const finded = profile?.permissions?.find(
(item: any) => item.page_name === page
);
if (finded && finded.page_access.includes(access)) {
return true;
} else {
return false;
}
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLButtonElement>) => {
if (isAdmin && page && access && children) {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
});
}
};
useEffect(() => {
const handleClick = () => {
if (contextMenu) {
setContextMenu(null);
}
};
if (contextMenu) {
document.addEventListener("click", handleClick);
}
return () => {
document.removeEventListener("click", handleClick);
};
}, [contextMenu]);
return (
<>
<button
{...props}
disabled={disabled}
onContextMenu={handleContextMenu}
className={clsx(
`${
ableToSeeButton() ? "flex" : "hidden"
} flex items-center justify-center gap-1 backdrop-blur-md transition-all duration-200 focus:outline-none`,
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
fullWidth ? "w-full" : inputWidths,
!className.includes("bg-") ? children && bgPrimaryColor : "hover-",
directionClass,
!className.includes("text-") && textColorOnPrimary,
rounded ? "rounded-2xl" : "rounded-[8px]",
sizeStyles.padding,
sizeStyles.text,
className,
checkIsMobile() && !icon && !variant && children && mobileBorders
)}
style={{ height }}
>
<div className="w-full flex justify-center items-center">
{variant && !icon && <>{getVariantIcon()}</>}
<span className="whitespace-nowrap">{children}</span>
{icon && <div>{icon}</div>}
{excelInfo && (
<a
onClick={() => {
openBackdrop();
api
.get(excelInfo?.link || "", {
responseType: "blob",
})
.then((response) => {
closeBackdrop();
const url = window.URL.createObjectURL(
new Blob([response.data])
);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${excelInfo?.title}.xlsx`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
})
.catch((error) => {
console.error("Error downloading file:", error);
closeBackdrop();
showToast("خطا در دانلود فایل", "error");
});
}}
>
<SVGImage
src={excel}
className={` text-primary-600 dark:text-primary-100 w-5 h-5`}
/>
</a>
)}
</div>
</button>
{contextMenu && page && access && (
<RolesContextMenu
page={page}
access={access}
position={contextMenu}
onClose={() => setContextMenu(null)}
/>
)}
</>
);
};
export default Button;

View File

@@ -0,0 +1,322 @@
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { createPortal } from "react-dom";
import Typography from "../Typography/Typography";
import Checkbox from "../CheckBox/CheckBox";
import { useApiRequest } from "../../utils/useApiRequest";
import { useFetchProfile } from "../../hooks/useFetchProfile";
import { useQueryClient } from "@tanstack/react-query";
import { useToast } from "../../hooks/useToast";
import api from "../../utils/axios";
import { getFaPermissions } from "../../utils/getFaPermissions";
type RolesContextMenuProps = {
page: string;
access: string;
isPage?: boolean;
position: { x: number; y: number };
onClose: () => void;
};
export const RolesContextMenu = ({
page,
access,
isPage,
position,
onClose,
}: RolesContextMenuProps) => {
const menuRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { getProfile } = useFetchProfile();
const showToast = useToast();
const { data: rolesData, refetch: refetchRoles } = useApiRequest({
api: "/auth/api/v1/role/",
method: "get",
params: { page: 1, page_size: 1000 },
queryKey: ["roles-permissions"],
});
const { data: permissionsData } = useApiRequest({
api: "/auth/api/v1/permission/",
method: "get",
params: { page: 1, page_size: 1000 },
queryKey: ["all-permissions"],
});
const findPermissionId = (page: string, access: string): number | null => {
if (!permissionsData?.results) return null;
const permission = permissionsData.results.find(
(p: any) => p.page === page && p.name === access
);
return permission?.id || null;
};
const checkRoleHasPermission = (
role: any,
page: string,
access: string
): boolean => {
if (!role?.permissions || !Array.isArray(role.permissions)) {
return false;
}
if (!permissionsData?.results) return false;
const permissionId = findPermissionId(page, access);
if (!permissionId) return false;
const rolePermissionIds = role.permissions.map((p: any) => p.id || p);
return rolePermissionIds.includes(permissionId);
};
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
new Set()
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!rolesData?.results) return;
const next = new Set<number>();
rolesData.results.forEach((role: any) => {
if (checkRoleHasPermission(role, page, access)) {
next.add(role.id);
}
});
setSelectedRoleIds(next);
}, [rolesData?.results, permissionsData?.results, page, access]);
const handleToggleLocal = (roleId: number) => {
setSelectedRoleIds((prev) => {
const next = new Set(prev);
if (next.has(roleId)) {
next.delete(roleId);
} else {
next.add(roleId);
}
return next;
});
};
const handleSubmit = async () => {
try {
const permissionId = findPermissionId(page, access);
if (!permissionId) {
showToast("دسترسی یافت نشد", "error");
return;
}
if (!rolesData?.results) return;
setSubmitting(true);
const updates = rolesData.results.flatMap((role: any) => {
const currentPermissionIds = (role.permissions || []).map(
(p: any) => p.id || p
);
const currentlyHas = currentPermissionIds.includes(permissionId);
const shouldHave = selectedRoleIds.has(role.id);
if (currentlyHas === shouldHave) return [];
const updatedPermissionIds = shouldHave
? currentPermissionIds.includes(permissionId)
? currentPermissionIds
: [...currentPermissionIds, permissionId]
: currentPermissionIds.filter((id: number) => id !== permissionId);
return [
api.put(`/auth/api/v1/role/${role.id}/`, {
role_name: role.role_name,
description: role.description,
type: role.type?.id,
permissions: updatedPermissionIds,
...(role.parent_role?.id
? { parent_role: role.parent_role.id }
: {}),
}),
];
});
if (updates.length === 0) {
showToast("تغییری برای ارسال وجود ندارد", "info");
return;
}
await Promise.all(updates);
showToast("به‌روزرسانی نقش‌ها با موفقیت انجام شد", "success");
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["roles-permissions"] });
refetchRoles();
getProfile();
onClose();
} catch (error: any) {
showToast(
error?.response?.data?.message || "خطا در به‌روزرسانی نقش‌ها",
"error"
);
} finally {
setSubmitting(false);
}
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
useEffect(() => {
const menu = menuRef.current;
if (!menu) return;
setTimeout(() => {
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = position.x;
let adjustedY = position.y;
if (adjustedX + rect.width > viewportWidth) {
adjustedX = viewportWidth - rect.width - 10;
}
if (adjustedY + rect.height > viewportHeight) {
adjustedY = viewportHeight - rect.height - 10;
}
if (adjustedX < 10) adjustedX = 10;
if (adjustedY < 10) adjustedY = 10;
menu.style.left = `${adjustedX}px`;
menu.style.top = `${adjustedY}px`;
}, 0);
}, [position]);
const rolesWithPermission =
rolesData?.results?.filter((role: any) => selectedRoleIds.has(role.id)) ||
[];
return createPortal(
<AnimatePresence>
<motion.div
ref={menuRef}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="fixed z-[10000] bg-white dark:bg-dark-700 rounded-lg shadow-xl ring-1 ring-black/10 dark:ring-white/10 min-w-[300px] max-w-[400px] max-h-[500px] overflow-hidden flex flex-col"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
onClick={(e) => e.stopPropagation()}
>
{isPage && (
<div className="p-3 border-b border-gray-200/60 dark:border-gray-700/40">
<Typography
variant="subtitle2"
className="text-gray-700 dark:text-gray-300 font-medium text-sm mb-1"
>
نمایش صفحه
</Typography>
</div>
)}
<div className="p-3 border-b border-gray-200/60 dark:border-gray-700/40">
<Typography
variant="subtitle2"
className="text-gray-700 dark:text-gray-300 font-medium text-sm mb-1"
>
نام صفحه: {page} ({getFaPermissions(page)})
</Typography>
<Typography
variant="caption"
className="text-xs text-gray-500 dark:text-gray-400"
>
دسترسی: {access}
</Typography>
<Typography
variant="caption"
className="text-xs text-primary-600 dark:text-primary-400 mt-1 block"
>
{rolesWithPermission.length} / {rolesData?.results?.length || 0} نقش
</Typography>
</div>
<div className="flex-1 overflow-y-auto p-2 modern-scrollbar">
{!rolesData?.results ? (
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
<Typography variant="caption" className="text-xs">
در حال بارگذاری...
</Typography>
</div>
) : rolesData.results.length > 0 ? (
<div className="space-y-1">
{rolesData.results.map((role: any) => {
const hasPermission = selectedRoleIds.has(role.id);
return (
<div
key={role.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-gray-50/50 dark:bg-dark-600/30 border border-gray-200/50 dark:border-gray-700/50 hover:bg-gray-100/60 dark:hover:bg-dark-600/50 transition-colors"
>
<Checkbox
checked={hasPermission}
disabled={submitting}
onChange={(e) => {
e.stopPropagation();
handleToggleLocal(role.id);
}}
size="small"
/>
<Typography
variant="caption"
className="text-xs text-gray-700 dark:text-gray-300 flex-1"
>
{role.role_name}
</Typography>
{role.type?.name && (
<Typography
variant="caption"
className="text-xs text-gray-500 dark:text-gray-400"
>
({role.type.name})
</Typography>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
<Typography variant="caption" className="text-xs">
نقشی یافت نشد
</Typography>
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 p-3 border-t border-gray-200/60 dark:border-gray-700/40">
<button
className="px-3 py-1.5 text-xs rounded-md bg-gray-100 dark:bg-dark-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200/80 dark:hover:bg-dark-500"
onClick={onClose}
disabled={submitting}
>
انصراف
</button>
<button
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-60"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "در حال ارسال..." : "ثبت تغییرات"}
</button>
</div>
</motion.div>
</AnimatePresence>,
document.body
);
};

View File

@@ -0,0 +1,66 @@
import React from "react";
import { textColor } from "../../data/getColorBasedOnMode";
import { getSizeStyles } from "../../data/getInputSizes";
interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
label?: string;
size?: "small" | "medium" | "large";
}
const Checkbox: React.FC<CheckboxProps> = ({
label,
checked,
onChange,
disabled,
size,
...rest
}) => {
return (
<div className={"flex items-center"}>
<label className="inline-flex items-center space-x-2 cursor-pointer select-none">
<span className="relative inline-flex items-center">
<input
type="checkbox"
checked={checked}
onChange={onChange}
disabled={disabled}
className={`${getSizeStyles(size).check} cursor-pointer
appearance-none border-1 rounded
checked:border-primary-600 border-dark-300 bg-gray-100 dark:border-gray-50 checked:bg-primary-600
checked:border-none focus:outline-none
disabled:cursor-not-allowed disabled:opacity-50
peer
`}
{...rest}
/>
<svg
className={` ${getSizeStyles(size).check} ${textColor}
absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none
hidden peer-checked:block text-white
`}
viewBox="1 1 24 20"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
{label && (
<span
className={`${size === "small" ? "text-xs" : "text-sm"} ${
disabled ? "text-dark-400 cursor-not-allowed" : textColor
}`}
>
{label}
</span>
)}
</label>
</div>
);
};
export default Checkbox;

View File

@@ -0,0 +1,44 @@
import { ReactNode } from "react";
type DividerProps = {
size?: "fullWidth" | "middle" | "inset";
children?: ReactNode;
className?: string;
};
export default function Divider({
size = "fullWidth",
children,
className = "",
}: DividerProps) {
const getWidthClass = () => {
switch (size) {
case "fullWidth":
return "w-full";
case "middle":
return "w-1/2 mx-auto";
case "inset":
return "w-1/3 ml-6";
default:
return "w-full";
}
};
if (children) {
return (
<div
className={`flex items-center gap-4 text-md text-dark-700 dark:text-primary-100 nt-medium ${getWidthClass()} ${className}`}
>
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-primary-600 to-transparent rounded-full opacity-70" />
<span className="whitespace-nowrap">{children}</span>
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-primary-600 to-transparent rounded-full opacity-70" />
</div>
);
}
return (
<div
className={`h-px bg-gradient-to-r from-transparent via-dark-300 to-transparent rounded-full opacity-70 ${getWidthClass()} ${className}`}
/>
);
}

View File

@@ -0,0 +1,57 @@
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import { motion } from "framer-motion";
type Props = {
title?: string;
link?: string;
};
export const DocumentDownloader = ({ title, link }: Props) => {
return (
<button
disabled={!link}
onClick={() => {
window.location.href = link || "";
}}
className={`relative overflow-hidden group ${
link ? "cursor-pointer" : "cursor-not-allowed opacity-60"
} `}
>
<motion.span
className={`flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-info-500/20 text-info-700 dark:text-primary-400 dark:bg-info-800/20`}
initial={false}
whileTap={{ scale: 0.95 }}
>
<motion.span
className="flex items-center gap-2"
initial={false}
animate={{
opacity: [1, 0.7, 1],
}}
transition={{
repeat: Infinity,
duration: 3,
ease: "easeInOut",
}}
>
{title || "دریافت"}
<ArrowDownTrayIcon className="w-4 h-4 ml-1" />
</motion.span>
<motion.span
className="absolute inset-0 bg-primary-500/10 opacity-0 group-hover:opacity-100"
initial={false}
transition={{ duration: 0.3 }}
style={{
borderRadius: "50%",
scale: 0,
}}
whileHover={{
scale: 2,
opacity: 0,
transition: { duration: 0.6 },
}}
/>
</motion.span>
</button>
);
};

View File

@@ -0,0 +1,135 @@
import { useEffect, useState, useRef } from "react";
import { ArrowRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { useDrawerStore } from "../../context/zustand-store/appStore";
import { checkIsMobile } from "../../utils/checkIsMobile";
import { panelBgAndTextColors } from "../../data/getColorBasedOnMode";
import Divider from "../Divider/Divider";
type Direction = "top" | "bottom" | "left" | "right" | null;
const Drawer: React.FC = () => {
const { drawerState, closeDrawer } = useDrawerStore();
const [shouldRender, setShouldRender] = useState<boolean>(
!!drawerState.isOpen
);
const [animate, setAnimate] = useState<boolean>(false);
const [mouseDownTarget, setMouseDownTarget] = useState<EventTarget | null>(
null
);
const drawerRef = useRef<HTMLDivElement>(null);
const rawDirection = drawerState.direction;
const direction: Direction = checkIsMobile()
? "top"
: rawDirection && ["top", "bottom", "left", "right"].includes(rawDirection)
? (rawDirection as Direction)
: "left";
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === mouseDownTarget && e.target === e.currentTarget) {
closeDrawer();
}
};
const handleMouseDown = (e: React.MouseEvent) => {
setMouseDownTarget(e.target);
};
useEffect(() => {
if (drawerState.isOpen) {
setShouldRender(true);
requestAnimationFrame(() => {
setAnimate(true);
});
} else {
setAnimate(false);
const timer = setTimeout(() => setShouldRender(false), 500);
return () => clearTimeout(timer);
}
}, [drawerState.isOpen]);
if (!shouldRender) {
return null;
}
const directionClasses: Record<Exclude<Direction, null>, string> = {
top: "transform -translate-y-full",
bottom: "transform translate-y-full",
left: "transform -translate-x-full",
right: "transform translate-x-full",
};
const openClasses: Record<Exclude<Direction, null>, string> = {
top: "top-0 left-0 right-0 w-full h-full sm:h-1/3 sm:w-auto translate-y-0 top-0",
bottom:
"bottom-0 left-0 right-0 w-full h-full sm:h-1/3 sm:w-auto translate-y-0 bottom-0",
left: "left-0 top-0 bottom-0 h-full sm:w-1/2 md:w-2/4 lg:w-[400px] translate-x-0",
right:
"right-0 top-0 bottom-0 h-full sm:w-1/2 md:w-2/4 lg:w-[400px] translate-x-0",
};
const isMobile = checkIsMobile();
if ((direction === "left" || direction === "right") && isMobile) {
openClasses.left = "left-0 top-0 bottom-0 w-full h-full translate-x-0";
openClasses.right = "right-0 top-0 bottom-0 w-full h-full translate-x-0";
}
const currentClasses =
direction && animate && direction in openClasses
? openClasses[direction]
: direction
? directionClasses[direction]
: "";
return (
<div
className={`fixed inset-0 bg-opacity-50 transition-opacity duration-300
${drawerState.isOpen ? "opacity-100" : "opacity-0 pointer-events-none"}
flex z-50`}
onClick={handleBackdropClick}
onMouseDown={handleMouseDown}
>
<div
ref={drawerRef}
className={`${panelBgAndTextColors} fixed shadow-lg p-4 transition-transform duration-300 ease-in-out
${currentClasses} ${!animate ? "opacity-0" : "opacity-100 "}`}
onClick={(e) => e.stopPropagation()}
>
{checkIsMobile() ? (
<div className=" items-center justify-between pb-2">
<div className="flex items-center justify-between pb-2">
<button
onClick={closeDrawer}
className="text-primary-500 hover:text-blue-600 transition-colors"
>
<ArrowRightIcon className="w-6 h-6" />
</button>
<h2 className="text-base font-medium text-gray-900 dark:text-white">
{drawerState?.title}
</h2>
<div className="w-5" />
</div>
<Divider />
</div>
) : (
<div className="flex justify-between items-center border-b pb-2 ">
<h2 className="text-lg ">{drawerState.title}</h2>
<button
onClick={closeDrawer}
className="mr-1 text-primary-500 hover:text-red-700 dark:text-white rounded-full p-0.5 transition-colors cursor-pointer "
>
<XMarkIcon className="h-5.5 5-3.5" />
</button>
</div>
)}
<div className="p-4 overflow-y-auto max-h-[94vh] scrollbar-hidden pb-24">
{drawerState.content}
</div>
</div>
</div>
);
};
export default Drawer;

View File

@@ -0,0 +1,122 @@
import { TrashIcon } from "@heroicons/react/24/outline";
import { useState, useRef, ChangeEvent } from "react";
interface FileUploaderProps {
onFileSelected: (base64: string) => void;
error?: string;
defaultValue?: string;
title?: string;
}
export default function FileUploader({
onFileSelected,
error,
defaultValue,
title = "سند",
}: FileUploaderProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
const base64 = await convertToBase64(file);
onFileSelected(base64);
};
const convertToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
const handleRemoveFile = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onFileSelected("");
};
const handleButtonClick = () => {
fileInputRef.current?.click();
};
const formatFileSize = (size: number) => {
const sizeInMB = size / (1024 * 1024);
return sizeInMB.toFixed(2) + " MB";
};
const getDefaultDocumentName = () => {
const name = defaultValue?.split("/")[defaultValue?.split("/")?.length - 1];
if (name) {
return name;
} else {
return null;
}
};
return (
<>
<div className="flex items-center w-full gap-2 border border-gray1-200 dark:border-dark-400 rounded-lg">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="*/*"
/>
<button
type="button"
onClick={handleButtonClick}
className={`flex justify-center cursor-pointer items-center px-4 py-2 rounded-lg transition-colors w-full ${
selectedFile
? "border-l border-gray1-200 dark:border-dark-400 bg-gray-50 dark:bg-dark-600 dark:text-dark-100 hover:bg-gray-100 dark:hover:bg-dark-600 text-gray-700"
: "bg-white1-200 dark:bg-dark-500 dark:text-dark-100 text-gray-700 hover:bg-white1-300 dark:hover:bg-dark-600"
}`}
>
{selectedFile || getDefaultDocumentName() ? (
<div className="flex items-center gap-2">
<div className="flex justify-center">
<span className=" text-[10px] w-10 bg-info-500 rounded-2xl text-white">
{title}
</span>
</div>
{selectedFile ? (
<span className="">
{selectedFile.name?.slice(0, 15)}
{selectedFile.name?.length > 15 ? "..." : ""} (
{formatFileSize(selectedFile.size)})
</span>
) : (
<span>{getDefaultDocumentName()}</span>
)}
</div>
) : (
<span>انتخاب {title}</span>
)}
</button>
{selectedFile && (
<button
type="button"
onClick={handleRemoveFile}
className="p-2 text-red-400 rounded-lg transition-colors"
aria-label="Remove file"
>
<TrashIcon className="h-5 w-5" />
</button>
)}
</div>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</>
);
}

View File

@@ -0,0 +1,416 @@
import { useEffect, useState } from "react";
import { useApiRequest } from "../../utils/useApiRequest";
import AutoComplete from "../AutoComplete/AutoComplete";
import { getFaPermissions } from "../../utils/getFaPermissions";
import { getNestedValue } from "../../utils/getNestedValue";
type FormEnterLocationsProps = {
title: string;
api: string;
error?: boolean;
errorMessage?: any;
multiple?: boolean;
onChange?: (data: any) => void;
onChangeValue?: (data: any) => void;
keyField?: string;
secondaryKey?: string | string[];
tertiaryKey?: string | string[];
valueField?: string | string[];
valueField2?: string | string[];
valueField3?: string | string[];
filterAddress?: string[];
filterValue?: string[];
defaultKey?: string | number | any[];
valueTemplate?: string;
valueTemplateProps?: Array<{ [key: string]: "string" | "number" }>;
groupBy?: string | string[];
groupFunction?: (item: any) => string;
selectField?: boolean;
};
export const FormApiBasedAutoComplete = ({
title,
api,
error,
errorMessage,
onChange,
onChangeValue,
keyField = "id",
secondaryKey,
tertiaryKey,
valueField = "name",
valueField2,
valueField3,
defaultKey,
multiple = false,
filterValue,
filterAddress,
valueTemplate,
valueTemplateProps,
groupBy,
groupFunction,
selectField = false,
}: FormEnterLocationsProps) => {
const [data, setData] = useState<any>([]);
const [selectedKeys, setSelectedKeys] = useState<(string | number)[]>([]);
const [groupedItemsMap, setGroupedItemsMap] = useState<
Map<string | number, any[]>
>(new Map());
const formatValue = (value: any, fieldKey: string) => {
if (!valueTemplate || !valueTemplateProps) return value;
const templateProp = 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: api,
method: "get",
params: { page: 1, page_size: 1000 },
queryKey: [api],
disableBackdrop: defaultKey ? true : false,
});
useEffect(() => {
if (apiData?.results) {
let data;
if (filterAddress && filterValue) {
data = apiData.results?.filter(
(item: any) =>
!filterValue.includes(getNestedValue(item, filterAddress))
);
} else {
data = apiData.results;
}
const d = data?.map((option: any) => ({
key: option[keyField],
secondaryKey: secondaryKey
? typeof secondaryKey === "string"
? option[secondaryKey]
: getNestedValue(option, secondaryKey)
: undefined,
tertiaryKey: tertiaryKey
? typeof tertiaryKey === "string"
? option[tertiaryKey]
: getNestedValue(option, tertiaryKey)
: undefined,
value: valueTemplate
? valueTemplate
.replace(
/v1/g,
formatValue(
valueField === "page"
? getFaPermissions(option[valueField])
: typeof valueField === "string"
? option[valueField]
: getNestedValue(option, valueField),
"v1"
)
)
.replace(
/v2/g,
formatValue(
valueField2
? typeof valueField2 === "string"
? option[valueField2]
: getNestedValue(option, valueField2)
: "",
"v2"
)
)
.replace(
/v3/g,
formatValue(
valueField3
? typeof valueField3 === "string"
? option[valueField3]
: getNestedValue(option, valueField3)
: "",
"v3"
)
)
: `${
valueField === "page"
? getFaPermissions(option[valueField])
: typeof valueField === "string"
? option[valueField]
: getNestedValue(option, valueField)
} ${
valueField2
? " - " +
(typeof valueField2 === "string"
? option[valueField2]
: getNestedValue(option, valueField2))
: ""
} ${
valueField3
? "(" +
(typeof valueField3 === "string"
? option[valueField3]
: getNestedValue(option, valueField3)) +
")"
: ""
}`,
groupValue: groupBy
? typeof groupBy === "string"
? option[groupBy]
: getNestedValue(option, groupBy)
: undefined,
}));
let finalData = d;
if (groupBy) {
const grouped = new Map<string | number, typeof d>();
d.forEach((item: any) => {
const groupKey = item.groupValue ?? "";
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(item);
});
setGroupedItemsMap(grouped);
finalData = [];
grouped.forEach((items, groupKey) => {
finalData.push({
key: `__group__${groupKey}`,
value: groupFunction ? groupFunction(groupKey) : String(groupKey),
disabled: true,
isGroupHeader: true,
originalGroupKey: groupKey,
});
finalData.push(...items);
});
} else {
setGroupedItemsMap(new Map());
}
setData(finalData);
const actualDataItems = finalData.filter(
(item: any) => !item.isGroupHeader && !item.disabled
);
if (defaultKey !== undefined && defaultKey !== null) {
if (multiple) {
if (Array.isArray(defaultKey)) {
if (defaultKey.length === 0) {
setSelectedKeys([]);
} else {
const defaultIds = defaultKey.map((item: any) =>
typeof item === "object" ? item[keyField] : item
);
const defaultItems = actualDataItems.filter((item: any) =>
defaultIds.includes(item.key)
);
setSelectedKeys(defaultItems.map((item: any) => item.key));
if (onChange) {
if (secondaryKey) {
onChange({
key1: defaultItems.map((item: any) => item.key),
key2: defaultItems.map((item: any) => item.secondaryKey),
...(tertiaryKey
? {
key3: defaultItems.map(
(item: any) => item.tertiaryKey
),
}
: {}),
});
} else {
onChange(defaultItems.map((item: any) => item.key));
}
}
}
}
} else {
if (!Array.isArray(defaultKey)) {
const keyToFind =
typeof defaultKey === "object"
? defaultKey[keyField]
: defaultKey;
const defaultItem = actualDataItems.find(
(item: any) => item.key === keyToFind
);
if (defaultItem) {
setSelectedKeys([keyToFind]);
if (onChange) {
if (secondaryKey) {
onChange({
key1: defaultItem.key,
key2: defaultItem.secondaryKey,
...(tertiaryKey ? { key3: defaultItem.tertiaryKey } : {}),
});
} else {
onChange(keyToFind);
}
}
if (onChangeValue) {
onChangeValue({
key1: defaultItem.key,
key2: defaultItem.secondaryKey,
...(tertiaryKey ? { key3: defaultItem.tertiaryKey } : {}),
value: defaultItem.value.trim(),
});
}
}
}
}
}
}
}, [apiData]);
const handleGroupHeaderClick = (groupKey: string | number) => {
if (!multiple || !groupBy) return;
const groupItems = groupedItemsMap.get(groupKey) || [];
if (groupItems.length === 0) return;
const groupItemKeys = groupItems.map((item: any) => item.key);
const allGroupItemsSelected = groupItemKeys.every((key) =>
selectedKeys.includes(key)
);
let newSelectedKeys: (string | number)[];
if (allGroupItemsSelected) {
newSelectedKeys = selectedKeys.filter(
(key) => !groupItemKeys.includes(key)
);
} else {
const newKeys = groupItemKeys.filter(
(key) => !selectedKeys.includes(key)
);
newSelectedKeys = [...selectedKeys, ...newKeys];
}
setSelectedKeys(newSelectedKeys);
if (!onChange) return;
const selectedItems = data.filter(
(item: any) =>
newSelectedKeys.includes(item.key) &&
!item.isGroupHeader &&
!item.disabled
);
if (secondaryKey) {
onChange(
selectedItems.map((item: any) => ({
key1: item.key,
key2: item.secondaryKey,
...(tertiaryKey ? { key3: item.tertiaryKey } : {}),
}))
);
if (onChangeValue) {
onChangeValue(selectedItems.map((item: any) => item.value.trim()));
}
} else {
onChange(newSelectedKeys);
}
};
return (
<>
<AutoComplete
multiselect={multiple}
selectField={selectField}
data={data}
selectedKeys={selectedKeys}
onChange={(newSelectedKeys) => {
setSelectedKeys(newSelectedKeys);
if (!onChange) return;
if (multiple) {
if (secondaryKey) {
const selectedItems = data.filter(
(item: any) =>
newSelectedKeys.includes(item.key) &&
!item.isGroupHeader &&
!item.disabled
);
onChange(
selectedItems.map((item: any) => ({
key1: item.key,
key2: item.secondaryKey,
...(tertiaryKey ? { key3: item.tertiaryKey } : {}),
}))
);
if (onChangeValue) {
onChangeValue(
selectedItems.map((item: any) => item.value.trim())
);
}
} else {
const validKeys = newSelectedKeys.filter(
(key) => !String(key).startsWith("__group__")
);
onChange(validKeys);
}
} else {
if (secondaryKey) {
const selectedItem = data.find(
(item: any) =>
item.key === newSelectedKeys[0] &&
!item.isGroupHeader &&
!item.disabled
);
if (onChangeValue) {
onChangeValue({
value: selectedItem?.value.trim() ?? "",
key1: selectedItem?.key ?? "",
key2: selectedItem?.secondaryKey ?? "",
...(tertiaryKey
? { key3: selectedItem?.tertiaryKey ?? "" }
: {}),
});
}
if (selectedItem) {
onChange({
key1: selectedItem.key,
key2: selectedItem.secondaryKey,
...(tertiaryKey ? { key3: selectedItem.tertiaryKey } : {}),
});
}
} else {
const validKey =
newSelectedKeys[0] &&
!String(newSelectedKeys[0]).startsWith("__group__")
? newSelectedKeys[0]
: "";
onChange(validKey);
}
}
}}
title={title}
error={error}
helperText={errorMessage}
onGroupHeaderClick={handleGroupHeaderClick}
/>
</>
);
};

View File

@@ -0,0 +1,243 @@
import { useEffect, useState, useMemo, useRef } from "react";
import { useApiMutation } from "../../utils/useApiRequest";
import AutoComplete from "../AutoComplete/AutoComplete";
import { useUserProfileStore } from "../../context/zustand-store/userStore";
type FormEnterLocationsProps = {
cityError: boolean;
cityValue?: string | number;
provinceValue?: string | number;
provincError: boolean;
cityErrorMessage?: any;
provinceErrMessage?: any;
roleControlled?: boolean;
onChange?: (data: { province: string | any; city: string | any }) => void;
onChangeValue?: (data: {
province: string | any;
city: string | any;
}) => void;
};
export const FormEnterLocations = ({
roleControlled,
cityError,
cityValue,
provinceValue,
cityErrorMessage,
provincError,
provinceErrMessage,
onChange,
onChangeValue,
}: FormEnterLocationsProps) => {
const [provinceApiData, setProvinceAPiData] = useState<any>(null);
const [provinceData, setProvinceData] = useState<any>([]);
const [cityApiData, setCityAPiData] = useState<any>(null);
const [cityData, setCityData] = useState<any>([]);
const [selectedProvinceKeys, setSelectedProvinceKeys] = useState<
(string | number)[]
>([]);
const [selectedCityKeys, setSelectedCityKeys] = useState<(string | number)[]>(
[]
);
const [value, setValue] = useState<{
province: string | any;
city: string | any;
}>({ province: "", city: "" });
const lastFetchedProvinceId = useRef<number | string | undefined>(undefined);
const { profile } = useUserProfileStore();
const isProvinceDisabled =
roleControlled &&
profile?.organization?.field_of_activity !== "CO" &&
profile?.role?.type?.key !== "ADM";
const isCityDisabled =
roleControlled &&
profile?.organization?.field_of_activity === "CI" &&
profile?.role?.type?.key !== "ADM";
const provinceId = useMemo(() => {
return isProvinceDisabled
? profile?.user?.province
: selectedProvinceKeys[0];
}, [isProvinceDisabled, profile?.user?.province, selectedProvinceKeys]);
const cityId = useMemo(() => {
return isCityDisabled ? profile?.user?.city : selectedCityKeys[0];
}, [isCityDisabled, profile?.user?.city, selectedCityKeys]);
const mutationProvince = useApiMutation({
api: "/auth/api/v1/province/",
method: "get",
disableBackdrop: provinceValue ? true : false,
});
const mutationCity = useApiMutation({
api: `/auth/api/v1/city/`,
method: "get",
disableBackdrop: provinceValue ? true : false,
});
const getPages = async () => {
const data = await mutationProvince.mutateAsync({
page: 1,
page_size: 1000,
});
setProvinceAPiData(data);
};
const getCities = async (provinceIdParam: number | string | undefined) => {
if (!provinceIdParam) return;
if (lastFetchedProvinceId.current === provinceIdParam) {
return;
}
lastFetchedProvinceId.current = provinceIdParam;
const data = await mutationCity.mutateAsync({
page: 1,
page_size: 1000,
province: provinceIdParam,
});
setCityAPiData(data);
};
useEffect(() => {
getPages();
}, []);
useEffect(() => {
if (provinceApiData?.results) {
const d = provinceApiData.results.map((province: any) => ({
key: province.id,
value: province.name,
}));
setProvinceData(d);
}
}, [provinceApiData]);
useEffect(() => {
const currentProvinceId = provinceId;
if (currentProvinceId) {
if (isProvinceDisabled && profile?.user?.province) {
getCities(currentProvinceId);
} else if (selectedProvinceKeys.length) {
getCities(currentProvinceId);
}
}
if (!isProvinceDisabled && selectedProvinceKeys.length === 0) {
setCityData([]);
setSelectedCityKeys([]);
lastFetchedProvinceId.current = undefined;
}
}, [
provinceId,
selectedProvinceKeys.length,
isProvinceDisabled,
profile?.user?.province,
]);
useEffect(() => {
if (cityApiData) {
const d = cityApiData.map((city: any) => ({
key: city.id,
value: city.name,
}));
setCityData(d);
}
}, [cityApiData]);
useEffect(() => {
if (
isProvinceDisabled &&
profile?.user?.province &&
provinceData.length > 0
) {
const initialProvince = provinceData.find(
(province: any) => province.key === profile.user.province
);
if (initialProvince) {
setSelectedProvinceKeys([initialProvince.key]);
}
} else if (provinceValue && provinceData.length > 0) {
const initialProvince = provinceData.find(
(province: any) => province.key === provinceValue
);
if (initialProvince) {
setSelectedProvinceKeys([initialProvince.key]);
}
}
}, [
provinceValue,
provinceData,
isProvinceDisabled,
profile?.user?.province,
]);
useEffect(() => {
if (isCityDisabled && profile?.user?.city && cityData.length > 0) {
const initialCity = cityData.find(
(city: any) => city.key === profile.user.city
);
if (initialCity) {
setSelectedCityKeys([initialCity.key]);
}
} else if (cityValue && cityData.length > 0) {
const initialCity = cityData.find((city: any) => city.key === cityValue);
if (initialCity) {
setSelectedCityKeys([initialCity.key]);
}
}
}, [cityValue, cityData, isCityDisabled, profile?.user?.city]);
useEffect(() => {
if (onChange)
onChange({
province:
typeof selectedProvinceKeys[0] === "number"
? selectedProvinceKeys[0]
: "",
city: typeof cityId === "number" ? cityId : "",
});
}, [selectedProvinceKeys, selectedCityKeys, cityId]);
useEffect(() => {
if (value.province && value.city) onChangeValue?.(value);
}, [value]);
return (
<>
<AutoComplete
data={provinceData}
selectedKeys={selectedProvinceKeys}
onChange={(newselectedProvinceKeys) => {
setSelectedProvinceKeys(newselectedProvinceKeys);
}}
onChangeValue={(r) => {
setValue((prev) => ({ ...prev, province: r.value }));
}}
title="استان"
error={provincError}
helperText={provinceErrMessage}
disabled={isProvinceDisabled}
/>
<AutoComplete
disabled={isCityDisabled || !selectedProvinceKeys.length}
data={cityData}
selectedKeys={selectedCityKeys}
onChange={(newselectedProvinceKeys) => {
setSelectedCityKeys(newselectedProvinceKeys);
}}
onChangeValue={(r) => {
setValue((prev) => ({ ...prev, city: r.value }));
}}
title="شهر"
error={cityError}
helperText={cityErrorMessage}
/>
</>
);
};

View File

@@ -0,0 +1,54 @@
import React from "react";
interface GridProps {
children?: React.ReactNode;
className?: string;
container?: boolean;
column?: boolean;
isDashboard?: boolean;
xs?: string;
sm?: string;
md?: string;
lg?: string;
onClick?: () => void;
}
export const Grid: React.FC<GridProps> = ({
children,
className,
container,
column,
isDashboard,
xs,
sm,
md,
lg,
onClick,
...props
}) => {
const getWidthSizes = () => {
let sizes;
if (xs || sm || md || lg) {
sizes = `w-${xs} ${sm && `w-${sm}`} ${md && `md:w-${md}`} ${
lg && `lg:w-${lg}`
}`;
}
return sizes;
};
return (
<div
onClick={onClick}
{...props}
className={`${
isDashboard && "shadow-xl rounded-2xl shadow-red-500/10"
} ${className} ${container && "flex"} ${getWidthSizes()} ${
column && "flex-col"
}`}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,500 @@
import { PhotoIcon, XMarkIcon, CheckIcon } from "@heroicons/react/24/outline";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useRef, ChangeEvent } from "react";
type Props = {
onImageSelected?: (base64: string) => void;
maxSize?: number;
title?: string;
error?: string;
defaultValue?: string;
width?: number;
height?: number;
};
export const ImageUploader = ({
onImageSelected,
maxSize,
title = "تصویر",
error: externalError,
defaultValue,
width,
height,
}: Props) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [error, setError] = useState<string>("");
const [showCropModal, setShowCropModal] = useState(false);
const [imageSrc, setImageSrc] = useState<string>("");
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropArea, setCropArea] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const fileInputRef = useRef<HTMLInputElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("لطفا یک فایل تصویری انتخاب کنید");
return;
}
if (maxSize && file.size > maxSize) {
const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(2);
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2);
setError(
`حجم فایل (${fileSizeMB} MB) بیشتر از حد مجاز (${maxSizeMB} MB) است`
);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
// Check image dimensions if width and height are specified
if (width && height) {
try {
const dimensions = await checkImageDimensions(file);
if (dimensions.width !== width || dimensions.height !== height) {
// If image is smaller than required, show error
if (dimensions.width < width || dimensions.height < height) {
setError(
`ابعاد تصویر باید حداقل ${width}×${height} پیکسل باشد. ابعاد فعلی: ${dimensions.width}×${dimensions.height} پیکسل`
);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
// If image is larger, show cropping modal
if (dimensions.width > width || dimensions.height > height) {
const base64 = await convertToBase64(file);
setImageSrc(base64);
setCropFile(file);
setShowCropModal(true);
setError("");
return;
}
}
} catch {
setError("خطا در بررسی ابعاد تصویر");
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
}
setError("");
setSelectedFile(file);
const base64 = await convertToBase64(file);
onImageSelected?.(base64);
};
const convertToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
const checkImageDimensions = (
file: File
): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = e.target?.result as string;
};
reader.onerror = (error) => reject(error);
});
};
const handleButtonClick = () => {
fileInputRef.current?.click();
};
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
setSelectedFile(null);
setError("");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onImageSelected?.("");
};
const getFileNameFromUrl = (url: string): string => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const fileName = pathname.split("/").pop() || "";
return fileName;
} catch {
const parts = url.split("/");
return parts[parts.length - 1] || "";
}
};
const initializeCropArea = () => {
if (!imageRef.current || !width || !height) return;
const img = imageRef.current;
const imgRect = img.getBoundingClientRect();
// Calculate scale factor
const scaleX = imgRect.width / img.naturalWidth;
const scaleY = imgRect.height / img.naturalHeight;
// Calculate crop area size in display pixels
const cropDisplayWidth = width * scaleX;
const cropDisplayHeight = height * scaleY;
// Center the crop area
const x = (imgRect.width - cropDisplayWidth) / 2;
const y = (imgRect.height - cropDisplayHeight) / 2;
setCropArea({
x: Math.max(0, x),
y: Math.max(0, y),
width: Math.min(cropDisplayWidth, imgRect.width),
height: Math.min(cropDisplayHeight, imgRect.height),
});
};
const handleImageLoad = () => {
initializeCropArea();
};
const handleMouseDown = (e: React.MouseEvent) => {
if (!imageContainerRef.current || !imageRef.current) return;
const img = imageRef.current;
const imgRect = img.getBoundingClientRect();
const x = e.clientX - imgRect.left;
const y = e.clientY - imgRect.top;
// Check if click is within crop area
if (
x >= cropArea.x &&
x <= cropArea.x + cropArea.width &&
y >= cropArea.y &&
y <= cropArea.y + cropArea.height
) {
setIsDragging(true);
setDragStart({
x: x - cropArea.x,
y: y - cropArea.y,
});
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !imageRef.current) return;
const img = imageRef.current;
const imgRect = img.getBoundingClientRect();
const x = e.clientX - imgRect.left - dragStart.x;
const y = e.clientY - imgRect.top - dragStart.y;
// Constrain crop area within image bounds
const maxX = imgRect.width - cropArea.width;
const maxY = imgRect.height - cropArea.height;
setCropArea({
...cropArea,
x: Math.max(0, Math.min(x, maxX)),
y: Math.max(0, Math.min(y, maxY)),
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
const cropImage = async (): Promise<string> => {
if (!imageRef.current || !cropFile || !width || !height) return "";
return new Promise((resolve, reject) => {
const img = imageRef.current!;
const imgRect = img.getBoundingClientRect();
// Calculate scale factor
const scaleX = img.naturalWidth / imgRect.width;
const scaleY = img.naturalHeight / imgRect.height;
// Calculate actual crop coordinates in image pixels
const sourceX = cropArea.x * scaleX;
const sourceY = cropArea.y * scaleY;
const sourceWidth = cropArea.width * scaleX;
const sourceHeight = cropArea.height * scaleY;
// Create canvas and crop
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Failed to get canvas context"));
return;
}
const image = new Image();
image.onload = () => {
ctx.drawImage(
image,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
width,
height
);
const base64 = canvas.toDataURL("image/png");
resolve(base64);
};
image.onerror = () => reject(new Error("Failed to load image"));
image.src = imageSrc;
});
};
const handleCropConfirm = async () => {
try {
const croppedBase64 = await cropImage();
if (croppedBase64 && cropFile) {
setSelectedFile(cropFile);
setShowCropModal(false);
setError("");
onImageSelected?.(croppedBase64);
}
} catch {
setError("خطا در برش تصویر");
}
};
const handleCropCancel = () => {
setShowCropModal(false);
setImageSrc("");
setCropFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const displayError = error || externalError;
return (
<div className="flex flex-col gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="image/*"
/>
<button
type="button"
onClick={handleButtonClick}
className={`relative overflow-hidden group ${
displayError ? "cursor-pointer" : "cursor-pointer"
}`}
>
<motion.span
className={`flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg ${
selectedFile || defaultValue
? "bg-success-500/20 text-success-700 dark:text-success-400 dark:bg-success-800/20"
: "bg-info-500/20 text-info-700 dark:text-primary-400 dark:bg-info-800/20"
}`}
initial={false}
whileTap={{ scale: 0.95 }}
>
<motion.span
className="flex items-center gap-2"
initial={false}
animate={
!selectedFile && !defaultValue
? {
opacity: [1, 0.7, 1],
}
: {}
}
transition={{
repeat: Infinity,
duration: 3,
ease: "easeInOut",
}}
>
{selectedFile
? selectedFile.name
: defaultValue
? getFileNameFromUrl(defaultValue)
: `انتخاب ${title}`}
<PhotoIcon className="w-4 h-4 ml-1" />
</motion.span>
{selectedFile && (
<button
type="button"
onClick={handleRemove}
className="ml-2 p-0.5 rounded-full hover:bg-red-500/20 dark:hover:bg-red-500/30 transition-colors"
aria-label="حذف تصویر"
>
<XMarkIcon className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
</button>
)}
<motion.span
className="absolute inset-0 bg-primary-500/10 opacity-0 group-hover:opacity-100"
initial={false}
transition={{ duration: 0.3 }}
style={{
borderRadius: "50%",
scale: 0,
}}
whileHover={{
scale: 2,
opacity: 0,
transition: { duration: 0.6 },
}}
/>
</motion.span>
</button>
{displayError && (
<p className="text-xs text-red-500 dark:text-red-400">{displayError}</p>
)}
<AnimatePresence>
{showCropModal && width && height && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 dark:bg-black/70"
onClick={handleCropCancel}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white dark:bg-dark-600 rounded-lg p-4 max-w-4xl max-h-[90vh] w-full mx-4 flex flex-col gap-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
برش تصویر ({width}×{height} پیکسل)
</h3>
<button
type="button"
onClick={handleCropCancel}
className="p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<XMarkIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
</div>
<div className="flex-1 overflow-auto">
<div
ref={imageContainerRef}
className="relative inline-block max-w-full"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
<img
ref={imageRef}
src={imageSrc}
alt="Crop"
onLoad={handleImageLoad}
className="max-w-full h-auto select-none"
draggable={false}
/>
{cropArea.width > 0 && cropArea.height > 0 && (
<>
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50"
style={{
clipPath: `polygon(
0% 0%,
0% 100%,
${cropArea.x}px 100%,
${cropArea.x}px ${cropArea.y}px,
${cropArea.x + cropArea.width}px ${cropArea.y}px,
${cropArea.x + cropArea.width}px ${
cropArea.y + cropArea.height
}px,
${cropArea.x}px ${cropArea.y + cropArea.height}px,
${cropArea.x}px 100%,
100% 100%,
100% 0%
)`,
}}
/>
{/* Crop area border */}
<div
className="absolute border-2 border-white shadow-lg"
style={{
left: `${cropArea.x}px`,
top: `${cropArea.y}px`,
width: `${cropArea.width}px`,
height: `${cropArea.height}px`,
boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)",
}}
>
{/* Corner handles */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-full" />
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-full" />
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-full" />
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-full" />
</div>
</>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={handleCropCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
انصراف
</button>
<button
type="button"
onClick={handleCropConfirm}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 dark:bg-primary-500 rounded-lg hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors flex items-center gap-2"
>
<CheckIcon className="w-4 h-4" />
تایید
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,431 @@
import React, { useMemo, useState, useEffect } from "react";
import { motion } from "framer-motion";
type DataPoint = {
x: number | Date | string;
y: number;
label?: string | number;
};
type LineChartProps = {
data: DataPoint[];
className?: string;
margin?: { top: number; right: number; bottom: number; left: number };
strokeWidth?: number;
showGrid?: boolean;
showDots?: boolean;
dotSize?: number;
showArea?: boolean;
xAxisLabel?: string;
yAxisLabel?: string;
animate?: boolean;
curveType?: "linear" | "monotone" | "step";
};
const getSmoothPath = (points: { x: number; y: number }[]) => {
let path = `M${points[0].x},${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
const controlX1 = prev.x + (curr.x - prev.x) * 0.3;
const controlX2 = curr.x - (next ? (next.x - curr.x) * 0.3 : 0);
path += ` C${controlX1},${prev.y} ${controlX2},${curr.y} ${curr.x},${curr.y}`;
}
return path;
};
const LineChart: React.FC<LineChartProps> = ({
data,
className = "w-full h-64 md:h-96",
margin = { top: 20, right: 20, bottom: 40, left: 40 },
strokeWidth = 2,
showGrid = true,
showDots = true,
dotSize = 4,
showArea = true,
xAxisLabel,
yAxisLabel,
animate = true,
curveType = "monotone",
}) => {
const [hoveredPoint, setHoveredPoint] = useState<DataPoint | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
if (containerRef) {
setDimensions({
width: containerRef.clientWidth,
height: containerRef.clientHeight,
});
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [containerRef]);
const width = dimensions.width;
const height = dimensions.height;
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const { minX, maxX, minY, maxY } = useMemo(() => {
const xValues = data.map((d, i) =>
typeof d.x === "number" || d.x instanceof Date
? d.x instanceof Date
? d.x.getTime()
: d.x
: i
);
const yValues = data.map((d) => d.y);
return {
minX: Math.min(...xValues),
maxX: Math.max(...xValues),
minY: Math.min(0, ...yValues),
maxY: Math.max(...yValues),
};
}, [data]);
const scaleX = (value: number | Date | string) => {
if (typeof value === "string") {
const index = data.findIndex((d) => d.x === value);
return (index / (data.length - 1)) * innerWidth;
}
const numericValue = value instanceof Date ? value.getTime() : value;
return ((numericValue - minX) / (maxX - minX)) * innerWidth;
};
const scaleY = (value: number) => {
return innerHeight - ((value - minY) / (maxY - minY)) * innerHeight;
};
const linePath = useMemo(() => {
if (data.length === 0) return "";
const points = data.map((point) => ({
x: scaleX(point.x),
y: scaleY(point.y),
}));
if (curveType === "linear") {
return `M${points.map((p) => `${p.x},${p.y}`).join(" L")}`;
} else if (curveType === "step") {
let path = `M${points[0].x},${points[0].y}`;
for (let i = 1; i < points.length; i++) {
path += ` H${(points[i].x + points[i - 1].x) / 2}`;
path += ` V${points[i].y}`;
path += ` H${points[i].x}`;
}
return path;
} else {
return getSmoothPath(points);
}
}, [data, scaleX, scaleY, curveType]);
const areaPath = useMemo(() => {
if (data.length === 0 || !showArea) return "";
const points = data.map((point) => ({
x: scaleX(point.x),
y: scaleY(point.y),
}));
let path;
if (curveType === "linear") {
path = `M${points[0].x},${innerHeight} L${points
.map((p) => `${p.x},${p.y}`)
.join(" L")} L${points[points.length - 1].x},${innerHeight} Z`;
} else if (curveType === "step") {
path = `M${points[0].x},${innerHeight} L${points[0].x},${points[0].y}`;
for (let i = 1; i < points.length; i++) {
path += ` H${(points[i].x + points[i - 1].x) / 2}`;
path += ` V${points[i].y}`;
path += ` H${points[i].x}`;
}
path += ` V${innerHeight} Z`;
} else {
path = `M${points[0].x},${innerHeight} L${points[0].x},${points[0].y}`;
path += getSmoothPath(points).substring(1);
path += ` L${points[points.length - 1].x},${innerHeight} Z`;
}
return path;
}, [data, scaleX, scaleY, showArea, innerHeight, curveType]);
const formatXLabel = (value: number | Date | string) => {
if (typeof value === "string") return value;
if (value instanceof Date) return value.toLocaleDateString();
return value.toString();
};
const formatYLabel = (value: number) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toFixed(2);
};
const xTicks = useMemo(() => {
if (data.length <= 6) {
return data.map((point) => ({
x: scaleX(point.x),
value: point.x,
}));
}
const tickCount = Math.min(5, data.length);
return Array.from({ length: tickCount }).map((_, i) => {
const index = Math.floor((i / (tickCount - 1)) * (data.length - 1));
const point = data[index];
return {
x: scaleX(point.x),
value: point.x,
};
});
}, [data, scaleX]);
const yTicks = useMemo(() => {
const tickCount = 5;
return Array.from({ length: tickCount }).map((_, i) => {
const value = minY + (i / (tickCount - 1)) * (maxY - minY);
return {
y: scaleY(value),
value,
};
});
}, [minY, maxY, scaleY]);
const handlePointHover = (point: DataPoint) => {
setHoveredPoint(point);
const xPos = scaleX(point.x) + margin.left;
const yPos = scaleY(point.y) + margin.top - 20;
setTooltipPos({
x: xPos,
y: yPos,
});
};
if (data.length === 0) {
return (
<div
className={`flex items-center justify-center rounded-lg bg-gray-50 dark:bg-gray-800 ${className}`}
>
<p className="text-gray-500 dark:text-gray-400">اطلاعات موجود نیست</p>
</div>
);
}
return (
<div ref={setContainerRef} className={`relative ${className}`}>
<svg
width={width}
height={height}
className="rounded-lg bg-white dark:bg-gray-900 shadow-sm dark:shadow-none dark:border dark:border-gray-700"
>
<rect
width={width}
height={height}
rx="8"
className="fill-white dark:fill-dark-900"
/>
<g transform={`translate(${margin.left},${margin.top})`}>
{showGrid && (
<>
{xTicks.map((tick, i) => (
<g key={`x-grid-${i}`}>
<line
x1={tick.x}
y1={0}
x2={tick.x}
y2={innerHeight}
className="stroke-gray-200 dark:stroke-gray-700"
strokeWidth={1}
/>
</g>
))}
{yTicks.map((tick, i) => (
<g key={`y-grid-${i}`}>
<line
x1={0}
y1={tick.y}
x2={innerWidth}
y2={tick.y}
className="stroke-gray-200 dark:stroke-gray-700"
strokeWidth={1}
/>
</g>
))}
</>
)}
{showArea && (
<motion.path
d={areaPath}
className="fill-primary-100/30 dark:fill-primary-900/30"
stroke="none"
initial={animate ? { opacity: 0 } : undefined}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
/>
)}
<motion.path
d={linePath}
fill="none"
className="stroke-primary-500 dark:stroke-primary-400"
strokeWidth={strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
initial={animate ? { pathLength: 0 } : undefined}
animate={{ pathLength: 1 }}
transition={{ duration: 1.5, ease: "easeInOut" }}
/>
{showDots &&
data.map((point, i) => (
<g key={`point-${i}`}>
<motion.circle
cx={scaleX(point.x)}
cy={scaleY(point.y)}
r={dotSize}
className="fill-primary-500 dark:fill-primary-400 stroke-white dark:stroke-gray-900"
strokeWidth={2}
initial={animate ? { scale: 0 } : undefined}
animate={{ scale: 1 }}
transition={{ delay: i * 0.05, type: "spring" }}
onMouseEnter={() => handlePointHover(point)}
onMouseLeave={() => setHoveredPoint(null)}
style={{ cursor: "pointer" }}
/>
</g>
))}
<line
x1={0}
y1={innerHeight}
x2={innerWidth}
y2={innerHeight}
className="stroke-gray-300 dark:stroke-gray-600"
strokeWidth={1.5}
/>
<line
x1={0}
y1={0}
x2={0}
y2={innerHeight}
className="stroke-gray-300 dark:stroke-gray-600"
strokeWidth={1.5}
/>
{xTicks.map((tick, i) => (
<g key={`x-tick-${i}`}>
<line
x1={tick.x}
y1={innerHeight}
x2={tick.x}
y2={innerHeight + 6}
className="stroke-gray-400 dark:stroke-gray-500"
strokeWidth={1}
/>
<text
x={tick.x}
y={innerHeight + 16}
textAnchor="middle"
fontSize={10}
className="fill-gray-500 dark:fill-gray-400 font-medium"
>
{formatXLabel(tick.value)}
</text>
</g>
))}
{yTicks.map((tick, i) => (
<g key={`y-tick-${i}`}>
<line
x1={0}
y1={tick.y}
x2={-6}
y2={tick.y}
className="stroke-gray-400 dark:stroke-gray-500"
strokeWidth={1}
/>
<text
x={-8}
y={tick.y + 3}
textAnchor="end"
fontSize={10}
className="fill-gray-500 dark:fill-gray-400 font-medium"
>
{formatYLabel(tick.value)}
</text>
</g>
))}
{xAxisLabel && (
<text
x={innerWidth / 2}
y={innerHeight + 30}
textAnchor="middle"
fontSize={12}
className="fill-gray-700 dark:fill-gray-300 font-semibold"
>
{xAxisLabel}
</text>
)}
{yAxisLabel && (
<text
x={-margin.left / 2}
y={innerHeight / 2}
textAnchor="middle"
fontSize={12}
className="fill-gray-700 dark:fill-gray-300 font-semibold"
transform={`rotate(-90, ${-margin.left / 2}, ${innerHeight / 2})`}
>
{yAxisLabel}
</text>
)}
</g>
</svg>
{hoveredPoint && (
<motion.div
className="absolute z-10 p-2 text-xs md:text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg dark:shadow-xl pointer-events-none"
style={{
left: tooltipPos.x,
top: tooltipPos.y,
transform: "translateX(-50%)",
}}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ type: "spring", damping: 25 }}
>
<div className="font-semibold text-primary-600 dark:text-primary-400">
{hoveredPoint.label || "Data Point"}
</div>
<div className="text-gray-700 dark:text-gray-300">
<span className="font-medium">{xAxisLabel}:</span>{" "}
{formatXLabel(hoveredPoint.x)}
</div>
<div className="text-gray-700 dark:text-gray-300">
<span className="font-medium">{yAxisLabel}:</span>{" "}
{formatYLabel(hoveredPoint.y)}
</div>
</motion.div>
)}
</div>
);
};
export default LineChart;

View File

@@ -0,0 +1,123 @@
import { useModalStore } from "../../context/zustand-store/appStore";
import { XMarkIcon } from "@heroicons/react/16/solid";
import { FC, useRef, useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
const Modal: FC = () => {
const { isOpen, title, content, closeModal, isFullSize } = useModalStore();
const modalRef = useRef<HTMLDivElement>(null);
const [mouseDownTarget, setMouseDownTarget] = useState<EventTarget | null>(
null
);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === mouseDownTarget && e.target === e.currentTarget) {
closeModal();
}
};
const handleMouseDown = (e: React.MouseEvent) => {
setMouseDownTarget(e.target);
};
useEffect(() => {
if (isOpen) {
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalStyle;
};
}
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="hidden md:flex fixed inset-0 backdrop-blur-[2px] justify-center items-center z-40 p-4"
onClick={handleBackdropClick}
onMouseDown={handleMouseDown}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<motion.div
ref={modalRef}
className={`bg-white dark:bg-gray-900 rounded-xl shadow-2xl overflow-y-auto ${
isFullSize ? "w-[80%] h-[90%] p-3" : "max-w-md w-full p-6"
} max-h-[90vh]`}
onClick={(e) => e.stopPropagation()}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center justify-between pb-2 mb-4 border-b border-gray-200 dark:border-gray-700">
<h2
className={`${
isFullSize ? "text-sm" : "text-md font-semibold"
} text-gray-900 dark:text-white`}
>
{title}
</h2>
<div className="flex items-center gap-2">
<button
onClick={closeModal}
className={`rounded-full cursor-pointer ${
isFullSize ? "border-1 " : "p-2"
} hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors duration-200`}
aria-label="Close modal"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</div>
<div className="overflow-hidden pt-1">{content}</div>
</motion.div>
</motion.div>
<motion.div
className="fixed inset-0 z-50 md:hidden"
initial="hidden"
animate="visible"
exit="hidden"
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }}
transition={{ duration: 0.2 }}
>
<motion.div
className="absolute inset-0 bg-opacity-30 backdrop-blur-[1px]"
onClick={handleBackdropClick}
onMouseDown={handleMouseDown}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
<motion.div
className="absolute inset-x-0 bottom-0 border-t-5 border-primary-100 bg-white rounded-t-3xl shadow-2xl p-4 pt-2 max-h-[80vh] overflow-y-auto dark:bg-dark-700 dark:border-none"
onClick={(e) => e.stopPropagation()}
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="w-12 h-1.5 bg-gray-300 rounded-full mx-auto mb-4" />
<div className="flex items-center justify-center pb-2 mb-2 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-medium text-gray-900 dark:text-white">
{title}
</h2>
</div>
<div className="text-sm text-gray-800 dark:text-gray-200 mt-2 overflow-hidden pt-1">
{content}
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default Modal;

View File

@@ -0,0 +1,46 @@
import Lottie from "lottie-react";
import animation from "../../assets/animations/nodata.json";
import Typography from "../Typography/Typography";
type Props = {
title?: string;
description?: string;
className?: string;
animationSize?: "sm" | "md" | "lg";
};
export const NoData = ({
title,
className = "",
animationSize = "md",
}: Props) => {
const sizeMap = {
sm: "w-24 h-24",
md: "w-32 h-32",
lg: "w-48 h-48",
};
return (
<div
className={`w-full flex flex-col items-center justify-center p-6 ${className}`}
>
<div className={`${sizeMap[animationSize]} mb-4`}>
<Lottie
animationData={animation}
loop={true}
className="drop-shadow-lg"
/>
</div>
<div className="text-center max-w-md">
<Typography
color="text-gray-700 dark:text-gray-200 select-none"
variant="h6"
className="font-semibold mb-2"
>
{title || "اطلاعات موجود نیست!"}
</Typography>
</div>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import { ListBulletIcon } from "@heroicons/react/24/outline";
type Props = {
title?: string;
};
export const PageTitle = ({ title }: Props) => {
return (
<div className="flex items-center gap-2">
<div className="h-6 w-6 text-gray-500 dark:text-gray-300">
<ListBulletIcon />
</div>
{title && (
<h2
className={`text-lg text-gray-500 dark:text-gray-300 font-semibold`}
>
{title}
</h2>
)}
</div>
);
};

View File

@@ -0,0 +1,571 @@
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>
);
};

View File

@@ -0,0 +1,758 @@
import { useState, useRef, useEffect, createContext, useContext } from "react";
import {
Cog6ToothIcon,
InformationCircleIcon,
MinusIcon,
DocumentTextIcon,
KeyIcon,
CheckIcon,
} from "@heroicons/react/24/outline";
import { motion, AnimatePresence } from "framer-motion";
import { Grid } from "../Grid/Grid";
import { createPortal } from "react-dom";
import { useUserProfileStore } from "../../context/zustand-store/userStore";
import { Tooltip } from "../Tooltip/Tooltip";
import Button from "../Button/Button";
import { useModalStore } from "../../context/zustand-store/appStore";
import Typography from "../Typography/Typography";
import { useToast } from "../../hooks/useToast";
import { ItemWithSubItems } from "../../types/userPermissions";
import { getUserPermissions } from "../../utils/getUserAvalableItems";
import { useApiRequest } from "../../utils/useApiRequest";
import Checkbox from "../CheckBox/CheckBox";
import { useFetchProfile } from "../../hooks/useFetchProfile";
import { useQueryClient } from "@tanstack/react-query";
import api from "../../utils/axios";
import { getFaPermissions } from "../../utils/getFaPermissions";
export const PopOverContext = createContext<boolean>(false);
export const usePopOverContext = () => useContext(PopOverContext);
const PermissionsContent = ({
filterProps,
showToast,
matchedSubItem,
}: {
filterProps: Array<{ page: string; access: string; title: string }>;
showToast: (text: string, type?: "success" | "error" | "default") => void;
matchedSubItem: any;
}) => {
const [copiedItem, setCopiedItem] = useState<{
type: "page" | "access";
index: number;
} | null>(null);
const [expandedPermissions, setExpandedPermissions] = useState<{
[key: string]: boolean;
}>({});
const queryClient = useQueryClient();
const { getProfile } = useFetchProfile();
const [selectedRoleIdsByKey, setSelectedRoleIdsByKey] = useState<{
[key: string]: Set<number>;
}>({});
const [submittingKey, setSubmittingKey] = useState<string | null>(null);
const { data: rolesData, refetch: refetchRoles } = useApiRequest({
api: "/auth/api/v1/role/",
method: "get",
params: { page: 1, page_size: 1000 },
queryKey: ["roles-permissions"],
});
const { data: permissionsData } = useApiRequest({
api: "/auth/api/v1/permission/",
method: "get",
params: { page: 1, page_size: 1000 },
queryKey: ["all-permissions"],
});
const findPermissionId = (page: string, access: string): number | null => {
if (!permissionsData?.results) return null;
const permission = permissionsData.results.find(
(p: any) => p.page === page && p.name === access
);
return permission?.id || null;
};
const checkRoleHasPermission = (
role: any,
page: string,
access: string
): boolean => {
if (!role?.permissions || !Array.isArray(role.permissions)) {
return false;
}
if (!permissionsData?.results) return false;
const permissionId = findPermissionId(page, access);
if (!permissionId) return false;
const rolePermissionIds = role.permissions.map((p: any) => p.id || p);
return rolePermissionIds.includes(permissionId);
};
const ensureInitialized = (key: string, page: string, access: string) => {
if (selectedRoleIdsByKey[key]) return;
if (!rolesData?.results) return;
const next = new Set<number>();
rolesData.results.forEach((role: any) => {
if (checkRoleHasPermission(role, page, access)) {
next.add(role.id);
}
});
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: next }));
};
const toggleLocal = (key: string, roleId: number) => {
setSelectedRoleIdsByKey((prev) => {
const current = new Set(prev[key] || []);
if (current.has(roleId)) current.delete(roleId);
else current.add(roleId);
return { ...prev, [key]: current };
});
};
const handleCancel = (key: string, page: string, access: string) => {
if (!rolesData?.results) return;
const next = new Set<number>();
rolesData.results.forEach((role: any) => {
if (checkRoleHasPermission(role, page, access)) {
next.add(role.id);
}
});
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: next }));
};
const handleSubmit = async (key: string, page: string, access: string) => {
try {
const permissionId = findPermissionId(page, access);
if (!permissionId) {
showToast("دسترسی یافت نشد", "error");
return;
}
if (!rolesData?.results) return;
let selected = selectedRoleIdsByKey[key];
if (!selected) {
const init = new Set<number>();
rolesData.results.forEach((role: any) => {
if (checkRoleHasPermission(role, page, access)) {
init.add(role.id);
}
});
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: init }));
selected = init;
}
const updates = rolesData.results.flatMap((role: any) => {
const currentPermissionIds = (role.permissions || []).map(
(p: any) => p.id || p
);
const currentlyHas = currentPermissionIds.includes(permissionId);
const shouldHave = selected.has(role.id);
if (currentlyHas === shouldHave) return [];
const updatedPermissionIds = shouldHave
? currentPermissionIds.includes(permissionId)
? currentPermissionIds
: [...currentPermissionIds, permissionId]
: currentPermissionIds.filter((id: number) => id !== permissionId);
return [
api.patch(`/auth/api/v1/role/${role.id}/`, {
permissions: updatedPermissionIds,
}),
];
});
if (updates.length === 0) {
showToast("تغییری برای ارسال وجود ندارد", "default");
return;
}
setSubmittingKey(key);
await Promise.all(updates);
showToast("به‌روزرسانی نقش‌ها با موفقیت انجام شد", "success");
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["roles-permissions"] });
refetchRoles();
getProfile();
} catch (error: any) {
showToast(
error?.response?.data?.message || "خطا در به‌روزرسانی نقش‌ها",
"error"
);
} finally {
setSubmittingKey(null);
}
};
const handleCopy = async (
text: string,
type: "page" | "access",
index: number
) => {
try {
await navigator.clipboard.writeText(text);
setCopiedItem({ type, index });
showToast(
type === "page" ? `صفحه "${text}" کپی شد` : `دسترسی "${text}" کپی شد`,
"success"
);
setTimeout(() => {
setCopiedItem(null);
}, 2000);
} catch {
showToast("خطا در کپی کردن", "error");
}
};
return (
<div className="">
<Grid
container
className="mb-2 gap-1 border-gray-200/60 dark:border-gray-700/40 justify-center items-center"
>
{matchedSubItem?.name && (
<div className="px-3 py-1.5 rounded-lg bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/50 dark:to-gray-800/30 border border-gray-200/50 dark:border-gray-700/30 shadow-sm">
<Typography
variant="caption"
className="text-gray-700 dark:text-gray-300 font-medium text-sm line-clamp-1"
>
صفحه فعلی: {matchedSubItem?.name} (
{getFaPermissions(matchedSubItem?.name)})
</Typography>
</div>
)}
</Grid>
<div className="space-y-3">
{filterProps && filterProps.length > 0 ? (
filterProps.map(
(
item: {
page: string;
access: string;
title: string;
},
index: number
) => (
<motion.div
key={`${item.page}-${index}`}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="relative bg-gradient-to-br from-gray-50 to-gray-100/50 dark:from-dark-700 dark:to-dark-800 rounded-lg p-2.5 border border-gray-200/60 dark:border-gray-600/40 shadow-sm hover:shadow transition-all duration-200 group"
>
<div className="flex items-center gap-2.5">
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center group-hover:bg-primary-200 dark:group-hover:bg-primary-900/50 transition-colors">
<span className="text-xs text-primary-600 dark:text-primary-400 font-bold">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
{item.title && (
<Typography
variant="subtitle2"
className="text-gray-700 dark:text-gray-300 font-medium text-sm mb-1.5 line-clamp-1"
>
{item.title}
</Typography>
)}
<div className="flex flex-wrap items-center gap-2">
{item.page && (
<div
onClick={(e) => {
e.stopPropagation();
handleCopy(item.page, "page", index);
}}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-dark-600/60 border border-gray-200/50 dark:border-gray-700/50 cursor-pointer hover:bg-white/80 dark:hover:bg-dark-600/80 transition-colors active:scale-95"
>
{copiedItem?.type === "page" &&
copiedItem?.index === index ? (
<CheckIcon className="w-3.5 h-3.5 text-green-500 dark:text-green-400 flex-shrink-0" />
) : (
<DocumentTextIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 flex-shrink-0" />
)}
<Typography
variant="caption"
className="text-gray-700 dark:text-gray-300 text-xs font-medium truncate max-w-[120px]"
>
{item.page}
</Typography>
</div>
)}
{item.access && (
<span
onClick={(e) => {
e.stopPropagation();
handleCopy(item.access, "access", index);
}}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-800 cursor-pointer hover:bg-primary-200 dark:hover:bg-primary-900/60 transition-colors active:scale-95"
>
{copiedItem?.type === "access" &&
copiedItem?.index === index ? (
<CheckIcon className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />
) : (
<KeyIcon className="w-3.5 h-3.5" />
)}
{item.access}
</span>
)}
</div>
{rolesData?.results && rolesData.results.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200/60 dark:border-gray-700/40">
<button
onClick={(e) => {
e.stopPropagation();
setExpandedPermissions((prev) => ({
...prev,
[`${item.page}-${item.access}-${index}`]:
!prev[`${item.page}-${item.access}-${index}`],
}));
const k = `${item.page}-${item.access}-${index}`;
ensureInitialized(k, item.page, item.access);
}}
className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors mb-2"
>
<span>
{expandedPermissions[
`${item.page}-${item.access}-${index}`
]
? "▲"
: "▼"}{" "}
نقشها (
{
rolesData.results.filter((role: any) =>
checkRoleHasPermission(
role,
item.page,
item.access
)
).length
}
/{rolesData.results.length})
</span>
</button>
{expandedPermissions[
`${item.page}-${item.access}-${index}`
] && (
<div className="space-y-2 max-h-60 overflow-y-auto">
{rolesData.results.map((role: any) => {
const k = `${item.page}-${item.access}-${index}`;
const selectedSet = selectedRoleIdsByKey[k];
const hasPermission =
selectedSet?.has(role.id) ??
checkRoleHasPermission(
role,
item.page,
item.access
);
return (
<div
key={role.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-white/40 dark:bg-dark-600/40 border border-gray-200/50 dark:border-gray-700/50 hover:bg-white/60 dark:hover:bg-dark-600/60 transition-colors"
>
<Checkbox
checked={hasPermission}
disabled={submittingKey === k}
onChange={(e) => {
e.stopPropagation();
ensureInitialized(
k,
item.page,
item.access
);
toggleLocal(k, role.id);
}}
size="small"
/>
<Typography
variant="caption"
className="text-xs text-gray-700 dark:text-gray-300 flex-1"
>
{role.role_name}
</Typography>
{role.type?.name && (
<Typography
variant="caption"
className="text-xs text-gray-500 dark:text-gray-400"
>
({role.type.name})
</Typography>
)}
</div>
);
})}
</div>
)}
{expandedPermissions[
`${item.page}-${item.access}-${index}`
] && (
<div className="flex items-center justify-between gap-2 mt-2">
<button
className="px-3 py-1.5 text-xs rounded-md bg-gray-100 dark:bg-dark-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200/80 dark:hover:bg-dark-500"
onClick={(e) => {
e.stopPropagation();
const k = `${item.page}-${item.access}-${index}`;
handleCancel(k, item.page, item.access);
}}
disabled={
submittingKey ===
`${item.page}-${item.access}-${index}`
}
>
انصراف
</button>
<button
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-60"
onClick={(e) => {
e.stopPropagation();
const k = `${item.page}-${item.access}-${index}`;
handleSubmit(k, item.page, item.access);
}}
disabled={
submittingKey ===
`${item.page}-${item.access}-${index}`
}
>
{submittingKey ===
`${item.page}-${item.access}-${index}`
? "در حال ارسال..."
: "ثبت تغییرات"}
</button>
</div>
)}
</div>
)}
</div>
</div>
</motion.div>
)
)
) : (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
<Typography variant="body2" className="text-sm">
دسترسیای یافت نشد
</Typography>
</div>
)}
</div>
</div>
);
};
export const Popover = ({
children,
className,
}: {
children: React.ReactNode | any;
className?: string;
}) => {
const { openModal } = useModalStore();
const { profile } = useUserProfileStore();
const menuItems: ItemWithSubItems[] = getUserPermissions(
profile?.permissions
);
const currentPath = window.location.pathname;
const matchedSubItem = menuItems
.flatMap((item) => item.subItems)
.find(
(sub) =>
sub.path === currentPath ||
sub.path.includes(currentPath.split("/")[1] + "/")
);
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
const buttonRect = buttonRef?.current?.getBoundingClientRect();
const showToast = useToast();
const filterProps: Array<{ page: string; access: string; title: string }> =
!Array.isArray(children)
? [
{
page:
children?.props?.page ||
children?.props?.children?.props?.page ||
"",
access:
children?.props?.access ||
children?.props?.children?.props?.access ||
"",
title: children?.props?.tooltipText || children?.props?.title || "",
},
]
: children
?.filter(
(item: any) =>
(item?.props?.page && item?.props?.access) ||
(item?.props?.children?.props?.access &&
item?.props?.children?.props?.page)
)
?.map((option: any) => {
return {
page:
option?.props?.page ||
option?.props?.children?.props?.page ||
"",
access:
option?.props?.access ||
option?.props?.children?.props?.access ||
"",
title: option?.props?.tooltipText || option?.props?.title || "",
};
}) || [];
const ableToSeeAnyButton = (
filterProps: Array<{ page: string; access: string }>
) => {
if (!filterProps || filterProps.length === 0) {
return true;
}
return filterProps?.some(({ page, access }) => {
if (!access || !page) {
return true;
}
const foundPermission = profile?.permissions?.find(
(item: any) => item.page_name === page
);
return foundPermission && foundPermission.page_access.includes(access);
});
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
if (
!buttonRef.current?.contains(e.target as Node) &&
!popoverRef.current?.contains(e.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
if (isMobile) document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
if (isMobile) document.body.style.overflow = "";
};
}, [isOpen, isMobile]);
const togglePopover = () => setIsOpen(!isOpen);
const [position, setPosition] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!isOpen || isMobile) return;
const updatePosition = () => {
const buttonRect = buttonRef.current?.getBoundingClientRect();
const popoverElement = popoverRef.current;
if (!buttonRect) return;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const padding = 16;
const maxPopoverHeight = viewportHeight - padding * 2;
let top = buttonRect.top + scrollY;
let left = buttonRect.right + scrollX + 8;
if (popoverElement) {
const popoverRect = popoverElement.getBoundingClientRect();
const popoverHeight = Math.min(popoverRect.height, maxPopoverHeight);
const popoverWidth = popoverRect.width;
const popoverBottomInViewport = buttonRect.top + popoverHeight;
if (popoverBottomInViewport > viewportHeight - padding) {
const overflow = popoverBottomInViewport - (viewportHeight - padding);
top = buttonRect.top + scrollY - overflow;
if (top < scrollY + padding) {
top = scrollY + padding;
}
}
const popoverRightInViewport = buttonRect.right + popoverWidth + 8;
if (popoverRightInViewport > viewportWidth - padding) {
left = buttonRect.left + scrollX - popoverWidth - 8;
if (left < scrollX + padding) {
left = scrollX + padding;
}
}
} else {
const estimatedHeight = Math.min(
(Array.isArray(children) ? children.length : 1) * 50 + 100,
maxPopoverHeight
);
if (buttonRect.top + estimatedHeight > viewportHeight - padding) {
const overflow =
buttonRect.top + estimatedHeight - (viewportHeight - padding);
top = buttonRect.top + scrollY - overflow;
if (top < scrollY + padding) {
top = scrollY + padding;
}
}
const estimatedWidth = 300;
if (buttonRect.right + estimatedWidth + 8 > viewportWidth - padding) {
left = buttonRect.left + scrollX - estimatedWidth - 8;
if (left < scrollX + padding) {
left = scrollX + padding;
}
}
}
setPosition({ top, left });
};
updatePosition();
const timeoutId = setTimeout(updatePosition, 0);
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
clearTimeout(timeoutId);
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}, [isOpen, isMobile, children]);
return (
<PopOverContext.Provider value={true}>
<div className={`relative ${className}`}>
<motion.button
disabled={!ableToSeeAnyButton(filterProps)}
ref={buttonRef}
onClick={togglePopover}
className={`p-2 rounded-full hover:bg-gray-100/50 dark:hover:bg-gray-800 transition-colors`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div animate={{ rotate: isOpen ? 90 : 0 }}>
{!ableToSeeAnyButton(filterProps) ? (
<MinusIcon className="h-5 w-5 text-red-600 cursor-not-allowed" />
) : (
<Cog6ToothIcon
className={`h-5 w-5 ${
isOpen
? "text-primary-600"
: "text-gray-600 dark:text-gray-300"
}`}
/>
)}
</motion.div>
</motion.button>
{isOpen &&
createPortal(
<AnimatePresence>
<>
{isMobile && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
/>
)}
<motion.div
ref={popoverRef}
id="popover-content"
initial={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
animate={isMobile ? { y: 0 } : { opacity: 1, y: 0 }}
exit={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className={`z-[9999] bg-white dark:bg-dark-700 rounded-lg shadow-lg ring-1 ring-black/10 dark:ring-white/10 ${
isMobile
? "fixed bottom-0 inset-x-0 rounded-t-3xl pb-[env(safe-area-inset-bottom)]"
: "fixed max-h-[calc(100vh-32px)]"
}`}
style={
!isMobile
? {
top: `${
buttonRect?.bottom && buttonRect?.bottom > 700
? position.top - 100
: position.top
}px`,
left: `${position.left}px`,
}
: {}
}
>
{isMobile && (
<div className="flex justify-center py-2">
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
</div>
)}
<PopOverContext.Provider value={true}>
<Grid
container
column
className="gap-2 p-2 max-h-[calc(100vh-32px)] overflow-y-auto"
onClick={() => setIsOpen(false)}
>
{children}
{profile?.role?.type?.key === "ADM" && (
<Tooltip
title="نمایش دسترسی های این قسمت"
position="right"
>
<Button
icon={
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-red-400" />
}
variant="detail"
onClick={() => {
openModal({
title: "دسترسی های این قسمت",
content: (
<PermissionsContent
filterProps={filterProps}
showToast={showToast}
matchedSubItem={matchedSubItem}
/>
),
});
}}
/>
</Tooltip>
)}
</Grid>
</PopOverContext.Provider>
</motion.div>
</>
</AnimatePresence>,
document.body
)}
</div>
</PopOverContext.Provider>
);
};

View File

@@ -0,0 +1,123 @@
import { useModalStore } from "../../context/zustand-store/appStore";
import { useUserProfileStore } from "../../context/zustand-store/userStore";
import { useToast } from "../../hooks/useToast";
import { useApiMutation } from "../../utils/useApiRequest";
import Button from "../Button/Button";
import { Grid } from "../Grid/Grid";
import { Tooltip } from "../Tooltip/Tooltip";
import { motion } from "framer-motion";
type Props = {
api?: string;
title?: string;
getData?: () => void;
page?: string;
access?: string;
tooltipText?: string;
};
export const DeleteButtonForPopOver = ({
api,
title,
getData,
page = "",
access = "",
tooltipText = "حذف",
}: Props) => {
const { openModal, closeModal } = useModalStore();
const showToast = useToast();
const { profile } = useUserProfileStore();
const mutation = useApiMutation({
api: api || "",
method: "delete",
});
const ableToSeeButton = () => {
if (!access || !page) {
return true;
} else {
const finded = profile?.permissions?.find(
(item: any) => item.page_name === page
);
if (finded && finded.page_access.includes(access)) {
return true;
} else {
return false;
}
}
};
const onSubmit = async () => {
try {
await mutation.mutateAsync({});
showToast("عملیات با موفقیت انجام شد", "success");
closeModal();
if (getData) {
getData();
}
} catch (error: any) {
if (error?.status === 400 || error?.status === 403) {
showToast(
error?.response?.data?.detail ||
error?.response?.data?.message + " !",
"error"
);
} else {
showToast("مشکلی پیش آمده است!", "error");
}
}
};
if (!ableToSeeButton()) {
return null;
}
return (
<Tooltip title={tooltipText} position="right">
<Button
variant="delete"
onClick={() => {
openModal({
title: title || "از حذف این مورد مطمئنید؟",
content: (
<Grid
container
xs="full"
column
className="flex justify-center items-center"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md p-4"
>
<Grid container className="flex-row space-y-0 space-x-4">
<Button
onClick={() => {
onSubmit();
}}
fullWidth
className="bg-[#eb5757] hover:bg-[#d44e4e] text-white py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
>
بله
</Button>
<Button
onClick={() => closeModal()}
fullWidth
className="bg-gray-200 text-gray-700 hover:bg-gray-100 py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
>
خیر
</Button>
</Grid>
</motion.div>
</Grid>
),
});
}}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,129 @@
import { ReactElement } from "react";
import { useModalStore } from "../../context/zustand-store/appStore";
import { useUserProfileStore } from "../../context/zustand-store/userStore";
import { useToast } from "../../hooks/useToast";
import { useApiMutation } from "../../utils/useApiRequest";
import Button from "../Button/Button";
import { Grid } from "../Grid/Grid";
import { Tooltip } from "../Tooltip/Tooltip";
import { motion } from "framer-motion";
type Props = {
api?: string;
title?: string;
tooltipText?: string;
getData?: () => void;
page?: string;
access?: string;
method?: "delete" | "post" | "put" | "patch";
icon?: ReactElement;
};
export const PopoverCustomModalOperation = ({
api,
title,
method = "delete",
tooltipText,
getData,
page = "",
access = "",
icon,
}: Props) => {
const { openModal, closeModal } = useModalStore();
const showToast = useToast();
const { profile } = useUserProfileStore();
const mutation = useApiMutation({
api: api || "",
method: method || "delete",
});
const ableToSeeButton = () => {
if (!access || !page) {
return true;
} else {
const finded = profile?.permissions?.find(
(item: any) => item.page_name === page
);
if (finded && finded.page_access.includes(access)) {
return true;
} else {
return false;
}
}
};
const onSubmit = async () => {
try {
await mutation.mutateAsync({});
showToast("عملیات با موفقیت انجام شد", "success");
closeModal();
if (getData) {
getData();
}
} catch (error: any) {
if (error?.status === 400) {
showToast(
error?.response?.data?.detail ||
error?.response?.data?.message + " !",
"error"
);
} else {
showToast("مشکلی پیش آمده است!", "error");
}
closeModal();
}
};
if (!ableToSeeButton()) {
return null;
}
return (
<Tooltip title={tooltipText || ""} position="right">
<Button
icon={icon}
onClick={() => {
openModal({
title: title || "آیا از انجام عملیات مطمئنید؟",
content: (
<Grid
container
xs="full"
column
className="flex justify-center items-center"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md p-4"
>
<Grid container className="flex-row space-y-0 space-x-4">
<Button
onClick={() => {
onSubmit();
}}
fullWidth
className="bg-[#eb5757] hover:bg-[#d44e4e] text-white py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
>
بله
</Button>
<Button
onClick={() => closeModal()}
fullWidth
className="bg-gray-200 text-gray-700 hover:bg-gray-100 py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
>
خیر
</Button>
</Grid>
</motion.div>
</Grid>
),
});
}}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,70 @@
import React from "react";
import clsx from "clsx";
export type RadioSize = "small" | "medium" | "large";
interface RadioButtonProps {
name: string;
value: string | any;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
label?: string;
disabled?: boolean;
isError?: boolean;
size?: RadioSize;
className?: string;
}
const sizeMap: Record<RadioSize, string> = {
small: "w-3 h-3",
medium: "w-4 h-4",
large: "w-5 h-5",
};
export const RadioButton: React.FC<RadioButtonProps> = ({
name,
value,
checked = false,
onChange,
label,
disabled = false,
isError = false,
size = "medium",
className,
}) => {
return (
<div className={"w-full items-center flex sm:w-auto md:w-auto lg:w-auto"}>
<label
className={clsx(
"inline-flex items-center gap-1 cursor-pointer",
disabled && "cursor-not-allowed opacity-60",
className
)}
>
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={onChange}
disabled={disabled}
className={clsx(
"appearance-none rounded-full w-5 h-5 border-2 transition-all duration-150",
sizeMap[size],
isError ? "border-red-500" : "border-dark-400",
checked
? "border-transparent bg-primary-600 dark:bg-dark-400 dark:ring-1 ring-gray-300 dark:ring-white"
: "bg-white dark:bg-dark-600"
)}
/>
{label && (
<span
className={`text-sm text-gray-700 dark:text-dark-100 select-none`}
>
{label}
</span>
)}
</label>
</div>
);
};

View File

@@ -0,0 +1,73 @@
import React from "react";
import { RadioButton, RadioSize } from "./RadioButton";
import clsx from "clsx";
import { inputWidths } from "../../data/getItemsWidth";
import Typography from "../Typography/Typography";
interface RadioOption {
value?: string | any;
label?: string;
disabled?: boolean;
}
interface RadioGroupProps {
name?: string;
groupTitle?: string;
options?: RadioOption[];
value?: string | any;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
isError?: boolean;
size?: RadioSize;
direction?: "row" | "column";
className?: string;
}
export const RadioGroup: React.FC<RadioGroupProps> = ({
name = "",
groupTitle = "",
options = [],
value = "",
onChange,
disabled = false,
isError = false,
size = "medium",
direction = "column",
className,
}) => {
return (
<div
className={clsx(
"flex ",
direction === "column"
? "flex-col space-y-2 items-start"
: "flex-row space-x-4 items-center",
inputWidths,
className
)}
>
{groupTitle && (
<Typography
color="text-gray-700 dark:text-primary-100"
variant="body2"
className="text-nowrap "
>
{groupTitle}
</Typography>
)}
{options.map((option) => (
<RadioButton
key={option.value ?? ""}
name={name}
value={option.value ?? ""}
label={option.label ?? ""}
checked={value === option.value}
onChange={onChange}
disabled={option.disabled || disabled}
isError={isError}
size={size}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,117 @@
import React from "react";
import { motion } from "framer-motion";
import Typography from "../Typography/Typography";
interface SettingCardProps {
title: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
onClick?: () => void;
children?: React.ReactNode;
className?: string;
iconColor?: string;
}
export const SettingCard: React.FC<SettingCardProps> = ({
title,
description,
icon: Icon,
onClick,
children,
className = "",
iconColor = "text-primary-600 dark:text-primary-400",
}) => {
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: "easeOut",
},
},
hover: {
y: -6,
scale: 1.03,
transition: { duration: 0.2 },
},
tap: { scale: 0.97 },
};
const iconVariants = {
hover: {
scale: 1.1,
rotate: [0, -5, 5, -5, 0],
transition: { duration: 0.4 },
},
};
return (
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
whileHover="hover"
whileTap="tap"
onClick={onClick}
className={`
bg-white dark:bg-gray-800 rounded-xl shadow-sm p-4
border border-gray-200 dark:border-gray-700
hover:border-primary-300 dark:hover:border-primary-800
transition-all duration-300
relative overflow-hidden group
${onClick ? "cursor-pointer" : ""}
${className}
`}
>
<div className="absolute inset-0 bg-gradient-to-br dark:from-primary-900/0 dark:via-primary-900/0 dark:to-primary-900/0 dark:group-hover:from-primary-900/20 dark:group-hover:via-primary-900/10 dark:group-hover:to-primary-900/20 transition-all duration-500" />
<div className="flex items-center gap-3 mb-2">
{Icon && (
<motion.div
variants={iconVariants}
whileHover="hover"
className={`flex-shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-primary-50 dark:bg-primary-900/30 ${iconColor} group-hover:bg-primary-100 dark:group-hover:bg-primary-900/50 transition-colors duration-300`}
>
<Icon className="w-5 h-5" />
</motion.div>
)}
<Typography
variant="subtitle2"
className="flex-1"
color="text-gray-900 dark:text-white"
fontWeight="semibold"
>
{title}
</Typography>
</div>
{description && (
<Typography
variant="body2"
className="mb-2 text-xs"
color="text-gray-600 dark:text-gray-300"
fontWeight="normal"
>
{description}
</Typography>
)}
{children && <div className="w-full min-w-0 mt-1">{children}</div>}
<motion.div
className="absolute bottom-0 left-0 h-1 bg-gradient-to-r from-primary-500 to-primary-600 dark:from-primary-400 dark:to-primary-500 rounded-b-2xl"
initial={{ width: 0 }}
whileHover={{ width: "100%" }}
transition={{ duration: 0.4, ease: "easeOut" }}
/>
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent dark:via-white/20 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
</div>
</motion.div>
);
};
export default SettingCard;

View File

@@ -0,0 +1,120 @@
import React from "react";
import Typography from "../Typography/Typography";
import { motion } from "framer-motion";
import { ClipboardDocumentIcon, CheckIcon } from "@heroicons/react/24/outline";
interface CardField {
label: string;
value: string | undefined;
}
interface ShowCardsListProps {
fields: CardField[];
}
export const ShowCardsStringList: React.FC<ShowCardsListProps> = ({
fields,
}) => {
const [copiedIndex, setCopiedIndex] = React.useState<number | null>(null);
const handleCopy = (text: string | undefined, index: number) => {
if (!text) return;
navigator.clipboard.writeText(text);
setCopiedIndex(index);
setTimeout(() => {
setCopiedIndex(null);
}, 2000);
};
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.05,
duration: 0.1,
ease: "easeOut",
},
}),
hover: {
y: -2,
scale: 1.01,
transition: { duration: 0.1 },
},
tap: { scale: 0.98 },
};
return (
<div className="flex justify-center w-full items-center">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-1 w-full max-w-7xl">
{fields.map((field, index) => (
<motion.div
key={index}
custom={index}
initial="hidden"
animate="visible"
whileHover="hover"
whileTap="tap"
variants={cardVariants}
onClick={() => handleCopy(field.value, index)}
className={`bg-white dark:bg-gray-700 rounded-xl shadow-sm p-4 cursor-pointer
border border-gray-100 dark:border-gray-700 hover:shadow-md transition-all
relative overflow-hidden group`}
>
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-primary-50/50 dark:via-primary-900/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<Typography
variant="subtitle2"
className="flex items-center"
color="text-gray-500 dark:text-gray-200 mb-1"
>
<motion.div
animate={{
scale: copiedIndex === index ? [1.2, 1] : 1,
opacity: copiedIndex === index ? 1 : 0.7,
}}
transition={{ duration: 0.3 }}
className="group-hover:text-primary-500 dark:group-hover:text-primary-400"
>
{copiedIndex === index ? (
<CheckIcon className="h-5 w-5 text-green-500" />
) : (
<ClipboardDocumentIcon className="h-5 w-5" />
)}
</motion.div>
{field.label}
</Typography>
<div className="w-full min-w-0">
<Typography
variant="body2"
color="font-medium text-gray-900 dark:text-white"
title={field.value}
className="pr-2 break-words"
style={{
display: "block",
wordBreak: "break-word",
overflowWrap: "break-word",
whiteSpace: "normal",
width: "100%",
}}
>
{field.value || "—"}
</Typography>
</div>
<motion.div
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-500"
initial={{ width: 0 }}
whileHover={{ width: "100%" }}
transition={{ duration: 0.4, ease: "easeOut" }}
/>
</motion.div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,170 @@
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowDownTrayIcon,
XMarkIcon,
ArrowPathIcon,
} from "@heroicons/react/24/solid";
import sampleImage from "../../assets/images/no-image.png";
const imageExtensions = [
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".webp",
".svg",
];
interface ShowImageProps {
src?: string;
size?: number | string;
className?: string;
noOpen?: boolean;
}
const ShowImage: React.FC<ShowImageProps> = ({
src = sampleImage,
size,
className,
noOpen = false,
}) => {
const [open, setOpen] = useState(false);
const [rotation, setRotation] = useState(0);
const handleOpen = () => {
!noOpen && setOpen(true);
};
const handleClose = () => setOpen(false);
const handleDownload = () => {
if (!src) return;
const link = document.createElement("a");
link.href = src;
const filename = src.split("/").pop() || "document";
link.download = filename;
link.click();
};
const handleRotate = () => {
setRotation((prev) => prev + 90);
};
const getFileExtension = () => {
if (!src) return "";
const filename = src.split("/").pop() || "";
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) return "";
return filename.substring(lastDotIndex + 1).toLowerCase();
};
const isImage = () => {
if (!src) return false;
const ext = getFileExtension();
return imageExtensions.includes(`.${ext}`);
};
if (!src) {
return <span className="text-gray-400 italic">-</span>;
}
if (!isImage()) {
const ext = getFileExtension();
const buttonText = ext ? `دانلود سند ${ext}` : "دانلود سند";
return (
<button
onClick={handleDownload}
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg shadow-lg transition-shadow duration-300 focus:outline-none focus:ring-2 focus:ring-blue-400"
type="button"
>
<ArrowDownTrayIcon className="w-5 h-5" />
{buttonText}
</button>
);
}
return (
<>
<img
src={src}
alt="thumbnail"
onClick={handleOpen}
className={`${className} cursor-pointer rounded-lg select-none transition-transform duration-300 ease-in-out hover:scale-105 ${
size
? typeof size === "number"
? `w-[${size}px] h-[${size}px]`
: `w-full h-full`
: "w-16 h-16"
}`}
style={{
width: typeof size === "number" ? size : undefined,
height: typeof size === "number" ? size : undefined,
}}
draggable={false}
/>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-opacity-70 backdrop-blur-sm"
aria-modal="true"
role="dialog"
>
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.85, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="relative max-w-[90vw] max-h-[90vh] min-w-[40vw] min-h-[40vh] rounded-2xl overflow-hidden bg-white dark:bg-dark-600 shadow-2xl flex flex-col items-center justify-center"
>
<img
src={src}
alt="full-size"
style={{ transform: `rotate(${rotation}deg)` }}
className="max-w-full max-h-[80vh] transition-transform duration-500 ease-in-out select-none"
draggable={false}
/>
<div className="absolute top-4 right-4 flex space-x-3">
<button
onClick={handleDownload}
title="دانلود تصویر"
className="flex items-center cursor-pointer justify-center bg-white bg-opacity-90 hover:bg-opacity-100 p-3 rounded-full shadow-lg transition-shadow duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
type="button"
>
<ArrowDownTrayIcon className="w-6 h-6 text-primary-700" />
</button>
<button
onClick={handleRotate}
title="چرخش تصویر"
className="flex items-center cursor-pointer justify-center bg-white bg-opacity-90 hover:bg-opacity-100 p-3 rounded-full shadow-lg transition-shadow duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400"
type="button"
>
<ArrowPathIcon className="w-6 h-6 text-primary-700" />
</button>
</div>
<button
onClick={handleClose}
className="absolute cursor-pointer top-4 left-4 bg-white bg-opacity-90 hover:bg-opacity-100 p-3 rounded-full shadow-lg transition-shadow duration-200 focus:outline-none focus:ring-2 focus:ring-red-500"
type="button"
aria-label="Close"
>
<XMarkIcon className="w-6 h-6 text-red-600" />
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default ShowImage;

View File

@@ -0,0 +1,551 @@
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
XMarkIcon,
MagnifyingGlassIcon,
Squares2X2Icon,
TableCellsIcon,
EyeIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
import { createPortal } from "react-dom";
import Typography from "../Typography/Typography";
import { getNestedValue } from "../../utils/getNestedValue";
import { useDarkMode } from "../../hooks/useDarkMode";
type ConditionProps = {
for: number;
condition: string;
apply: string;
otherwise: string;
};
type CustomFunctionProps = {
for: number;
apply: (value: any, item: any) => any;
};
interface ShowMoreInfoProps {
title: string;
disabled?: boolean;
children?: React.ReactNode;
className?: string;
data?: any;
accessKeys?: string[][];
columns?: string[];
counter?: string[];
conditions?: ConditionProps[];
customFunction?: CustomFunctionProps[];
groupBy?: string[];
hideCounter?: boolean;
}
const ShowMoreInfo: React.FC<ShowMoreInfoProps> = ({
title,
children,
className = "",
data,
accessKeys,
columns,
conditions,
customFunction,
counter,
disabled = false,
groupBy,
hideCounter = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [processedItems, setProcessedItems] = useState<any[]>([]);
const [groupedItems, setGroupedItems] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [isDark] = useDarkMode();
const [viewMode, setViewMode] = useState<"grid" | "table">(
data?.length === 1 ? "table" : "grid"
);
useEffect(() => {
if (data && accessKeys && columns) {
const mapped = data.map((item: any, i: number) => {
const nestedValues: any[] = [];
for (let index = 0; index < columns.length; index++) {
const customFn = customFunction?.find(
(opt: CustomFunctionProps) => opt.for === index
);
let renderedField = getNestedValue(item, accessKeys[index], {
disableLocaleString: !!customFn,
});
const foundedCondition = conditions?.find(
(opt: ConditionProps) => opt.for === index
);
if (foundedCondition) {
if (renderedField === foundedCondition?.condition) {
renderedField = foundedCondition?.apply;
} else {
renderedField = foundedCondition?.otherwise;
}
}
if (customFn) {
renderedField = customFn.apply(renderedField, item);
}
nestedValues.push(renderedField);
}
return {
row: hideCounter ? nestedValues : [i + 1, ...nestedValues],
raw: item,
index: i,
};
});
setProcessedItems(mapped);
} else {
setProcessedItems([]);
}
}, [data, accessKeys, columns, conditions, customFunction]);
useEffect(() => {
if (!groupBy || !groupBy.length) {
const groups = [
{
key: "__all",
label: "",
items: processedItems,
},
];
setGroupedItems(groups);
return;
}
const map = new Map<string, { label: string; items: any[] }>();
processedItems.forEach((p) => {
const values = groupBy.map((g) => {
const path = typeof g === "string" ? g.split(".") : g;
const val = getNestedValue(p.raw, path);
return val ?? "";
});
const key = values.join("|||");
const label = values.join(" / ");
if (!map.has(key)) {
map.set(key, { label, items: [p] });
} else {
map.get(key)!.items.push(p);
}
});
const result = Array.from(map.entries()).map(([k, v]) => ({
key: k,
label: v.label,
items: v.items,
}));
setGroupedItems(result);
}, [processedItems, groupBy]);
useEffect(() => {
if (searchTerm.trim() === "") {
setGroupedItems((prevGroups) =>
prevGroups.map((g) => ({ ...g, items: g.items }))
);
return;
}
const term = searchTerm.toLowerCase();
const filteredGroups = groupedItems.map((g) => {
const items = g.items.filter((p: any) =>
p.row.some(
(v: any) =>
v !== null &&
v !== undefined &&
v.toString().toLowerCase().includes(term)
)
);
return { ...g, items };
});
setGroupedItems(filteredGroups);
}, [searchTerm]);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
if (window.innerWidth < 768) {
setViewMode("table");
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const toggleModal = () => setIsOpen(!isOpen);
const toggleViewMode = () =>
setViewMode(viewMode === "grid" ? "table" : "grid");
const desktopVariants = {
hidden: {
opacity: 0,
scale: 0.95,
y: 20,
},
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: {
duration: 0.2,
ease: "easeOut",
},
},
exit: {
opacity: 0,
scale: 0.95,
y: 20,
transition: {
duration: 0.15,
ease: "easeIn",
},
},
};
const mobileVariants = {
hidden: {
opacity: 0,
y: "100%",
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: "easeOut",
},
},
exit: {
opacity: 0,
y: "100%",
transition: {
duration: 0.25,
ease: "easeIn",
},
},
};
const overlayVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
const totalCount = groupedItems.reduce(
(acc, g) => acc + (g.items?.length || 0),
0
);
return (
<>
<button
disabled={disabled}
onClick={() => {
if (children || totalCount) {
toggleModal();
}
}}
className={`relative overflow-hidden group ${className} ${
children || totalCount
? "cursor-pointer"
: "cursor-not-allowed opacity-50"
}`}
>
<motion.span
className={`flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg text-nowrap ${
columns
? `${
disabled
? "bg-primary-500/40 opacity-40 cursor-not-allowed"
: "bg-primary-500/40"
} text-primary-700 dark:text-primary-400 dark:bg-primary-500/20`
: `${
disabled
? "bg-primary-500/30 opacity-40 cursor-not-allowed"
: "bg-primary-500/30"
} text-primary-700 dark:text-primary-400 dark:bg-primary-100/20`
}`}
initial={false}
whileTap={{ scale: 0.95 }}
>
<motion.span
className="flex items-center text-nowrap"
initial={false}
animate={{
opacity: [1, 0.7, 1],
}}
transition={{
repeat: Infinity,
duration: 3,
ease: "easeInOut",
}}
>
{accessKeys ? (
<EyeIcon className="w-4 h-4 ml-1" />
) : (
<ListBulletIcon className="w-4 h-4 ml-1" />
)}
نمایش {!!counter && `(${counter})`}
</motion.span>
{!!totalCount && (
<span className="inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-primary-500/20 text-primary-700 dark:text-primary-300">
{totalCount}
</span>
)}
<motion.span
className="absolute inset-0 bg-primary-500/10 opacity-0 group-hover:opacity-100"
initial={false}
transition={{ duration: 0.3 }}
style={{
borderRadius: "50%",
scale: 0,
}}
whileHover={{
scale: 2,
opacity: 0,
transition: { duration: 0.6 },
}}
/>
</motion.span>
</button>
{createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center p-0 md:p-4">
<motion.div
variants={overlayVariants}
initial="hidden"
animate="visible"
exit="hidden"
className="absolute inset-0 bg-black/50 dark:bg-black/70"
onClick={toggleModal}
/>
<motion.div
variants={isMobile ? mobileVariants : desktopVariants}
initial="hidden"
animate="visible"
exit="exit"
className={`relative w-full ${
isMobile
? "max-h-[90vh] rounded-t-2xl"
: "max-w-2xl max-h-[90vh] rounded-lg"
} bg-white dark:bg-gray-800 shadow-xl overflow-hidden`}
>
<div className="flex justify-between items-center p-2">
<div className="w-8"></div>
<Typography
variant="body1"
className="absolute left-1/2 transform -translate-x-1/2"
>
{title}
</Typography>
<button
className="p-1 text-gray-500 dark:text-gray-200 hover:text-gray-700 dark:hover:text-gray-300 z-10 cursor-pointer"
onClick={toggleModal}
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<section
className={`flex w-full justify-start overflow-y-auto ${
isDark && "dark-scrollbar"
}`}
>
{data && columns && (
<div className="w-full max-w-7xl">
{totalCount > 0 && (
<div className="p-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
{searchTerm ? (
<button
className="absolute inset-y-0 left-0 pl-3 flex items-center"
onClick={() => setSearchTerm("")}
>
<XMarkIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
</button>
) : (
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
)}
</div>
<input
type="text"
placeholder="جستجو..."
className="block w-full pl-10 pr-3 py-1 border border-gray-300 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
onClick={toggleViewMode}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={
viewMode === "grid"
? "نمایش جدولی"
: "نمایش کارتی"
}
>
{viewMode === "grid" ? (
<TableCellsIcon className="h-5 w-5" />
) : (
<Squares2X2Icon className="h-5 w-5" />
)}
</button>
</div>
</div>
)}
{viewMode === "grid" ? (
<div className="space-y-3 max-h-[500px] overflow-y-auto p-2">
{groupedItems.map((group, gIdx) =>
!group.items || group.items.length === 0 ? null : (
<div key={`group-${gIdx}`}>
{group.label !== "" && (
<div className="px-2 py-1">
<p className="text-sm font-bold text-blue-900/50 dark:text-white">
{group.label}
</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 rounded-xl p-2">
{group.items.map(
(item: any, index: number) => (
<div
key={`item-${group.key}-${index}`}
className={`${
index % 2 === 0
? "bg-primary-50/50 dark:bg-dark-900"
: "bg-primary-50 dark:bg-dark-700"
} rounded-2xl shadow-md border border-gray-200 gap-2 p-2 transition-transform hover:scale-[1.02]`}
>
<div className="space-y-1 divide-y-2 divide-gray-200 dark:divide-gray-300">
{item?.row?.map(
(
opt: string,
innerIndex: number
) => (
<div
key={innerIndex}
className="space-y-1 flex justify-between items-center"
>
<p className="text-xs font-medium text-gray-500 dark:text-gray-200">
{[
...(hideCounter
? columns
: ["ردیف", ...columns])[
innerIndex
],
]}
</p>
<p className="text-sm font-semibold text-gray-800 dark:text-white break-words">
{opt}
</p>
</div>
)
)}
</div>
</div>
)
)}
</div>
</div>
)
)}
</div>
) : (
<div className="max-h-[500px] overflow-y-auto p-2">
<div className="overflow-x-auto rounded-lg">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-primary-50 dark:bg-dark-900">
<tr>
{(hideCounter
? columns
: ["ردیف", ...columns]
).map((column, index) => (
<th
key={index}
scope="col"
className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
{column}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{groupedItems.map((group, gIdx) =>
!group.items ||
group.items.length === 0 ? null : (
<React.Fragment key={`group-table-${gIdx}`}>
{group.label !== "" && (
<tr className="bg-primary-200/80 dark:bg-dark-900/30">
<td
colSpan={
(hideCounter
? columns
: ["ردیف", ...columns]
).length
}
className="px-3 py-2 text-sm text-dark-600/80 text-center dark:text-gray-200 font-bold"
>
{group.label}
</td>
</tr>
)}
{group.items.map(
(item: any, index: number) => (
<tr
key={`table-row-${group.key}-${index}`}
className={`${
index % 2 === 0
? "bg-white dark:bg-dark-800"
: "bg-primary-50 dark:bg-dark-900"
} hover:bg-primary-100 dark:hover:bg-dark-700 dark:hover:text-dark-100`}
>
{item?.row?.map(
(
opt: string,
innerIndex: number
) => (
<td
key={`table-cell-${group.key}-${index}-${innerIndex}`}
className="px-3 py-2 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200"
>
{opt}
</td>
)
)}
</tr>
)
)}
</React.Fragment>
)
)}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</section>
<section className="overflow-auto max-h-[500px] modern-scrollbar">
{children && (
<div className="mb-4 p-2 flex justify-center w-full items-center">
{children}
</div>
)}
</section>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
</>
);
};
export default ShowMoreInfo;

View File

@@ -0,0 +1,226 @@
import React, { useState, useMemo, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MagnifyingGlassIcon,
XMarkIcon,
HashtagIcon,
} from "@heroicons/react/24/outline";
interface ShowStringListProps {
strings: string[];
title?: string;
maxItems?: number;
showSearch?: boolean;
showNumbers?: boolean;
className?: string;
emptyMessage?: string;
searchPlaceholder?: string;
onItemClick?: (item: string, index: number) => void;
}
const ShowStringList: React.FC<ShowStringListProps> = ({
strings = [],
title,
maxItems = 50,
showSearch = true,
showNumbers = true,
className = "",
emptyMessage = "هیچ آیتمی یافت نشد",
searchPlaceholder = "جستجو...",
onItemClick,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [isSearchFocused, setIsSearchFocused] = useState(false);
const filteredStrings = useMemo(() => {
if (!searchTerm.trim()) return strings.slice(0, maxItems);
return strings
.filter((str) => str.toLowerCase().includes(searchTerm.toLowerCase()))
.slice(0, maxItems);
}, [strings, searchTerm, maxItems]);
const hasMoreItems = strings.length > maxItems;
const hasSearchResults = searchTerm.trim() && filteredStrings.length > 0;
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
},
[]
);
const clearSearch = useCallback(() => {
setSearchTerm("");
}, []);
const handleItemClick = useCallback(
(item: string, index: number) => {
onItemClick?.(item, index);
},
[onItemClick]
);
const itemVariants = {
hidden: { opacity: 0, y: 8, scale: 0.9, filter: "blur(4px)" },
visible: (i: number) => ({
opacity: 1,
y: 0,
scale: 1,
filter: "blur(0px)",
transition: {
delay: i * 0.03,
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
filter: { duration: 0.3 },
},
}),
exit: {
opacity: 0,
y: -8,
scale: 0.9,
filter: "blur(4px)",
transition: {
duration: 0.25,
ease: [0.4, 0, 1, 1],
},
},
hover: {
scale: 1.02,
y: -2,
transition: { duration: 0.2, ease: [0.16, 1, 0.3, 1] },
},
tap: {
scale: 0.98,
transition: { duration: 0.1 },
},
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.03,
delayChildren: 0.1,
},
},
};
if (!strings || strings.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<div className="text-gray-500 dark:text-gray-400 text-sm">
{emptyMessage}
</div>
</div>
);
}
return (
<div className={`space-y-3 ${className}`}>
{title && (
<div className="flex items-center gap-2">
<HashtagIcon className="w-4 h-4 text-primary-500" />
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{title}
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
({strings.length})
</span>
</div>
)}
{showSearch && strings.length > 1 && (
<div className="relative">
<div className="relative">
<MagnifyingGlassIcon className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder={searchPlaceholder}
value={searchTerm}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
className={`w-full px-3 py-1.5 pr-10 text-sm border rounded-md transition-all duration-200 ${
isSearchFocused
? "border-primary-500 ring-1 ring-primary-500/30 shadow-sm"
: "border-gray-300 dark:border-gray-600"
} bg-white dark:bg-dark-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none`}
aria-label="جستجو در لیست"
/>
{searchTerm && (
<button
onClick={clearSearch}
className="absolute left-2.5 top-1/2 transform -translate-y-1/2 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-full transition-colors"
aria-label="پاک کردن جستجو"
>
<XMarkIcon className="w-3 h-3 text-gray-500" />
</button>
)}
</div>
{hasSearchResults && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{filteredStrings.length} نتیجه از {strings.length} آیتم
</div>
)}
</div>
)}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="flex flex-wrap items-center gap-2"
>
<AnimatePresence mode="popLayout">
{filteredStrings.map((str, index) => (
<motion.button
key={`${str}-${index}`}
custom={index}
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
whileHover="hover"
whileTap="tap"
onClick={() => handleItemClick(str, index)}
className={`group inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium focus:outline-none focus:ring-2 focus:ring-primary-500/50 ${
index % 2 === 0
? "bg-gradient-to-r from-primary-100/80 to-primary-50/80 dark:from-dark-600/80 dark:to-dark-700/80 text-primary-700 dark:text-primary-300 border border-primary-200/50 dark:border-dark-500/50"
: "bg-gradient-to-r from-secondary-100/80 to-secondary-50/80 dark:from-dark-700/80 dark:to-dark-800/80 text-secondary-700 dark:text-secondary-300 border border-secondary-200/50 dark:border-dark-600/50"
} hover:shadow-lg hover:border-opacity-100`}
aria-label={`آیتم ${index + 1}: ${str}`}
>
{showNumbers && (
<span className="flex-shrink-0 w-4 h-4 bg-white/60 dark:bg-black/30 rounded-full flex items-center justify-center text-[10px] font-bold">
{index + 1}
</span>
)}
<span className="truncate max-w-[180px]" title={str}>
{str}
</span>
</motion.button>
))}
</AnimatePresence>
</motion.div>
{hasMoreItems && !searchTerm && (
<div className="text-center">
<span className="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded-md">
و {strings.length - maxItems} آیتم دیگر...
</span>
</div>
)}
{searchTerm && filteredStrings.length === 0 && (
<div className="text-center py-6">
<div className="text-gray-500 dark:text-gray-400 text-sm">
هیچ نتیجهای برای &quot;{searchTerm}&quot; یافت نشد
</div>
</div>
)}
</div>
);
};
export default ShowStringList;

View File

@@ -0,0 +1,19 @@
type Props = {
weight: string;
type?: string;
};
export const ShowWeight = ({ weight, type = "کیلوگرم" }: Props) => {
return (
<div className="!grid !justify-center !items-center">
<span className="text-[14px] gap-1 justify-center items-center flex text-center mb-1 border-b-[0.5px] border-gray-400 dark:text-white">
{weight?.toLocaleString()}{" "}
<span className="sm:block md:hidden text-red-300 text-[9px] dark:text-white">
{type}
</span>
</span>
<span className="text-[10px] text-center select-none hidden md:block dark:text-white">
{type}
</span>
</div>
);
};

View File

@@ -0,0 +1,66 @@
import React from "react";
import activeStepper from "../../assets/images/active-stepper.png";
interface Step {
key: string | number;
label: string;
disabled?: boolean;
}
interface StepperProps {
steps: Step[];
activeStep: string | number;
}
export const Stepper: React.FC<StepperProps> = ({ steps, activeStep }) => {
return (
<div className="w-full px-4 overflow-x-auto overflow-y-hidden select-none">
<div className="relative min-w-[300px]">
<div className="absolute left-0 right-0 top-1/2 h-[0.5px] bg-primary-800">
<div className="h-1 transition-all duration-300"></div>
</div>
<div className="relative flex justify-between sm:justify-center sm:gap-20 md:gap-25 lg:gap-30">
{steps.map((step) => {
const isActive = step.key === activeStep;
return (
<div
key={step.key}
className={`flex flex-col items-center px-2 sm:px-0`}
>
<div
className={`relative z-10 flex h-6 w-6 sm:h-7 sm:w-7 items-center justify-center mt-[25px] bg-white dark:bg-gray-900 rounded-full ${
step?.disabled ? "border-gray-200" : "border-gray-300"
} ${!isActive && "border-1 dark:border-white2-600"}`}
>
{isActive ? (
<img
src={activeStepper}
alt="Active step"
className="h-full"
/>
) : (
<span
className={`text-sm font-medium dark:text-white p-2 sm:p-3 `}
></span>
)}
</div>
<div
className={`mt-2 text-center text-xs sm:text-sm font-medium whitespace-nowrap ${
step?.disabled
? "text-gray-300 dark:text-gray-100 "
: "text-white1-900 dark:text-gray2-300 "
}`}
>
{step.label}
</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import React from "react";
interface SVGImageProps extends React.SVGProps<SVGSVGElement> {
src: React.FC<React.SVGProps<SVGSVGElement>> | any;
width?: string | number;
height?: string | number;
className?: string;
}
const SVGImage: React.FC<SVGImageProps> = ({
src: IconComponent,
width = "30px",
height = "30px",
className = "",
...props
}) => {
return (
<IconComponent
fill="currentColor"
width={width}
height={height}
className={`inline-block ${className}`}
{...props}
/>
);
};
export default SVGImage;

View File

@@ -0,0 +1,98 @@
import React from "react";
import clsx from "clsx";
import { bgPrimaryColor, textColor } from "../../data/getColorBasedOnMode";
import { inputWidths } from "../../data/getItemsWidth";
export type SwitchSize = "small" | "medium" | "large";
interface SwitchProps {
checked: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
isError?: boolean;
size?: SwitchSize;
label?: string;
name?: string;
className?: string;
}
const sizeMap: Record<
SwitchSize,
{ track: string; thumb: string; thumbTranslate: string }
> = {
small: {
track: "w-9 h-5",
thumb: "w-4 h-4",
thumbTranslate: "translate-x-4",
},
medium: {
track: "w-11 h-6",
thumb: "w-5 h-5",
thumbTranslate: "translate-x-5",
},
large: {
track: "w-14 h-8",
thumb: "w-6 h-6",
thumbTranslate: "translate-x-6",
},
};
export const Switch: React.FC<SwitchProps> = ({
checked,
onChange,
disabled = false,
isError = false,
size = "medium",
label,
name,
className,
}) => {
const sizeStyles = sizeMap[size];
return (
<label
className={clsx(
"inline-flex items-center gap-2 cursor-pointer",
disabled && "opacity-60 cursor-not-allowed",
inputWidths,
className
)}
>
<div className="relative">
<input
type="checkbox"
role="switch"
name={name}
checked={checked}
onChange={onChange}
disabled={disabled}
className="sr-only"
/>
<div
className={clsx(
"rounded-full transition-colors duration-300",
sizeStyles.track,
isError
? checked
? "bg-prima-500"
: "bg-red-300"
: checked
? bgPrimaryColor
: "bg-dark-300"
)}
>
<div
className={clsx(
"absolute top-0.5 left-0.5 rounded-full bg-white shadow transform transition-transform duration-300",
sizeStyles.thumb,
checked && sizeStyles.thumbTranslate
)}
/>
</div>
</div>
{label && (
<span className={`text-sm select-none ${textColor}`}>{label}</span>
)}
</label>
);
};

317
src/components/Tab/Tab.tsx Normal file
View File

@@ -0,0 +1,317 @@
import React, { useEffect, useState, useMemo } from "react";
import { motion } from "framer-motion";
import { checkIsMobile } from "../../utils/checkIsMobile";
import { useTabStore } from "../../context/zustand-store/appStore";
import { useUserProfileStore } from "../../context/zustand-store/userStore";
import { RolesContextMenu } from "../Button/RolesContextMenu";
import { checkAccess } from "../../utils/checkAccess";
type Tab = {
label: string;
visible?: boolean;
disabled?: boolean;
path?: string;
page?: string;
access?: string;
};
type TabsProps = {
tabs: Tab[];
onChange: (index: number) => void;
size?: "small" | "medium" | "large";
tabKey?: string;
};
const Tabs: React.FC<TabsProps> = ({
tabs,
onChange,
size = "medium",
tabKey = "default",
}) => {
const isMobile = checkIsMobile();
const { tabState, setActiveTab } = useTabStore();
const { profile } = useUserProfileStore();
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
page: string;
access: string;
} | null>(null);
const isAdmin = profile?.role?.type?.key === "ADM";
const isTabVisible = (tab: Tab): boolean => {
if (tab.visible === false) return false;
if (tab.page && tab.access) {
return checkAccess({ page: tab.page, access: tab.access });
}
return tab.visible === undefined || tab.visible === true;
};
const visibleTabs = useMemo(
() => tabs.filter(isTabVisible),
[tabs, profile?.permissions]
);
const hasSingleVisibleTab = visibleTabs.length === 1;
const getFirstVisibleTabIndex = () => {
return tabs.findIndex(isTabVisible);
};
const isValidTabIndex = (index: number) => {
return index >= 0 && index < tabs.length && isTabVisible(tabs[index]);
};
const storedTab = tabState.activeTabs?.[tabKey];
const storedIndex = storedTab?.index ?? 0;
const firstVisibleIndex = getFirstVisibleTabIndex();
const selectedIndex = isValidTabIndex(storedIndex)
? storedIndex
: firstVisibleIndex !== -1
? firstVisibleIndex
: 0;
useEffect(() => {
if (hasSingleVisibleTab) {
const singleTabIndex = tabs.findIndex(isTabVisible);
if (singleTabIndex !== -1 && singleTabIndex !== selectedIndex) {
setActiveTab({
index: singleTabIndex,
path: tabs[singleTabIndex]?.path || window.location.pathname,
label: tabs[singleTabIndex].label,
tabKey,
});
onChange(singleTabIndex);
}
}
}, [hasSingleVisibleTab, tabs, tabKey, profile?.permissions]);
useEffect(() => {
if (!isValidTabIndex(selectedIndex)) {
const firstVisible = getFirstVisibleTabIndex();
if (firstVisible !== -1) {
setActiveTab({
index: firstVisible,
path: tabs[firstVisible]?.path || window.location.pathname,
label: tabs[firstVisible].label,
tabKey,
});
onChange(firstVisible);
}
} else {
onChange(selectedIndex);
}
}, [tabs, selectedIndex, tabKey]);
useEffect(() => {
if (typeof window === "undefined") return;
const currentPath = window.location.pathname;
if (storedTab) {
const storedTabStillExists = tabs.some(
(tab) =>
tab.label === storedTab.label &&
(tab.path === storedTab.path || !tab.path) &&
isTabVisible(tab)
);
if (storedTabStillExists) {
const storedIndex = tabs.findIndex(
(tab) =>
tab.label === storedTab.label &&
(tab.path === storedTab.path || !tab.path) &&
isTabVisible(tab)
);
if (storedIndex !== -1 && storedIndex !== selectedIndex) {
setActiveTab({
index: storedIndex,
path: tabs[storedIndex]?.path || currentPath,
label: tabs[storedIndex].label,
tabKey,
});
onChange(storedIndex);
}
return;
}
}
const pathMatchIndex = tabs.findIndex(
(tab) => tab.path === currentPath && isTabVisible(tab)
);
if (pathMatchIndex !== -1) {
setActiveTab({
index: pathMatchIndex,
path: currentPath,
label: tabs[pathMatchIndex].label,
tabKey,
});
onChange(pathMatchIndex);
return;
}
if (!isValidTabIndex(selectedIndex)) {
const firstVisible = getFirstVisibleTabIndex();
if (firstVisible !== -1) {
setActiveTab({
index: firstVisible,
path: tabs[firstVisible]?.path || currentPath,
label: tabs[firstVisible].label,
tabKey,
});
onChange(firstVisible);
}
}
}, [tabs, tabKey, storedTab, selectedIndex, profile?.permissions]);
const handleTabClick = (index: number) => {
if (!tabs[index].disabled) {
setActiveTab({
index,
path: tabs[index]?.path || window.location.pathname,
label: tabs[index].label,
tabKey,
});
onChange(index);
}
};
const handleContextMenu = (
e: React.MouseEvent<HTMLButtonElement>,
tab: Tab
) => {
if (isAdmin && tab.page && tab.access) {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
page: tab.page,
access: tab.access,
});
}
};
useEffect(() => {
const handleClick = () => {
if (contextMenu) {
setContextMenu(null);
}
};
if (contextMenu) {
document.addEventListener("click", handleClick);
}
return () => {
document.removeEventListener("click", handleClick);
};
}, [contextMenu]);
const sizeClasses: Record<NonNullable<TabsProps["size"]>, string> = {
small: "text-xs py-1 px-2",
medium: "text-sm py-1.5 px-3",
large: "text-base py-2 px-4",
};
if (hasSingleVisibleTab) {
return null;
}
if (isMobile) {
return (
<>
<div className="flex flex-wrap justify-center gap-0.5 w-full">
{tabs.map((tab, index) =>
!isTabVisible(tab) ? null : (
<button
key={index}
onClick={() => handleTabClick(index)}
onContextMenu={(e) => handleContextMenu(e, tab)}
disabled={tab.disabled}
className={`text-center rounded-lg px-2 py-1 transition-colors duration-200 text-[11px] font-medium
${
tab.disabled
? "text-red-300 dark:text-red-400 bg-gray-200 opacity-60 dark:bg-dark-600 cursor-not-allowed"
: selectedIndex === index
? "bg-primary-100 text-primary-700"
: "bg-white text-gray-700 dark:bg-gray-200"
}`}
>
{tab.label}
</button>
)
)}
</div>
{contextMenu && (
<RolesContextMenu
page={contextMenu.page}
access={contextMenu.access}
position={{ x: contextMenu.x, y: contextMenu.y }}
onClose={() => setContextMenu(null)}
/>
)}
</>
);
}
return (
<>
<div className="w-full flex justify-center select-none">
<div className="relative flex gap-1 border-b border-gray-200">
{tabs.map((tab, index) =>
!isTabVisible(tab) ? null : (
<button
key={index}
onClick={() => handleTabClick(index)}
onContextMenu={(e) => handleContextMenu(e, tab)}
disabled={tab.disabled}
className={`relative transition-colors duration-200 rounded-t-md focus:outline-none
${
tab.disabled
? "text-gray-400 cursor-not-allowed"
: "cursor-pointer"
}
${
window.location.pathname === "/"
? "text-xs py-1 px-2"
: sizeClasses[size]
}
`}
>
<span
className={`${
selectedIndex === index
? "text-primary-600 font-semibold"
: "text-gray-600 dark:text-gray-300 hover:text-primary-800"
}`}
>
{tab.label}
</span>
{selectedIndex === index && !tab.disabled && (
<motion.div
layoutId={`tab-underline-${tabKey}`}
className="absolute bottom-[-1px] left-0 right-0 h-[2px] bg-primary-600 rounded-t"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
</button>
)
)}
</div>
</div>
{contextMenu && (
<RolesContextMenu
page={contextMenu.page}
access={contextMenu.access}
position={{ x: contextMenu.x, y: contextMenu.y }}
onClose={() => setContextMenu(null)}
/>
)}
</>
);
};
export default Tabs;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import { EyeIcon } from "@heroicons/react/24/outline";
import { motion } from "framer-motion";
import { ButtonHTMLAttributes } from "react";
type TableButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
columns?: boolean;
title?: string;
size?: "small" | "medium" | "large";
};
const sizeMap = {
small: {
button: "px-2 py-1",
content: "px-2 py-1 text-xs rounded-md gap-1",
icon: "w-4 h-4",
},
medium: {
button: "px-3 py-1.5",
content: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
icon: "w-4 h-4",
},
large: {
button: "px-4 py-2",
content: "px-4 py-2 text-base rounded-xl gap-2",
icon: "w-5 h-5",
},
};
export const TableButton = ({
size = "medium",
disabled = false,
className = "",
title = "نمایش",
onClick,
...props
}: TableButtonProps) => {
const styles = sizeMap[size];
return (
<button
disabled={disabled}
onClick={onClick}
className={`relative overflow-hidden group cursor-pointer ${styles.button} ${className}`}
{...props}
>
<motion.span
className={`flex items-center justify-center ${styles.content} ${
disabled
? "bg-primary-500/30 opacity-40 cursor-not-allowed"
: "bg-primary-500/30"
} text-primary-700 dark:text-primary-400 dark:bg-primary-100/20`}
initial={false}
whileTap={{ scale: 0.95 }}
>
<motion.span
className="flex items-center"
initial={false}
animate={{ opacity: [1, 0.7, 1] }}
transition={{ repeat: Infinity, duration: 3, ease: "easeInOut" }}
>
<EyeIcon className={`${styles.icon} ml-1`} />
{title}
</motion.span>
<motion.span
className="absolute inset-0 bg-primary-500/10 opacity-0 group-hover:opacity-100"
initial={false}
transition={{ duration: 0.3 }}
style={{ borderRadius: "50%", scale: 0 }}
whileHover={{
scale: 2,
opacity: 0,
transition: { duration: 0.6 },
}}
/>
</motion.span>
</button>
);
};

View File

@@ -0,0 +1,301 @@
import React, { forwardRef, useState, useEffect, useRef } from "react";
import { getSizeStyles } from "../../data/getInputSizes";
import { bgInputPrimaryColor, textColor } from "../../data/getColorBasedOnMode";
import { motion, AnimatePresence } from "framer-motion";
import { checkIsMobile } from "../../utils/checkIsMobile";
interface TextfieldProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type" | "value" | "defaultValue"
> {
label?: string;
value?: string | number;
error?: boolean;
password?: boolean;
fullWidth?: boolean;
isAutoComplete?: boolean;
helperText?: string;
className?: string;
inputSize?: "small" | "medium" | "large";
children?: React.ReactNode;
isNumber?: boolean;
handleCloseInput?: () => void;
start?: React.ReactNode;
end?: React.ReactNode;
formattedNumber?: boolean;
}
const Textfield = forwardRef<HTMLInputElement, TextfieldProps>(
(
{
placeholder,
error,
value = "",
helperText,
password,
className,
inputSize = "medium",
fullWidth,
children,
isNumber = false,
isAutoComplete = false,
onChange,
handleCloseInput,
start,
end,
formattedNumber = false,
onClick: propsOnClick,
onFocus: propsOnFocus,
onBlur: propsOnBlur,
...props
},
ref
) => {
const [inputValue, setInputValue] = useState(value);
const [displayValue, setDisplayValue] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
const isFirstClickRef = useRef(true);
const isMobile = checkIsMobile();
useEffect(() => {
setInputValue(value);
if (formattedNumber) {
if (value === "" || value === null || value === undefined) {
setDisplayValue("");
} else {
const numValue =
typeof value === "string" ? value.replace(/,/g, "") : value;
setDisplayValue(Number(numValue).toLocaleString());
}
} else {
setDisplayValue(value);
}
}, [value, placeholder, formattedNumber]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (formattedNumber) {
value = value.replace(/[^0-9]/g, "");
if (value === "") {
setDisplayValue("");
} else {
const formatted = Number(value).toLocaleString();
setDisplayValue(formatted);
}
e.target.value = value;
} else {
setDisplayValue(value);
}
setInputValue(value);
if (onChange) {
onChange(e);
}
};
const handleMouseDown = () => {
if (isAutoComplete && isMobile && isFirstClickRef.current) {
if (inputRef.current) {
inputRef.current.readOnly = true;
}
}
};
const handleTouchStart = () => {
if (isAutoComplete && isMobile && isFirstClickRef.current) {
if (inputRef.current) {
inputRef.current.readOnly = true;
}
}
};
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
if (isAutoComplete && isMobile && isFirstClickRef.current) {
e.preventDefault();
if (inputRef.current) {
inputRef.current.blur();
setTimeout(() => {
if (inputRef.current) {
inputRef.current.readOnly = false;
}
}, 100);
}
isFirstClickRef.current = false;
return;
}
if (propsOnClick) {
propsOnClick(e);
}
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
if (isAutoComplete && isMobile && isFirstClickRef.current) {
if (inputRef.current) {
inputRef.current.blur();
setTimeout(() => {
if (inputRef.current) {
inputRef.current.readOnly = false;
}
}, 100);
}
isFirstClickRef.current = false;
return;
}
if (propsOnFocus) {
propsOnFocus(e);
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
isFirstClickRef.current = true;
if (propsOnBlur) {
propsOnBlur(e);
}
};
const floatingLabelVariants = {
hidden: {
opacity: 0,
y: 10,
scale: 1,
transition: {
duration: 0.15,
ease: [0.4, 0, 0.2, 1],
},
},
visible: {
opacity: 1,
y: 0,
scale: 0.9,
transition: {
duration: 0.2,
ease: [0.4, 0, 0.2, 1],
delay: 0.05,
},
},
};
const shouldTextAlignLeft = isNumber && inputValue !== "";
const shouldFloatLabel = inputValue || inputValue === 0;
const inputClass = `py-2 ${
!start && !end && "border-[0.3px]"
} border-gray1-200 rounded-lg outline-0 dark:border-dark-400
${className || ""} ${start && end && "text-center"} ${
shouldTextAlignLeft ? "text-left" : ""
}
${error ? "border-red-400 focus:ring-red-400" : ""}
placeholder:text-dark-400 dark:placeholder:text-dark-100 ${
(!start || !end) && bgInputPrimaryColor
}
${getSizeStyles(inputSize).padding} ${textColor}
${
props.disabled
? "opacity-50 cursor-not-allowed bg-gray-100 dark:bg-dark-300 border-gray-300 dark:border-dark-500"
: "cursor-text"
}`;
const floatingLabelClass = `absolute right-1 bg-white dark:bg-dark-200 rounded-lg -top-2 text-[10px] px-2 mt-0.5 dark:text-dark-800 ${
error ? "text-red-400" : "text-gray-500"
} ${props.disabled ? "opacity-50" : ""} transition-all duration-200`;
return (
<div className={`flex flex-col gap-1 ${fullWidth ? "w-full" : ""} `}>
<div
className={`relative ${fullWidth ? "w-full" : ""} ${
(start || end) &&
`border-1 rounded-lg border-gray1-200 dark:bg-dark-500 ${
start ? "pr-2" : ""
} ${end ? "pl-2" : ""} w-auto text-right`
}`}
>
<div className="flex items-center justify-between">
{start && (
<div
className={`flex-shrink-0 min-w-12 text-gray-500 dark:text-dark-100 select-none ${
props.disabled ? "opacity-50" : ""
}`}
>
{start}
</div>
)}
<input
ref={(node) => {
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
inputRef.current = node;
}}
value={displayValue}
placeholder={placeholder}
type={password ? "password" : "text"}
onChange={handleChange}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onClick={handleClick}
onFocus={handleFocus}
onBlur={handleBlur}
className={`${inputClass} ${
fullWidth || (start && end) ? "w-full" : ""
} ${start ? "pl-2" : ""} ${end ? "pr-2" : ""}`}
{...props}
/>
{end && (
<div
dir="ltr"
className={`flex-shrink-0 min-w-12 text-gray-500 dark:text-dark-100 select-none text-left mr-1 flex items-center justify-start ${
props.disabled ? "opacity-50" : ""
}`}
>
{end}
</div>
)}
</div>
{placeholder && (
<AnimatePresence>
{shouldFloatLabel && (
<motion.label
key="floating-label"
variants={floatingLabelVariants}
initial="hidden"
animate="visible"
exit="hidden"
className={floatingLabelClass}
onClick={() => inputRef.current?.focus()}
>
{placeholder}
</motion.label>
)}
</AnimatePresence>
)}
</div>
{helperText && (
<p
onClick={() => {
if (isAutoComplete && handleCloseInput) {
handleCloseInput();
}
}}
className={`text-xs mt-1 ${
error ? "text-red-400" : "text-gray-500"
} ${props.disabled ? "opacity-50" : ""}`}
>
{helperText}
</p>
)}
{children}
</div>
);
}
);
Textfield.displayName = "Textfield";
export default Textfield;

View File

@@ -0,0 +1,103 @@
import React, { ButtonHTMLAttributes } from "react";
import { motion, HTMLMotionProps } from "framer-motion";
type PropsWeControl =
| "onDrag"
| "onDragStart"
| "onDragEnd"
| "onAnimationStart";
type ToggleButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
PropsWeControl | "ref"
> & {
isActive?: boolean;
title?: string;
size?: "small" | "medium" | "large";
} & Pick<HTMLMotionProps<"button">, PropsWeControl>;
const ToggleButton = React.forwardRef<HTMLButtonElement, ToggleButtonProps>(
(
{ isActive = false, title, className = "", size = "medium", ...props },
ref
) => {
return (
<div className="flex items-center gap-2">
{title && (
<span className="text-sm whitespace-nowrap text-gray-700 dark:text-dark-100">
{title}
</span>
)}
<motion.button
ref={ref}
className={`
relative flex items-center justify-between ${
size === "small"
? "w-8 h-4"
: size === "medium"
? "w-12 h-5"
: "w-14 h-5"
} rounded-full
transition-colors duration-300 focus:outline-none
${isActive ? "bg-primary-500" : "bg-black-200 dark:bg-gray-600"}
${className}
`}
aria-pressed={isActive}
whileTap={{ scale: 0.97 }}
dir="rtl"
{...props}
>
<motion.span
className={`${
size === "small"
? "w-3 h-3"
: size === "medium"
? "w-4 h-4"
: "w-4 h-4"
} rounded-full shadow-md ${
isActive ? "bg-white" : "bg-white dark:bg-gray-200"
}`}
layout
transition={{
type: "spring",
stiffness: 700,
damping: 30,
}}
initial={{
left: isActive
? `${
size === "small"
? "1rem"
: size === "medium"
? "1.75rem"
: "2.3rem"
}`
: "0.25rem",
}}
animate={{
left: isActive
? `${
size === "small"
? "1rem"
: size === "medium"
? "1.75rem"
: "2.3rem"
}`
: "0.25rem",
}}
style={{
position: "absolute",
top: "0.125rem",
left: isActive ? "1.75rem" : "0.25rem",
}}
/>
</motion.button>
</div>
);
}
);
ToggleButton.displayName = "ToggleButton";
export default ToggleButton;

View File

@@ -0,0 +1,131 @@
import React, { useState, ReactNode, useRef, useEffect } from "react";
import { motion } from "framer-motion";
import { checkIsMobile } from "../../utils/checkIsMobile";
import { useDarkMode } from "../../hooks/useDarkMode";
import { createPortal } from "react-dom";
import { usePopOverContext } from "../PopOver/PopOver";
type TooltipProps = {
title: string;
children: ReactNode | any;
position?: "top" | "bottom" | "left" | "right";
small?: boolean;
hidden?: boolean;
};
export const Tooltip: React.FC<TooltipProps> = ({
title,
children,
position = "top",
small,
hidden = false,
}) => {
const [visible, setVisible] = useState(false);
const [isDark] = useDarkMode();
const anchorRef = useRef<HTMLDivElement | null>(null);
const isInsidePopOver = usePopOverContext();
const [coords, setCoords] = useState<{
top: number;
left: number;
transform: string;
} | null>(null);
const updatePosition = () => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const gap = 8;
let top = 0;
let left = 0;
let transform = "";
if (position === "top") {
left = rect.left + rect.width / 2;
top = rect.top - gap;
transform = "translate(-50%, -100%)";
} else if (position === "bottom") {
left = rect.left + rect.width / 2;
top = rect.bottom + gap;
transform = "translate(-50%, 0)";
} else if (position === "left") {
top = rect.top + rect.height / 2;
left = rect.left - gap;
transform = "translate(-100%, -50%)";
} else {
top = rect.top + rect.height / 2;
left = rect.right + gap;
transform = "translate(0, -50%)";
}
setCoords({ top, left, transform });
};
useEffect(() => {
if (!visible) return;
updatePosition();
const onScroll = () => updatePosition();
const onResize = () => updatePosition();
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [visible, position]);
if (checkIsMobile() && isInsidePopOver) {
return (
<div
className="flex items-center justify-center"
onClick={() => {
children?.props?.onClick();
}}
>
<span className="w-1/6"></span>
<span className="text-xs text-nowrap text-gray-600 dark:text-white1-100/25 ">
{title}
</span>
<div className="flex items-center justify-center">{children}</div>
<span className="w-1/6"></span>
</div>
);
}
if (checkIsMobile()) {
return <div className="flex items-center justify-center">{children}</div>;
}
return (
<div
className="relative inline-block overflow-hidden"
onMouseEnter={() => !hidden && setVisible(true)}
onMouseLeave={() => setVisible(false)}
ref={anchorRef}
>
{children}
{!hidden &&
visible &&
coords &&
createPortal(
<motion.div
role="tooltip"
className={`fixed whitespace-nowrap px-3 py-1 rounded ${
small ? "text-[10px]" : "text-xs"
} text-white bg-black/70 dark:text-black dark:bg-white shadow-md pointer-events-none`}
style={{
top: `${coords.top}px`,
left: `${coords.left}px`,
transform: coords.transform,
zIndex: 9999,
}}
initial={{ opacity: 0 }}
animate={{ opacity: isDark ? 1 : 0.9 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
>
{title}
</motion.div>,
document.body
)}
</div>
);
};

View File

@@ -0,0 +1,140 @@
import React, { JSX } from "react";
import clsx from "clsx";
import {
ArrowLeftIcon,
BellAlertIcon,
InformationCircleIcon,
NoSymbolIcon,
} from "@heroicons/react/24/outline";
type Variant =
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "subtitle1"
| "subtitle2"
| "body1"
| "body2"
| "caption"
| "overline";
type Align = "left" | "center" | "right" | "justify";
interface TypographyProps extends React.HTMLAttributes<HTMLElement> {
variant?: Variant;
align?: Align;
color?: string;
sign?: string;
fontWeight?: "light" | "normal" | "medium" | "semibold" | "bold";
gutterBottom?: boolean;
noWrap?: boolean;
className?: string;
children: React.ReactNode;
component?: React.ElementType;
}
const variantStyles: Record<Variant, string> = {
h1: "text-5xl",
h2: "text-4xl",
h3: "text-3xl",
h4: "text-2xl",
h5: "text-xl",
h6: "text-lg",
subtitle1: "text-base",
subtitle2: "text-sm",
body1: "text-base",
body2: "text-sm",
caption: "text-xs",
overline: "text-[10px] uppercase tracking-widest",
};
const variantToElement: Record<Variant, keyof JSX.IntrinsicElements> = {
h1: "h1",
h2: "h2",
h3: "h3",
h4: "h4",
h5: "h5",
h6: "h6",
subtitle1: "h6",
subtitle2: "h6",
body1: "p",
body2: "p",
caption: "span",
overline: "span",
};
const Typography: React.FC<TypographyProps> = ({
variant = "body1",
align = "left",
color = "",
fontWeight = "normal",
gutterBottom = false,
noWrap = false,
className,
sign,
component,
children,
...props
}) => {
const Tag = (component || variantToElement[variant]) as React.ElementType;
const getColors = () => {
if (color) {
return color;
} else return "text-primary-900 dark:text-dark-100";
};
const getSign = () => {
switch (sign) {
case "info":
return <InformationCircleIcon className="w-6 h-6" />;
break;
case "alert":
return <BellAlertIcon className="w-6 h-6" />;
break;
case "error":
return <NoSymbolIcon className="w-6 h-6" />;
break;
case "arrow":
return <ArrowLeftIcon className="w-4 h-4" />;
break;
default:
break;
}
};
return (
<Tag
className={clsx(
"flex items-center gap-1",
variantStyles[variant],
color,
getColors(),
{
"mb-2": gutterBottom,
truncate: noWrap,
"text-left": align === "right",
"text-center": align === "center",
"text-right": align === "left",
"text-justify": align === "justify",
"font-light": fontWeight === "light",
"font-normal": fontWeight === "normal",
"font-medium": fontWeight === "medium",
"font-semibold": fontWeight === "semibold",
"font-bold": fontWeight === "bold",
},
className
)}
{...props}
>
{sign && getSign()}
{children}
</Tag>
);
};
export default Typography;

View File

@@ -0,0 +1,292 @@
import React, { useEffect, useRef, useState } from "react";
import moment from "jalali-moment";
import {
bgInputPrimaryColor,
mobileBorders,
textColor,
} from "../../data/getColorBasedOnMode";
import { getSizeStyles } from "../../data/getInputSizes";
import { checkIsMobile } from "../../utils/checkIsMobile";
interface DatePickerProps {
value?: string;
placeholderColor?: string;
onChange: (value: string) => void;
label?: string;
disabled?: boolean;
isTable?: boolean;
fullWidth?: boolean;
className?: string;
minYear?: number;
maxYear?: number;
size?: "small" | "medium" | "large";
}
const persianMonths = [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
];
const persianWeekDays = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
const DatePicker: React.FC<DatePickerProps> = ({
onChange,
label = "تاریخ",
disabled = false,
isTable = false,
className = "",
value = "",
placeholderColor = "",
minYear = 1390,
maxYear = 1410,
size = "medium",
}) => {
const pickerRef = useRef<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const today = moment().locale("fa");
const [selectedYear, setSelectedYear] = useState<number>(
parseInt(today.format("jYYYY"))
);
const [selectedMonthIndex, setSelectedMonthIndex] = useState<number>(
parseInt(today.format("jM")) - 1
);
const [selectedDay, setSelectedDay] = useState<number>(
parseInt(today.format("jD"))
);
const dayRef = useRef<HTMLDivElement>(null);
const monthRef = useRef<HTMLDivElement>(null);
const yearRef = useRef<HTMLDivElement>(null);
const daysInMonth = moment(
`${selectedYear}/${selectedMonthIndex + 1}/1`,
"jYYYY/jM/jD"
).jDaysInMonth();
useEffect(() => {
if (value) {
const jDate = moment(value, "YYYY-MM-DD").locale("fa");
const jYear = parseInt(jDate.format("jYYYY"));
const jMonth = parseInt(jDate.format("jM")) - 1;
const jDay = parseInt(jDate.format("jD"));
setSelectedYear(jYear);
setSelectedMonthIndex(jMonth);
setSelectedDay(jDay);
}
}, [value]);
useEffect(() => {
const daysInNewMonth = moment(
`${selectedYear}/${selectedMonthIndex + 1}/1`,
"jYYYY/jM/jD"
).jDaysInMonth();
if (selectedDay > daysInNewMonth) {
setSelectedDay(daysInNewMonth);
}
}, [selectedYear, selectedMonthIndex]);
useEffect(() => {
if (isOpen) {
const scrollToSelected = (
ref: React.RefObject<HTMLDivElement>,
selector: string
) => {
const el = ref.current?.querySelector(selector);
el?.scrollIntoView({ behavior: "auto", block: "nearest" });
};
scrollToSelected(
dayRef as React.RefObject<HTMLDivElement>,
".selected-day"
);
scrollToSelected(
monthRef as React.RefObject<HTMLDivElement>,
".selected-month"
);
scrollToSelected(
yearRef as React.RefObject<HTMLDivElement>,
".selected-year"
);
}
}, [isOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
pickerRef.current &&
!pickerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
const jDate = moment(
`${selectedYear}/${selectedMonthIndex + 1}/${selectedDay}`,
"jYYYY/jM/jD"
);
const gDate = jDate.locale("en").format("YYYY-MM-DD");
if (value !== gDate) {
onChange(gDate);
}
}, [selectedYear, selectedMonthIndex, selectedDay, value]);
const getDayOfWeek = (day: number) => {
const date = moment(
`${selectedYear}/${selectedMonthIndex + 1}/${day}`,
"jYYYY/jM/jD"
);
return persianWeekDays[date.day()];
};
return (
<div className={`relative w-full ${className}`} ref={pickerRef}>
<div className="flex items-center gap-2 ">
<label
onClick={() => setIsOpen(!isOpen)}
className={`text-sm text-nowrap select-none ${
disabled ? "text-gray-400" : textColor
}`}
>
{label}
</label>
<input
readOnly
value={`${selectedDay} / ${persianMonths[selectedMonthIndex]} / ${selectedYear} `}
onClick={() => setIsOpen(!isOpen)}
className={`${
isTable ? "w-full md:w-30 text-xs" : "w-full min-w-40 text-sm"
} border-1 focus:ring-dark-400 rounded-lg text-center text-dark-800 dark:text-gray-100 ${
disabled && "opacity-80"
} ${disabled ? "text-gray-400" : textColor} ${
placeholderColor
? placeholderColor
: checkIsMobile()
? "bg-gray-50 border-gray-300"
: "bg-white border-black-100"
} ${checkIsMobile() && mobileBorders} ${
getSizeStyles(size).padding
} ${bgInputPrimaryColor} ${disabled ? "text-gray-400" : textColor}`}
disabled={disabled}
/>
</div>
{isOpen && (
<div
className={`absolute z-50 mt-2 flex rounded-lg shadow-lg text-white text-sm w-56 bg-white dark:bg-dark-500 `}
>
{[
{
label: "روز",
options: Array.from({ length: daysInMonth }, (_, i) => i + 1),
selected: selectedDay,
setSelected: setSelectedDay,
ref: dayRef,
selectedClass: "selected-day",
},
{
label: "ماه",
options: persianMonths,
selected: selectedMonthIndex,
setSelected: (v: number) => setSelectedMonthIndex(v),
ref: monthRef,
selectedClass: "selected-month",
},
{
label: "سال",
options: Array.from(
{ length: maxYear - minYear + 1 },
(_, i) => minYear + i
),
selected: selectedYear,
setSelected: setSelectedYear,
ref: yearRef,
selectedClass: "selected-year",
},
].map(
(
{ label, options, selected, setSelected, ref, selectedClass },
colIndex
) => (
<div
key={label}
ref={ref}
className="flex-1 h-48 overflow-y-auto snap-y snap-mandatory text-center space-y-1 scrollbar-hide relative"
>
<ul className="relative z-20">
<li className="h-6" aria-hidden />
{options.map((opt, idx) => {
const isString = typeof opt === "string";
const isSelected =
(colIndex === 0 && opt === selected) ||
(colIndex === 1 && idx === selected) ||
(colIndex === 2 && opt === selected);
const itemClass = isSelected ? selectedClass : "";
return (
<li
key={isString ? opt : String(opt)}
className={`py-2 cursor-pointer transition ${itemClass} ${
isSelected
? "text-amber-900 dark:text-amber-200 font-bold scale-105"
: textColor
} ${
(opt === parseInt(today.format("jD")) ||
opt ===
persianMonths[parseInt(today.format("jM")) - 1] ||
opt === parseInt(today.format("jYYYY"))) &&
" decoration-cyan-200 rounded-4xl"
}`}
onClick={() =>
setSelected(colIndex === 1 ? idx : (opt as number))
}
>
{colIndex === 0 ? (
<span className="flex items-center justify-between mx-5">
<span className={`text-[10px]`}>
{getDayOfWeek((opt as number) + 1)}
</span>
<span
className={`${
opt === parseInt(today.format("jD")) &&
" underline underline-offset-4 decoration-cyan-200 rounded-4xl"
}`}
>
{opt}{" "}
</span>
</span>
) : (
opt
)}
</li>
);
})}
<li className="h-6" aria-hidden />
</ul>
</div>
)
)}
</div>
)}
</div>
);
};
export default DatePicker;

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useRef, useState } from "react";
import {
bgInputPrimaryColor,
mobileBorders,
textColor,
} from "../../data/getColorBasedOnMode";
import { getSizeStyles } from "../../data/getInputSizes";
import { checkIsMobile } from "../../utils/checkIsMobile";
interface TimePickerProps {
value?: string;
onChange?: (value: string) => void;
label?: string;
disabled?: boolean;
className?: string;
size?: "small" | "medium" | "large";
}
const TimePicker: React.FC<TimePickerProps> = ({
onChange,
label = "ساعت",
disabled = false,
className = "",
size = "medium",
}) => {
const pickerRef = useRef<HTMLDivElement>(null!);
const hourRef = useRef<HTMLDivElement>(null!);
const minuteRef = useRef<HTMLDivElement>(null!);
const [isOpen, setIsOpen] = useState(false);
const now = new Date();
const initialHour = now.getHours();
const initialMinute = now.getMinutes();
const [selectedHour, setSelectedHour] = useState<number>(initialHour);
const [selectedMinute, setSelectedMinute] = useState<number>(initialMinute);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
pickerRef.current &&
!pickerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
const timeString = `${String(selectedHour).padStart(2, "0")}:${String(
selectedMinute
).padStart(2, "0")}:00`;
if (onChange) {
onChange(timeString);
}
}, [selectedHour, selectedMinute, onChange]);
const scrollToSelected = (
ref: React.RefObject<HTMLDivElement>,
selected: number
) => {
const el = ref.current?.querySelector(`[data-value="${selected}"]`);
el?.scrollIntoView({ behavior: "auto", block: "center" });
};
useEffect(() => {
if (isOpen) {
scrollToSelected(hourRef, selectedHour);
scrollToSelected(minuteRef, selectedMinute);
}
}, [isOpen]);
const renderColumn = (
title: string,
range: number,
selected: number,
setSelected: (val: number) => void,
ref: React.RefObject<HTMLDivElement>
) => (
<div className="h-60 w-30 flex flex-col items-center">
<div className={`py-1 text-xs font-semibold ${textColor}`}>{title}</div>
<div
ref={ref}
className="flex-1 overflow-x-hidden w-full overflow-y-auto overflow-hidden xs:scrollbar-hide text-center"
>
<ul className="space-y-1">
{Array.from({ length: range }, (_, i) => (
<li
key={i}
data-value={i}
className={`py-2 cursor-pointer ${
i === selected
? "text-amber-900 dark:text-amber-200 font-bold scale-105"
: textColor
}`}
onClick={() => setSelected(i)}
>
{String(i).padStart(2, "0")}
</li>
))}
</ul>
</div>
</div>
);
return (
<div
className={`relative w-28 justify-center ${className}`}
ref={pickerRef}
>
<div className="flex items-center gap-2">
<label className={`text-sm ${textColor}`}>{label}</label>
<input
readOnly
value={`${String(selectedHour).padStart(2, "0")}:${String(
selectedMinute
).padStart(2, "0")}`}
onClick={() => setIsOpen(!isOpen)}
className={`w-full border rounded-xl bg-white text-sm text-center text-dark-800 shadow-sm focus:border-blue-400 focus:ring focus:ring-blue-200 ${
checkIsMobile() && mobileBorders
} ${getSizeStyles(size).padding} ${bgInputPrimaryColor} ${textColor}`}
disabled={disabled}
/>
</div>
{isOpen && (
<div
className={`absolute z-50 mt-2 flex rounded-lg shadow-lg text-white text-sm w-40 ${bgInputPrimaryColor}`}
>
{renderColumn(
"دقیقه",
60,
selectedMinute,
setSelectedMinute,
minuteRef
)}
{renderColumn("ساعت", 24, selectedHour, setSelectedHour, hourRef)}
</div>
)}
</div>
);
};
export default TimePicker;

View File

@@ -0,0 +1,120 @@
.time-picker {
position: relative;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
width: 100%;
max-width: 100px;
}
.time-picker-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
font-weight: 500;
}
.time-picker-input-container {
position: relative;
display: flex;
align-items: center;
}
.time-picker-input {
width: 100%;
padding: 10px 40px 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.time-picker-input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2);
}
.time-picker-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.time-picker-toggle {
position: absolute;
right: 8px;
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.time-picker-toggle:disabled {
color: #ccc;
cursor: not-allowed;
}
.time-picker-toggle:hover:not(:disabled) {
color: #333;
}
.time-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
display: flex;
max-height: 300px;
overflow-y: auto;
}
.time-picker-column {
flex: 1;
min-width: 60px;
}
.time-picker-header {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #666;
background-color: #f9f9f9;
border-bottom: 1px solid #eee;
text-align: center;
}
.time-picker-list {
overflow-y: auto;
max-height: 250px;
}
.time-picker-item {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
text-align: center;
cursor: pointer;
font-size: 14px;
color: #333;
}
.time-picker-item:hover {
background-color: #f0f7ff;
}
.time-picker-item.selected {
background-color: #4a90e2;
color: white;
}