diff --git a/components.json b/components.json new file mode 100644 index 0000000000..289139b33f --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/resources/css/app.css", + "baseColor": "gray", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "~/components", + "utils": "~/lib/utils", + "ui": "~/components/ui", + "lib": "~/lib", + "hooks": "~/hooks" + } +} \ No newline at end of file diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000000..d754ca03b9 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000000..6363cd7ca4 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; + +import { cn } from '~/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000000..bd0c391ddd --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/package-lock.json b/package-lock.json index 9dae798b71..7cd2c299b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@headlessui/tailwindcss": "^0.1.3", "@hello-pangea/dnd": "^16.2.0", "@monaco-editor/react": "^4.4.6", + "@radix-ui/react-slot": "^1.1.0", "@react-oauth/google": "^0.9.0", "@reduxjs/toolkit": "^1.9.1", "@sentry/react": "^7.77.0", @@ -23,7 +24,9 @@ "antd": "^5.6.1", "array-move": "^4.0.0", "axios": "^0.27.2", + "class-variance-authority": "^0.7.0", "classnames": "^2.3.2", + "clsx": "^2.1.1", "collect.js": "^4.34.3", "currency.js": "^2.0.4", "dayjs": "^1.11.7", @@ -37,6 +40,7 @@ "jotai": "^2.0.3", "js-sha256": "^0.11.0", "lodash": "^4.17.21", + "lucide-react": "^0.447.0", "mitt": "^3.0.1", "playwright": "^1.44.0", "pretty-bytes": "^6.0.0", @@ -50,6 +54,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-feather": "^2.0.10", + "react-grid-layout": "^1.4.4", "react-hot-toast": "^2.4.0", "react-i18next": "^12.1.1", "react-icons": "^4.7.1", @@ -73,7 +78,9 @@ "remove": "^0.1.5", "socket.io-client": "^4.7.5", "styled-components": "^6.0.7", + "tailwind-merge": "^2.5.2", "tailwind-scrollbar": "^3.0.0", + "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.0" }, "devDependencies": { @@ -90,6 +97,7 @@ "@types/react-date-range": "^1.4.4", "@types/react-datepicker": "^4.8.0", "@types/react-dom": "^18.0.9", + "@types/react-grid-layout": "^1.3.5", "@types/socket.io-client": "^3.0.0", "@types/uuid": "^9.0.0", "@typescript-eslint/eslint-plugin": "^5.45.1", @@ -242,12 +250,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", + "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" + "@babel/highlight": "^7.25.7", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -308,15 +317,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", + "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.0", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@babel/types": "^7.25.7", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" @@ -356,31 +366,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", @@ -412,10 +397,11 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", + "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -445,17 +431,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", + "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", + "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -484,23 +472,29 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", + "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.25.7", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.7.tgz", + "integrity": "sha512-aZn7ETtQsjjGG5HruveUK06cU3Hljuhd9Iojm4M8WWv3wLE6OkE5PWbDUkItmMgegmccaITudyuW5RPYrYlgWw==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.7" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -671,12 +665,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.22.5.tgz", - "integrity": "sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.7.tgz", + "integrity": "sha512-rR+5FDjpCHqqZN2bzZm18bVYGaejGq5ZkpVCJLXor/+zlSrSoc4KWcHI0URVWjl/68Dyr1uwZUz/1njycEAv9g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -727,34 +722,33 @@ } }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", + "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", + "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7", + "debug": "^4.3.1", "globals": "^11.1.0" }, "engines": { @@ -762,12 +756,13 @@ } }, "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.7.tgz", + "integrity": "sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-string-parser": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", "to-fast-properties": "^2.0.0" }, "engines": { @@ -2237,13 +2232,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -2258,9 +2254,10 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -2271,9 +2268,10 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2359,6 +2357,39 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rc-component/color-picker": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-1.4.1.tgz", @@ -3040,6 +3071,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz", + "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.8", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.8.tgz", @@ -3408,6 +3449,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -4143,6 +4185,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -4156,6 +4199,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -4263,6 +4307,27 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/classnames": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", @@ -4288,6 +4353,15 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5680,9 +5754,10 @@ } }, "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -6154,6 +6229,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", "engines": { "node": ">=4" } @@ -8723,15 +8799,16 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -8938,6 +9015,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.447.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.447.0.tgz", + "integrity": "sha512-SZ//hQmvi+kDKrNepArVkYK7/jfeZ5uFNEnYmd45RKZcbGD78KLnrcNXmgeg6m+xNHFvTG+CblszXCy4n6DN4w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", @@ -11188,6 +11274,29 @@ "react": "^18.2.0" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-dropzone": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", @@ -11220,6 +11329,30 @@ "react": ">=16.8.6" } }, + "node_modules/react-grid-layout": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz", + "integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.5", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout/node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/react-hot-toast": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", @@ -11477,6 +11610,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, "node_modules/react-resizable-panels": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.0.12.tgz", @@ -12616,6 +12762,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -12634,6 +12781,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz", + "integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwind-scrollbar": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-3.0.5.tgz", @@ -12681,6 +12838,15 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -12729,9 +12895,10 @@ } }, "node_modules/tiny-invariant": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", - "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" }, "node_modules/tiny-warning": { "version": "1.0.3", diff --git a/package.json b/package.json index 458480c4f2..fef147df8c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@headlessui/tailwindcss": "^0.1.3", "@hello-pangea/dnd": "^16.2.0", "@monaco-editor/react": "^4.4.6", + "@radix-ui/react-slot": "^1.1.0", "@react-oauth/google": "^0.9.0", "@reduxjs/toolkit": "^1.9.1", "@sentry/react": "^7.77.0", @@ -18,7 +19,9 @@ "antd": "^5.6.1", "array-move": "^4.0.0", "axios": "^0.27.2", + "class-variance-authority": "^0.7.0", "classnames": "^2.3.2", + "clsx": "^2.1.1", "collect.js": "^4.34.3", "currency.js": "^2.0.4", "dayjs": "^1.11.7", @@ -32,6 +35,7 @@ "jotai": "^2.0.3", "js-sha256": "^0.11.0", "lodash": "^4.17.21", + "lucide-react": "^0.447.0", "mitt": "^3.0.1", "playwright": "^1.44.0", "pretty-bytes": "^6.0.0", @@ -45,6 +49,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-feather": "^2.0.10", + "react-grid-layout": "^1.4.4", "react-hot-toast": "^2.4.0", "react-i18next": "^12.1.1", "react-icons": "^4.7.1", @@ -68,7 +73,9 @@ "remove": "^0.1.5", "socket.io-client": "^4.7.5", "styled-components": "^6.0.7", + "tailwind-merge": "^2.5.2", "tailwind-scrollbar": "^3.0.0", + "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.0" }, "scripts": { @@ -107,6 +114,7 @@ "@types/react-date-range": "^1.4.4", "@types/react-datepicker": "^4.8.0", "@types/react-dom": "^18.0.9", + "@types/react-grid-layout": "^1.3.5", "@types/socket.io-client": "^3.0.0", "@types/uuid": "^9.0.0", "@typescript-eslint/eslint-plugin": "^5.45.1", diff --git a/src/common/hooks/useReactSettings.ts b/src/common/hooks/useReactSettings.ts index 2346cf5df8..1ea0be0b57 100644 --- a/src/common/hooks/useReactSettings.ts +++ b/src/common/hooks/useReactSettings.ts @@ -16,6 +16,8 @@ import { Record as ClientMapRecord } from '../constants/exports/client-map'; import { Entity } from '$app/components/CommonActionsPreferenceModal'; import { PerPage } from '$app/components/DataTable'; import { ThemeColorField } from '$app/pages/settings/user/components/StatusColorTheme'; +import { DashboardGridLayouts } from '$app/pages/dashboard/components/ResizableDashboardCards'; +import { DashboardField } from '../interfaces/company-user'; export type ChartsDefaultView = 'day' | 'week' | 'month'; @@ -52,6 +54,28 @@ export type ImportTemplates = Record>; type ColorTheme = Record; +export type DashboardConfigurationBreakpoint = + | 'xxs' + | 'xs' + | 'sm' + | 'md' + | 'lg'; + +export interface DashboardCardConfiguration { + i: string; + h: number; + w: number; + x: number; + y: number; + minH: number; + minW: number; + maxH: number; + maxW: number; + isResizable: boolean; + moved: boolean; + static: boolean; +} + export interface ReactSettings { show_pdf_preview: boolean; react_table_columns?: Record; @@ -67,6 +91,10 @@ export interface ReactSettings { show_table_footer?: boolean; dark_mode?: boolean; color_theme?: ColorTheme; + dashboard_cards_configuration?: DashboardGridLayouts; + removed_dashboard_cards?: Record; + dashboard_fields?: DashboardField[]; + preference_cards_configuration?: DashboardGridLayouts; } export type ReactTableColumns = diff --git a/src/common/hooks/useRefetch.tsx b/src/common/hooks/useRefetch.tsx index 9a86100c14..89cafde3bf 100644 --- a/src/common/hooks/useRefetch.tsx +++ b/src/common/hooks/useRefetch.tsx @@ -20,6 +20,7 @@ export const keys = { '/api/v1/activities/entity', '/api/v1/activities', '/api/v1/documents', + '/api/v1/charts/calculated_fields', ], }, designs: { @@ -64,6 +65,7 @@ export const keys = { '/api/v1/charts/totals_v2', '/api/v1/charts/chart_summary_v2', '/api/v1/documents', + '/api/v1/charts/calculated_fields', ], }, group_settings: { @@ -80,6 +82,7 @@ export const keys = { '/api/v1/charts/chart_summary_v2', '/api/v1/activities', '/api/v1/documents', + '/api/v1/charts/calculated_fields', ], }, purchase_orders: { @@ -96,7 +99,11 @@ export const keys = { }, tasks: { path: '/api/v1/tasks', - dependencies: ['/api/v1/projects', '/api/v1/documents'], + dependencies: [ + '/api/v1/projects', + '/api/v1/documents', + '/api/v1/charts/calculated_fields', + ], }, tax_rates: { path: '/api/v1/tax_rates', @@ -143,11 +150,12 @@ export const keys = { '/api/v1/payments', '/api/v1/expenses', '/api/v1/tasks', + '/api/v1/charts/calculated_fields', ], }, company_users: { path: '/api/v1/company_users', - dependencies: [], + dependencies: ['/api/v1/charts/calculated_fields'], }, clients: { path: '/api/v1/clients', @@ -181,6 +189,7 @@ export const keys = { '/api/v1/clients', '/api/v1/activities', '/api/v1/documents', + '/api/v1/charts/calculated_fields', ], }, recurring_invoices: { diff --git a/src/common/interfaces/company-user.ts b/src/common/interfaces/company-user.ts index 39833652fc..f8d94e184c 100644 --- a/src/common/interfaces/company-user.ts +++ b/src/common/interfaces/company-user.ts @@ -32,10 +32,39 @@ export interface CompanyUser { react_settings: ReactSettings; } +export type Format = 'time' | 'money'; +export type Period = 'current' | 'previous' | 'total'; +export type Calculate = 'sum' | 'avg' | 'count'; +export type Field = + | 'active_invoices' + | 'outstanding_invoices' + | 'completed_payments' + | 'refunded_payments' + | 'active_quotes' + | 'unapproved_quotes' + | 'logged_tasks' + | 'invoiced_tasks' + | 'paid_tasks' + | 'logged_expenses' + | 'pending_expenses' + | 'invoiced_expenses' + | 'invoice_paid_expenses'; + +export interface DashboardField { + id: string; + calculate: Calculate; + field: Field; + format: Format; + period: Period; +} + export interface Settings { accent_color: string; table_columns?: Record; react_table_columns?: Record; + dashboard_fields?: DashboardField[]; + dashboard_fields_per_row_desktop?: number; + dashboard_fields_per_row_mobile?: number; } export interface Notifications { diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index 8e7a1722cc..56fcad33d4 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -152,6 +152,8 @@ interface Props extends CommonProps { withoutPerPageAsPreference?: boolean; withoutSortQueryParameter?: boolean; showRestoreBulk?: (selectedResources: T[]) => boolean; + withTransparentBackground?: boolean; + height?: 'full'; } export type ResourceAction = (resource: T) => ReactElement; @@ -467,7 +469,12 @@ export function DataTable(props: Props) { }, []); return ( -
+
{!props.withoutActions && ( (props: Props) { } isDataLoading={isLoading} style={props.style} + height={props.height} + withTransparentBackground={props.withTransparentBackground} > {!props.withoutActions && !hideEditableOptions && ( diff --git a/src/components/DropdownDateRangePicker.tsx b/src/components/DropdownDateRangePicker.tsx index 5c3c2277a7..50f8b438aa 100644 --- a/src/components/DropdownDateRangePicker.tsx +++ b/src/components/DropdownDateRangePicker.tsx @@ -71,8 +71,8 @@ export function DropdownDateRangePicker(props: Props) { const accentColor = useAccentColor(); return ( -
- +
+ ) => unknown; @@ -56,17 +63,53 @@ interface Props { withoutHeaderBorder?: boolean; topRight?: ReactNode; height?: 'full'; + renderFromShadcn?: boolean; } export function Card(props: Props) { const [t] = useTranslation(); - const { padding = 'regular', height } = props; + const { padding = 'regular', height, renderFromShadcn } = props; const [isCollapsed, setIsCollpased] = useState(props.collapsed); const colors = useColorScheme(); + if (renderFromShadcn) { + return ( + + {Boolean(props.title || props.description) && ( + +
+
+ {props.title && {props.title}} + + {props.description && ( + {props.description} + )} +
+ + {props.topRight && ( +
{props.topRight}
+ )} +
+
+ )} + + {props.children} +
+ ); + } + return (
{props.isLoading && } />} diff --git a/src/components/tables/Table.tsx b/src/components/tables/Table.tsx index 7426b9aa8e..a834bd67fe 100644 --- a/src/components/tables/Table.tsx +++ b/src/components/tables/Table.tsx @@ -21,10 +21,12 @@ interface Props extends CommonProps { withoutRightBorder?: boolean; onVerticalOverflowChange?: (overflow: boolean) => void; isDataLoading?: boolean; + withTransparentBackground?: boolean; + height?: 'full'; } export function Table(props: Props) { - const { onVerticalOverflowChange } = props; + const { onVerticalOverflowChange, withTransparentBackground } = props; const [tableParentHeight, setTableParentHeight] = useState(); const [tableHeight, setTableHeight] = useState(); @@ -82,11 +84,17 @@ export function Table(props: Props) { className={classNames('flex flex-col', { 'mt-2': !props.withoutPadding, })} + style={{ + height: props.height === 'full' ? '100%' : undefined, + }} >
diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx index d1e6be90ea..4bc46dad7c 100644 --- a/src/pages/dashboard/Dashboard.tsx +++ b/src/pages/dashboard/Dashboard.tsx @@ -9,26 +9,13 @@ */ import { useTitle } from '$app/common/hooks/useTitle'; -import { Activity } from '$app/pages/dashboard/components/Activity'; -import { PastDueInvoices } from '$app/pages/dashboard/components/PastDueInvoices'; -import { RecentPayments } from '$app/pages/dashboard/components/RecentPayments'; -import { Totals } from '$app/pages/dashboard/components/Totals'; -import { UpcomingInvoices } from '$app/pages/dashboard/components/UpcomingInvoices'; -import { useTranslation } from 'react-i18next'; import { Default } from '../../components/layouts/Default'; -import { ExpiredQuotes } from './components/ExpiredQuotes'; -import { UpcomingQuotes } from './components/UpcomingQuotes'; -import { useEnabled } from '$app/common/guards/guards/enabled'; -import { ModuleBitmask } from '../settings'; -import { UpcomingRecurringInvoices } from './components/UpcomingRecurringInvoices'; +import { ResizableDashboardCards } from './components/ResizableDashboardCards'; import { useSocketEvent } from '$app/common/queries/sockets'; import { $refetch } from '$app/common/hooks/useRefetch'; export default function Dashboard() { - const [t] = useTranslation(); - useTitle('dashboard'); - - const enabled = useEnabled(); + const { documentTitle } = useTitle('dashboard'); useSocketEvent({ on: 'App\\Events\\Invoice\\InvoiceWasPaid', @@ -36,48 +23,8 @@ export default function Dashboard() { }); return ( - - - -
-
- -
- -
- -
- - {enabled(ModuleBitmask.Invoices) && ( -
- -
- )} - - {enabled(ModuleBitmask.Invoices) && ( -
- -
- )} - - {enabled(ModuleBitmask.Quotes) && ( -
- -
- )} - - {enabled(ModuleBitmask.Quotes) && ( -
- -
- )} - - {enabled(ModuleBitmask.RecurringInvoices) && ( -
- -
- )} -
+ + ); } diff --git a/src/pages/dashboard/components/Activity.tsx b/src/pages/dashboard/components/Activity.tsx index be285cb467..70be79c6ae 100644 --- a/src/pages/dashboard/components/Activity.tsx +++ b/src/pages/dashboard/components/Activity.tsx @@ -17,9 +17,14 @@ import { useTranslation } from 'react-i18next'; import { NonClickableElement } from '$app/components/cards/NonClickableElement'; import { ActivityRecord } from '$app/common/interfaces/activity-record'; import { useGenerateActivityElement } from '../hooks/useGenerateActivityElement'; -import React from 'react'; +import React, { ReactNode } from 'react'; -export function Activity() { +interface Props { + isEditMode?: boolean; + topRight?: ReactNode; +} + +export function Activity({ topRight, isEditMode }: Props) { const [t] = useTranslation(); const { data, isLoading, isError } = useQuery( @@ -33,8 +38,11 @@ export function Activity() { return ( {isLoading && ( @@ -46,11 +54,11 @@ export function Activity() { {t('error_refresh_page')} )} -
-
+
+
{data?.data.data && data.data.data.map((record: ActivityRecord, index: number) => ( diff --git a/src/pages/dashboard/components/Chart.tsx b/src/pages/dashboard/components/Chart.tsx index 44629d26cc..415c65276d 100644 --- a/src/pages/dashboard/components/Chart.tsx +++ b/src/pages/dashboard/components/Chart.tsx @@ -12,7 +12,7 @@ import { useCurrentCompanyDateFormats } from '$app/common/hooks/useCurrentCompan import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { date as formatDate, useParseDayjs } from '$app/common/helpers'; -import { ChartData, TotalColors } from './Totals'; +import { ChartData, TotalColors } from './ResizableDashboardCards'; import { Line, CartesianGrid, diff --git a/src/pages/dashboard/components/DashboardCard.tsx b/src/pages/dashboard/components/DashboardCard.tsx new file mode 100644 index 0000000000..eca743264c --- /dev/null +++ b/src/pages/dashboard/components/DashboardCard.tsx @@ -0,0 +1,125 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +import { useFormatMoney } from '$app/common/hooks/money/useFormatMoney'; +import { DashboardField } from '$app/common/interfaces/company-user'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FIELDS_LABELS } from './DashboardCardSelector'; +import { useQueryClient } from 'react-query'; +import { request } from '$app/common/helpers/request'; +import { endpoint } from '$app/common/helpers'; +import { Spinner } from '$app/components/Spinner'; +import { Card as ShadcnCard } from '../../../../components/ui/card'; +import classNames from 'classnames'; + +interface DashboardCardsProps { + dateRange: string; + startDate: string; + endDate: string; + currencyId: string; +} + +interface CardProps extends DashboardCardsProps { + field: DashboardField; + layoutBreakpoint: string | undefined; +} + +export const PERIOD_LABELS = { + current: 'current_period', + previous: 'previous_period', +}; + +export function DashboardCard(props: CardProps) { + const [t] = useTranslation(); + + const { dateRange, startDate, endDate, field, currencyId } = props; + + const queryClient = useQueryClient(); + const formatMoney = useFormatMoney(); + + const [isFormBusy, setIsFormBusy] = useState(false); + const [responseData, setResponseData] = useState(); + + useEffect(() => { + (async () => { + typeof responseData === 'undefined' && setIsFormBusy(true); + + const response = await queryClient.fetchQuery( + [ + '/api/v1/charts/calculated_fields', + dateRange, + startDate, + endDate, + field.field, + field.calculate, + field.period, + currencyId, + ], + () => + request('POST', endpoint('/api/v1/charts/calculated_fields'), { + date_range: dateRange, + start_date: startDate, + end_date: endDate, + field: field.field, + calculation: field.calculate, + period: field.period, + format: field.format, + currency_id: currencyId, + }).then((response) => response.data), + { staleTime: Infinity } + ); + + setResponseData(response); + typeof responseData === 'undefined' && setIsFormBusy(false); + })(); + }, [field]); + + return ( + + {isFormBusy && ( +
+ +
+ )} + + {!isFormBusy && ( +
+ {t(FIELDS_LABELS[field.field])} + + + {field.format === 'money' && field.calculate !== 'count' + ? formatMoney(responseData ?? 0, '', '') + : responseData} + + + + {t( + PERIOD_LABELS[field.period as keyof typeof PERIOD_LABELS] ?? + field.period + )} + +
+ )} +
+ ); +} diff --git a/src/pages/dashboard/components/DashboardCardSelector.tsx b/src/pages/dashboard/components/DashboardCardSelector.tsx new file mode 100644 index 0000000000..18eb257aba --- /dev/null +++ b/src/pages/dashboard/components/DashboardCardSelector.tsx @@ -0,0 +1,316 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +import { endpoint } from '$app/common/helpers'; +import { request } from '$app/common/helpers/request'; +import { toast } from '$app/common/helpers/toast/toast'; +import { useCurrentUser } from '$app/common/hooks/useCurrentUser'; +import { $refetch } from '$app/common/hooks/useRefetch'; +import { + Calculate, + CompanyUser, + DashboardField, + Field, + Format, + Period, +} from '$app/common/interfaces/company-user'; +import { GenericSingleResourceResponse } from '$app/common/interfaces/generic-api-response'; +import { User } from '$app/common/interfaces/user'; +import { Button, SelectField } from '$app/components/forms'; +import { Icon } from '$app/components/icons/Icon'; +import { Modal } from '$app/components/Modal'; +import { cloneDeep, set } from 'lodash'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CgOptions } from 'react-icons/cg'; +import { MdClose } from 'react-icons/md'; +import { useDispatch } from 'react-redux'; +import { updateUser } from '$app/common/stores/slices/user'; +import { PERIOD_LABELS } from './DashboardCard'; +import { v4 } from 'uuid'; + +const FIELDS = [ + 'active_invoices', + 'outstanding_invoices', + 'completed_payments', + 'refunded_payments', + 'active_quotes', + 'unapproved_quotes', + 'logged_tasks', + 'invoiced_tasks', + 'paid_tasks', + 'logged_expenses', + 'pending_expenses', + 'invoiced_expenses', + 'invoice_paid_expenses', +]; + +export const FIELDS_LABELS = { + active_invoices: 'total_active_invoices', + outstanding_invoices: 'total_outstanding_invoices', + completed_payments: 'total_completed_payments', + refunded_payments: 'total_refunded_payments', + active_quotes: 'total_active_quotes', + unapproved_quotes: 'total_unapproved_quotes', + logged_tasks: 'total_logged_tasks', + invoiced_tasks: 'total_invoiced_tasks', + paid_tasks: 'total_paid_tasks', + logged_expenses: 'total_logged_expenses', + pending_expenses: 'total_pending_expenses', + invoiced_expenses: 'total_invoiced_expenses', + invoice_paid_expenses: 'total_invoice_paid_expenses', +}; + +export function DashboardCardSelector() { + const [t] = useTranslation(); + const dispatch = useDispatch(); + + const currentUser = useCurrentUser(); + + const [currentFields, setCurrentFields] = useState([]); + const [currentField, setCurrentField] = useState({ + id: v4(), + field: '' as Field, + period: 'current', + calculate: 'sum', + format: 'money', + }); + + const [isFormBusy, setIsFormBusy] = useState(false); + const [isCardsModalOpen, setIsCardsModalOpen] = useState(false); + const [isFieldsModalOpen, setIsFieldsModalOpen] = useState(false); + + const handleCardsModalClose = () => { + setIsCardsModalOpen(false); + }; + + const handleFieldsModalClose = () => { + setIsFieldsModalOpen(false); + + setCurrentField({ + id: v4(), + field: '' as Field, + period: 'current', + calculate: 'sum', + format: 'money', + }); + }; + + const handleDelete = (fieldKey: string) => { + setCurrentFields((currentFields) => + currentFields.filter((field) => field.id !== fieldKey) + ); + }; + + const handleSaveCards = () => { + const updatedUser = cloneDeep(currentUser) as User; + + if (updatedUser && !isFormBusy) { + toast.processing(); + setIsFormBusy(true); + + set( + updatedUser, + 'company_user.react_settings.dashboard_fields', + currentFields + ); + + request( + 'PUT', + endpoint('/api/v1/company_users/:id', { id: updatedUser.id }), + updatedUser + ) + .then((response: GenericSingleResourceResponse) => { + toast.success('updated_settings'); + + set(updatedUser, 'company_user', response.data.data); + + $refetch(['company_users']); + + dispatch(updateUser(updatedUser)); + + handleCardsModalClose(); + }) + .finally(() => setIsFormBusy(false)); + } + }; + + useEffect(() => { + if (currentUser && Object.keys(currentUser).length && isCardsModalOpen) { + setCurrentFields( + currentUser.company_user?.react_settings.dashboard_fields ?? [] + ); + } + }, [currentUser, isCardsModalOpen]); + + return ( + <> +
setIsCardsModalOpen(true)} + > + +
+ + +
+ {!currentFields.length && ( + + {t('no_records_found')} + + )} + +
+ {currentFields.map((field) => ( +
+ handleDelete(field.id)} + /> + +
+

{t(FIELDS_LABELS[field.field])}

+ +
+ + {t( + PERIOD_LABELS[ + field.period as keyof typeof PERIOD_LABELS + ] ?? field.period + )} + + · + + {t( + field.calculate === 'avg' ? 'average' : field.calculate + )} + +
+
+
+ ))} +
+ + + + +
+
+ + +
+ + setCurrentField((currentField) => ({ + ...currentField, + field: value as Field, + })) + } + withBlank + > + {FIELDS.map((field) => ( + + ))} + + + + setCurrentField((currentField) => ({ + ...currentField, + period: value as Period, + })) + } + > + + + + + + + setCurrentField((currentField) => ({ + ...currentField, + calculate: value as Calculate, + })) + } + > + + + + + + {currentField.field.endsWith('tasks') && ( + + setCurrentField((currentField) => ({ + ...currentField, + format: value as Format, + })) + } + > + + + + )} + + +
+
+ + ); +} diff --git a/src/pages/dashboard/components/ExpiredQuotes.tsx b/src/pages/dashboard/components/ExpiredQuotes.tsx index 94878d9203..c8eea9848d 100644 --- a/src/pages/dashboard/components/ExpiredQuotes.tsx +++ b/src/pages/dashboard/components/ExpiredQuotes.tsx @@ -18,8 +18,14 @@ import dayjs from 'dayjs'; import { Badge } from '$app/components/Badge'; import { useDisableNavigation } from '$app/common/hooks/useDisableNavigation'; import { DynamicLink } from '$app/components/DynamicLink'; +import { ReactNode } from 'react'; -export function ExpiredQuotes() { +interface Props { + topRight?: ReactNode; + isEditMode: boolean; +} + +export function ExpiredQuotes({ topRight, isEditMode }: Props) { const [t] = useTranslation(); const formatMoney = useFormatMoney(); @@ -73,15 +79,24 @@ export function ExpiredQuotes() { return ( -
+
diff --git a/src/pages/dashboard/components/PastDueInvoices.tsx b/src/pages/dashboard/components/PastDueInvoices.tsx index fa7aa368f7..231ac8dfee 100644 --- a/src/pages/dashboard/components/PastDueInvoices.tsx +++ b/src/pages/dashboard/components/PastDueInvoices.tsx @@ -19,8 +19,14 @@ import { useDisableNavigation } from '$app/common/hooks/useDisableNavigation'; import { useCurrentCompanyDateFormats } from '$app/common/hooks/useCurrentCompanyDateFormats'; import { DynamicLink } from '$app/components/DynamicLink'; import { useTranslation } from 'react-i18next'; +import { ReactNode } from 'react'; -export function PastDueInvoices() { +interface Props { + topRight?: ReactNode; + isEditMode: boolean; +} + +export function PastDueInvoices({ topRight, isEditMode }: Props) { const [t] = useTranslation(); const formatMoney = useFormatMoney(); const { dateFormat } = useCurrentCompanyDateFormats(); @@ -80,15 +86,23 @@ export function PastDueInvoices() { return ( -
+
diff --git a/src/pages/dashboard/components/PreferenceCardsGrid.tsx b/src/pages/dashboard/components/PreferenceCardsGrid.tsx new file mode 100644 index 0000000000..7d27b6834d --- /dev/null +++ b/src/pages/dashboard/components/PreferenceCardsGrid.tsx @@ -0,0 +1,471 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +import { + CompanyUser, + DashboardField, +} from '$app/common/interfaces/company-user'; +import classNames from 'classnames'; +import { DashboardCard } from './DashboardCard'; +import ReactGridLayout, { Responsive } from 'react-grid-layout'; +import { WidthProvider } from 'react-grid-layout'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { useReactSettings } from '$app/common/hooks/useReactSettings'; +import collect from 'collect.js'; +import { useDebounce } from 'react-use'; +import { diff } from 'deep-object-diff'; +import { cloneDeep } from 'lodash'; +import { request } from '$app/common/helpers/request'; +import { endpoint } from '$app/common/helpers'; +import { GenericSingleResourceResponse } from '$app/common/interfaces/generic-api-response'; +import { set } from 'lodash'; +import { $refetch } from '$app/common/hooks/useRefetch'; +import { updateUser } from '$app/common/stores/slices/user'; +import { useCurrentUser } from '$app/common/hooks/useCurrentUser'; +import { User } from '$app/common/interfaces/user'; +import { useDispatch } from 'react-redux'; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +interface Props { + currentDashboardFields: DashboardField[]; + dateRange: string; + startDate: string; + endDate: string; + currencyId: string; + layoutBreakpoint: string | undefined; + isEditMode: boolean; + setMainLayouts: Dispatch>; + mainLayouts: ReactGridLayout.Layouts; + isLayoutRestored: boolean; +} + +export function PreferenceCardsGrid(props: Props) { + const { + currentDashboardFields, + dateRange, + startDate, + endDate, + currencyId, + layoutBreakpoint, + isEditMode, + setMainLayouts, + mainLayouts, + isLayoutRestored, + } = props; + + const dispatch = useDispatch(); + + const user = useCurrentUser(); + const reactSettings = useReactSettings(); + + const [layouts, setLayouts] = useState({}); + const [isLayoutsInitialized, setIsLayoutsInitialized] = + useState(false); + const [isLayoutHeightUpdated, setIsLayoutHeightUpdated] = + useState(false); + + const updateLayoutHeight = () => { + if (!layoutBreakpoint) { + return; + } + + const totalCards = currentDashboardFields?.length || 0; + let widthPerScreenSize = 0; + + switch (layoutBreakpoint) { + case 'xxl': + widthPerScreenSize = 150; + break; + case 'xl': + widthPerScreenSize = 200; + break; + case 'lg': + widthPerScreenSize = 250; + break; + case 'md': + widthPerScreenSize = 300; + break; + case 'sm': + widthPerScreenSize = 350; + break; + case 'xs': + widthPerScreenSize = 500; + break; + case 'xxs': + widthPerScreenSize = 1000; + break; + default: + widthPerScreenSize = 200; + } + + setLayouts((prevLayouts) => { + const currentLayoutForBreakpoint = prevLayouts[layoutBreakpoint] || []; + + const nonExistingCards = currentDashboardFields?.filter( + (currentCard) => + !currentLayoutForBreakpoint.some((card) => currentCard.id === card.i) + ); + + if (!nonExistingCards.length) { + return prevLayouts; + } + + const cardsPerRow = Math.floor(1000 / (widthPerScreenSize + 20)); + const rows = Math.ceil(totalCards / cardsPerRow); + const newCards = []; + const cardsToAdd = [...nonExistingCards]; + + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cardsPerRow; j++) { + const card = cardsToAdd.shift(); + + if (card) { + newCards.push({ + i: card.id, + x: j * (widthPerScreenSize + 20), + y: 0, + w: widthPerScreenSize, + h: 7.3, + }); + } + } + } + + return cloneDeep({ + ...prevLayouts, + [layoutBreakpoint]: [...currentLayoutForBreakpoint, ...newCards], + }); + }); + + setTimeout(() => { + setIsLayoutHeightUpdated(true); + }, 250); + }; + + const onDragStart = () => { + if (!layoutBreakpoint) { + return; + } + + setMainLayouts((prevMainLayouts) => { + const currentMainLayout = prevMainLayouts[layoutBreakpoint] || []; + + const preferenceCardBox = currentMainLayout.find( + (card) => card.i === '1' + ); + + if (!preferenceCardBox) { + return prevMainLayouts; + } + + const uniqueYCoordinates = collect(layouts[layoutBreakpoint]) + .pluck('y') + .unique() + .toArray(); + + const isMaximumRows = + uniqueYCoordinates.length === layouts[layoutBreakpoint]?.length; + + return { + ...prevMainLayouts, + [layoutBreakpoint]: [ + ...(currentMainLayout?.filter((card) => card.i !== '1') || []), + { + ...preferenceCardBox, + h: + (currentMainLayout?.find((card) => card.i === '1')?.h || 0) + + (isMaximumRows ? 0 : 9), + }, + ], + }; + }); + }; + + const onDragStop = (currentLayout: ReactGridLayout.Layout[]) => { + const gridElement = document.querySelector('.preference-cards-grid'); + + if (!layoutBreakpoint || !gridElement?.clientHeight) { + return; + } + + setLayouts((prevLayouts) => + cloneDeep({ + ...prevLayouts, + [layoutBreakpoint]: currentLayout, + }) + ); + + const preferenceCardBoxHeight = mainLayouts[layoutBreakpoint]?.find( + (card) => card.i === '1' + )?.h; + + if ( + preferenceCardBoxHeight && + preferenceCardBoxHeight === gridElement?.clientHeight / 21 + ) { + return; + } + + setMainLayouts((prevMainLayouts) => { + const currentMainLayout = prevMainLayouts[layoutBreakpoint] || []; + + const preferenceCardBox = currentMainLayout.find( + (card) => card.i === '1' + ); + + if (!preferenceCardBox) { + return prevMainLayouts; + } + + return { + ...prevMainLayouts, + [layoutBreakpoint]: [ + ...(currentMainLayout?.filter((card) => card.i !== '1') || []), + { + ...preferenceCardBox, + h: gridElement?.clientHeight / 21 + (isEditMode ? 1 : 0), + }, + ], + }; + }); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleOnDrag = ( + layout: ReactGridLayout.Layout[], + oldItem: ReactGridLayout.Layout, + newItem: ReactGridLayout.Layout, + placeholder: ReactGridLayout.Layout + ) => { + const isDraggingDown = newItem.y > placeholder.y; + + if (!isDraggingDown) return; + + const itemsBelow = layout.filter( + (item) => item.y > oldItem.y && item.i !== oldItem.i + ); + + if (itemsBelow.length) { + const closestItem = itemsBelow.reduce((closest, current) => { + const isInSameColumn = Math.abs(current.x - oldItem.x) < 10; + const isCloserVertically = current.y < closest.y; + + return isInSameColumn && isCloserVertically ? current : closest; + }, itemsBelow[0]); + + const isDraggingTallerItem = oldItem.h > closestItem.h * 0.9; + + if (newItem.y > oldItem.h / 3 + oldItem.y && isDraggingTallerItem) { + const oldX = oldItem.x; + const oldY = oldItem.y; + closestItem.x = oldX; + closestItem.y = oldY; + } + } + }; + + const handleUpdateUserPreferences = () => { + const updatedUser = cloneDeep(user) as User; + + set( + updatedUser, + 'company_user.react_settings.preference_cards_configuration', + cloneDeep(layouts) + ); + + request( + 'PUT', + endpoint('/api/v1/company_users/:id', { id: updatedUser.id }), + updatedUser + ).then((response: GenericSingleResourceResponse) => { + set(updatedUser, 'company_user', response.data.data); + + $refetch(['company_users']); + + dispatch(updateUser(updatedUser)); + }); + }; + + useEffect(() => { + if (layoutBreakpoint) { + setTimeout(() => { + updateLayoutHeight(); + }, 25); + } + }, [currentDashboardFields, layoutBreakpoint, isLayoutRestored]); + + useEffect(() => { + if (isLayoutHeightUpdated) { + setMainLayouts((prevMainLayouts) => { + if (!layoutBreakpoint) { + return prevMainLayouts; + } + + const currentMainLayout = prevMainLayouts[layoutBreakpoint] || []; + const gridElement = document.querySelector('.preference-cards-grid'); + + if (!gridElement) { + return prevMainLayouts; + } + + const preferenceCardBox = currentMainLayout.find( + (card) => card.i === '1' + ); + + if (!preferenceCardBox) { + return prevMainLayouts; + } + + return { + ...prevMainLayouts, + [layoutBreakpoint]: [ + ...(currentMainLayout?.filter((card) => card.i !== '1') || []), + preferenceCardBox + ? { + ...preferenceCardBox, + h: gridElement.clientHeight / 21 + (isEditMode ? 1 : 0), + } + : { + i: '1', + x: 0, + y: 1, + w: 1000, + h: gridElement.clientHeight / 21 + (isEditMode ? 1 : 0), + isResizable: false, + }, + ], + }; + }); + + setIsLayoutHeightUpdated(false); + } + }, [isLayoutHeightUpdated]); + + useEffect(() => { + setMainLayouts((prevMainLayouts) => { + if (!layoutBreakpoint) { + return prevMainLayouts; + } + + const currentMainLayout = prevMainLayouts[layoutBreakpoint] || []; + const gridElement = document.querySelector('.preference-cards-grid'); + + if (!gridElement) { + return prevMainLayouts; + } + + const preferenceCardBox = currentMainLayout.find( + (card) => card.i === '1' + ); + + if (!preferenceCardBox) { + return prevMainLayouts; + } + + return { + ...prevMainLayouts, + [layoutBreakpoint]: [ + ...(currentMainLayout?.filter((card) => card.i !== '1') || []), + { + ...preferenceCardBox, + h: gridElement.clientHeight / 21 + (isEditMode ? 1 : 0), + }, + ], + }; + }); + }, [isEditMode]); + + useEffect(() => { + if (layoutBreakpoint) { + if ( + reactSettings?.preference_cards_configuration && + !isLayoutsInitialized + ) { + setLayouts(cloneDeep(reactSettings?.preference_cards_configuration)); + + setIsLayoutsInitialized(true); + } + + setTimeout(() => { + updateLayoutHeight(); + }, 50); + } + }, [layoutBreakpoint, reactSettings]); + + useDebounce( + () => { + if ( + reactSettings && + ((reactSettings.preference_cards_configuration && + Object.keys( + diff(reactSettings.preference_cards_configuration, layouts) + ).length) || + !reactSettings.preference_cards_configuration) + ) { + handleUpdateUserPreferences(); + } + }, + 1500, + [layouts] + ); + + return ( + + {currentDashboardFields.map((field) => ( +
+ +
+ ))} +
+ ); +} diff --git a/src/pages/dashboard/components/RecentPayments.tsx b/src/pages/dashboard/components/RecentPayments.tsx index 054b23b3b2..0a8711d611 100644 --- a/src/pages/dashboard/components/RecentPayments.tsx +++ b/src/pages/dashboard/components/RecentPayments.tsx @@ -20,8 +20,14 @@ import { useCurrentCompanyDateFormats } from '$app/common/hooks/useCurrentCompan import { useDisableNavigation } from '$app/common/hooks/useDisableNavigation'; import { DynamicLink } from '$app/components/DynamicLink'; import { useTranslation } from 'react-i18next'; +import { ReactNode } from 'react'; -export function RecentPayments() { +interface Props { + topRight?: ReactNode; + isEditMode?: boolean; +} + +export function RecentPayments({ topRight, isEditMode }: Props) { const [t] = useTranslation(); const formatMoney = useFormatMoney(); const { dateFormat } = useCurrentCompanyDateFormats(); @@ -94,14 +100,20 @@ export function RecentPayments() { return ( -
+
diff --git a/src/pages/dashboard/components/ResizableDashboardCards.tsx b/src/pages/dashboard/components/ResizableDashboardCards.tsx new file mode 100644 index 0000000000..969fd42072 --- /dev/null +++ b/src/pages/dashboard/components/ResizableDashboardCards.tsx @@ -0,0 +1,2016 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; +import '$app/resources/css/gridLayout.css'; +import { Button, SelectField } from '$app/components/forms'; +import { endpoint } from '$app/common/helpers'; +import { useEffect, useState } from 'react'; +import { Spinner } from '$app/components/Spinner'; +import { DropdownDateRangePicker } from '../../../components/DropdownDateRangePicker'; +import { Card } from '$app/components/cards'; +import { useTranslation } from 'react-i18next'; +import { request } from '$app/common/helpers/request'; +import { useFormatMoney } from '$app/common/hooks/money/useFormatMoney'; +import { useCurrentCompany } from '$app/common/hooks/useCurrentCompany'; +import { useCurrentUser } from '$app/common/hooks/useCurrentUser'; +import { Badge } from '$app/components/Badge'; +import { + ChartsDefaultView, + useReactSettings, +} from '$app/common/hooks/useReactSettings'; +import { usePreferences } from '$app/common/hooks/usePreferences'; +import collect from 'collect.js'; +import { useColorScheme } from '$app/common/colors'; +import { CurrencySelector } from '$app/components/CurrencySelector'; +import { useQuery } from 'react-query'; +import { DashboardCardSelector } from './DashboardCardSelector'; +import GridLayout, { Responsive, WidthProvider } from 'react-grid-layout'; +import { Icon } from '$app/components/icons/Icon'; +import { BiMove } from 'react-icons/bi'; +import classNames from 'classnames'; +import { ModuleBitmask } from '$app/pages/settings'; +import { UpcomingQuotes } from './UpcomingQuotes'; +import { UpcomingRecurringInvoices } from './UpcomingRecurringInvoices'; +import { ExpiredQuotes } from './ExpiredQuotes'; +import { PastDueInvoices } from './PastDueInvoices'; +import { UpcomingInvoices } from './UpcomingInvoices'; +import { Activity } from './Activity'; +import { RecentPayments } from './RecentPayments'; +import { useEnabled } from '$app/common/guards/guards/enabled'; +import dayjs from 'dayjs'; +import { useDebounce } from 'react-use'; +import { diff } from 'deep-object-diff'; +import { User } from '$app/common/interfaces/user'; +import { cloneDeep, isEqual, set } from 'lodash'; +import { GenericSingleResourceResponse } from '$app/common/interfaces/generic-api-response'; +import { + CompanyUser, + DashboardField, +} from '$app/common/interfaces/company-user'; +import { $refetch } from '$app/common/hooks/useRefetch'; +import { updateUser } from '$app/common/stores/slices/user'; +import { useDispatch } from 'react-redux'; +import { toast } from '$app/common/helpers/toast/toast'; +import { RestoreCardsModal } from './RestoreCardsModal'; +import { RestoreLayoutAction } from './RestoreLayoutAction'; +import { Chart } from './Chart'; +import { PreferenceCardsGrid } from './PreferenceCardsGrid'; +import { MdDragHandle } from 'react-icons/md'; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +interface TotalsRecord { + revenue: { paid_to_date: string; code: string }; + expenses: { amount: string; code: string }; + invoices: { invoiced_amount: string; code: string; date: string }; + outstanding: { outstanding_count: number; amount: string; code: string }; +} + +interface Currency { + value: string; + label: string; +} + +type CardName = + | 'account_login_text' + | 'upcoming_invoices' + | 'upcoming_recurring_invoices' + | 'upcoming_quotes' + | 'expired_quotes' + | 'past_due_invoices' + | 'activity' + | 'recent_payments' + | 'overview'; + +export type DashboardGridLayouts = GridLayout.Layouts; + +export interface ChartData { + invoices: { + total: string; + date: string; + currency: string; + }[]; + payments: { + total: string; + date: string; + currency: string; + }[]; + outstanding: { + total: string; + date: string; + currency: string; + }[]; + expenses: { + total: string; + date: string; + currency: string; + }[]; +} + +export enum TotalColors { + Green = '#54B434', + Blue = '#2596BE', + Red = '#BE4D25', + Gray = '#242930', +} + +const GLOBAL_DATE_RANGES: Record = { + last7_days: { + start: dayjs().subtract(7, 'days').format('YYYY-MM-DD'), + end: dayjs().format('YYYY-MM-DD'), + }, + last30_days: { + start: dayjs().subtract(1, 'month').format('YYYY-MM-DD'), + end: dayjs().format('YYYY-MM-DD'), + }, + last365_days: { + start: dayjs().subtract(365, 'days').format('YYYY-MM-DD'), + end: dayjs().format('YYYY-MM-DD'), + }, + this_month: { + start: dayjs().startOf('month').format('YYYY-MM-DD'), + end: dayjs().endOf('month').format('YYYY-MM-DD'), + }, + last_month: { + start: dayjs().startOf('month').subtract(1, 'month').format('YYYY-MM-DD'), + end: dayjs().subtract(1, 'month').endOf('month').format('YYYY-MM-DD'), + }, + this_quarter: { + start: dayjs().startOf('quarter').format('YYYY-MM-DD'), + end: dayjs().endOf('quarter').format('YYYY-MM-DD'), + }, + last_quarter: { + start: dayjs() + .subtract(1, 'quarter') + .startOf('quarter') + .format('YYYY-MM-DD'), + end: dayjs().subtract(1, 'quarter').endOf('quarter').format('YYYY-MM-DD'), + }, + this_year: { + start: dayjs().startOf('year').format('YYYY-MM-DD'), + end: dayjs().format('YYYY-MM-DD'), + }, + last_year: { + start: dayjs().subtract(1, 'year').startOf('year').format('YYYY-MM-DD'), + end: dayjs().subtract(1, 'year').endOf('year').format('YYYY-MM-DD'), + }, +}; + +export const initialLayouts = { + xxl: [ + { + i: '0', + x: 300, + y: 0, + w: 1000, + h: 2.8, + isResizable: false, + static: true, + }, + { + i: '1', + x: 0, + y: 1, + w: 1000, + h: 6.3, + isResizable: false, + }, + { + i: '2', + x: 0, + y: 2, + w: 330, + h: 25.4, + minH: 20, + minW: 250, + maxH: 30, + maxW: 400, + }, + { + i: '3', + x: 400, + y: 2, + w: 660, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '4', + x: 0, + y: 3, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '5', + x: 510, + y: 3, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '6', + x: 0, + y: 4, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '7', + x: 510, + y: 4, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '8', + x: 0, + y: 5, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '9', + x: 510, + y: 5, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '10', + x: 0, + y: 6, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + ], + xl: [ + { + i: '0', + x: 300, + y: 0, + w: 1000, + h: 2.8, + isResizable: false, + static: true, + }, + { + i: '1', + x: 0, + y: 1, + w: 1000, + h: 6.3, + isResizable: false, + }, + { + i: '2', + x: 0, + y: 2, + w: 330, + h: 25.4, + minH: 20, + minW: 250, + maxH: 30, + maxW: 400, + }, + { + i: '3', + x: 400, + y: 2, + w: 660, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '4', + x: 0, + y: 3, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '5', + x: 510, + y: 3, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '6', + x: 0, + y: 4, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '7', + x: 510, + y: 4, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '8', + x: 0, + y: 5, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '9', + x: 510, + y: 5, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '10', + x: 0, + y: 6, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + ], + lg: [ + { + i: '0', + x: 300, + y: 0, + w: 1000, + h: 2.8, + isResizable: false, + static: true, + }, + { + i: '1', + x: 0, + y: 1, + w: 1000, + h: 7.3, + isResizable: false, + }, + { + i: '2', + x: 0, + y: 2, + w: 330, + h: 25.4, + minH: 20, + minW: 250, + maxH: 30, + maxW: 400, + }, + { + i: '3', + x: 400, + y: 2, + w: 660, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '4', + x: 0, + y: 3, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '5', + x: 510, + y: 3, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '6', + x: 0, + y: 4, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '7', + x: 510, + y: 4, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '8', + x: 0, + y: 5, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '9', + x: 510, + y: 5, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + { + i: '10', + x: 0, + y: 6, + w: 495, + h: 20, + minH: 16, + minW: 350, + maxH: 30, + maxW: 700, + }, + ], + md: [ + { + i: '0', + x: 300, + y: 0, + w: 1000, + h: 2.8, + isResizable: false, + static: true, + }, + { + i: '1', + x: 0, + y: 1, + w: 1000, + h: 6.3, + isResizable: false, + }, + { + i: '2', + x: 0, + y: 1, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '3', + x: 0, + y: 2, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '4', + x: 0, + y: 3, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '5', + x: 0, + y: 4, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '6', + x: 0, + y: 5, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '7', + x: 0, + y: 6, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '8', + x: 0, + y: 7, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '9', + x: 0, + y: 8, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '10', + x: 0, + y: 9, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + ], + sm: [ + { + i: '0', + x: 300, + y: 0, + w: 1000, + h: 2.8, + isResizable: false, + static: true, + }, + { + i: '1', + x: 0, + y: 1, + w: 1000, + h: 6.3, + isResizable: false, + }, + { + i: '2', + x: 0, + y: 1, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '3', + x: 0, + y: 2, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '4', + x: 0, + y: 3, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '5', + x: 0, + y: 4, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '6', + x: 0, + y: 5, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '7', + x: 0, + y: 6, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '8', + x: 0, + y: 7, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '9', + x: 0, + y: 8, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '10', + x: 0, + y: 9, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + ], + xs: [ + { + i: '0', + x: 300, + y: 0, + w: 1000, + h: 2.8, + isResizable: false, + static: true, + }, + { + i: '1', + x: 0, + y: 1, + w: 1000, + h: 6.3, + isResizable: false, + }, + { + i: '2', + x: 0, + y: 1, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '3', + x: 0, + y: 2, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '4', + x: 0, + y: 3, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '5', + x: 0, + y: 4, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '6', + x: 0, + y: 5, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '7', + x: 0, + y: 6, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '8', + x: 0, + y: 7, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '9', + x: 0, + y: 8, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '10', + x: 0, + y: 9, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + ], + xxs: [ + { + i: '0', + x: 300, + y: 0, + w: 1000, + h: 2.8, + isResizable: false, + static: true, + }, + { + i: '1', + x: 0, + y: 1, + w: 1000, + h: 6.3, + isResizable: false, + }, + { + i: '2', + x: 0, + y: 1, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '3', + x: 0, + y: 2, + w: 1000, + h: 25.4, + minH: 20, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '4', + x: 0, + y: 3, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '5', + x: 0, + y: 4, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '6', + x: 0, + y: 5, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '7', + x: 0, + y: 6, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '8', + x: 0, + y: 7, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '9', + x: 0, + y: 8, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + { + i: '10', + x: 0, + y: 9, + w: 1000, + h: 20, + minH: 16, + minW: 400, + maxH: 30, + maxW: 1000, + }, + ], +}; + +export function ResizableDashboardCards() { + const [t] = useTranslation(); + + const { Preferences, update } = usePreferences(); + + const enabled = useEnabled(); + const dispatch = useDispatch(); + const formatMoney = useFormatMoney(); + + const user = useCurrentUser(); + const colors = useColorScheme(); + const company = useCurrentCompany(); + const settings = useReactSettings(); + + const [chartData, setChartData] = useState([]); + const [currencies, setCurrencies] = useState([]); + const [totalsData, setTotalsData] = useState([]); + + const [layoutBreakpoint, setLayoutBreakpoint] = useState(); + const [layouts, setLayouts] = useState(initialLayouts); + + const [isEditMode, setIsEditMode] = useState(false); + const [isLayoutsInitialized, setIsLayoutsInitialized] = + useState(false); + const [isLayoutRestored, setIsLayoutRestored] = useState(false); + const [areCardsRestored, setAreCardsRestored] = useState(false); + const [arePreferenceCardsChanged, setArePreferenceCardsChanged] = + useState(false); + const [currentDashboardFields, setCurrentDashboardFields] = useState< + DashboardField[] + >([]); + + const chartScale = + settings?.preferences?.dashboard_charts?.default_view || 'month'; + const currency = settings?.preferences?.dashboard_charts?.currency || 1; + const dateRange = + settings?.preferences?.dashboard_charts?.range || 'this_month'; + + const [dates, setDates] = useState<{ start_date: string; end_date: string }>({ + start_date: GLOBAL_DATE_RANGES[dateRange]?.start || '', + end_date: GLOBAL_DATE_RANGES[dateRange]?.end || '', + }); + + const [body, setBody] = useState<{ + start_date: string; + end_date: string; + date_range: string; + }>({ + start_date: GLOBAL_DATE_RANGES[dateRange]?.start || '', + end_date: GLOBAL_DATE_RANGES[dateRange]?.end || '', + date_range: dateRange, + }); + + const handleDateChange = (DateSet: string) => { + const [startDate, endDate] = DateSet.split(','); + if (new Date(startDate) > new Date(endDate)) { + setBody({ + start_date: endDate, + end_date: startDate, + date_range: 'custom', + }); + } else { + setBody({ + start_date: startDate, + end_date: endDate, + date_range: 'custom', + }); + } + }; + + const totals = useQuery({ + queryKey: ['/api/v1/charts/totals_v2', body], + queryFn: () => + request('POST', endpoint('/api/v1/charts/totals_v2'), body).then( + (response) => response.data + ), + staleTime: Infinity, + }); + + const chart = useQuery({ + queryKey: ['/api/v1/charts/chart_summary_v2', body], + queryFn: () => + request('POST', endpoint('/api/v1/charts/chart_summary_v2'), body).then( + (response) => response.data + ), + staleTime: Infinity, + }); + + const onResizeStop = ( + layout: GridLayout.Layout[], + oldItem: GridLayout.Layout, + newItem: GridLayout.Layout + ) => { + if (layoutBreakpoint) { + setLayouts((current) => ({ + ...current, + [layoutBreakpoint]: layout.map((item) => ({ + ...item, + h: + item.y === newItem.y && + (initialLayouts[ + layoutBreakpoint as keyof typeof initialLayouts + ].find((initial) => initial.i === item.i)?.minH ?? 0) <= newItem.h + ? newItem.h + : item.h, + })), + })); + } + }; + + const onDragStop = (layout: GridLayout.Layout[]) => { + if (!layoutBreakpoint) return; + + const updatedLayout = cloneDeep(layout); + + const rowGroups: Record = updatedLayout.reduce( + (groups, item) => { + const existingGroup = Object.keys(groups).find( + (y) => Math.abs(Number(y) - item.y) < 10 + ); + + if (existingGroup) { + groups[existingGroup].push(item); + } else { + groups[item.y] = [item]; + } + return groups; + }, + {} as Record + ); + + Object.values(rowGroups).forEach((items) => { + const maxHeight = Math.max(...items.map((item) => item.h)); + items.forEach( + (item) => + (item.h = + item.i.length < 5 && item.i !== '0' && item.i !== '1' + ? maxHeight + : item.h) + ); + }); + + setLayouts((current) => ({ + ...current, + [layoutBreakpoint]: cloneDeep(updatedLayout), + })); + }; + + const handleUpdateUserPreferences = () => { + const updatedUser = cloneDeep(user) as User; + + set( + updatedUser, + 'company_user.react_settings.dashboard_cards_configuration', + cloneDeep(layouts) + ); + + // delete updatedUser.company_user?.settings?.dashboard_fields; + + // delete updatedUser.company_user?.react_settings + // ?.preference_cards_configuration; + + // delete updatedUser.company_user.react_settings + // .dashboard_cards_configuration; + + // delete updatedUser.company_user.react_settings.removed_dashboard_cards; + + request( + 'PUT', + endpoint('/api/v1/company_users/:id', { id: updatedUser.id }), + updatedUser + ).then((response: GenericSingleResourceResponse) => { + set(updatedUser, 'company_user', response.data.data); + + $refetch(['company_users']); + + dispatch(updateUser(updatedUser)); + }); + }; + + const handleRemoveCard = (cardName: CardName) => { + toast.processing(); + + const updatedUser = cloneDeep(user) as User; + + const removedCards = + settings?.removed_dashboard_cards?.[layoutBreakpoint || ''] || []; + + set( + updatedUser, + `company_user.react_settings.removed_dashboard_cards.${layoutBreakpoint}`, + [...removedCards, cardName] + ); + + request( + 'PUT', + endpoint('/api/v1/company_users/:id', { id: updatedUser.id }), + updatedUser + ).then((response: GenericSingleResourceResponse) => { + set(updatedUser, 'company_user', response.data.data); + + toast.success('removed'); + + $refetch(['company_users']); + + dispatch(updateUser(updatedUser)); + }); + }; + + const isCardRemoved = (cardName: CardName) => { + if (!layoutBreakpoint) return false; + + return settings?.removed_dashboard_cards?.[ + layoutBreakpoint || '' + ]?.includes(cardName); + }; + + const handleOnLayoutChange = (currentLayout: GridLayout.Layout[]) => { + if (layoutBreakpoint) { + let isAnyRestored = false; + + setLayouts((currentLayouts) => ({ + ...currentLayouts, + [layoutBreakpoint]: currentLayout.map((layoutCard) => { + if (layoutCard.h === 1 && layoutCard.w === 1) { + const initialCardLayout = initialLayouts[ + layoutBreakpoint as keyof typeof initialLayouts + ]?.find((initial) => initial.i === layoutCard.i); + + if (initialCardLayout) { + isAnyRestored = true; + } + + return initialCardLayout + ? { + ...initialCardLayout, + y: initialCardLayout.i === '1' ? 1 : Infinity, + } + : layoutCard; + } + + return layoutCard; + }), + })); + + arePreferenceCardsChanged && setArePreferenceCardsChanged(false); + + if (!arePreferenceCardsChanged) { + setTimeout(() => { + if (isAnyRestored) { + setAreCardsRestored(false); + + window.scrollTo({ + top: + document.querySelector('.responsive-grid-box')?.scrollHeight || + 0, + behavior: 'smooth', + }); + } + }, 450); + } + } + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleScroll = (isDraggingDown: boolean) => { + const scrollAmount = 15; + + const containerRect = document + .querySelector('.responsive-grid-box') + ?.getBoundingClientRect(); + if (!containerRect) return; + + if (isDraggingDown) { + window.scrollBy({ + behavior: 'smooth', + top: scrollAmount, + }); + } else { + window.scrollBy({ + behavior: 'smooth', + top: -scrollAmount, + }); + } + }; + + const handleOnDrag = ( + layout: GridLayout.Layout[], + oldItem: GridLayout.Layout, + newItem: GridLayout.Layout, + placeholder: GridLayout.Layout + ) => { + const isDraggingDown = newItem.y > placeholder.y; + + //handleScroll(isDraggingDown); + + if (newItem.i.length > 5) return; + + if (!isDraggingDown) return; + + const itemsBelow = layout.filter( + (item) => item.y > oldItem.y && item.i !== oldItem.i + ); + + if (itemsBelow.length) { + const closestItem = itemsBelow.reduce((closest, current) => { + const isInSameColumn = Math.abs(current.x - oldItem.x) < 10; + const isCloserVertically = current.y < closest.y; + + return isInSameColumn && isCloserVertically ? current : closest; + }, itemsBelow[0]); + + const isDraggingTallerItem = oldItem.h > closestItem.h * 0.9; + + if (newItem.y > oldItem.h / 1.2 + oldItem.y && isDraggingTallerItem) { + const oldX = oldItem.x; + const oldY = oldItem.y; + closestItem.x = oldX; + closestItem.y = oldY; + } + } + }; + + useEffect(() => { + setBody((current) => ({ + ...current, + date_range: dateRange, + })); + }, [settings?.preferences?.dashboard_charts?.range]); + + useEffect(() => { + setArePreferenceCardsChanged(true); + }, [currentDashboardFields]); + + useEffect(() => { + if ( + user?.company_user?.react_settings?.dashboard_fields && + !isEqual( + user?.company_user?.react_settings?.dashboard_fields, + currentDashboardFields + ) + ) { + setCurrentDashboardFields( + cloneDeep(user?.company_user?.react_settings?.dashboard_fields) + ); + } + }, [user?.company_user?.react_settings?.dashboard_fields]); + + useEffect(() => { + if (totals.data) { + setTotalsData(totals.data); + + const currencies: Currency[] = []; + + Object.entries(totals.data.currencies).map(([id, name]) => { + currencies.push({ value: id, label: name as unknown as string }); + }); + + const $currencies = collect(currencies) + .pluck('value') + .map((value) => parseInt(value as string)) + .toArray() as number[]; + + if (!$currencies.includes(currency)) { + update('preferences.dashboard_charts.currency', $currencies[0]); + } + + setCurrencies(currencies); + } + }, [totals.data]); + + useEffect(() => { + if (chart.data) { + setDates({ + start_date: chart.data.start_date, + end_date: chart.data.end_date, + }); + + setChartData(chart.data); + } + }, [chart.data]); + + useEffect(() => { + if (layoutBreakpoint) { + if (settings?.dashboard_cards_configuration && !isLayoutsInitialized) { + setLayouts(cloneDeep(settings?.dashboard_cards_configuration)); + + setIsLayoutsInitialized(true); + } + } + }, [layoutBreakpoint]); + + useDebounce( + () => { + if ( + settings && + !settings.dashboard_cards_configuration && + Object.keys(diff(initialLayouts, layouts)).length + ) { + handleUpdateUserPreferences(); + } + + if ( + settings && + settings.dashboard_cards_configuration && + Object.keys(diff(settings.dashboard_cards_configuration, layouts)) + .length + ) { + handleUpdateUserPreferences(); + } + }, + 1500, + [layouts] + ); + + return ( +
+ {!totals.isLoading ? ( + + setLayoutBreakpoint(currentBreakPoint) + } + onResizeStop={onResizeStop} + onDragStop={onDragStop} + onLayoutChange={(current) => + (areCardsRestored || arePreferenceCardsChanged) && + handleOnLayoutChange(current) + } + //resizeHandles={['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne']} + resizeHandles={['se']} + draggableCancel=".cancelDraggingCards" + onDrag={handleOnDrag} + > + {(totals.isLoading || !isLayoutsInitialized) && ( +
+ +
+ )} + + {/* Quick date, currency & date picker. */} + +
+
+ {currencies && ( + + update( + 'preferences.dashboard_charts.currency', + parseInt(value) + ) + } + > + + + {currencies.map((currency, index) => ( + + ))} + + )} + +
+ + + + + +
+ + + update('preferences.dashboard_charts.range', value) + } + value={body.date_range} + /> + + + + + + update('preferences.dashboard_charts.currency', parseInt(v)) + } + /> + + + update( + 'preferences.dashboard_charts.default_view', + value as ChartsDefaultView + ) + } + > + + + + + + + update('preferences.dashboard_charts.range', value) + } + > + + + + + + + + + + + + +
setIsEditMode((current) => !current)} + > + +
+ + {isEditMode && ( + <> + + + + + )} +
+
+ + {currentDashboardFields?.length ? ( +
+
+ +
+ + + +
+ +
+
+ ) : null} + + {company && !isCardRemoved('account_login_text') ? ( +
+ handleRemoveCard('account_login_text')} + > + {t('remove')} + + ) + } + > +
+
+ {`${user?.first_name} ${user?.last_name}`} + + + {t('recent_transactions')} + +
+ +
+
+ + {t('invoices')} + + + +
+ {formatMoney( + totalsData[currency]?.invoices?.invoiced_amount || + 0, + company.settings.country_id, + currency.toString(), + 2 + )} +
+
+
+ +
+ + {t('payments')} + + +
+ {formatMoney( + totalsData[currency]?.revenue?.paid_to_date || 0, + company.settings.country_id, + currency.toString(), + 2 + )} +
+
+
+ +
+ + {t('expenses')} + + +
+ {formatMoney( + totalsData[currency]?.expenses?.amount || 0, + company.settings.country_id, + currency.toString(), + 2 + )} +
+
+
+ +
+ + {t('outstanding')} + + +
+ {formatMoney( + totalsData[currency]?.outstanding?.amount || 0, + company.settings.country_id, + currency.toString(), + 2 + )} +
+
+
+ +
+ + {t('total_invoices_outstanding')} + + + +
+ {totalsData[currency]?.outstanding + ?.outstanding_count || 0} +
+
+
+
+
+
+
+ ) : null} + + {chartData && !isCardRemoved('overview') ? ( +
+ handleRemoveCard('overview')} + > + {t('remove')} + + ) + } + renderFromShadcn + > + + +
+ ) : null} + + {!isCardRemoved('activity') ? ( +
+ handleRemoveCard('activity')} + > + {t('remove')} + + ) + } + /> +
+ ) : null} + + {!isCardRemoved('recent_payments') ? ( +
+ handleRemoveCard('recent_payments')} + > + {t('remove')} + + ) + } + /> +
+ ) : null} + + {enabled(ModuleBitmask.Invoices) && + !isCardRemoved('upcoming_invoices') ? ( +
+ handleRemoveCard('upcoming_invoices')} + > + {t('remove')} + + ) + } + /> +
+ ) : null} + + {enabled(ModuleBitmask.Invoices) && + !isCardRemoved('past_due_invoices') ? ( +
+ handleRemoveCard('past_due_invoices')} + > + {t('remove')} + + ) + } + /> +
+ ) : null} + + {enabled(ModuleBitmask.Quotes) && !isCardRemoved('expired_quotes') ? ( +
+ handleRemoveCard('expired_quotes')} + > + {t('remove')} + + ) + } + /> +
+ ) : null} + + {enabled(ModuleBitmask.Quotes) && + !isCardRemoved('upcoming_quotes') ? ( +
+ handleRemoveCard('upcoming_quotes')} + > + {t('remove')} + + ) + } + /> +
+ ) : null} + + {enabled(ModuleBitmask.RecurringInvoices) && + !isCardRemoved('upcoming_recurring_invoices') ? ( +
+ + handleRemoveCard('upcoming_recurring_invoices') + } + > + {t('remove')} + + ) + } + /> +
+ ) : null} +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/pages/dashboard/components/RestoreCardsModal.tsx b/src/pages/dashboard/components/RestoreCardsModal.tsx new file mode 100644 index 0000000000..f5450f0973 --- /dev/null +++ b/src/pages/dashboard/components/RestoreCardsModal.tsx @@ -0,0 +1,181 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +import { useColorScheme } from '$app/common/colors'; +import { toast } from '$app/common/helpers/toast/toast'; +import { useCurrentUser } from '$app/common/hooks/useCurrentUser'; +import { useReactSettings } from '$app/common/hooks/useReactSettings'; +import { User } from '$app/common/interfaces/user'; +import { set } from 'lodash'; +import { Button } from '$app/components/forms'; +import { Icon } from '$app/components/icons/Icon'; +import { Modal } from '$app/components/Modal'; +import { cloneDeep } from 'lodash'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MdRefresh, MdRestorePage } from 'react-icons/md'; +import styled from 'styled-components'; +import { request } from '$app/common/helpers/request'; +import { endpoint } from '$app/common/helpers'; +import { GenericSingleResourceResponse } from '$app/common/interfaces/generic-api-response'; +import { CompanyUser } from '$app/common/interfaces/company-user'; +import { $refetch } from '$app/common/hooks/useRefetch'; +import { useDispatch } from 'react-redux'; +import { updateUser } from '$app/common/stores/slices/user'; +import { DashboardGridLayouts } from './ResizableDashboardCards'; + +const StyledDiv = styled.div` + &:hover { + &:hover { + background-color: ${(props) => props.theme.hoverBgColor}; + } + } +`; + +interface Props { + layoutBreakpoint: string | undefined; + setLayouts: Dispatch>; + setAreCardsRestored: Dispatch>; +} + +export function RestoreCardsModal(props: Props) { + const [t] = useTranslation(); + + const { layoutBreakpoint, setAreCardsRestored } = props; + + const dispatch = useDispatch(); + + const user = useCurrentUser(); + const colors = useColorScheme(); + const settings = useReactSettings(); + + const [isFormBusy, setIsFormBusy] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [currentCards, setCurrentCards] = useState(); + + const handleOnClose = () => { + setIsModalOpen(false); + setCurrentCards(undefined); + }; + + const handleRestoreCards = () => { + if (!isFormBusy && currentCards) { + toast.processing(); + setIsFormBusy(true); + + const updatedUser = cloneDeep(user) as User; + + set( + updatedUser, + `company_user.react_settings.removed_dashboard_cards.${layoutBreakpoint}`, + [...currentCards] + ); + + request( + 'PUT', + endpoint('/api/v1/company_users/:id', { id: updatedUser.id }), + updatedUser + ) + .then((response: GenericSingleResourceResponse) => { + setAreCardsRestored(true); + + setTimeout(() => { + set(updatedUser, 'company_user', response.data.data); + + toast.success('restored'); + + $refetch(['company_users']); + + dispatch(updateUser(updatedUser)); + + props.setLayouts( + cloneDeep( + response.data.data.react_settings + .dashboard_cards_configuration as DashboardGridLayouts + ) + ); + + handleOnClose(); + }, 50); + }) + .finally(() => setIsFormBusy(false)); + } + }; + + useEffect(() => { + if ( + settings?.removed_dashboard_cards && + isModalOpen && + !currentCards && + layoutBreakpoint + ) { + setCurrentCards(settings?.removed_dashboard_cards[layoutBreakpoint]); + } + }, [settings?.removed_dashboard_cards, isModalOpen, layoutBreakpoint]); + + return ( + <> + handleOnClose()} + disableClosing={isFormBusy} + > + {Boolean(!currentCards?.length) && ( + {t('no_records_found')} + )} + + {Boolean(currentCards?.length) && ( +
+ {currentCards?.map((card) => ( + + setCurrentCards((current) => + current?.filter((c) => c !== card) + ) + } + > + {t(card)} + +
+ +
+
+ ))} +
+ )} + + +
+ +
setIsModalOpen(true)} + > + +
+ + ); +} diff --git a/src/pages/dashboard/components/RestoreLayoutAction.tsx b/src/pages/dashboard/components/RestoreLayoutAction.tsx new file mode 100644 index 0000000000..c87c12e780 --- /dev/null +++ b/src/pages/dashboard/components/RestoreLayoutAction.tsx @@ -0,0 +1,78 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +import { Dispatch, useState } from 'react'; +import { initialLayouts } from './ResizableDashboardCards'; +import { MdRefresh } from 'react-icons/md'; +import { DashboardGridLayouts } from './ResizableDashboardCards'; +import { SetStateAction } from 'react'; +import { Icon } from '$app/components/icons/Icon'; +import { + ConfirmActionModal, + confirmActionModalAtom, +} from '$app/pages/recurring-invoices/common/components/ConfirmActionModal'; +import { useSetAtom } from 'jotai'; +import { cloneDeep } from 'lodash'; + +interface Props { + layoutBreakpoint: string | undefined; + setLayouts: Dispatch>; + setIsLayoutRestored: Dispatch>; +} + +export function RestoreLayoutAction(props: Props) { + const { layoutBreakpoint, setLayouts, setIsLayoutRestored } = props; + + const setIsModalVisible = useSetAtom(confirmActionModalAtom); + + const [isRestoring, setIsRestoring] = useState(false); + + if (!layoutBreakpoint) { + return null; + } + + return ( + <> + { + if (!layoutBreakpoint) { + return; + } + + setIsLayoutRestored(true); + + setLayouts((currentLayouts) => + cloneDeep({ + ...currentLayouts, + [layoutBreakpoint]: + initialLayouts[layoutBreakpoint as keyof typeof initialLayouts], + }) + ); + + setIsRestoring(true); + + setTimeout(() => { + setIsRestoring(false); + setIsModalVisible(false); + setIsLayoutRestored(false); + }, 300); + }} + disabledButton={isRestoring} + /> + +
setIsModalVisible(true)} + > + +
+ + ); +} diff --git a/src/pages/dashboard/components/Totals.tsx b/src/pages/dashboard/components/Totals.tsx deleted file mode 100644 index 9dc0e3fe7e..0000000000 --- a/src/pages/dashboard/components/Totals.tsx +++ /dev/null @@ -1,467 +0,0 @@ -/** - * Invoice Ninja (https://invoiceninja.com). - * - * @link https://github.com/invoiceninja/invoiceninja source repository - * - * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) - * - * @license https://www.elastic.co/licensing/elastic-license - */ - -import { Button, SelectField } from '$app/components/forms'; -import { endpoint } from '$app/common/helpers'; -import { Chart } from '$app/pages/dashboard/components/Chart'; -import { useEffect, useState } from 'react'; -import { Spinner } from '$app/components/Spinner'; -import { DropdownDateRangePicker } from '../../../components/DropdownDateRangePicker'; -import { Card } from '$app/components/cards'; -import { useTranslation } from 'react-i18next'; -import { request } from '$app/common/helpers/request'; -import { useFormatMoney } from '$app/common/hooks/money/useFormatMoney'; -import { useCurrentCompany } from '$app/common/hooks/useCurrentCompany'; -import { useCurrentUser } from '$app/common/hooks/useCurrentUser'; -import { Badge } from '$app/components/Badge'; -import { - ChartsDefaultView, - useReactSettings, -} from '$app/common/hooks/useReactSettings'; -import { usePreferences } from '$app/common/hooks/usePreferences'; -import collect from 'collect.js'; -import { useColorScheme } from '$app/common/colors'; -import { CurrencySelector } from '$app/components/CurrencySelector'; -import { useQuery } from 'react-query'; -import dayjs from 'dayjs'; - -interface TotalsRecord { - revenue: { paid_to_date: string; code: string }; - expenses: { amount: string; code: string }; - invoices: { invoiced_amount: string; code: string; date: string }; - outstanding: { outstanding_count: number; amount: string; code: string }; -} - -interface Currency { - value: string; - label: string; -} - -export interface ChartData { - invoices: { - total: string; - date: string; - currency: string; - }[]; - payments: { - total: string; - date: string; - currency: string; - }[]; - outstanding: { - total: string; - date: string; - currency: string; - }[]; - expenses: { - total: string; - date: string; - currency: string; - }[]; -} - -export enum TotalColors { - Green = '#54B434', - Blue = '#2596BE', - Red = '#BE4D25', - Gray = '#242930', -} - -const GLOBAL_DATE_RANGES: Record = { - last7_days: { - start: dayjs().subtract(7, 'days').format('YYYY-MM-DD'), - end: dayjs().format('YYYY-MM-DD'), - }, - last30_days: { - start: dayjs().subtract(1, 'month').format('YYYY-MM-DD'), - end: dayjs().format('YYYY-MM-DD'), - }, - last365_days: { - start: dayjs().subtract(365, 'days').format('YYYY-MM-DD'), - end: dayjs().format('YYYY-MM-DD'), - }, - this_month: { - start: dayjs().startOf('month').format('YYYY-MM-DD'), - end: dayjs().endOf('month').format('YYYY-MM-DD'), - }, - last_month: { - start: dayjs().startOf('month').subtract(1, 'month').format('YYYY-MM-DD'), - end: dayjs().subtract(1, 'month').endOf('month').format('YYYY-MM-DD'), - }, - this_quarter: { - start: dayjs().startOf('quarter').format('YYYY-MM-DD'), - end: dayjs().endOf('quarter').format('YYYY-MM-DD'), - }, - last_quarter: { - start: dayjs() - .subtract(1, 'quarter') - .startOf('quarter') - .format('YYYY-MM-DD'), - end: dayjs().subtract(1, 'quarter').endOf('quarter').format('YYYY-MM-DD'), - }, - this_year: { - start: dayjs().startOf('year').format('YYYY-MM-DD'), - end: dayjs().format('YYYY-MM-DD'), - }, - last_year: { - start: dayjs().subtract(1, 'year').startOf('year').format('YYYY-MM-DD'), - end: dayjs().subtract(1, 'year').endOf('year').format('YYYY-MM-DD'), - }, -}; - -export function Totals() { - const [t] = useTranslation(); - - const settings = useReactSettings(); - - const { Preferences, update } = usePreferences(); - - const formatMoney = useFormatMoney(); - - const user = useCurrentUser(); - const colors = useColorScheme(); - const company = useCurrentCompany(); - - const [chartData, setChartData] = useState([]); - const [currencies, setCurrencies] = useState([]); - const [totalsData, setTotalsData] = useState([]); - - const chartScale = - settings?.preferences?.dashboard_charts?.default_view || 'month'; - const currency = settings?.preferences?.dashboard_charts?.currency || 1; - const dateRange = - settings?.preferences?.dashboard_charts?.range || 'this_month'; - - const [dates, setDates] = useState<{ start_date: string; end_date: string }>({ - start_date: GLOBAL_DATE_RANGES[dateRange]?.start || '', - end_date: GLOBAL_DATE_RANGES[dateRange]?.end || '', - }); - - const [body, setBody] = useState<{ - start_date: string; - end_date: string; - date_range: string; - }>({ - start_date: GLOBAL_DATE_RANGES[dateRange]?.start || '', - end_date: GLOBAL_DATE_RANGES[dateRange]?.end || '', - date_range: dateRange, - }); - - useEffect(() => { - setBody((current) => ({ - ...current, - date_range: dateRange, - })); - }, [settings?.preferences?.dashboard_charts?.range]); - - const handleDateChange = (DateSet: string) => { - const [startDate, endDate] = DateSet.split(','); - if (new Date(startDate) > new Date(endDate)) { - setBody({ - start_date: endDate, - end_date: startDate, - date_range: 'custom', - }); - } else { - setBody({ - start_date: startDate, - end_date: endDate, - date_range: 'custom', - }); - } - }; - - const totals = useQuery({ - queryKey: ['/api/v1/charts/totals_v2', body], - queryFn: () => - request('POST', endpoint('/api/v1/charts/totals_v2'), body).then( - (response) => response.data - ), - staleTime: Infinity, - }); - - const chart = useQuery({ - queryKey: ['/api/v1/charts/chart_summary_v2', body], - queryFn: () => - request('POST', endpoint('/api/v1/charts/chart_summary_v2'), body).then( - (response) => response.data - ), - staleTime: Infinity, - }); - - useEffect(() => { - if (totals.data) { - setTotalsData(totals.data); - - const currencies: Currency[] = []; - - Object.entries(totals.data.currencies).map(([id, name]) => { - currencies.push({ value: id, label: name as unknown as string }); - }); - - const $currencies = collect(currencies) - .pluck('value') - .map((value) => parseInt(value as string)) - .toArray() as number[]; - - if (!$currencies.includes(currency)) { - update('preferences.dashboard_charts.currency', $currencies[0]); - } - - setCurrencies(currencies); - } - }, [totals.data]); - - useEffect(() => { - if (chart.data) { - setDates({ - start_date: chart.data.start_date, - end_date: chart.data.end_date, - }); - - setChartData(chart.data); - } - }, [chart.data]); - - return ( - <> - {totals.isLoading && ( -
- -
- )} - - {/* Quick date, currency & date picker. */} -
-
- {currencies && ( - - update('preferences.dashboard_charts.currency', parseInt(value)) - } - > - - - {currencies.map((currency, index) => ( - - ))} - - )} - -
- - - - - -
- -
- - update('preferences.dashboard_charts.range', value) - } - value={body.date_range} - /> -
- - - - update('preferences.dashboard_charts.currency', parseInt(v)) - } - /> - - - update( - 'preferences.dashboard_charts.default_view', - value as ChartsDefaultView - ) - } - > - - - - - - - update('preferences.dashboard_charts.range', value) - } - > - - - - - - - - - - - -
-
- -
- {company && ( - -
-
- {`${user?.first_name} ${user?.last_name}`} - - {t('recent_transactions')} -
- -
-
- {t('invoices')} - - - - {formatMoney( - totalsData[currency]?.invoices?.invoiced_amount || 0, - company.settings.country_id, - currency.toString(), - 2 - )} - - -
- -
- {t('payments')} - - - {formatMoney( - totalsData[currency]?.revenue?.paid_to_date || 0, - company.settings.country_id, - currency.toString(), - 2 - )} - - -
- -
- {t('expenses')} - - - {formatMoney( - totalsData[currency]?.expenses?.amount || 0, - company.settings.country_id, - currency.toString(), - 2 - )} - - -
- -
- {t('outstanding')} - - - {formatMoney( - totalsData[currency]?.outstanding?.amount || 0, - company.settings.country_id, - currency.toString(), - 2 - )} - - -
- -
- {t('total_invoices_outstanding')} - - - - {totalsData[currency]?.outstanding?.outstanding_count || - 0} - - -
-
-
-
- )} - - {chartData && ( - - - - )} -
- - ); -} diff --git a/src/pages/dashboard/components/UpcomingInvoices.tsx b/src/pages/dashboard/components/UpcomingInvoices.tsx index dd8740cc21..3366f171b7 100644 --- a/src/pages/dashboard/components/UpcomingInvoices.tsx +++ b/src/pages/dashboard/components/UpcomingInvoices.tsx @@ -19,8 +19,14 @@ import { useDisableNavigation } from '$app/common/hooks/useDisableNavigation'; import { useCurrentCompanyDateFormats } from '$app/common/hooks/useCurrentCompanyDateFormats'; import { DynamicLink } from '$app/components/DynamicLink'; import { useTranslation } from 'react-i18next'; +import { ReactNode } from 'react'; -export function UpcomingInvoices() { +interface Props { + topRight?: ReactNode; + isEditMode: boolean; +} + +export function UpcomingInvoices({ topRight, isEditMode }: Props) { const [t] = useTranslation(); const formatMoney = useFormatMoney(); @@ -84,14 +90,22 @@ export function UpcomingInvoices() { return ( -
+
diff --git a/src/pages/dashboard/components/UpcomingQuotes.tsx b/src/pages/dashboard/components/UpcomingQuotes.tsx index be75063863..7db95b95ff 100644 --- a/src/pages/dashboard/components/UpcomingQuotes.tsx +++ b/src/pages/dashboard/components/UpcomingQuotes.tsx @@ -18,8 +18,14 @@ import dayjs from 'dayjs'; import { Badge } from '$app/components/Badge'; import { useDisableNavigation } from '$app/common/hooks/useDisableNavigation'; import { DynamicLink } from '$app/components/DynamicLink'; +import { ReactNode } from 'react'; -export function UpcomingQuotes() { +interface Props { + isEditMode: boolean; + topRight?: ReactNode; +} + +export function UpcomingQuotes({ topRight, isEditMode }: Props) { const [t] = useTranslation(); const formatMoney = useFormatMoney(); @@ -73,15 +79,24 @@ export function UpcomingQuotes() { return ( -
+
diff --git a/src/pages/dashboard/components/UpcomingRecurringInvoices.tsx b/src/pages/dashboard/components/UpcomingRecurringInvoices.tsx index 7db7324b80..9a632dee32 100644 --- a/src/pages/dashboard/components/UpcomingRecurringInvoices.tsx +++ b/src/pages/dashboard/components/UpcomingRecurringInvoices.tsx @@ -20,8 +20,19 @@ import { useDateTime } from '$app/common/hooks/useDateTime'; import { useTranslation } from 'react-i18next'; import { useGetSetting } from '$app/common/hooks/useGetSetting'; import { useGetTimezone } from '$app/common/hooks/useGetTimezone'; +import { ReactNode } from 'react'; -export function UpcomingRecurringInvoices() { +interface Props { + topRight?: ReactNode; + className?: string; + isEditMode: boolean; +} + +export function UpcomingRecurringInvoices({ + topRight, + className, + isEditMode, +}: Props) { const [t] = useTranslation(); const getSetting = useGetSetting(); @@ -92,15 +103,24 @@ export function UpcomingRecurringInvoices() { return ( -
+
diff --git a/src/pages/tasks/common/components/TaskSlider.tsx b/src/pages/tasks/common/components/TaskSlider.tsx index dc4831bb17..794db40f33 100644 --- a/src/pages/tasks/common/components/TaskSlider.tsx +++ b/src/pages/tasks/common/components/TaskSlider.tsx @@ -46,8 +46,8 @@ import { calculateTaskHours } from '$app/pages/projects/common/hooks/useInvoiceP import { date as formatDate } from '$app/common/helpers'; import { useFormatTimeLog } from '../../kanban/common/hooks'; import { TaskClock } from '../../kanban/components/TaskClock'; -import { useUserNumberPrecision } from '$app/common/hooks/useUserNumberPrecision'; import { useCompanyTimeFormat } from '$app/common/hooks/useCompanyTimeFormat'; +import { useUserNumberPrecision } from '$app/common/hooks/useUserNumberPrecision'; export const taskSliderAtom = atom(null); export const taskSliderVisibilityAtom = atom(false); @@ -105,8 +105,8 @@ export function TaskSlider() { }); const { timeFormat } = useCompanyTimeFormat(); - const userNumberPrecision = useUserNumberPrecision(); const { dateFormat } = useCurrentCompanyDateFormats(); + const userNumberPrecision = useUserNumberPrecision(); const formatMoney = useFormatMoney(); const formatTimeLog = useFormatTimeLog(); diff --git a/src/resources/css/app.css b/src/resources/css/app.css index 3c9d447983..f216b1333c 100644 --- a/src/resources/css/app.css +++ b/src/resources/css/app.css @@ -23,3 +23,66 @@ animation: jiggle 0.8s ease-in-out infinite; } } +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 224 71.4% 4.1%; + --card: 0 0% 100%; + --card-foreground: 224 71.4% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 224 71.4% 4.1%; + --primary: 220.9 39.3% 11%; + --primary-foreground: 210 20% 98%; + --secondary: 220 14.3% 95.9%; + --secondary-foreground: 220.9 39.3% 11%; + --muted: 220 14.3% 95.9%; + --muted-foreground: 220 8.9% 46.1%; + --accent: 220 14.3% 95.9%; + --accent-foreground: 220.9 39.3% 11%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 20% 98%; + --border: 220 13% 91%; + --input: 220 13% 91%; + --ring: 224 71.4% 4.1%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 224 71.4% 4.1%; + --foreground: 210 20% 98%; + --card: 224 71.4% 4.1%; + --card-foreground: 210 20% 98%; + --popover: 224 71.4% 4.1%; + --popover-foreground: 210 20% 98%; + --primary: 210 20% 98%; + --primary-foreground: 220.9 39.3% 11%; + --secondary: 215 27.9% 16.9%; + --secondary-foreground: 210 20% 98%; + --muted: 215 27.9% 16.9%; + --muted-foreground: 217.9 10.6% 64.9%; + --accent: 215 27.9% 16.9%; + --accent-foreground: 210 20% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 20% 98%; + --border: 215 27.9% 16.9%; + --input: 215 27.9% 16.9%; + --ring: 216 12.2% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/resources/css/gridLayout.css b/src/resources/css/gridLayout.css new file mode 100644 index 0000000000..4632bd3caf --- /dev/null +++ b/src/resources/css/gridLayout.css @@ -0,0 +1,4 @@ +.react-grid-item.react-grid-placeholder { + background: transparent !important; + border-color: transparent !important; +} diff --git a/tailwind.config.js b/tailwind.config.js index b03625e5d4..1fda281441 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,20 +2,65 @@ const defaultTheme = require('tailwindcss/defaultTheme'); module.exports = { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - darkMode: 'class', // or 'media' or 'class' + darkMode: ['class', 'class'], // or 'media' or 'class' theme: { - extend: { - fontFamily: { - sans: ['Inter var', ...defaultTheme.fontFamily.sans], - }, - colors: { - ninja: { - gray: '#242930', - 'gray-darker': '#2F2E2E', - 'gray-lighter': '#363D47', - }, - }, - }, + extend: { + fontFamily: { + sans: ['Inter var', ...defaultTheme.fontFamily.sans] + }, + colors: { + ninja: { + gray: '#242930', + 'gray-darker': '#2F2E2E', + 'gray-lighter': '#363D47' + }, + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + } + } }, variants: { extend: {}, @@ -24,5 +69,6 @@ module.exports = { require('@tailwindcss/forms'), require('tailwind-scrollbar'), require('@tailwindcss/typography'), - ], + require("tailwindcss-animate") +], };