first commit
This commit is contained in:
653
src/components/AutoComplete/AutoComplete.tsx
Normal file
653
src/components/AutoComplete/AutoComplete.tsx
Normal 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;
|
||||
69
src/components/BackDrop/Backdrop.tsx
Normal file
69
src/components/BackDrop/Backdrop.tsx
Normal 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;
|
||||
124
src/components/BarChart/BarChart.tsx
Normal file
124
src/components/BarChart/BarChart.tsx
Normal 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;
|
||||
104
src/components/BooleanQuestion/BooleanQuestion.tsx
Normal file
104
src/components/BooleanQuestion/BooleanQuestion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
300
src/components/Button/Button.tsx
Normal file
300
src/components/Button/Button.tsx
Normal 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;
|
||||
322
src/components/Button/RolesContextMenu.tsx
Normal file
322
src/components/Button/RolesContextMenu.tsx
Normal 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
|
||||
);
|
||||
};
|
||||
66
src/components/CheckBox/CheckBox.tsx
Normal file
66
src/components/CheckBox/CheckBox.tsx
Normal 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;
|
||||
44
src/components/Divider/Divider.tsx
Normal file
44
src/components/Divider/Divider.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
src/components/DocumentDownloader/DocumentDownloader.tsx
Normal file
57
src/components/DocumentDownloader/DocumentDownloader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
135
src/components/Drawer/Drawer.tsx
Normal file
135
src/components/Drawer/Drawer.tsx
Normal 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;
|
||||
122
src/components/FIleUploader/FileUploader.tsx
Normal file
122
src/components/FIleUploader/FileUploader.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
416
src/components/FormItems/FormApiBasedAutoComplete.tsx
Normal file
416
src/components/FormItems/FormApiBasedAutoComplete.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
243
src/components/FormItems/FormEnterLocation.tsx
Normal file
243
src/components/FormItems/FormEnterLocation.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
54
src/components/Grid/Grid.tsx
Normal file
54
src/components/Grid/Grid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
500
src/components/ImageUploader/ImageUploader.tsx
Normal file
500
src/components/ImageUploader/ImageUploader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
431
src/components/LineChart/LineChart.tsx
Normal file
431
src/components/LineChart/LineChart.tsx
Normal 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;
|
||||
123
src/components/Modal/Modal.tsx
Normal file
123
src/components/Modal/Modal.tsx
Normal 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;
|
||||
46
src/components/NoData/NoData.tsx
Normal file
46
src/components/NoData/NoData.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
src/components/PageTitle/PageTitle.tsx
Normal file
22
src/components/PageTitle/PageTitle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
571
src/components/PaginationParameters/PaginationParameters.tsx
Normal file
571
src/components/PaginationParameters/PaginationParameters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
758
src/components/PopOver/PopOver.tsx
Normal file
758
src/components/PopOver/PopOver.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
123
src/components/PopOverButtons/PopOverButtons.tsx
Normal file
123
src/components/PopOverButtons/PopOverButtons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
70
src/components/RadioButton/RadioButton.tsx
Normal file
70
src/components/RadioButton/RadioButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
src/components/RadioButton/RadioGroup.tsx
Normal file
73
src/components/RadioButton/RadioGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
src/components/SettingCard/SettingCard.tsx
Normal file
117
src/components/SettingCard/SettingCard.tsx
Normal 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;
|
||||
120
src/components/ShowCardsStringList/ShowCardsStringList.tsx
Normal file
120
src/components/ShowCardsStringList/ShowCardsStringList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
170
src/components/ShowImage/ShowImage.tsx
Normal file
170
src/components/ShowImage/ShowImage.tsx
Normal 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;
|
||||
551
src/components/ShowMoreInfo/ShowMoreInfo.tsx
Normal file
551
src/components/ShowMoreInfo/ShowMoreInfo.tsx
Normal 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;
|
||||
226
src/components/ShowStringList/ShowStringList.tsx
Normal file
226
src/components/ShowStringList/ShowStringList.tsx
Normal 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">
|
||||
هیچ نتیجهای برای "{searchTerm}" یافت نشد
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowStringList;
|
||||
19
src/components/ShowWeight/ShowWeight.tsx
Normal file
19
src/components/ShowWeight/ShowWeight.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
66
src/components/Stepper/Stepper.tsx
Normal file
66
src/components/Stepper/Stepper.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
src/components/SvgImage/SvgImage.tsx
Normal file
28
src/components/SvgImage/SvgImage.tsx
Normal 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;
|
||||
98
src/components/Switch/Switch.tsx
Normal file
98
src/components/Switch/Switch.tsx
Normal 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
317
src/components/Tab/Tab.tsx
Normal 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;
|
||||
1191
src/components/Table/Table.tsx
Normal file
1191
src/components/Table/Table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
79
src/components/TableButton/TableButton.tsx
Normal file
79
src/components/TableButton/TableButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
301
src/components/Textfeild/Textfeild.tsx
Normal file
301
src/components/Textfeild/Textfeild.tsx
Normal 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;
|
||||
103
src/components/ToggleButton/ToggleButton.tsx
Normal file
103
src/components/ToggleButton/ToggleButton.tsx
Normal 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;
|
||||
131
src/components/Tooltip/Tooltip.tsx
Normal file
131
src/components/Tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
140
src/components/Typography/Typography.tsx
Normal file
140
src/components/Typography/Typography.tsx
Normal 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;
|
||||
292
src/components/date-picker/DatePicker.tsx
Normal file
292
src/components/date-picker/DatePicker.tsx
Normal 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;
|
||||
148
src/components/time-picker/CustomTimePicker.tsx
Normal file
148
src/components/time-picker/CustomTimePicker.tsx
Normal 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;
|
||||
120
src/components/time-picker/TimePicker.css
Normal file
120
src/components/time-picker/TimePicker.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user