diff --git a/components.json b/components.json new file mode 100644 index 0000000..3dfc47e --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} + diff --git a/package-lock.json b/package-lock.json index 94cec12..3db9e99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "0.0.0", "dependencies": { "@headlessui/react": "^2.2.7", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-icons": "^1.3.2", "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.85.5", "axios": "^1.11.0", + "clsx": "^2.1.1", "formik": "^2.4.6", "i18next": "^25.4.2", "iconsax-react": "^0.0.8", @@ -20,6 +23,7 @@ "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", "react-i18next": "^15.7.2", + "react-loading-skeleton": "^3.5.0", "react-multi-date-picker": "^4.5.2", "react-otp-input": "^3.1.1", "react-router-dom": "^7.8.2", @@ -1156,6 +1160,231 @@ "node": ">= 8" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-aria/focus": { "version": "3.21.1", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.1.tgz", @@ -1927,7 +2156,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.8.tgz", "integrity": "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2342,9 +2571,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -4361,6 +4590,15 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-loading-skeleton": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz", + "integrity": "sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-multi-date-picker": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz", @@ -4713,13 +4951,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -4909,9 +5147,9 @@ } }, "node_modules/vite": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", - "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -4919,7 +5157,7 @@ "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", - "tinyglobby": "^0.2.14" + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" diff --git a/package.json b/package.json index c7c3906..340ff76 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,12 @@ }, "dependencies": { "@headlessui/react": "^2.2.7", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-icons": "^1.3.2", "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.85.5", "axios": "^1.11.0", + "clsx": "^2.1.1", "formik": "^2.4.6", "i18next": "^25.4.2", "iconsax-react": "^0.0.8", @@ -22,6 +25,7 @@ "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", "react-i18next": "^15.7.2", + "react-loading-skeleton": "^3.5.0", "react-multi-date-picker": "^4.5.2", "react-otp-input": "^3.1.1", "react-router-dom": "^7.8.2", diff --git a/src/components/DefaultTableSkeleton.tsx b/src/components/DefaultTableSkeleton.tsx new file mode 100644 index 0000000..867f975 --- /dev/null +++ b/src/components/DefaultTableSkeleton.tsx @@ -0,0 +1,30 @@ +import { type FC, Fragment } from 'react' +import Td from './Td' +import Skeleton from 'react-loading-skeleton' + +type Props = { + tdCount: number, + trCount?: number, +} + +const DefaultTableSkeleton: FC = ({ tdCount, trCount = 5 }) => { + return ( + + { + Array.from({ length: trCount }).map((_, rowIndex) => ( + + { + Array.from({ length: tdCount }).map((_, colIndex) => ( + + + + )) + } + + )) + } + + ) +} + +export default DefaultTableSkeleton diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index c39cd30..d381ceb 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -11,33 +11,41 @@ const Pagination: React.FC = ({ totalPages, onPageChange, }) => { + // اگر فقط یک صفحه داریم، pagination نمایش داده نمی‌شود + if (totalPages <= 1) return null; + const getPageNumbers = () => { const pageNumbers: (number | string)[] = []; - const maxVisiblePages = 5; // تعداد حداکثری صفحات قابل مشاهده + const maxVisiblePages = window.innerWidth < 768 ? 3 : 5; // تعداد کمتر برای موبایل if (totalPages <= maxVisiblePages) { for (let i = 1; i <= totalPages; i++) { pageNumbers.push(i); } } else { + // نمایش صفحه اول + pageNumbers.push(1); + if (currentPage > 3) { - pageNumbers.push(1); - if (currentPage > 4) { - pageNumbers.push("..."); - } + pageNumbers.push("..."); } - const start = Math.max(2, currentPage - 1); - const end = Math.min(totalPages - 1, currentPage + 1); + // صفحات میانی + const start = Math.max(2, Math.min(currentPage - 1, totalPages - 3)); + const end = Math.min(totalPages - 1, Math.max(currentPage + 1, 4)); for (let i = start; i <= end; i++) { - pageNumbers.push(i); + if (!pageNumbers.includes(i)) { + pageNumbers.push(i); + } } if (currentPage < totalPages - 2) { - if (currentPage < totalPages - 3) { - pageNumbers.push("..."); - } + pageNumbers.push("..."); + } + + // نمایش صفحه آخر + if (!pageNumbers.includes(totalPages)) { pageNumbers.push(totalPages); } } @@ -48,50 +56,52 @@ const Pagination: React.FC = ({ const pageNumbers = getPageNumbers(); return ( -
- {/* دکمه قبلی */} - {/* */} + {/* شماره صفحات */} - {pageNumbers.map((page, index) => - typeof page === "number" ? ( - - ) : ( - - {page} - - ) - )} +
+ {pageNumbers.map((page, index) => + typeof page === "number" ? ( + + ) : ( + + {page} + + ) + )} +
- {/* دکمه بعدی */} - {/* */} +
); }; diff --git a/src/components/RowActionsDropdown.tsx b/src/components/RowActionsDropdown.tsx new file mode 100644 index 0000000..06aa578 --- /dev/null +++ b/src/components/RowActionsDropdown.tsx @@ -0,0 +1,145 @@ +import React, { useState, useRef, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { More } from 'iconsax-react' + +export interface RowActionItem { + label: string; + icon?: React.ReactNode; + onClick: () => void; + className?: string; +} + +interface RowActionsDropdownProps { + actions: RowActionItem[]; + className?: string; + trigger?: React.ReactNode; + topOffset?: number; + leftOffset?: number; + topOffsetMobile?: number; + leftOffsetMobile?: number; +} + +const RowActionsDropdown: React.FC = ({ + actions, + className = '', + trigger, + topOffset = 0, + leftOffset = 0, + topOffsetMobile = 0, + leftOffsetMobile = 0 +}) => { + const [isOpen, setIsOpen] = useState(false) + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }) + const dropdownRef = useRef(null) + const buttonRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const calculatePosition = (rect: DOMRect) => { + const isMobile = window.innerWidth < 768 + const dropdownWidth = isMobile ? 140 : 150 + const dropdownHeight = actions.length * (isMobile ? 36 : 40) + + // انتخاب offset بر اساس سایز صفحه + const currentTopOffset = isMobile ? topOffsetMobile : topOffset + const currentLeftOffset = isMobile ? leftOffsetMobile : leftOffset + + let left = rect.left + window.scrollX - dropdownWidth + rect.width + currentLeftOffset + let top = rect.bottom + window.scrollY + currentTopOffset + + if (left + dropdownWidth > window.innerWidth) { + left = rect.left + window.scrollX - dropdownWidth + currentLeftOffset + } + + if (left < 0) { + left = isMobile ? 20 : 40 + } + + if (top + dropdownHeight > window.innerHeight + window.scrollY) { + top = rect.top + window.scrollY - dropdownHeight + currentTopOffset + } + + // اطمینان از اینکه dropdown از صفحه خارج نشود + if (left < 0) left = 20 + if (left + dropdownWidth > window.innerWidth) left = window.innerWidth - dropdownWidth - 20 + if (top < 0) top = 20 + if (top + dropdownHeight > window.innerHeight) top = window.innerHeight - dropdownHeight - 20 + + return { top, left } + } + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation() + + if (!isOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + const calculatedPosition = calculatePosition(rect) + setDropdownPosition(calculatedPosition) + } + + setIsOpen(!isOpen) + } + + const handleActionClick = (action: RowActionItem) => { + action.onClick() + setIsOpen(false) + } + + const renderDropdown = () => { + if (!isOpen) return null + + return createPortal( +
+ {actions.map((action, index) => ( + + ))} +
, + document.body + ) + } + + return ( +
+ + + {renderDropdown()} +
+ ) +} + +export default RowActionsDropdown \ No newline at end of file diff --git a/src/components/Table.tsx b/src/components/Table.tsx new file mode 100644 index 0000000..6f82515 --- /dev/null +++ b/src/components/Table.tsx @@ -0,0 +1,363 @@ +import React, { Fragment, useState, memo } from 'react'; +import DefaultTableSkeleton from '../components/DefaultTableSkeleton'; +import Td from '../components/Td'; +import type { TableProps, RowDataType, ColumnType } from '@/components/types/TableTypes'; +import { Checkbox } from '../components/ui/checkbox'; +import RowActionsDropdown from '../components/RowActionsDropdown'; +import Pagination from '../components/Pagination'; + +const Table = ({ + columns, + data, + isLoading = false, + onRowClick, + className = '', + rowClassName = '', + noDataMessage = 'هیچ داده‌ای یافت نشد', + headerClassName = 'bg-gray-50', + emptyRowsCount = 5, + showHeader = true, + actions = null, + actionsPosition = 'top', + selectable = false, + selectedActions = null, + onSelectionChange, + rowActions, + pagination, +}: TableProps): React.ReactElement => { + + const [selectedRows, setSelectedRows] = useState([]); + + const getRowClassName = (item: T, index: number): string => { + if (typeof rowClassName === 'function') { + return rowClassName(item, index); + } + return rowClassName; + }; + + const getCellClassName = (column: ColumnType): string => { + const alignClass = column.align ? `text-${column.align}` : ''; + return `px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${alignClass} ${column.className || ''}`.trim(); + }; + + const handleRowClick = (item: T) => { + if (onRowClick) { + onRowClick(item.id, item); + } + }; + + const handleSelectAll = () => { + const dataArray = data || []; + const newSelectedRows = selectedRows.length === dataArray.length ? [] : [...dataArray]; + setSelectedRows(newSelectedRows); + if (onSelectionChange) { + onSelectionChange(newSelectedRows); + } + }; + + const handleSingleRowSelect = (item: T, checked: boolean) => { + const newSelectedRows = checked + ? [...selectedRows, item] + : selectedRows.filter((row) => row.id !== item.id); + setSelectedRows(newSelectedRows); + if (onSelectionChange) { + onSelectionChange(newSelectedRows); + } + }; + + const isRowSelected = (item: T): boolean => { + return selectedRows.some((row) => row.id === item.id); + }; + + // Gmail-style layout برای وقتی header نمایش داده نمی‌شود + const renderGmailStyleRows = () => { + const hasActions = (actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions)); + const roundedClass = hasActions ? 'rounded-b-2xl' : 'rounded-2xl'; + + if (isLoading) { + return ( +
+ {Array.from({ length: emptyRowsCount }).map((_, index) => ( +
+ ))} +
+ ); + } + + if (!data || data.length === 0) { + return ( +
+ {noDataMessage} +
+ ); + } + + return ( +
+ {data.map((item, rowIndex) => ( +
handleRowClick(item)} + > + {selectable && ( +
e.stopPropagation()} + > + handleSingleRowSelect(item, !!checked)} + /> +
+ )} + +
+ {/* محتوای اصلی - ستون اول */} +
+ {columns[0]?.render ? columns[0].render(item, rowIndex) : (item[columns[0]?.key] as React.ReactNode)} +
+ + {/* اطلاعات اضافی - سه ستون آخر در یک خط */} + {columns.length > 1 && ( +
+ {columns.slice(1).map((col) => ( +
+ {col.render ? col.render(item, rowIndex) : (item[col.key] as React.ReactNode)} +
+ ))} +
+ )} +
+ + {rowActions && ( +
e.stopPropagation()} + > + +
+ )} +
+ ))} +
+ ); + }; + + const renderTableBody = () => { + if (isLoading) { + return ( + + + + ); + } + + if (!data || data.length === 0) { + return ( + + + {noDataMessage} + + + ); + } + return (data || []).map((item, rowIndex) => ( + handleRowClick(item)} + > + {selectable && ( + e.stopPropagation()} + > + handleSingleRowSelect(item, !!checked)} + /> + + )} + {columns.map((col) => ( + + {col.render ? col.render(item, rowIndex) : item[col.key] as React.ReactNode} + + ))} + {rowActions && ( + e.stopPropagation()} + > + + + )} + + )); + }; + + const renderHeader = () => { + if (!showHeader || actionsPosition === 'header-replace') return null; + + return ( + + + {selectable && ( + e.stopPropagation()} + > + 0 && selectedRows.length === (data?.length || 0)} + onCheckedChange={handleSelectAll} + /> + + )} + {columns.map((col) => ( + + ))} + {rowActions && ( + + )} + + + ); + }; + + const renderActions = () => { + if (!actions && (!selectable || selectedRows.length === 0 || !selectedActions)) return null; + + return ( +
+
+ {actions} + {selectable && selectedRows.length > 0 && selectedActions && selectedActions} +
+
+ ); + }; + + // اگر header نشان داده نمی‌شود، از Gmail-style layout فقط در موبایل استفاده کن + if (!showHeader) { + return ( +
+ {/* نسخه موبایل - Gmail style */} +
+ {(actionsPosition === 'top' || actionsPosition === 'above-header') && ( +
+
+ {actions} + {selectable && selectedRows.length > 0 && selectedActions && selectedActions} +
+
+ )} + {renderGmailStyleRows()} +
+ + {/* نسخه دسکتاپ - Table معمولی */} +
+ {(actionsPosition === 'top' || actionsPosition === 'above-header') && ( +
+
+ {actions} + {selectable && selectedRows.length > 0 && selectedActions && selectedActions} +
+
+ )} +
0 && selectedActions)) ? 'rounded-b-3xl' : 'rounded-3xl'} bg-white`}> + + + {renderTableBody()} + +
+
+
+ + {pagination && ( + + )} +
+ ); + } + + return ( +
+ {(actionsPosition === 'top' || actionsPosition === 'above-header') && renderActions()} + +
+ + {renderHeader()} + {actionsPosition === 'header-replace' && ( + + + + + + )} + + {renderTableBody()} + +
+ {renderActions()} +
+
+ + {pagination && ( + + )} +
+ ); +}; + +export default memo(Table) as (props: TableProps) => React.ReactElement; + +/* +مثال استفاده با pagination: + +import Table from '@/components/Table'; +import { useState } from 'react'; + +const MyComponent = () => { + const [currentPage, setCurrentPage] = useState(1); + const totalPages = 10; + + const columns = [ + { title: 'نام', key: 'name' }, + { title: 'ایمیل', key: 'email' }, + ]; + + const data = [ + { id: 1, name: 'علی', email: 'ali@example.com' }, + { id: 2, name: 'فاطمه', email: 'fateme@example.com' }, + ]; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + // اینجا می‌توانید API call جدید برای دریافت داده‌های صفحه جدید بزنید + }; + + return ( + + ); +}; +*/ \ No newline at end of file diff --git a/src/components/Td.tsx b/src/components/Td.tsx index d2a995f..b29b84a 100644 --- a/src/components/Td.tsx +++ b/src/components/Td.tsx @@ -1,14 +1,15 @@ -import { type FC, type ReactNode } from 'react' +import type { FC, ReactNode } from 'react' interface Props { - text: string, + text: string | number | ReactNode, children?: ReactNode, dir?: string, + className?: string, } const Td: FC = (props: Props) => { return ( -
+ { props.text ? props.text diff --git a/src/components/types/TableTypes.ts b/src/components/types/TableTypes.ts new file mode 100644 index 0000000..5d87e0c --- /dev/null +++ b/src/components/types/TableTypes.ts @@ -0,0 +1,47 @@ +export interface RowDataType { + id: string | number; + [key: string]: unknown; +} + +export interface ColumnType { + title: string; + key: string; + align?: "left" | "center" | "right"; + className?: string; + width?: string | number; + render?: (item: T, index?: number) => React.ReactNode; +} + +export interface PaginationType { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export interface ActionType { + label: string; + onClick: () => void; + icon?: React.ReactNode; + className?: string; + disabled?: boolean; +} + +export interface TableProps { + columns: ColumnType[]; + data?: T[]; + isLoading?: boolean; + onRowClick?: (id: string | number, item: T) => void; + className?: string; + rowClassName?: string | ((item: T, index: number) => string); + noDataMessage?: string; + headerClassName?: string; + emptyRowsCount?: number; + showHeader?: boolean; + actions?: React.ReactNode; + actionsPosition?: "top" | "above-header" | "bottom" | "header-replace"; + selectable?: boolean; + selectedActions?: React.ReactNode; + onSelectionChange?: (selectedRows: T[]) => void; + rowActions?: (item: T) => ActionType[]; + pagination?: PaginationType; +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c7ff07b --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { cn } from "@/lib/utils" +import { CheckIcon } from "@radix-ui/react-icons" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..6fcf374 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + diff --git a/src/pages/auth/components/LoginStep1.tsx b/src/pages/auth/components/LoginStep1.tsx index 652b712..6df0eeb 100644 --- a/src/pages/auth/components/LoginStep1.tsx +++ b/src/pages/auth/components/LoginStep1.tsx @@ -1,7 +1,7 @@ import { type FC } from 'react' import Input from '../../../components/Input' import { useTranslation } from 'react-i18next' -import { LoginType } from '../types/AuthTypes' +import { type LoginType } from '../types/AuthTypes' import { useFormik } from 'formik' import * as Yup from 'yup' import { useAuthStore } from '../store/AuthStore' @@ -10,7 +10,7 @@ import Button from '../../../components/Button' import { isEmail } from '../../../config/func' import { useCheckHasAccount, useLoginWithOtp } from '../hooks/useAuthData' import { toast } from 'react-toastify' -import { ErrorType } from '../../../helpers/types' +import { type ErrorType } from '../../../helpers/types' const LoginStep1: FC = () => { diff --git a/src/pages/auth/components/LoginStep2.tsx b/src/pages/auth/components/LoginStep2.tsx index 3ab544c..775f586 100644 --- a/src/pages/auth/components/LoginStep2.tsx +++ b/src/pages/auth/components/LoginStep2.tsx @@ -1,6 +1,6 @@ import { type FC } from 'react' import { useTranslation } from 'react-i18next' -import { OtpVerifyType } from '../types/AuthTypes' +import { type OtpVerifyType } from '../types/AuthTypes' import { useFormik } from 'formik' import * as Yup from 'yup' import { useAuthStore } from '../store/AuthStore' @@ -10,7 +10,7 @@ import { useCountDown } from '../../../hooks/useCountDown' // import ArrowLeftIcon from '../../../assets/images/arrow-left.svg' import { Pages } from '../../../config/Pages' import { useOtpVerify } from '../hooks/useAuthData' -import { ErrorType } from '../../../helpers/types' +import { type ErrorType } from '../../../helpers/types' import { toast } from 'react-toastify' import { setToken, setRefreshToken } from '../../../config/func' diff --git a/src/pages/auth/components/LoginStep3.tsx b/src/pages/auth/components/LoginStep3.tsx index d934f73..b3f96b3 100644 --- a/src/pages/auth/components/LoginStep3.tsx +++ b/src/pages/auth/components/LoginStep3.tsx @@ -1,7 +1,7 @@ import { type FC } from 'react' import Input from '../../../components/Input' import { useTranslation } from 'react-i18next' -import { LoginWithPasswordType } from '../types/AuthTypes' +import { type LoginWithPasswordType } from '../types/AuthTypes' import { useFormik } from 'formik' import * as Yup from 'yup' import { useAuthStore } from '../store/AuthStore' @@ -12,7 +12,7 @@ import { Pages } from '../../../config/Pages' import ArrowLeftIcon from '../../../assets/images/arrow-left.svg' import { useLoginWithPassword } from '../hooks/useAuthData' import { toast } from 'react-toastify' -import { ErrorType } from '../../../helpers/types' +import { type ErrorType } from '../../../helpers/types' import { setRefreshToken } from '../../../config/func' import { setToken } from '../../../config/func' diff --git a/src/pages/auth/components/RegisterStep1.tsx b/src/pages/auth/components/RegisterStep1.tsx index f339ec9..372fd1a 100644 --- a/src/pages/auth/components/RegisterStep1.tsx +++ b/src/pages/auth/components/RegisterStep1.tsx @@ -1,7 +1,7 @@ import { type FC } from 'react' import Input from '../../../components/Input' import { useTranslation } from 'react-i18next' -import { CheckHasAccountPhoneType } from '../types/AuthTypes' +import { type CheckHasAccountPhoneType } from '../types/AuthTypes' import { useFormik } from 'formik' import * as Yup from 'yup' import { useAuthStore } from '../store/AuthStore' @@ -9,7 +9,7 @@ import Error from '../../../components/Error' import Button from '../../../components/Button' import { useCheckHasAccountRegister, useLoginWithOtp } from '../hooks/useAuthData' import { toast } from 'react-toastify' -import { ErrorType } from '../../../helpers/types' +import { type ErrorType } from '../../../helpers/types' const RegisterStep1: FC = () => { diff --git a/src/pages/food/List.tsx b/src/pages/food/List.tsx new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.app.json b/tsconfig.app.json index 227a6c6..d075f3e 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,7 +21,13 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + /* Path Mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..fec8c8e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,5 +3,11 @@ "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } - ] + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } } diff --git a/vite.config.ts b/vite.config.ts index 4ff4f8f..506e619 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,14 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import path from "path"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, });