diff --git a/app/controllers/internal_api/v1/projects_controller.rb b/app/controllers/internal_api/v1/projects_controller.rb index 37a1fa47d6..4f31ef3bc5 100644 --- a/app/controllers/internal_api/v1/projects_controller.rb +++ b/app/controllers/internal_api/v1/projects_controller.rb @@ -17,6 +17,10 @@ def projects @_projects ||= current_company.projects.kept end + def projects + @_projects ||= current_company.projects.kept + end + def project @_project ||= Project.includes(:project_members, project_members: [:user]).find(params[:id]) end diff --git a/app/javascript/src/common/AmountBox/index.tsx b/app/javascript/src/common/AmountBox/index.tsx new file mode 100644 index 0000000000..f79370ddc9 --- /dev/null +++ b/app/javascript/src/common/AmountBox/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +const AmountBoxContainer = ({ amountBox, cssClass="" }) => ( + +); + +export default AmountBoxContainer; diff --git a/app/javascript/src/common/ChartBar/index.tsx b/app/javascript/src/common/ChartBar/index.tsx new file mode 100644 index 0000000000..ee99fd8979 --- /dev/null +++ b/app/javascript/src/common/ChartBar/index.tsx @@ -0,0 +1,57 @@ +import React, { Fragment } from "react"; +import ReactTooltip from "react-tooltip"; +import { IChartBarGraph, ISingleClient } from "./interface"; +import { minutesToHHMM } from "../../helpers/hhmm-parser"; + +const Client = ({ element, totalMinutes, index }:ISingleClient) => { + const chartColor = ["miru-chart-green", "miru-chart-blue", "miru-chart-pink", "miru-chart-orange"]; + const chartColorIndex = index%4; + const randomColor = chartColor[chartColorIndex]; + const hourPercentage = (element.minutes * 100)/totalMinutes; + + const divStyle = { + width: `${hourPercentage}%` + }; + + return ( +
+ +

{element.name}

+

{minutesToHHMM(element.minutes)}

+
+ +
+ ); +}; + +const GetClientBar = ({ data, totalMinutes }:IChartBarGraph) => ( + +

+ TOTAL HOURS: {minutesToHHMM(totalMinutes)} +

+
+ {data.map((element, index) => ) + } +
+
+ + 0 + + + {minutesToHHMM(totalMinutes)} + +
+
+); + +export default GetClientBar; diff --git a/app/javascript/src/common/ChartBar/interface.ts b/app/javascript/src/common/ChartBar/interface.ts new file mode 100644 index 0000000000..0d979781f3 --- /dev/null +++ b/app/javascript/src/common/ChartBar/interface.ts @@ -0,0 +1,19 @@ +interface ClientArray { + clients: any; +} + +export interface IChartBar extends ClientArray { + handleSelectChange: any + totalMinutes: number; +} + +export interface ISingleClient { + totalMinutes: number; + index: number; + element: any; +} + +export interface IChartBarGraph { + data: any; + totalMinutes: number; +} diff --git a/app/javascript/src/common/Table/index.tsx b/app/javascript/src/common/Table/index.tsx new file mode 100644 index 0000000000..20d121d04c --- /dev/null +++ b/app/javascript/src/common/Table/index.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { useTable, useRowSelect } from "react-table"; + +const IndeterminateCheckbox = React.forwardRef( // eslint-disable-line react/display-name + ({ indeterminate, ...rest }:any, ref) => { + const defaultRef = React.useRef(); + const resolvedRef:any = ref || defaultRef; + + React.useEffect(() => { + resolvedRef.current.indeterminate = indeterminate; + }, [resolvedRef, indeterminate]); + + return ( +
+ +
+ + + + + + + +
+
+ ); + } +); + +const getTableCheckbox = hooks => { + hooks.visibleColumns.push(columns => [ + { + id: "selection", + Header: ({ getToggleAllRowsSelectedProps }) => ( +
+ +
+ ), + Cell: ({ row }) => ( +
+ +
+ ) + }, + ...columns + ]); +}; + +const Table = ({ + tableHeader, + tableRowArray, + hasCheckbox = false +}) => { + + const data = React.useMemo(() => tableRowArray, []); + const columns = React.useMemo(() => tableHeader, []); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow + } = useTable( + { + columns, + data + }, + useRowSelect, + hasCheckbox ? getTableCheckbox : () => {} // eslint-disable-line @typescript-eslint/no-empty-function + ); + + return ( + <> + + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => ( + + ) + )} + + ))} + + + {rows.slice(0, 10).map((row, index) => { + prepareRow(row); + const isLastChild = rows.length - 1 !== index; + return ( + + {row.cells.map(cell => )} + + ); + })} + +
{column.render("Header")}
{cell.render("Cell")}
+ + ); +}; + +export default Table; diff --git a/app/javascript/src/components/Invoices/BannerBox/index.tsx b/app/javascript/src/components/Invoices/BannerBox/index.tsx deleted file mode 100644 index 6ef9270e04..0000000000 --- a/app/javascript/src/components/Invoices/BannerBox/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from "react"; - -const BannerBox = ({ title, value }) => ( -
  • -

    {title}

    -

    {value}

    -
  • -); - -export default BannerBox; diff --git a/app/javascript/src/components/Invoices/container.tsx b/app/javascript/src/components/Invoices/container.tsx index 25fc685f1f..2ab3ca07dd 100644 --- a/app/javascript/src/components/Invoices/container.tsx +++ b/app/javascript/src/components/Invoices/container.tsx @@ -1,8 +1,7 @@ import * as React from "react"; -import BannerBox from "./BannerBox"; +import AmountBoxContainer from "common/AmountBox"; import Table from "./Table"; - const Container = ({ invoiceList, setInvoiceList }) => { const handleSelectAll = (isChecked) => { @@ -20,13 +19,24 @@ const Container = ({ invoiceList, setInvoiceList }) => { setInvoiceList(newInvoiceList); }; + const amountBox = [{ + title: "OVERDUE", + amount: "$35.5k" + }, + { + title: "OUTSTANDING", + amount: "$24.3k" + }, + { + title: "AMOUNT IN DRAFT", + amount: "$24.5k" + }]; + return ( - +
    + +
    diff --git a/app/javascript/src/components/Projects/Details/index.tsx b/app/javascript/src/components/Projects/Details/index.tsx new file mode 100644 index 0000000000..1d0e9ea0b7 --- /dev/null +++ b/app/javascript/src/components/Projects/Details/index.tsx @@ -0,0 +1,173 @@ +import * as React from "react"; +import { setAuthHeaders, registerIntercepts } from "apis/axios"; +import projectAPI from "apis/projects"; +import AmountBoxContainer from "common/AmountBox"; +import ChartBar from "common/ChartBar"; +import Table from "common/Table"; +import { ArrowLeft, DotsThreeVertical, Receipt, Pencil, UsersThree, Trash } from "phosphor-react"; +import { unmapper } from "../../../mapper/project.mapper"; + +const getTableData = (project) => { + if (project) { + return project.members.map((member) => { + const hours = member.minutes/60; + const cost = hours * parseInt(member.hourlyRate); + return { + col1:
    {member.name}
    , + col2:
    ${member.hourlyRate}
    , + col3:
    {member.minutes}
    , + col4:
    ${cost}
    + }; + }); + } +}; + +const ProjectDetails = ({ id }) => { + + const [project, setProject] = React.useState(); + const [isHeaderMenuVisible, setHeaderMenuVisibility] = React.useState(false); + const fetchProject = async () => { + try { + const resp = await projectAPI.show(id); + setProject(unmapper(resp.data.project_details)); + } catch (err) { + // Add error handling + } + }; + + React.useEffect(() => { + setAuthHeaders(); + registerIntercepts(); + fetchProject(); + }, []); + + const tableData = getTableData(project); + + const tableHeader = [ + { + Header: "TEAM MEMBER", + accessor: "col1", // accessor is the "key" in the data + cssClass: "abc" + }, + { + Header: "HOURLY RATE", + accessor: "col2", + cssClass: "text-right" + }, + { + Header: "HOURS LOGGED", + accessor: "col3", + cssClass: "text-right" // accessor is the "key" in the data + }, + { + Header: "COST", + accessor: "col4", + cssClass: "text-right" // accessor is the "key" in the data + } + ]; + + const amountBox = [{ + title: "OVERDUE", + amount: "$35.5k" + }, + { + title: "OUTSTANDING", + amount: "$24.3k" + }]; + + const handleMenuVisibility = () => { + setHeaderMenuVisibility(!isHeaderMenuVisible); + }; + + const menuBackground = isHeaderMenuVisible ? "bg-miru-gray-1000" : ""; + + return ( + <> +
    +
    +
    + +

    + {project?.name} +

    + + BILLABLE + +
    +
    + + { isHeaderMenuVisible &&
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    } +
    +
    +

    {project && project.client.name}

    +
    +
    +
    + +
    + {project && } + +
    +
    +
    +
    +
    + { project &&
    } + + + + + + ); + +}; +export default ProjectDetails; diff --git a/app/javascript/src/components/Projects/projectList.tsx b/app/javascript/src/components/Projects/List/index.tsx similarity index 100% rename from app/javascript/src/components/Projects/projectList.tsx rename to app/javascript/src/components/Projects/List/index.tsx diff --git a/app/javascript/src/components/Projects/project.tsx b/app/javascript/src/components/Projects/List/project.tsx similarity index 84% rename from app/javascript/src/components/Projects/project.tsx rename to app/javascript/src/components/Projects/List/project.tsx index 3935301317..3ca5a4e410 100644 --- a/app/javascript/src/components/Projects/project.tsx +++ b/app/javascript/src/components/Projects/List/project.tsx @@ -1,21 +1,6 @@ import * as React from "react"; import { minutesToHHMM } from "helpers/hhmm-parser"; - -export interface IProject { - id: number; - name: string; - clientName: string; - isBillable: boolean; - minutesSpent: number; - editIcon: string; - deleteIcon: string; - isAdminUser: boolean; - setShowEditDialog: any; - setProjectToEdit: any; - setProjectToDelete: any; - setShowDeleteDialog: any; - projectClickHandler: any; -} +import { IProject } from "../interface"; export const Project = ({ id, diff --git a/app/javascript/src/components/Projects/index.tsx b/app/javascript/src/components/Projects/index.tsx index 50a5241814..0f4e992722 100644 --- a/app/javascript/src/components/Projects/index.tsx +++ b/app/javascript/src/components/Projects/index.tsx @@ -1,11 +1,11 @@ import * as React from "react"; import { setAuthHeaders, registerIntercepts } from "apis/axios"; import projectApi from "apis/projects"; -import { IProject } from "./project"; -import ProjectDetails from "./projectDetails"; -import ProjectList from "./projectList"; +import ProjectDetails from "./Details"; +import { IProject } from "./interface"; +import ProjectList from "./List"; -const Projects = ({ editIcon, deleteIcon, isAdminUser }) => { +const Projects = ({ isAdminUser }) => { const [projects, setProjects] = React.useState([]); const [showProjectDetails, setShowProjectDetails] = React.useState(null); @@ -14,11 +14,9 @@ const Projects = ({ editIcon, deleteIcon, isAdminUser }) => { try { const resp = await projectApi.get(); setProjects(resp.data.projects); - } catch (error) - { + } catch (error) { // Add error handling } - }; React.useEffect(() => { @@ -35,9 +33,7 @@ const Projects = ({ editIcon, deleteIcon, isAdminUser }) => { showProjectDetails ? : + /> : { - const [grayColor, setGrayColor] = React.useState(""); - const [isHover, setHover] = React.useState(false); - - const handleMouseEnter = () => { - setGrayColor("bg-miru-gray-100"); - setHover(true); - }; - - const handleMouseLeave = () => { - setGrayColor(""); - setHover(false); - }; - - return ( - - - - - - - - - ); -}; diff --git a/app/javascript/src/components/Projects/projectDetails.tsx b/app/javascript/src/components/Projects/projectDetails.tsx deleted file mode 100644 index ead531242a..0000000000 --- a/app/javascript/src/components/Projects/projectDetails.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import * as React from "react"; -import { setAuthHeaders, registerIntercepts } from "apis/axios"; -import projectAPI from "apis/projects"; -import { Member } from "./member"; - -export interface IProjectDetails { - id: number; - name: string; - client: any; - is_billable: boolean; - total_minutes_logged: number; - members: any; - editIcon: string; - deleteIcon: string; - isAdminUser: boolean; - setShowEditDialog: any; - setProjectToEdit: any; - setProjectToDelete: any; - setShowDeleteDialog: any; - projectClickHandler: any; -} - -const ProjectDetails = ({ id, editIcon, deleteIcon, isAdminUser }) => { - - const [project, setProject] = React.useState(); - - const fetchProject = async () => { - - try { - const resp = await projectAPI.show(id); - setProject(resp.data.project_details); - } catch (err) { - // Add error handling - } - - }; - - React.useEffect(() => { - setAuthHeaders(); - registerIntercepts(); - fetchProject(); - }, []); - - return ( - <> -
    Showing details for project {id}
    -
    Project name: {project?.name}
    -
    Client name: {project?.client.name}
    -
    Billable: {project?.is_billable}
    -
    Minutes spent(week): {project?.total_minutes_logged}
    -
    -
    -
    -
    -
    - {name} - - {hourly_rate} - - {minutesToHHMM(minutes_logged)} - - {minutes_logged * hourly_rate} - - {isAdminUser && isHover && - } - - { isAdminUser && isHover && - } -
    - - - - - - - - - - - - {project?.members?.map((member, index) => ( - - )) - } - -
    - MEMBER - - HOURLY RATE - - HOURS LOGGED - - COST -
    -
    - - - - - - ); - -}; -export default ProjectDetails; diff --git a/app/javascript/src/mapper/project.mapper.ts b/app/javascript/src/mapper/project.mapper.ts new file mode 100644 index 0000000000..7427e95509 --- /dev/null +++ b/app/javascript/src/mapper/project.mapper.ts @@ -0,0 +1,16 @@ + +const getMember = (input:any) => input.map((elem) => ({ + hourlyRate: elem.hourly_rate, + id: elem.id, + minutes: elem.minutes_logged, + name: elem.name +})); + +export const unmapper = (data:any = {}) => ({ + totalMinutes: data.total_minutes_logged, + client: data.client, + id: data.id, + is_billable: data.is_billable, + members: getMember(data.members), + name: data.name +}); diff --git a/app/javascript/stylesheets/application.scss b/app/javascript/stylesheets/application.scss index 39308fb730..84c294eb71 100644 --- a/app/javascript/stylesheets/application.scss +++ b/app/javascript/stylesheets/application.scss @@ -42,6 +42,33 @@ } } + .button-icon{ + &__back { + @apply mr-4 h-8 p-1; + } + &__back:hover { + background-color: rgba(205, 214, 223, 0.2); + @apply rounded; + } + } + + .menuButton { + &__button { + @apply rounded p-2; + } + &__button:hover { + background-color: rgba(205, 214, 223, 0.2); + @apply rounded; + } + &__wrapper { + box-shadow: 0px 0px 40px rgba(0, 0, 0, 0.1); + @apply absolute bg-white right-0 py-5 w-64 rounded-xl; + } + &__list-item { + @apply text-sm block hover:bg-miru-gray-100 w-full px-5 py-1.5 text-miru-han-purple-1000 items-center flex; + } + } + .header { &__title { @apply text-3xl font-extrabold leading-7 text-gray-900; @@ -62,10 +89,10 @@ .page-display { &__wrap { - @apply bg-gray-50 p-5 mt-5 w-full flex items-stretch flex-col sm:flex-row text-miru-dark-purple-1000; + @apply pt-5 w-full flex items-stretch flex-col sm:flex-row text-miru-dark-purple-1000 border-t border-miru-gray-1000; } &__box { - @apply flex flex-col justify-start my-5 pl-8 w-full border-r-0 sm:border-r; + @apply flex flex-col justify-start mr-12 w-full border-r-0 sm:border-r sm:border-miru-gray-1000; } &__box:last-child { @apply border-r-0; @@ -195,10 +222,10 @@ @apply min-w-full divide-y divide-gray-200 mt-4; } &__header { - @apply px-6 py-5 text-left font-normal text-xs text-miru-dark-purple-600 tracking-widest; + @apply p-5 text-left font-normal text-xs text-miru-dark-purple-600 tracking-widest; } &__cell { - @apply px-5 py-5 text-left text-xs font-normal text-miru-dark-purple-1000 tracking-normal; + @apply p-5 text-left text-xs font-normal text-miru-dark-purple-1000 tracking-normal; } &__body { @apply bg-white divide-y divide-gray-200; @@ -258,10 +285,12 @@ &__checkbox:checked + div { @apply border-miru-han-purple-1000; } - &__checkbox:checked + div svg { + &__checkbox:checked + div .custom__checkbox-tick { + @apply block; + } + &__checkbox:indeterminate + div .custom__checkbox-dash { @apply block; } - &__radio:checked + label i { @apply bg-miru-han-purple-1000 h-3 w-3; box-shadow: 0px 0px 0px 2px white inset; diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb index 3dc9a7596f..96a4fa8251 100644 --- a/app/views/projects/index.html.erb +++ b/app/views/projects/index.html.erb @@ -1,17 +1,7 @@
    -
    -
    -

    - <%= t("projects.projects") %> -

    -
    -
    - <%= react_component("Projects", { - editIcon: image_url('edit_image_button.svg'), - deleteIcon: image_url('delete_image_button.svg'), isAdminUser: current_user.has_owner_or_admin_role?(current_user.current_workspace) }) %> diff --git a/config/webpack/environment.js b/config/webpack/environment.js index e3e9a6a6be..94d49fe9e9 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -8,6 +8,8 @@ environment.config.merge({ const aliasConfig = require("./alias"); environment.config.merge(aliasConfig); + + const webpack = require('webpack') environment.plugins.prepend('Provide', new webpack.ProvidePlugin({ @@ -16,4 +18,11 @@ environment.plugins.prepend('Provide', }) ); -module.exports = environment; +module.exports = environment + +const nodeModulesLoader = environment.loaders.get('nodeModules') + +if (!Array.isArray(nodeModulesLoader.exclude)) { + nodeModulesLoader.exclude = (nodeModulesLoader.exclude == null) ? [] : [nodeModulesLoader.exclude] +} +nodeModulesLoader.exclude.push(/react-table/) diff --git a/package.json b/package.json index 698ec84454..27ed4c0880 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,9 @@ "prop-types": "^15.7.2", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-router-dom": "^6.1.1", + "react-router-dom": "^6.2.2", "react-select": "^5.2.2", + "react-table": "^7.7.0", "react-toastify": "^8.1.0", "react-tooltip": "^4.2.21", "react-transition-group": "1.x", diff --git a/yarn.lock b/yarn.lock index e2777f004e..a7c8d3f372 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4666,10 +4666,10 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== -history@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/history/-/history-5.1.0.tgz#2e93c09c064194d38d52ed62afd0afc9d9b01ece" - integrity sha512-zPuQgPacm2vH2xdORvGGz1wQMuHSIB56yNAy5FnLuwOwgSYyPKptJtcMm6Ev+hRGeS+GzhbmRacHzvlESbFwDg== +history@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" + integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== dependencies: "@babel/runtime" "^7.7.6" @@ -7617,20 +7617,20 @@ react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-router-dom@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.1.1.tgz#ed59376ff9115bc49227e87982a32e91e9530ca3" - integrity sha512-O3UH89DI4o+swd2q6lF4dSmpuNCxwkUXcj0zAFcVc1H+YoPE6T7uwoFMX0ws1pUvCY8lYDucFpOqCCdal6VFzg== +react-router-dom@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.2.2.tgz#f1a2c88365593c76b9612ae80154a13fcb72e442" + integrity sha512-AtYEsAST7bDD4dLSQHDnk/qxWLJdad5t1HFa1qJyUrCeGgEuCSw0VB/27ARbF9Fi/W5598ujvJOm3ujUCVzuYQ== dependencies: - history "^5.1.0" - react-router "6.1.1" + history "^5.2.0" + react-router "6.2.2" -react-router@6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.1.1.tgz#16f41bf54e87d995bcd4d447720a693f77d8fcb9" - integrity sha512-55o96RiDZmC0uD17DPqVmzzfdNd2Dc+EjkYvMAmHl43du/GItaTdFr5WwjTryNWPXZ+OOVQxQhwAX25UwxpHtw== +react-router@6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.2.2.tgz#495e683a0c04461eeb3d705fe445d6cf42f0c249" + integrity sha512-/MbxyLzd7Q7amp4gDOGaYvXwhEojkJD5BtExkuKmj39VEE0m3l/zipf6h2WIB2jyAO0lI6NGETh4RDcktRm4AQ== dependencies: - history "^5.1.0" + history "^5.2.0" react-select@^5.2.2: version "5.2.2" @@ -7645,6 +7645,11 @@ react-select@^5.2.2: prop-types "^15.6.0" react-transition-group "^4.3.0" +react-table@^7.7.0: + version "7.7.0" + resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.7.0.tgz#e2ce14d7fe3a559f7444e9ecfe8231ea8373f912" + integrity sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA== + react-toastify@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-8.1.0.tgz#acaca4e8c4415c8474562dd84a14e6f390ed7f17"