diff --git a/index.html b/index.html index 8dd728e..3dc45c5 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - wildcat-dashboard-ui + Wildcat Dashboard
diff --git a/package-lock.json b/package-lock.json index f1a8cb8..ade03d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,12 @@ "version": "0.0.1", "dependencies": { "@hey-api/client-fetch": "^0.8.3", + "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", @@ -22,34 +26,44 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", - "@tailwindcss/vite": "^4.0.12", - "@tanstack/react-query": "^5.67.2", + "@tailwindcss/vite": "^4.0.13", + "@tanstack/react-query": "^5.67.3", + "big.js": "^6.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.4", "lucide-react": "^0.479.0", + "next-themes": "^0.4.6", "react": "^19.0.0", + "react-day-picker": "^9.6.2", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-router": "^7.3.0", "recharts": "^2.15.1", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", - "tailwindcss": "^4.0.12", + "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zod": "^3.24.2" }, "devDependencies": { - "@eslint/js": "^9.21.0", + "@eslint/js": "^9.22.0", "@faker-js/faker": "^9.6.0", - "@hey-api/openapi-ts": "^0.64.10", + "@hey-api/openapi-ts": "^0.64.11", "@mswjs/data": "^0.16.2", "@testing-library/jest-dom": "^6.6.3", - "@types/node": "^22.13.9", + "@types/big.js": "^6.2.2", + "@types/date-fns": "^2.5.3", + "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.0.8", - "eslint": "^9.21.0", + "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -58,7 +72,7 @@ "msw": "^2.7.3", "prettier": "^3.5.3", "typescript": "~5.8.2", - "typescript-eslint": "^8.26.0", + "typescript-eslint": "^8.26.1", "vite": "^6.2.1", "vitest": "^3.0.8" } @@ -541,6 +555,12 @@ "node": ">=18" } }, + "node_modules/@date-fns/tz": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -986,6 +1006,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", + "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/core": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", @@ -1034,9 +1063,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", - "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", + "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1145,9 +1174,9 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.64.10", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.10.tgz", - "integrity": "sha512-mTTD4DtOt68OmrZ6VXM4+sCma+JxhqDjiqdaUCpLIS8yWNWAmgBCRS5LE3i8AS8HUN1dsCetTGFAIUT2rElDVg==", + "version": "0.64.11", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.11.tgz", + "integrity": "sha512-ehvi2P3cJY7GC5768N+OvYK8Pak0N3oSjosDmjUjc+W7C8JTbabiCLwszxLefCtqFyv2G2mLl0vqlCj6Bpy+DQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1169,6 +1198,18 @@ "typescript": "^5.5.3" } }, + "node_modules/@hookform/resolvers": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz", + "integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "license": "Apache-2.0", @@ -1463,6 +1504,23 @@ "msw": "^2.0.8" } }, + "node_modules/@mswjs/data/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.37.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", @@ -1590,6 +1648,32 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.3.tgz", + "integrity": "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "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-checkbox": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", @@ -1620,6 +1704,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", + "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "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-collection": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", @@ -1754,6 +1868,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "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-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1866,6 +2009,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.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-popover": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", @@ -2260,6 +2443,35 @@ } } }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz", + "integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "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-tooltip": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", @@ -2451,43 +2663,49 @@ "linux" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.12.tgz", - "integrity": "sha512-a6J11K1Ztdln9OrGfoM75/hChYPcHYGNYimqciMrvKXRmmPaS8XZTHhdvb5a3glz4Kd4ZxE1MnuFE2c0fGGmtg==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.13.tgz", + "integrity": "sha512-P9TmtE9Vew0vv5FwyD4bsg/dHHsIsAuUXkenuGUc5gm8fYgaxpdoxIKngCyEMEQxyCKR8PQY5V5VrrKNOx7exg==", "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "tailwindcss": "4.0.12" + "tailwindcss": "4.0.13" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.12.tgz", - "integrity": "sha512-DWb+myvJB9xJwelwT9GHaMc1qJj6MDXRDR0CS+T8IdkejAtu8ctJAgV4r1drQJLPeS7mNwq2UHW2GWrudTf63A==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.13.tgz", + "integrity": "sha512-pTH3Ex5zAWC9LbS+WsYAFmkXQW3NRjmvxkKJY3NP1x0KHBWjz0Q2uGtdGMJzsa0EwoZ7wq9RTbMH1UNPceCpWw==", "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.12", - "@tailwindcss/oxide-darwin-arm64": "4.0.12", - "@tailwindcss/oxide-darwin-x64": "4.0.12", - "@tailwindcss/oxide-freebsd-x64": "4.0.12", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.12", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.12", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.12", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.12", - "@tailwindcss/oxide-linux-x64-musl": "4.0.12", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.12", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.12" + "@tailwindcss/oxide-android-arm64": "4.0.13", + "@tailwindcss/oxide-darwin-arm64": "4.0.13", + "@tailwindcss/oxide-darwin-x64": "4.0.13", + "@tailwindcss/oxide-freebsd-x64": "4.0.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.13", + "@tailwindcss/oxide-linux-x64-musl": "4.0.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.13" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.12.tgz", - "integrity": "sha512-dAXCaemu3mHLXcA5GwGlQynX8n7tTdvn5i1zAxRvZ5iC9fWLl5bGnjZnzrQqT7ttxCvRwdVf3IHUnMVdDBO/kQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.13.tgz", + "integrity": "sha512-+9zmwaPQ8A9ycDcdb+hRkMn6NzsmZ4YJBsW5Xqq5EdOu9xlIgmuMuJauVzDPB5BSbIWfhPdZ+le8NeRZpl1coA==", "cpu": [ "arm64" ], @@ -2501,9 +2719,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.12.tgz", - "integrity": "sha512-vPNI+TpJQ7sizselDXIJdYkx9Cu6JBdtmRWujw9pVIxW8uz3O2PjgGGzL/7A0sXI8XDjSyRChrUnEW9rQygmJQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.13.tgz", + "integrity": "sha512-Bj1QGlEJSjs/205CIRfb5/jeveOqzJ4pFMdRxu0gyiYWxBRyxsExXqaD+7162wnLP/EDKh6S1MC9E/1GwEhLtA==", "cpu": [ "arm64" ], @@ -2517,9 +2735,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.12.tgz", - "integrity": "sha512-RL/9jM41Fdq4Efr35C5wgLx98BirnrfwuD+zgMFK6Ir68HeOSqBhW9jsEeC7Y/JcGyPd3MEoJVIU4fAb7YLg7A==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.13.tgz", + "integrity": "sha512-lRTkxjTpMGXhLLM5GjZ0MtjPczMuhAo9j7PeSsaU6Imkm7W7RbrXfT8aP934kS7cBBV+HKN5U19Z0WWaORfb8Q==", "cpu": [ "x64" ], @@ -2533,9 +2751,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.12.tgz", - "integrity": "sha512-7WzWiax+LguJcMEimY0Q4sBLlFXu1tYxVka3+G2M9KmU/3m84J3jAIV4KZWnockbHsbb2XgrEjtlJKVwHQCoRA==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.13.tgz", + "integrity": "sha512-p/YLyKhs+xFibVeAPlpMGDVMKgjChgzs12VnDFaaqRSJoOz+uJgRSKiir2tn50e7Nm4YYw35q/DRBwpDBNo1MQ==", "cpu": [ "x64" ], @@ -2549,9 +2767,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.12.tgz", - "integrity": "sha512-X9LRC7jjE1QlfIaBbXjY0PGeQP87lz5mEfLSVs2J1yRc9PSg1tEPS9NBqY4BU9v5toZgJgzKeaNltORyTs22TQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.13.tgz", + "integrity": "sha512-Ua/5ydE/QOTX8jHuc7M9ICWnaLi6K2MV/r+Ws2OppsOjy8tdlPbqYainJJ6Kl7ofm524K+4Fk9CQITPzeIESPw==", "cpu": [ "arm" ], @@ -2565,9 +2783,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.12.tgz", - "integrity": "sha512-i24IFNq2402zfDdoWKypXz0ZNS2G4NKaA82tgBlE2OhHIE+4mg2JDb5wVfyP6R+MCm5grgXvurcIcKWvo44QiQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.13.tgz", + "integrity": "sha512-/W1+Q6tBAVgZWh/bhfOHo4n7Ryh6E7zYj4bJd9SRbkPyLtRioyK3bi6RLuDj57sa7Amk/DeomSV9iycS0xqIPA==", "cpu": [ "arm64" ], @@ -2581,9 +2799,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.12.tgz", - "integrity": "sha512-LmOdshJBfAGIBG0DdBWhI0n5LTMurnGGJCHcsm9F//ISfsHtCnnYIKgYQui5oOz1SUCkqsMGfkAzWyNKZqbGNw==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.13.tgz", + "integrity": "sha512-GQj6TWevNxwsYw20FdT2r2d1f7uiRsF07iFvNYxPIvIyPEV74eZ0zgFEsAH1daK1OxPy+LXdZ4grV17P5tVzhQ==", "cpu": [ "arm64" ], @@ -2597,9 +2815,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.12.tgz", - "integrity": "sha512-OSK667qZRH30ep8RiHbZDQfqkXjnzKxdn0oRwWzgCO8CoTxV+MvIkd0BWdQbYtYuM1wrakARV/Hwp0eA/qzdbw==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.13.tgz", + "integrity": "sha512-sQRH09faifF9w9WS6TKDWr1oLi4hoPx0EIWXZHQK/jcjarDpXGQ2DbF0KnALJCwWBxOIP/1nrmU01fZwwMzY3g==", "cpu": [ "x64" ], @@ -2613,9 +2831,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.12.tgz", - "integrity": "sha512-uylhWq6OWQ8krV8Jk+v0H/3AZKJW6xYMgNMyNnUbbYXWi7hIVdxRKNUB5UvrlC3RxtgsK5EAV2i1CWTRsNcAnA==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.13.tgz", + "integrity": "sha512-Or1N8DIF3tP+LsloJp+UXLTIMMHMUcWXFhJLCsM4T7MzFzxkeReewRWXfk5mk137cdqVeUEH/R50xAhY1mOkTQ==", "cpu": [ "x64" ], @@ -2629,9 +2847,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.12.tgz", - "integrity": "sha512-XDLnhMoXZEEOir1LK43/gHHwK84V1GlV8+pAncUAIN2wloeD+nNciI9WRIY/BeFTqES22DhTIGoilSO39xDb2g==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.13.tgz", + "integrity": "sha512-u2mQyqCFrr9vVTP6sfDRfGE6bhOX3/7rInehzxNhHX1HYRIx09H3sDdXzTxnZWKOjIg3qjFTCrYFUZckva5PIg==", "cpu": [ "arm64" ], @@ -2645,9 +2863,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.12.tgz", - "integrity": "sha512-I/BbjCLpKDQucvtn6rFuYLst1nfFwSMYyPzkx/095RE+tuzk5+fwXuzQh7T3fIBTcbn82qH/sFka7yPGA50tLw==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.13.tgz", + "integrity": "sha512-sOEc4iCanp1Yqyeu9suQcEzfaUcHnqjBUgDg0ZXpjUMUwdSi37S1lu1RGoV1BYInvvGu3y3HHTmvsSfDhx2L8w==", "cpu": [ "x64" ], @@ -2661,24 +2879,24 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.12.tgz", - "integrity": "sha512-JM3gp601UJiryIZ9R2bSqalzcOy15RCybQ1Q+BJqDEwVyo4LkWKeqQAcrpHapWXY31OJFTuOUVBFDWMhzHm2Bg==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.13.tgz", + "integrity": "sha512-0XTd/NoVUAktIDaA4MdXhve0QWYh7WlZg20EHCuBFR80F8FhbVkRX+AY5cjbUP/IO2itHzt0iHc0iSE5kBUMhQ==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.0.12", - "@tailwindcss/oxide": "4.0.12", - "lightningcss": "^1.29.1", - "tailwindcss": "4.0.12" + "@tailwindcss/node": "4.0.13", + "@tailwindcss/oxide": "4.0.13", + "lightningcss": "1.29.2", + "tailwindcss": "4.0.13" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "node_modules/@tanstack/query-core": { - "version": "5.67.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.67.2.tgz", - "integrity": "sha512-+iaFJ/pt8TaApCk6LuZ0WHS/ECVfTzrxDOEL9HH9Dayyb5OVuomLzDXeSaI2GlGT/8HN7bDGiRXDts3LV+u6ww==", + "version": "5.67.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.67.3.tgz", + "integrity": "sha512-pq76ObpjcaspAW4OmCbpXLF6BCZP2Zr/J5ztnyizXhSlNe7fIUp0QKZsd0JMkw9aDa+vxDX/OY7N+hjNY/dCGg==", "license": "MIT", "funding": { "type": "github", @@ -2686,12 +2904,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.67.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.67.2.tgz", - "integrity": "sha512-6Sa+BVNJWhAV4QHvIqM73norNeGRWGC3ftN0Ix87cmMvI215I1wyJ44KUTt/9a0V9YimfGcg25AITaYVel71Og==", + "version": "5.67.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.67.3.tgz", + "integrity": "sha512-u/n2HsQeH1vpZIOzB/w2lqKlXUDUKo6BxTdGXSMvNzIq5MHYFckRMVuFABp+QB7RN8LFXWV6X1/oSkuDq+MPIA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.67.2" + "@tanstack/query-core": "5.67.3" }, "funding": { "type": "github", @@ -2773,6 +2991,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2842,6 +3067,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/date-fns": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz", + "integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "license": "MIT" @@ -2867,9 +3099,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", - "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2921,17 +3153,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", - "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", + "integrity": "sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/type-utils": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/type-utils": "8.26.1", + "@typescript-eslint/utils": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2951,16 +3183,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", - "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.1.tgz", + "integrity": "sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/typescript-estree": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4" }, "engines": { @@ -2976,14 +3208,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", - "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.1.tgz", + "integrity": "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0" + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2994,14 +3226,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", - "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.1.tgz", + "integrity": "sha512-Kcj/TagJLwoY/5w9JGEFV0dclQdyqw9+VMndxOJKtoFSjfZhLXhYjzsQEeyza03rwHx2vFEGvrJWJBXKleRvZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/utils": "8.26.0", + "@typescript-eslint/typescript-estree": "8.26.1", + "@typescript-eslint/utils": "8.26.1", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -3018,9 +3250,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", - "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.1.tgz", + "integrity": "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ==", "dev": true, "license": "MIT", "engines": { @@ -3032,14 +3264,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", - "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.1.tgz", + "integrity": "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -3098,16 +3330,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", - "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.1.tgz", + "integrity": "sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0" + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/typescript-estree": "8.26.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3122,13 +3354,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", - "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.1.tgz", + "integrity": "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/types": "8.26.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -3607,6 +3839,19 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "license": "MIT", @@ -4282,22 +4527,21 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "license": "MIT", @@ -4399,15 +4643,12 @@ "license": "MIT" }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/detect-node-es": { @@ -4749,17 +4990,18 @@ } }, "node_modules/eslint": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", - "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", + "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.2", + "@eslint/config-helpers": "^0.1.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.21.0", + "@eslint/js": "9.22.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -4771,7 +5013,7 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", + "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", @@ -4874,7 +5116,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -4926,6 +5170,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -6298,12 +6544,12 @@ } }, "node_modules/lightningcss": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", - "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", "license": "MPL-2.0", "dependencies": { - "detect-libc": "^1.0.3" + "detect-libc": "^2.0.3" }, "engines": { "node": ">= 12.0.0" @@ -6313,22 +6559,22 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.1", - "lightningcss-darwin-x64": "1.29.1", - "lightningcss-freebsd-x64": "1.29.1", - "lightningcss-linux-arm-gnueabihf": "1.29.1", - "lightningcss-linux-arm64-gnu": "1.29.1", - "lightningcss-linux-arm64-musl": "1.29.1", - "lightningcss-linux-x64-gnu": "1.29.1", - "lightningcss-linux-x64-musl": "1.29.1", - "lightningcss-win32-arm64-msvc": "1.29.1", - "lightningcss-win32-x64-msvc": "1.29.1" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", - "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", "cpu": [ "arm64" ], @@ -6346,9 +6592,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", - "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", "cpu": [ "x64" ], @@ -6366,9 +6612,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", - "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", "cpu": [ "x64" ], @@ -6386,9 +6632,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", - "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", "cpu": [ "arm" ], @@ -6406,9 +6652,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", - "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", "cpu": [ "arm64" ], @@ -6426,9 +6672,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", - "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", "cpu": [ "arm64" ], @@ -6446,9 +6692,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", - "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", "cpu": [ "x64" ], @@ -6466,9 +6712,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", - "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", "cpu": [ "x64" ], @@ -6486,9 +6732,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", - "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", "cpu": [ "arm64" ], @@ -6506,9 +6752,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", - "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", "cpu": [ "x64" ], @@ -6891,6 +7137,16 @@ "dev": true, "license": "MIT" }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-fetch-native": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz", @@ -7386,6 +7642,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.6.2.tgz", + "integrity": "sha512-HVrTpDUYGKbi9scU9v2N3BPGMnL5jwekGFXcyyrvpamZdgYGfzVcovsBD4/yNGxhpCIX5X/5IoLDEAayNv1EqA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.2.0", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.0.0", "license": "MIT", @@ -7396,6 +7673,22 @@ "react": "^19.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8013,6 +8306,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonner": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.1.tgz", + "integrity": "sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8323,9 +8626,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz", - "integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.13.tgz", + "integrity": "sha512-gbvFrB0fOsTv/OugXWi2PtflJ4S6/ctu6Mmn3bCftmLY/6xRsQVEJPgIIpABwpZ52DpONkCA3bEj5b54MHxF2Q==", "license": "MIT" }, "node_modules/tailwindcss-animate": { @@ -8668,15 +8971,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.0.tgz", - "integrity": "sha512-PtVz9nAnuNJuAVeUFvwztjuUgSnJInODAUx47VDwWPXzd5vismPOtPtt83tzNXyOjVQbPRp786D6WFW/M2koIA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.1.tgz", + "integrity": "sha512-t/oIs9mYyrwZGRpDv3g+3K6nZ5uhKEMt2oNmAPwaY4/ye0+EH4nXIPYNtkYFS6QHm+1DFg34DbglYBz5P9Xysg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "@typescript-eslint/utils": "8.26.0" + "@typescript-eslint/eslint-plugin": "8.26.1", + "@typescript-eslint/parser": "8.26.1", + "@typescript-eslint/utils": "8.26.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9480,6 +9783,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 0442b74..78b5325 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,12 @@ }, "dependencies": { "@hey-api/client-fetch": "^0.8.3", + "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", @@ -30,34 +34,44 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", - "@tailwindcss/vite": "^4.0.12", - "@tanstack/react-query": "^5.67.2", + "@tailwindcss/vite": "^4.0.13", + "@tanstack/react-query": "^5.67.3", + "big.js": "^6.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.4", "lucide-react": "^0.479.0", + "next-themes": "^0.4.6", "react": "^19.0.0", + "react-day-picker": "^9.6.2", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-router": "^7.3.0", "recharts": "^2.15.1", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", - "tailwindcss": "^4.0.12", + "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zod": "^3.24.2" }, "devDependencies": { - "@eslint/js": "^9.21.0", + "@eslint/js": "^9.22.0", "@faker-js/faker": "^9.6.0", - "@hey-api/openapi-ts": "^0.64.10", + "@hey-api/openapi-ts": "^0.64.11", "@mswjs/data": "^0.16.2", "@testing-library/jest-dom": "^6.6.3", - "@types/node": "^22.13.9", + "@types/big.js": "^6.2.2", + "@types/date-fns": "^2.5.3", + "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.0.8", - "eslint": "^9.21.0", + "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -66,7 +80,7 @@ "msw": "^2.7.3", "prettier": "^3.5.3", "typescript": "~5.8.2", - "typescript-eslint": "^8.26.0", + "typescript-eslint": "^8.26.1", "vite": "^6.2.1", "vitest": "^3.0.8" }, diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index f4c6fbd..6b0f034 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,68 +1,106 @@ -import { Bitcoin, Home, Inbox, Settings, InfoIcon } from "lucide-react" -import { NavLink } from "react-router" +import { Bitcoin, Home, Inbox, Settings, InfoIcon, LifeBuoy, Send } from "lucide-react" +import { Sidebar, SidebarContent, SidebarFooter, SidebarRail } from "@/components/ui/sidebar" +import { NavUser } from "./nav/NavUser" +import { randomAvatar } from "@/utils/dev" +import { NavMain } from "./nav/NavMain" +import { NavSecondary } from "./nav/NavSecondary" -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar" +const data = { + navMain: [ + { + title: "Home", + url: "/", + icon: Home, + }, + { + title: "Balances", + url: "/balances", + icon: Bitcoin, + }, + { + title: "Quotes", + url: "/quotes", + icon: Inbox, + items: [ + { + title: "Pending", + url: "/quotes/pending", + }, + { + title: "Offered", + url: "/quotes/offered", + disabled: true, + }, + { + title: "Accepted", + url: "/quotes/accepted", + }, + { + title: "Denied", + url: "/quotes/denied", + disabled: true, + }, + { + title: "Rejected", + url: "/quotes/rejected", + disabled: true, + }, + { + title: "Expired", + url: "/quotes/expired", + disabled: true, + }, + ], + }, + { + title: "Settings", + url: "/settings", + icon: Settings, + items: [ + { + title: "General", + url: "/settings", + }, + ], + }, + { + title: "Info", + url: "/info", + icon: InfoIcon, + }, + ], -// Menu items. -const items = [ - { - title: "Home", - url: "/", - icon: Home, - }, - { - title: "Balances", - url: "/balances", - icon: Bitcoin, - }, - { - title: "Quotes", - url: "/quotes", - icon: Inbox, - }, - { - title: "Settings", - url: "/settings", - icon: Settings, - }, - { - title: "Info", - url: "/info", - icon: InfoIcon, - }, -] + navSecondary: [ + { + title: "Support", + url: "/#", + icon: LifeBuoy, + }, + { + title: "Feedback", + url: "/#", + icon: Send, + }, + ], +} export function AppSidebar() { return ( - + - - Dashboard - - - {items.map((item) => ( - - - - - {item.title} - - - - ))} - - - + + + + + + ) } diff --git a/src/components/Drawers.tsx b/src/components/Drawers.tsx new file mode 100644 index 0000000..e0e13a0 --- /dev/null +++ b/src/components/Drawers.tsx @@ -0,0 +1,70 @@ +import { Button, buttonVariants } from "@/components/ui/button" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { VariantProps } from "class-variance-authority" + +type DrawerProps = Parameters[0] +type BaseDrawerProps = DrawerProps & { + title: string + description?: string + trigger?: React.ReactNode + children?: React.ReactNode +} +export function BaseDrawer({ title, description = "", trigger, children, ...drawerProps }: BaseDrawerProps) { + return ( + + {trigger && {trigger}} + +
+ + {title} + {description && {description}} + + {children} +
+
+
+ ) +} + +type ConfirmDrawerProps = BaseDrawerProps & { + cancelButtonText?: string + submitButtonText?: string + submitButtonVariant?: VariantProps["variant"] + onSubmit: () => void +} + +export function ConfirmDrawer({ + cancelButtonText = "Cancel", + submitButtonText = "Confirm", + submitButtonVariant, + onSubmit, + children, + ...drawerProps +}: ConfirmDrawerProps) { + return ( + + {children} + +
+ + + + +
+
+
+ ) +} diff --git a/src/components/GrossToNetDiscountForm.tsx b/src/components/GrossToNetDiscountForm.tsx new file mode 100644 index 0000000..e7e5173 --- /dev/null +++ b/src/components/GrossToNetDiscountForm.tsx @@ -0,0 +1,204 @@ +import { useEffect, useMemo, useState } from "react" +import { useForm } from "react-hook-form" +import Big from "big.js" +import { parseFloatSafe, parseIntSafe } from "@/utils/numbers" +import { daysBetween } from "@/utils/dates" +import { Act360 } from "@/utils/discount-util" +import { Button } from "./ui/button" +import { InputContainer } from "./InputContainer" + +interface CurrencyAmount { + value: Big + currency: string +} + +interface CommonDiscountFormProps { + startDate?: Date + endDate: Date + onSubmit: (values: FormResult) => void +} + +type GrossToNetProps = CommonDiscountFormProps & { + gross: CurrencyAmount + submitButtonText?: string +} + +interface FormResult { + days: number + discountRate: Big + net: CurrencyAmount + gross: CurrencyAmount +} + +interface FormValues { + daysInput?: string + discountRateInput?: string +} + +const INPUT_DAYS_MIN_VALUE = 1 +const INPUT_DAYS_MAX_VALUE = 360 + +type GrossToNetFormValues = FormValues + +const GrossToNetDiscountForm = ({ + startDate, + endDate, + gross, + onSubmit, + submitButtonText = "Submit", +}: GrossToNetProps) => { + const { + watch, + register, + setValue, + handleSubmit, + formState: { isValid, errors }, + } = useForm({ + mode: "all", + }) + + const { daysInput, discountRateInput } = watch() + + const days = useMemo(() => { + return parseIntSafe(daysInput) + }, [daysInput]) + + const discountRate = useMemo(() => { + const parsed = parseFloatSafe(discountRateInput) + return parsed === undefined ? undefined : new Big(parsed).div(new Big("100")) + }, [discountRateInput]) + + const [net, setNet] = useState() + + const discount = useMemo(() => { + return net === undefined + ? undefined + : { + value: net.value.sub(gross.value), + currency: net.currency, + } + }, [gross, net]) + + useEffect(() => { + if (startDate === undefined) return + setValue("daysInput", String(Math.min(Math.max(1, daysBetween(startDate, endDate)), INPUT_DAYS_MAX_VALUE)), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + }, [startDate, endDate, setValue]) + + useEffect(() => { + if (!isValid || discountRate === undefined || days === undefined) { + setNet(undefined) + return + } + + setNet({ + value: Act360.grossToNet(gross.value, discountRate, days), + currency: gross.currency, + }) + }, [isValid, gross, days, discountRate]) + + return ( +
{ + handleSubmit(() => { + if (net === undefined || discountRate === undefined || days === undefined) return + + onSubmit({ + days, + discountRate, + net, + gross, + }) + })(e).catch(() => { + // TODO + }) + }} + > +
+ Days}> + + + {errors.daysInput && ( +
+ <> + Please enter a valid value between {INPUT_DAYS_MIN_VALUE} and {INPUT_DAYS_MAX_VALUE}. + +
+ )} +
+ +
+ Discount rate}> +
+ + % +
+
+ {errors.discountRateInput && ( +
+ <> + Please enter a valid value between {0}% and {99.9999}%. + +
+ )} +
+ +
+ <>Gross amount + +
+ {gross.value.toNumber()} + {gross.currency} +
+
+ +
+ <>Discount + +
+ {discount === undefined ? <>? : <>{discount.value.toNumber()}} + {discount?.currency} +
+
+ +
+ <>Net amount + +
+ {net === undefined ? <>? : <>{net.value.toNumber()}} + {net?.currency} +
+
+ + +
+ ) +} + +export { GrossToNetDiscountForm } diff --git a/src/components/InputContainer.tsx b/src/components/InputContainer.tsx new file mode 100644 index 0000000..ada6110 --- /dev/null +++ b/src/components/InputContainer.tsx @@ -0,0 +1,24 @@ +import { LabelHTMLAttributes, PropsWithChildren } from "react" +import { cn } from "@/lib/utils" + +type InputContainerProps = PropsWithChildren<{ + htmlFor: LabelHTMLAttributes["htmlFor"] + label: React.ReactNode +}> + +const InputContainer = ({ children, htmlFor, label }: InputContainerProps) => { + return ( +
+ + {children} +
+ ) +} + +export { InputContainer } diff --git a/src/components/nav/NavMain.tsx b/src/components/nav/NavMain.tsx new file mode 100644 index 0000000..283c999 --- /dev/null +++ b/src/components/nav/NavMain.tsx @@ -0,0 +1,93 @@ +import { ChevronRight, type LucideIcon } from "lucide-react" +import { NavLink } from "react-router" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from "@/components/ui/sidebar" +import { cn } from "@/lib/utils" + +export function NavMain({ + items, +}: { + items: { + title: string + url: string + icon?: LucideIcon + isActive?: boolean + disabled?: boolean + items?: { + title: string + url: string + disabled?: boolean + }[] + }[] +}) { + const { state } = useSidebar() + + return ( + + Dashboard + + {items.map((item) => + (item.items ?? []).length === 0 || state === "collapsed" ? ( + + + {item.disabled === true ? ( + <> + {item.icon && } + {item.title} + + ) : ( + + {item.icon && } + {item.title} + + )} + + + ) : ( + + + + + {item.icon && } + {item.title} + + + + + + {item.items?.map((subItem) => ( + + + e.preventDefault() : undefined} + className={cn({ + "opacity-50": subItem.disabled, + "cursor-not-allowed": subItem.disabled, + })} + > + {subItem.title} + + + + ))} + + + + + ), + )} + + + ) +} diff --git a/src/components/nav/NavSecondary.tsx b/src/components/nav/NavSecondary.tsx new file mode 100644 index 0000000..e260576 --- /dev/null +++ b/src/components/nav/NavSecondary.tsx @@ -0,0 +1,41 @@ +import * as React from "react" +import { type LucideIcon } from "lucide-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { Link } from "react-router" + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string + url: string + icon: LucideIcon + }[] +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ) +} diff --git a/src/components/nav/NavUser.tsx b/src/components/nav/NavUser.tsx new file mode 100644 index 0000000..82e3be4 --- /dev/null +++ b/src/components/nav/NavUser.tsx @@ -0,0 +1,86 @@ +import { BadgeCheck, Bell, ChevronsUpDown, LogOut } from "lucide-react" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar" + +export function NavUser({ + user, +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + + return ( + + + + + + + + {user.name} + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + {user.email} +
+
+
+ + + + + Account + + + + Notifications + + + + + + Log out + +
+
+
+
+ ) +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b7224f0 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 61875fc..19fc75c 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0 cursor-pointer", { variants: { variant: { diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..77aa5aa --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,73 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: React.ComponentProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "size-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: + "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", + day_range_end: + "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + PreviousMonthButton: ({ className }) => ( + + ), + NextMonthButton: ({ className }) => ( + + ), + }} + {...props} + /> + ) +} + +export { Calendar } diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..77f86be --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..12d7c45 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..7d7474c --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,165 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +
+ +
diff --git a/src/pages/quotes/AcceptedQuotesPage.tsx b/src/pages/quotes/AcceptedQuotesPage.tsx new file mode 100644 index 0000000..25afa34 --- /dev/null +++ b/src/pages/quotes/AcceptedQuotesPage.tsx @@ -0,0 +1,127 @@ +import { Breadcrumbs } from "@/components/Breadcrumbs" +import { PageTitle } from "@/components/PageTitle" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { listAcceptedQuotesOptions } from "@/generated/client/@tanstack/react-query.gen" +import useLocalStorage from "@/hooks/use-local-storage" +import { cn } from "@/lib/utils" +import { useSuspenseQuery } from "@tanstack/react-query" +import { LoaderIcon } from "lucide-react" +import { Suspense } from "react" +import { Link, useNavigate } from "react-router" + +function Loader() { + return ( +
+ + + + + + +
+ ) +} + +function QuoteListAccepted() { + const navigate = useNavigate() + + const { data, isFetching } = useSuspenseQuery({ + ...listAcceptedQuotesOptions(), + }) + + return ( + <> +
+ +
+ +
+ {data.quotes.length === 0 &&
No accepted quotes.
} + {data.quotes.map((it, index) => { + return ( +
+ + {isFetching ? ( + <>{it} + ) : ( + <> + {it} + + )} + + + +
+ ) + })} +
+ + ) +} + +function DevSection() { + const [devMode] = useLocalStorage("devMode", false) + + const { data: quotesAccepted } = useSuspenseQuery({ + ...listAcceptedQuotesOptions({}), + }) + + return ( + <> + {devMode && ( + <> +
+            {JSON.stringify(quotesAccepted, null, 2)}
+          
+ + )} + + ) +} + +function PageBody() { + return ( +
+
+ +
+
+ ) +} + +export default function AcceptedQuotesPage() { + return ( + <> + + Quotes + , + ]} + > + Accepted + + Accepted Quotes + }> + + + + + + + ) +} diff --git a/src/pages/quotes/PendingQuotesPage.tsx b/src/pages/quotes/PendingQuotesPage.tsx new file mode 100644 index 0000000..2dd6451 --- /dev/null +++ b/src/pages/quotes/PendingQuotesPage.tsx @@ -0,0 +1,176 @@ +import { Breadcrumbs } from "@/components/Breadcrumbs" +import { PageTitle } from "@/components/PageTitle" +import { Button } from "@/components/ui/button" +import { Card, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { InfoReply } from "@/generated/client" +import { adminLookupQuoteOptions, listPendingQuotesOptions } from "@/generated/client/@tanstack/react-query.gen" +import useLocalStorage from "@/hooks/use-local-storage" +import { useSuspenseQuery } from "@tanstack/react-query" +import { LoaderIcon } from "lucide-react" +import { Suspense } from "react" +import { Link, useNavigate } from "react-router" +import { ParticipantsOverviewCard } from "./QuotePage" +import { humanReadableDuration } from "@/utils/dates" +import { formatNumber, truncateString } from "@/utils/strings" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +function Loader() { + return ( +
+ + + + + +
+ ) +} + +function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: boolean }) { + const navigate = useNavigate() + + const { data, isFetching } = useSuspenseQuery({ + enabled: false, + staleTime: 60 * 1_000, + ...adminLookupQuoteOptions({ + path: { + id, + }, + }), + }) + + return ( + <> + +
+ +
+ + {isFetching || isLoading ? ( + <>{truncateString(id, 16)} + ) : ( + <> + {truncateString(id, 16)} + + )} + + {isFetching && } +
+
+
+
+ {formatNumber("en", data.bill?.sum)} sat +
+ {humanReadableDuration("en", new Date(Date.parse(data.bill.maturity_date)))} +
+
+
+
+ +
+ +
+
+ + ) +} + +function QuoteListPending() { + const { data, isFetching } = useSuspenseQuery({ + ...listPendingQuotesOptions(), + }) + + return ( + <> +
+ +
+ +
+ {data.quotes.length === 0 &&
💪 No pending quotes.
} + {data.quotes.map((it, index) => { + return ( +
+ +
+ ) + })} +
+ + ) +} + +function DevSection() { + const [devMode] = useLocalStorage("devMode", false) + + const { data: quotesPending } = useSuspenseQuery({ + ...listPendingQuotesOptions({}), + }) + + return ( + <> + {devMode && ( + <> +
+            {JSON.stringify(quotesPending, null, 2)}
+          
+ + )} + + ) +} + +function PageBody() { + return ( +
+
+ +
+
+ ) +} + +export default function PendingQuotesPage() { + return ( + <> + + Quotes + , + ]} + > + Pending + + + Pending Quotes + }> + + + + + + + ) +} diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 420ac71..c48717f 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -1,20 +1,34 @@ import { Breadcrumbs } from "@/components/Breadcrumbs" import { PageTitle } from "@/components/PageTitle" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table" -import { InfoReply } from "@/generated/client" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { IdentityPublicData, InfoReply } from "@/generated/client" import { adminLookupQuoteOptions, adminLookupQuoteQueryKey, resolveQuoteMutation, } from "@/generated/client/@tanstack/react-query.gen" import useLocalStorage from "@/hooks/use-local-storage" +import { cn } from "@/lib/utils" +import { formatDate, humanReadableDuration } from "@/utils/dates" +import { randomAvatar } from "@/utils/dev" +import { formatNumber, truncateString } from "@/utils/strings" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" -import { Suspense } from "react" +import { Suspense, useMemo, useState } from "react" import { Link, useParams } from "react-router" +import { BaseDrawer, ConfirmDrawer } from "@/components/Drawers" +import { GrossToNetDiscountForm } from "@/components/GrossToNetDiscountForm" +import Big from "big.js" +import { toast } from "sonner" +import { useForm } from "react-hook-form" +import { Calendar } from "@/components/ui/calendar" +import { InputContainer } from "@/components/InputContainer" +import { addDays } from "date-fns" function Loader() { return ( @@ -24,15 +38,202 @@ function Loader() { ) } +interface TimeToLiveFormValues { + ttl?: Date +} + +interface TimeToLiveFormResult { + ttl: Date +} + +interface TimeToLiveFormProps { + submitButtonText?: string + onSubmit: (values: TimeToLiveFormResult) => void +} + +const TimeToLiveForm = ({ onSubmit, submitButtonText = "Submit" }: TimeToLiveFormProps) => { + const { + watch, + handleSubmit, + setValue, + formState: { isValid, errors }, + } = useForm({ + mode: "all", + }) + + const { ttl } = watch() + + return ( +
{ + handleSubmit(() => { + if (errors.root !== undefined || ttl === undefined) return + + onSubmit({ + ttl, + }) + })(e).catch(() => { + // TODO + }) + }} + > +
+ Valid until}> + + +
+ setValue("ttl", day)} + fromDate={addDays(new Date(Date.now()), 1)} + /> +
+
+ + +
+ ) +} + +interface OfferFormResult { + discount: Parameters[0]["onSubmit"]>[0] + ttl: Parameters[0]["onSubmit"]>[0] +} + +interface OfferFormProps { + discount: Omit[0], "onSubmit"> + onSubmit: (result: OfferFormResult) => void +} + +function OfferForm({ onSubmit, discount }: OfferFormProps) { + const [discountResult, setDiscountResult] = useState() + return ( + <> + {!discountResult ? ( + <> + + + ) : ( + <> + + onSubmit({ + discount: discountResult, + ttl: ttlResult, + }) + } + submitButtonText="Next" + /> + + )} + + ) +} + +type OfferFormDrawerProps = Parameters[0] & { + value: InfoReply + onSubmit: OfferFormProps["onSubmit"] +} + +function OfferFormDrawer({ value, onSubmit, children, ...drawerProps }: OfferFormDrawerProps) { + return ( + +
+ +
+
+ ) +} + +type OfferConfirmDrawerProps = Parameters[0] & { + onSubmit: () => void + children?: React.ReactNode +} + +function OfferConfirmDrawer({ children, onSubmit, ...drawerProps }: OfferConfirmDrawerProps) { + return ( + + <> +
+ Are you sure you want to offer the quote? +
+ <>{children} + +
+ ) +} + +type DenyConfirmDrawerProps = Parameters[0] & { + onSubmit: () => void + children?: React.ReactNode +} + +function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDrawerProps) { + return ( + +
+ Are you sure you want to deny offering a quote? +
+
+ ) +} + function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { + const [offerFormData, setOfferFormData] = useState() + const [offerFormDrawerOpen, setOfferFormDrawerOpen] = useState(false) + const [offerConfirmDrawerOpen, setOfferConfirmDrawerOpen] = useState(false) + const [denyConfirmDrawerOpen, setDenyConfirmDrawerOpen] = useState(false) + + const effectiveDiscount = useMemo(() => { + if (!offerFormData) return + console.table(offerFormData) + return new Big(1).minus(offerFormData.discount.net.value.div(offerFormData.discount.gross.value)) + }, [offerFormData]) + const queryClient = useQueryClient() const denyQuote = useMutation({ ...resolveQuoteMutation(), + onSettled: () => { + toast.dismiss(`quote-${value.id}-deny`) + }, onError: (error) => { - console.log(error) + toast.error("Error while denying quote: " + error.message) + console.warn(error) }, onSuccess: () => { + toast.success("Quote has been denied.") void queryClient.invalidateQueries({ queryKey: adminLookupQuoteQueryKey({ path: { @@ -44,10 +245,15 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }) const offerQuote = useMutation({ ...resolveQuoteMutation(), + onSettled: () => { + toast.dismiss(`quote-${value.id}-offer`) + }, onError: (error) => { - console.log(error) + toast.error("Error while offering quote: " + error.message) + console.warn(error) }, onSuccess: () => { + toast.success("Quote has been offered.") void queryClient.invalidateQueries({ queryKey: adminLookupQuoteQueryKey({ path: { @@ -59,6 +265,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }) const onDenyQuote = () => { + toast.loading("Denying quote…", { id: `quote-${value.id}-deny` }) denyQuote.mutate({ path: { id: value.id, @@ -69,69 +276,240 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }) } - const onOfferQuote = () => { + const onOfferQuote = (result: OfferFormResult) => { + toast.loading("Offering quote…", { id: `quote-${value.id}-offer` }) offerQuote.mutate({ path: { id: value.id, }, body: { action: "offer", - discount: "1", - ttl: "1", + discount: result.discount.net.value.div(result.discount.gross.value).toFixed(4), + ttl: result.ttl.ttl.getTime().toFixed(0), }, }) } return ( - <> -
+
+ { + onDenyQuote() + setDenyConfirmDrawerOpen(false) + }} + > - + + { + if (!offerFormData) return + onOfferQuote(offerFormData) + setOfferConfirmDrawerOpen(false) + }} + > +
+ + Effective discount (relative):{" "} + {effectiveDiscount?.mul(new Big("100")).toFixed(2)}% + + + Effective discount (absolute):{" "} + {offerFormData?.discount.gross.value.minus(offerFormData?.discount.net.value).toFixed(0)}{" "} + {offerFormData?.discount.net.currency} + + + Net amount: {offerFormData?.discount.net.value.round(0).toFixed(0)}{" "} + {offerFormData?.discount.net.currency} + + + Valid until: {offerFormData?.ttl.ttl.toDateString()} ( + {offerFormData && humanReadableDuration("en", offerFormData.ttl.ttl)}) + +
+
+
+ ) +} + +export function ParticipantsOverviewCard({ + drawee, + drawer, + holder, + payee, + className, +}: { + drawee?: IdentityPublicData + drawer?: IdentityPublicData + holder?: IdentityPublicData + payee?: IdentityPublicData + className?: string +}) { + return ( +
+
+
- +
+ +
+
+ +
+
+ +
+
) } -function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { +function IdentityPublicDataAvatar({ value, tooltip }: { value?: IdentityPublicData; tooltip?: React.ReactNode }) { + const avatar = ( + + + {value?.name} + + ) + return !tooltip ? ( + avatar + ) : ( + + + {avatar} + {tooltip} + + + ) +} + +function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) { return ( - <> -
- - - - id: - {value.id} - - - status: - - - {value.status} - - - - - bill: - {value.bill ?
{JSON.stringify(value.bill, null, 2)}
: "(empty)"}
-
-
-
- - +
+
+
- +
+
{value?.name}
+ +
+ {value?.address}, {value?.zip}, {value?.city}, {value?.country} +
+
+
{value?.node_id}
+
+
+
+ ) +} + +function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { + return ( +
+ + + + ID: + + {value.id} + + + + Status: + + + {value.status} + + + + + Sum: + {formatNumber("en", value.bill?.sum)} sat + + + Maturity date: + + {!value.bill?.maturity_date ? ( + <>(empty) + ) : ( +
+ {formatDate("en", new Date(Date.parse(value.bill.maturity_date)))} + ({humanReadableDuration("en", new Date(Date.parse(value.bill.maturity_date)))}) +
+ )} +
+
+ + Participants: + + + + + + Drawee: + + + + + + Drawer: + + + + + + Payee: + + + + + + Holder: + + + + +
+
+ + +
) } @@ -171,7 +549,13 @@ function PageBody({ id }: { id: InfoReply["id"] }) { return ( <>
- {isFetching && } + {" "} +
@@ -196,7 +580,9 @@ export default function QuotePage() { > {id} - Quote {id} + + Quote {truncateString(id, 16)} + }> diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx index 7d273ff..e16e417 100644 --- a/src/pages/quotes/QuotesPage.tsx +++ b/src/pages/quotes/QuotesPage.tsx @@ -1,116 +1,24 @@ +import { Suspense } from "react" import { Breadcrumbs } from "@/components/Breadcrumbs" -import { H3 } from "@/components/Headings" import { PageTitle } from "@/components/PageTitle" -import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { listAcceptedQuotesOptions, listPendingQuotesOptions } from "@/generated/client/@tanstack/react-query.gen" -import useLocalStorage from "@/hooks/use-local-storage" import { useSuspenseQuery } from "@tanstack/react-query" -import { LoaderIcon } from "lucide-react" -import { Suspense } from "react" -import { Link, useNavigate } from "react-router" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" +import { Link } from "react-router" +import { ChevronRight } from "lucide-react" function Loader() { return (
- + +
) } -function QuoteListPending() { - const navigate = useNavigate() - - const { data, isFetching } = useSuspenseQuery({ - ...listPendingQuotesOptions(), - }) - - return ( - <> -

- - Pending - {isFetching && } - -

- -
- {data.quotes.map((it, index) => { - return ( -
- {isFetching ? ( - <>{it} - ) : ( - <> - {it} - - )} - - -
- ) - })} -
- - ) -} - -function QuoteListAccepted() { - const navigate = useNavigate() - - const { data, isFetching } = useSuspenseQuery({ - ...listAcceptedQuotesOptions(), - }) - - return ( - <> -

- - Accepted - {isFetching && } - -

- -
- {data.quotes.map((it, index) => { - return ( -
- {isFetching ? ( - <>{it} - ) : ( - <> - {it} - - )} - - -
- ) - })} -
- - ) -} - -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - +function PageBody() { const { data: quotesPending } = useSuspenseQuery({ ...listPendingQuotesOptions({}), }) @@ -120,29 +28,49 @@ function DevSection() { }) return ( - <> - {devMode && ( - <> -
-            {JSON.stringify(quotesPending, null, 2)}
-          
-
-            {JSON.stringify(quotesAccepted, null, 2)}
-          
- - )} - - ) -} - -function PageBody() { - return ( - <> - }> - - - - +
+ + 0, + })} + > +
+
+ + Pending Quotes + + + {quotesPending.quotes.length === 0 ? ( + <>💪 No pending quotes. + ) : ( + <>{quotesPending.quotes.length} pending + )} + +
+
+ +
+
+
+ + + +
+
+ + Accepted quotes + + {quotesAccepted.quotes.length} accepted +
+
+ +
+
+
+ +
) } @@ -151,9 +79,8 @@ export default function QuotesPage() { <> Quotes Quotes - - - + }> + ) diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 9178ac0..5fabd74 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -5,6 +5,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import useLocalStorage from "@/hooks/use-local-storage" import { Suspense } from "react" +import { toast } from "sonner" function Loader() { return ( @@ -26,6 +27,15 @@ function PageBody() { className="cursor-pointer" checked={devMode} onCheckedChange={() => { + toast.info( + <> + Developer mode is {(!devMode && "ON") || "OFF"} + , + { + id: "settings-dev-mode", + duration: 1_337, + }, + ) setDevMode((it) => !it) }} /> diff --git a/src/utils/dates.ts b/src/utils/dates.ts new file mode 100644 index 0000000..7c2ba2f --- /dev/null +++ b/src/utils/dates.ts @@ -0,0 +1,47 @@ +import { differenceInCalendarYears, differenceInMinutes } from "date-fns" +import { differenceInCalendarDays, differenceInCalendarMonths, differenceInHours, differenceInSeconds } from "date-fns" + +export const daysBetween = (startDate: Date, endDate: Date): number => { + return differenceInCalendarDays(endDate, startDate) +} + +export function humanReadableDuration(locale: string, from: Date, until = new Date(Date.now())) { + const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) + + const diffYears = differenceInCalendarYears(from, until) + if (Math.abs(diffYears) >= 1) { + return relativeTimeFormatter.format(diffYears, "years") + } + const diffMonths = differenceInCalendarMonths(from, until) + if (Math.abs(diffMonths) >= 1) { + return relativeTimeFormatter.format(diffMonths, "months") + } + const diffDays = differenceInCalendarDays(from, until) + if (Math.abs(diffDays) >= 1) { + return relativeTimeFormatter.format(diffDays, "days") + } + const diffHours = differenceInHours(from, until) + if (Math.abs(diffHours) > 1) { + return relativeTimeFormatter.format(diffHours, "hours") + } + const diffMinutes = differenceInMinutes(from, until) + if (Math.abs(diffMinutes) > 1) { + return relativeTimeFormatter.format(diffMinutes, "minutes") + } + const diffSeconds = differenceInSeconds(from, until) + return relativeTimeFormatter.format(diffSeconds, "seconds") +} + +export function humanReadableDurationDays(locale: string, from: Date, until = new Date(Date.now())) { + const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) + + const diffMillis = from.getTime() - until.getTime() + return relativeTimeFormatter.format(Math.round(diffMillis / (1000 * 60 * 60 * 24)), "day") +} + +export const formatDate = (locale: string, date: Date): string => { + const year = new Intl.DateTimeFormat(locale, { year: "2-digit" }).format(date) + const month = new Intl.DateTimeFormat(locale, { month: "short" }).format(date) + const day = new Intl.DateTimeFormat(locale, { day: "2-digit" }).format(date) + return `${day}-${month}-${year}` +} diff --git a/src/utils/dev.ts b/src/utils/dev.ts new file mode 100644 index 0000000..3f50e32 --- /dev/null +++ b/src/utils/dev.ts @@ -0,0 +1,9 @@ +export const randomAvatar = (path: "men" | "women" | undefined, seed: string | undefined) => { + const _path = path ?? (Math.random() > 0.5 ? "men" : "women") + const _seed = + (seed ?? `${Math.floor(Math.random() * 100)}`) + .split("") + .map((it) => it.charCodeAt(0)) + .reduce((prev, curr) => prev + curr, 0) % 100 + return `https://randomuser.me/api/portraits/${_path}/${_seed}.jpg` +} diff --git a/src/utils/discount-util.test.ts b/src/utils/discount-util.test.ts new file mode 100644 index 0000000..31556d1 --- /dev/null +++ b/src/utils/discount-util.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest" +import Big from "big.js" +import { daysBetween } from "@/utils/dates" +import { Act360 } from "./discount-util" + +describe("discount-util", () => { + describe("Act360", () => { + describe("netToGross", () => { + it("should calculate gross amount correctly (0)", () => { + const startDate = new Date(2024, 11, 6) + const endDate = new Date(2025, 2, 31) + const netAmount = new Big("10.12") + const discountRate = new Big("0.045") + + const days = daysBetween(startDate, endDate) + const grossAmount = Act360.netToGross(netAmount, discountRate, days) + expect(grossAmount).toStrictEqual(new Big("10.26759670259987317692")) + expect(grossAmount!.toNumber()).toBe(10.267596702599873) + }) + + it("should calculate gross amount correctly (1)", () => { + expect(Act360.netToGross(new Big("1"), new Big("1"), -1)).toStrictEqual(new Big("0.99722991689750692521")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 0)).toStrictEqual(new Big("1")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 1)).toStrictEqual(new Big("1.00278551532033426184")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 355)).toStrictEqual(new Big("71.99999999999999999424")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 360)).toStrictEqual(undefined) + expect(Act360.netToGross(new Big("1"), new Big("1"), 365)).toStrictEqual(new Big("-71.99999999999999999424")) + }) + + it("should calculate gross amount correctly (2)", () => { + expect(Act360.netToGross(new Big(1), new Big("0.9863"), 365)).toStrictEqual(new Big("719999.999999999424")) + expect(Act360.netToGross(new Big(1), new Big("0.9864"), 365)).toStrictEqual(new Big("-10000")) + expect(Act360.netToGross(new Big(1), new Big("0.9865"), 365)).toStrictEqual( + new Big("-4965.51724137931031743163"), + ) + }) + + it("should calculate gross amount correctly (step-by-step)", () => { + const startDate = new Date(2024, 11, 6) + const endDate = new Date(2025, 2, 31) + const netAmount = new Big("10.12") + const discountRate = new Big("0.045") + + const days = daysBetween(startDate, endDate) + expect(days, "sanity check").toBe(115) + + const discountDays = discountRate.times(days).div(360) + expect(discountDays.toNumber(), "sanity check").toBe(0.014375) + + const factor = new Big(1).minus(discountDays) + + const grossAmount = netAmount.div(factor) + expect(grossAmount).toStrictEqual(new Big("10.26759670259987317692")) + expect(grossAmount.toNumber()).toBe(10.267596702599873) + + const calcGrossAmount = Act360.netToGross(netAmount, discountRate, days) + expect(calcGrossAmount).toStrictEqual(grossAmount) + }) + }) + + describe("grossToNet", () => { + it("should calculate net amount correctly (0)", () => { + const startDate = new Date(2024, 11, 6) + const endDate = new Date(2025, 2, 31) + const grossAmount = new Big("10.12") + const discountRate = new Big("0.045") + + const days = daysBetween(startDate, endDate) + const netAmount = Act360.grossToNet(grossAmount, discountRate, days) + expect(netAmount).toStrictEqual(new Big("9.974525")) + expect(netAmount.toNumber()).toBe(9.974525) + }) + + it("should calculate net amount correctly (1)", () => { + expect(Act360.grossToNet(new Big("1"), new Big("1"), -1)).toStrictEqual(new Big("1.00277777777777777778")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 0)).toStrictEqual(new Big("1")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 1)).toStrictEqual(new Big("0.99722222222222222222")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 355)).toStrictEqual(new Big("0.01388888888888888889")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 360)).toStrictEqual(new Big("0")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 365)).toStrictEqual(new Big("-0.01388888888888888889")) + }) + + it("should calculate net amount correctly (2)", () => { + expect(Act360.grossToNet(new Big(1), new Big("0.9863"), 365)).toStrictEqual(new Big("0.00000138888888888889")) + expect(Act360.grossToNet(new Big(1), new Big("0.9864"), 365)).toStrictEqual(new Big("-0.0001")) + expect(Act360.grossToNet(new Big(1), new Big("0.9865"), 365)).toStrictEqual(new Big("-0.00020138888888888889")) + }) + }) + }) +}) diff --git a/src/utils/discount-util.ts b/src/utils/discount-util.ts new file mode 100644 index 0000000..9150d04 --- /dev/null +++ b/src/utils/discount-util.ts @@ -0,0 +1,19 @@ +import Big from "big.js" + +const BIG_1 = new Big("1") +const BIG_360 = new Big("360") + +const factor = (discountRate: Big, days: number) => { + const discountDays = discountRate.times(days).div(BIG_360) + return BIG_1.minus(discountDays) +} + +export const Act360 = { + netToGross: (netAmount: Big, discountRate: Big, days: number): Big | undefined => { + const divisor = factor(discountRate, days) + return divisor.toNumber() !== 0 ? netAmount.div(divisor) : undefined + }, + grossToNet: (grossAmount: Big, discountRate: Big, days: number): Big => { + return grossAmount.times(factor(discountRate, days)) + }, +} diff --git a/src/utils/numbers.test.ts b/src/utils/numbers.test.ts new file mode 100644 index 0000000..1d03258 --- /dev/null +++ b/src/utils/numbers.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest" +import { parseFloatSafe, parseIntSafe } from "./numbers" + +describe("util", () => { + describe("parseFloatSafe", () => { + it("should safely parse floats", () => { + expect(parseFloatSafe("")).toBe(undefined) + expect(parseFloatSafe("NaN")).toBe(undefined) + expect(parseFloatSafe("Infinity")).toBe(undefined) + expect(parseFloatSafe(String(1 / 0))).toBe(undefined) + expect(parseFloatSafe("foobar")).toBe(undefined) + expect(parseFloatSafe("0")).toBe(0) + expect(parseFloatSafe("1")).toBe(1) + expect(parseFloatSafe("-1")).toBe(-1) + expect(parseFloatSafe("1.23456789")).toBe(1.23456789) + expect(parseFloatSafe("-1.23456789")).toBe(-1.23456789) + }) + }) + + describe("parseIntSafe", () => { + it("should safely parse ints", () => { + expect(parseIntSafe("")).toBe(undefined) + expect(parseIntSafe("NaN")).toBe(undefined) + expect(parseIntSafe("Infinity")).toBe(undefined) + expect(parseIntSafe(String(1 / 0))).toBe(undefined) + expect(parseIntSafe("foobar")).toBe(undefined) + expect(parseIntSafe("0")).toBe(0) + expect(parseIntSafe("1")).toBe(1) + expect(parseIntSafe("-1")).toBe(-1) + expect(parseIntSafe("1.23456789")).toBe(1) + expect(parseIntSafe("-1.23456789")).toBe(-1) + }) + }) +}) diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts new file mode 100644 index 0000000..ed1ae06 --- /dev/null +++ b/src/utils/numbers.ts @@ -0,0 +1,11 @@ +export const parseFloatSafe = (str: string | undefined) => { + if (str === undefined) return undefined + const parsed = parseFloat(str) + return isNaN(parsed) || !isFinite(parsed) ? undefined : parsed +} + +export const parseIntSafe = (str: string | undefined) => { + if (str === undefined) return undefined + const parsed = parseInt(str, 10) + return isNaN(parsed) || !isFinite(parsed) ? undefined : parsed +} diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 0000000..856ba62 --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,8 @@ +export const truncateString = (str: string, maxLength: number): string => + str.length <= maxLength + ? str + : str.slice(0, Math.floor((maxLength - 3) / 2)) + "…" + str.slice(-Math.floor((maxLength - 3) / 2)) + +export const formatNumber = (locale: string, value: number): string => { + return new Intl.NumberFormat(locale).format(value) +}