diff --git a/biome.json b/biome.json index 2fc85351..27712b22 100644 --- a/biome.json +++ b/biome.json @@ -1,35 +1,37 @@ { - "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "complexity": { - "noUselessFragments": "error" - }, - "suspicious": { - "noExplicitAny": "off" - }, - "correctness": { - "noUnusedVariables": "error" - } - }, - "ignore": ["/generated"] - }, - "javascript": { - "formatter": { - "trailingComma": "none" - } - }, - "formatter": { - "enabled": true, - "lineWidth": 100, - "indentWidth": 2, - "indentStyle": "space", - "formatWithErrors": false, - "ignore": ["/generated"] - } + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noUselessFragments": "error", + "noForEach": "off" + }, + "suspicious": { + "noExplicitAny": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "useExhaustiveDependencies": "off" + } + }, + "ignore": ["/generated"] + }, + "javascript": { + "formatter": { + "trailingComma": "none" + } + }, + "formatter": { + "enabled": true, + "lineWidth": 100, + "indentWidth": 2, + "indentStyle": "space", + "formatWithErrors": false, + "ignore": ["/generated"] + } } diff --git a/home-lambdas-API-spec b/home-lambdas-API-spec index bc86c972..9a6fc410 160000 --- a/home-lambdas-API-spec +++ b/home-lambdas-API-spec @@ -1 +1 @@ -Subproject commit bc86c972cf22192e87683361bb960e17c0ff5067 +Subproject commit 9a6fc410ac14b0ee81fe96dd9dee6982f13b3ce1 diff --git a/src/api/api.ts b/src/api/api.ts index 5570942f..eb4f134d 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -12,6 +12,7 @@ import { AllocationsApi, Configuration as LambdaConfiguration, ProjectsApi, + SlackAvatarsApi, TasksApi, TimeEntriesApi } from "../generated/homeLambdasClient" @@ -69,6 +70,7 @@ export const getLambdasApiClient = (accessToken?: string) => { allocationsApi: new AllocationsApi(getConfiguration()), projectsApi: new ProjectsApi(getConfiguration()), tasksApi: new TasksApi(getConfiguration()), - timeEntriesApi: new TimeEntriesApi(getConfiguration()) + timeEntriesApi: new TimeEntriesApi(getConfiguration()), + slackAvatarsApi: new SlackAvatarsApi(getConfiguration()) }; }; \ No newline at end of file diff --git a/src/app/config.ts b/src/app/config.ts index 0353bdf0..fecb145b 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -43,4 +43,4 @@ const config: Config = { } }; -export default config; +export default config; \ No newline at end of file diff --git a/src/atoms/person.ts b/src/atoms/person.ts index 6f52be4d..cf164027 100644 --- a/src/atoms/person.ts +++ b/src/atoms/person.ts @@ -1,6 +1,7 @@ import { atom } from "jotai"; import { DailyEntry, Person, PersonTotalTime } from "../generated/client"; import { DailyEntryWithIndexSignature, PersonWithTotalTime } from "../types"; +import { UsersAvatars } from "src/generated/homeLambdasClient"; export const personsAtom = atom([]); export const personTotalTimeAtom = atom(undefined); @@ -8,3 +9,4 @@ export const personsWithTotalTimeAtom = atom([]); export const timebankScreenPersonTotalTimeAtom = atom(undefined); export const personDailyEntryAtom = atom(undefined); export const dailyEntriesAtom = atom([]); +export const avatarsAtom = atom([]); \ No newline at end of file diff --git a/src/components/constants/index.ts b/src/components/constants/index.ts index e1d2bed5..5cc03321 100644 --- a/src/components/constants/index.ts +++ b/src/components/constants/index.ts @@ -4,12 +4,31 @@ import { theme } from "src/theme"; * Colors array to be mapped in elements such as the pie chart. */ export const COLORS = [ - theme.palette.success.dark, - theme.palette.success.light, - theme.palette.warning.main - ]; + theme.palette.success.dark, + theme.palette.success.light, + theme.palette.warning.main +]; /** * Days of week array. - * */ -export const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; \ No newline at end of file + * */ +export const DAYS_OF_WEEK = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" +]; + +// TODO: These statuses should be updated in the future to include all statuses from the forecast data. +/** + * Task statuses. + * */ +export const STATUS = { + TODO: "TODO", + INPROGRESS: "INPROGRESS", + DONE: "DONE", + ALL: "" +}; diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index fae88a35..01d358d6 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -1,19 +1,25 @@ -import { MouseEvent, useState } from "react"; -import AppBar from "@mui/material/AppBar"; -import Box from "@mui/material/Box"; -import Toolbar from "@mui/material/Toolbar"; -import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; -import Container from "@mui/material/Container"; -import Avatar from "@mui/material/Avatar"; -import Tooltip from "@mui/material/Tooltip"; -import MenuItem from "@mui/material/MenuItem"; +import { MouseEvent, useEffect, useState } from "react"; +import { + MenuItem, + AppBar, + Box, + Toolbar, + IconButton, + Menu, + Container, + Tooltip, + Avatar +} from "@mui/material"; import LocalizationButtons from "../layout-components/localization-buttons"; import strings from "src/localization/strings"; -import { authAtom } from "src/atoms/auth"; -import { useAtomValue } from "jotai"; +import { authAtom, userProfileAtom } from "src/atoms/auth"; +import { useAtom, useAtomValue } from "jotai"; import NavItems from "./navitems"; import SyncButton from "./sync-button"; +import { avatarsAtom, personsAtom } from "src/atoms/person"; +import { Person } from "src/generated/client"; +import config from "src/app/config"; +import { useLambdasApi } from "src/hooks/use-api"; /** * NavBar component @@ -21,6 +27,16 @@ import SyncButton from "./sync-button"; const NavBar = () => { const auth = useAtomValue(authAtom); const [anchorElUser, setAnchorElUser] = useState(null); + const [avatars, setAvatars] = useAtom(avatarsAtom); + const persons: Person[] = useAtomValue(personsAtom); + const userProfile = useAtomValue(userProfileAtom); + const { slackAvatarsApi } = useLambdasApi(); + const loggedInPerson = persons.find( + (person: Person) => + person.id === config.person.forecastUserIdOverride || person.keycloakId === userProfile?.id + ); + const loggedInPersonAvatar = + avatars.find((avatar) => loggedInPerson?.id === avatar.personId)?.imageOriginal || ""; /** * Handles opening user menu @@ -45,6 +61,18 @@ const NavBar = () => { auth?.logout(); }; + /** + * fetch avatars + */ + const getSlackAvatars = async () => { + const fetchedAvatars = await slackAvatarsApi.slackAvatar(); + setAvatars(fetchedAvatars); + }; + + useEffect(() => { + getSlackAvatars(); + }, [loggedInPerson]); + return ( <> @@ -56,7 +84,7 @@ const NavBar = () => { - + { }; useEffect(() => { - if (auth) getPersonsList(); + if (auth) { + getPersonsList(); + } }, [auth]); /** diff --git a/src/components/screens/sprint-view-screen.tsx b/src/components/screens/sprint-view-screen.tsx index b41c2b25..6805a8de 100644 --- a/src/components/screens/sprint-view-screen.tsx +++ b/src/components/screens/sprint-view-screen.tsx @@ -1,13 +1,242 @@ +import { useState, useEffect } from "react"; +import { Card, CircularProgress, Typography, Box, FormControlLabel, Switch } from "@mui/material"; +import { useLambdasApi } from "src/hooks/use-api"; +import type { Person } from "src/generated/client"; +import { useAtomValue, useSetAtom } from "jotai"; +import { personsAtom } from "src/atoms/person"; +import config from "src/app/config"; +import { userProfileAtom } from "src/atoms/auth"; +import type { + Allocations, + Projects, + TimeEntries +} from "src/generated/homeLambdasClient/models/"; +import { DataGrid } from "@mui/x-data-grid"; +import { getHoursAndMinutes, getSprintEnd, getSprintStart } from "src/utils/time-utils"; +import TaskTable from "src/components/sprint-view-table/tasks-table"; import strings from "src/localization/strings"; +import sprintViewProjectsColumns from "src/components/sprint-view-table/sprint-projects-columns"; +import { errorAtom } from "src/atoms/error"; +import { + calculateWorkingLoad, + totalAllocations, + filterAllocationsAndProjects +} from "src/utils/sprint-utils"; +import { TaskStatusFilter } from "src/components/sprint-view-table/menu-Item-filter-table"; /** - * Sprint View screen component + * Sprint view screen component */ const SprintViewScreen = () => { - + const { allocationsApi, projectsApi, timeEntriesApi } = useLambdasApi(); + const persons: Person[] = useAtomValue(personsAtom); + const userProfile = useAtomValue(userProfileAtom); + const loggedInPerson = persons.find( + (person: Person) => + person.id === config.person.forecastUserIdOverride || person.keycloakId === userProfile?.id + ); + const [allocations, setAllocations] = useState([]); + const [projects, setProjects] = useState([]); + const [timeEntries, setTimeEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [myTasks, setMyTasks] = useState(true); + const [filter, setFilter] = useState(""); + const todaysDate = new Date().toISOString(); + const sprintStartDate = getSprintStart(todaysDate); + const sprintEndDate = getSprintEnd(todaysDate); + const columns = sprintViewProjectsColumns({ allocations, timeEntries, projects }); + const setError = useSetAtom(errorAtom); + + /** + * Get project data if user is logged in + */ + useEffect(() => { + fetchProjectDetails(); + }, [loggedInPerson]); + + /** + * Fetch allocations, project names and time entries + */ + const fetchProjectDetails = async () => { + setLoading(true); + if (!loggedInPerson) return; + + try { + const fetchedAllocations = await allocationsApi.listAllocations({ + startDate: new Date(), + endDate: new Date(), + personId: loggedInPerson?.id.toString() + }); + const fetchedProjects = await projectsApi.listProjects({ startDate: new Date() }); + const { filteredAllocations, filteredProjects } = filterAllocationsAndProjects( + fetchedAllocations, + fetchedProjects + ); + const fetchedTimeEntries = await Promise.all( + filteredAllocations.map(async (allocation) => { + try { + if (allocation.project) { + const totalTimeEntries = await timeEntriesApi.listProjectTimeEntries({ + projectId: allocation.project, + startDate: allocation.startDate, + endDate: allocation.endDate + }); + let totalMinutes = 0; + totalTimeEntries.forEach((timeEntry: TimeEntries) => { + if (loggedInPerson && timeEntry.person === loggedInPerson.id) { + totalMinutes += timeEntry.timeRegistered || 0; + } + }); + return totalMinutes; + } + } catch (error) { + if (allocation.id) { + const message: string = strings + .formatString( + strings.sprintRequestError.fetchAllocationError, + allocation.id.toString(), + error as string + ) + .toString(); + setError(message); + } + } + return 0; + }) + ); + setProjects(filteredProjects); + setAllocations(filteredAllocations); + setTimeEntries(fetchedTimeEntries); + } catch (error) { + setError(`${strings.sprintRequestError.fetchError}, ${error}`); + } + setLoading(false); + }; + + /** + * Calculate total unallocated time for the user in the current 2 week period + * + * @param allocation task allocated within a project + */ + const unallocatedTime = (allocation: Allocations[]) => { + const totalAllocatedTime = allocation.reduce( + (total, allocation) => total + totalAllocations(allocation), + 0 + ); + return calculateWorkingLoad(loggedInPerson) - totalAllocatedTime; + }; + + /** + * Featute for task filtering + */ + const handleOnClickTask = () => { + setMyTasks(!myTasks); + setFilter(""); + }; + return ( -

{ strings.sprint.sprintviewScreen }

+ <> + {loading ? ( + + { + + {strings.placeHolder.pleaseWait} + + + } + + ) : ( + <> + } + label={strings.sprint.showMyTasks} + onClick={() => handleOnClickTask()} + /> + + + + + + {strings.sprint.unAllocated} + + {getHoursAndMinutes(unallocatedTime(allocations))} + + + + {strings.formatString( + strings.sprint.current, + sprintStartDate.toLocaleString(), + sprintEndDate.toLocaleString() + )} + + + + {projects.map((project) => ( + + ))} + + )} + ); }; -export default SprintViewScreen; \ No newline at end of file +export default SprintViewScreen; diff --git a/src/components/screens/timebank-screen.tsx b/src/components/screens/timebank-screen.tsx index 3c8a6097..9da2ae9a 100644 --- a/src/components/screens/timebank-screen.tsx +++ b/src/components/screens/timebank-screen.tsx @@ -14,7 +14,8 @@ const TimebankScreen = () => { const userProfile = useAtomValue(userProfileAtom); const persons = useAtomValue(personsAtom); const loggedInPerson = persons.find( - (person: Person) => person.id === config.person.forecastUserIdOverride || person.keycloakId === userProfile?.id + (person: Person) => + person.id === config.person.forecastUserIdOverride || person.keycloakId === userProfile?.id ); const [selectedEmployeeId, setSelectedEmployeeId] = useState(loggedInPerson?.id); diff --git a/src/components/sprint-view-table/menu-Item-filter-table.tsx b/src/components/sprint-view-table/menu-Item-filter-table.tsx new file mode 100644 index 00000000..b5644f08 --- /dev/null +++ b/src/components/sprint-view-table/menu-Item-filter-table.tsx @@ -0,0 +1,47 @@ +import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; +import strings from 'src/localization/strings'; +import { STATUS } from '../constants'; + +/** + * Component properties + */ +interface Props { + setFilter: (string: string) => void; +} + +/** + * Filters tasks by status categories + */ +export const TaskStatusFilter = ({setFilter}: Props) => { + const statusFilters = [ + { key: 1, value: STATUS.TODO, label: strings.sprint.toDo }, + { key: 2, value: STATUS.INPROGRESS, label: strings.sprint.inProgress }, + { key: 3, value: STATUS.DONE, label: strings.sprint.completed }, + { key: 4, value: STATUS.ALL, label: strings.sprint.allTasks } + ]; + + return ( + + {strings.sprint.taskStatus} + + + ) +} \ No newline at end of file diff --git a/src/components/sprint-view-table/sprint-projects-columns.tsx b/src/components/sprint-view-table/sprint-projects-columns.tsx new file mode 100644 index 00000000..e3f829b5 --- /dev/null +++ b/src/components/sprint-view-table/sprint-projects-columns.tsx @@ -0,0 +1,84 @@ +import type { GridColDef } from "@mui/x-data-grid"; +import { Box } from "@mui/material"; +import strings from "../../localization/strings"; +import { getHoursAndMinutes } from "src/utils/time-utils"; +import { + getProjectColor, + getProjectName, + getTotalTimeEntriesAllocations, + timeLeft, + totalAllocations +} from "src/utils/sprint-utils"; +import type { Allocations, Projects } from "src/generated/homeLambdasClient"; + +/** + * Component properties + */ +interface Props { + allocations: Allocations[]; + timeEntries: number[]; + projects: Projects[]; +} + +/** + * Sprint view projects table columns component + * + * @param props component properties + */ +const sprintViewProjectsColumns = ({ allocations, timeEntries, projects }: Props) => { + /** + * Define columns for data grid + */ + const columns: GridColDef[] = [ + { + field: "projectName", + headerClassName: "header-color", + filterable: false, + headerName: strings.sprint.myAllocation, + flex: 2, + valueGetter: (params) => getProjectName(params.row, allocations, projects), + renderCell: (params) => ( + <> + + {getProjectName(params.row, allocations, projects)} + + ) + }, + { + field: "allocation", + headerClassName: "header-color", + headerName: strings.sprint.allocation, + flex: 1, + valueGetter: (params) => getHoursAndMinutes(totalAllocations(params.row)) + }, + { + field: "timeEntries", + headerClassName: "header-color", + headerName: strings.sprint.timeEntries, + flex: 1, + valueGetter: (params) => + getHoursAndMinutes(getTotalTimeEntriesAllocations(params.row, allocations, timeEntries)) + }, + { + field: "allocationsLeft", + headerClassName: "header-color", + headerName: strings.sprint.allocationLeft, + flex: 1, + cellClassName: (params) => + timeLeft(params.row, allocations, timeEntries) < 0 ? "negative-value" : "", + valueGetter: (params) => getHoursAndMinutes(timeLeft(params.row, allocations, timeEntries)) + } + ]; + return columns; +}; + +export default sprintViewProjectsColumns; diff --git a/src/components/sprint-view-table/sprint-tasks-columns.tsx b/src/components/sprint-view-table/sprint-tasks-columns.tsx new file mode 100644 index 00000000..b7f47ba5 --- /dev/null +++ b/src/components/sprint-view-table/sprint-tasks-columns.tsx @@ -0,0 +1,79 @@ +import type { GridColDef } from "@mui/x-data-grid"; +import strings from "../../localization/strings"; +import { getHoursAndMinutes } from "../../utils/time-utils"; +import type { Tasks } from "../../generated/homeLambdasClient"; +import { getTotalTimeEntriesTasks } from "src/utils/sprint-utils"; +import UserAvatars from "./user-avatars-component"; + +/** + * Component properties + */ +interface Props { + tasks: Tasks[]; + timeEntries: number[]; +} + +/** + * Sprint view tasks table columns component + * + * @param props component properties + */ +const sprintViewTasksColumns = ({ tasks, timeEntries }: Props) => { + /** + * Define columns for data grid + */ + const columns: GridColDef[] = [ + { + field: "title", + headerClassName: "header-color", + headerName: strings.sprint.taskName, + minWidth: 0, + flex: 3 + }, + { + field: "assignedPersons", + headerClassName: "header-color", + headerName: strings.sprint.assigned, + flex: 1, + renderCell: (params) => ( + + ) + }, + { + field: "status", + headerClassName: "header-color", + headerName: strings.sprint.taskStatus, + flex: 1, + valueGetter: (params) => params.row.statusCategory || "", + renderCell: (params) => params.row.status + }, + { + field: "priority", + headerClassName: "header-color", + headerName: strings.sprint.taskPriority, + cellClassName: (params) => (params.row.highPriority ? "high_priority" : "low_priority"), + flex: 1, + valueGetter: (params) => (params.row.highPriority ? "High" : "Normal") + }, + { + field: "estimate", + headerClassName: "header-color", + headerName: strings.sprint.estimatedTime, + flex: 1, + valueGetter: (params) => getHoursAndMinutes(params.row.estimate || 0) + }, + { + field: "timeEntries", + headerClassName: "header-color", + headerName: strings.sprint.timeEntries, + flex: 1, + valueGetter: (params) => + getHoursAndMinutes(getTotalTimeEntriesTasks(params.row, tasks, timeEntries)) + } + ]; + return columns; +}; + +export default sprintViewTasksColumns; \ No newline at end of file diff --git a/src/components/sprint-view-table/tasks-table.tsx b/src/components/sprint-view-table/tasks-table.tsx new file mode 100644 index 00000000..7a4f3f41 --- /dev/null +++ b/src/components/sprint-view-table/tasks-table.tsx @@ -0,0 +1,179 @@ +import { Box, Card, CircularProgress, IconButton, Typography } from "@mui/material"; +import { Projects, Tasks, TimeEntries } from "src/generated/homeLambdasClient"; +import { DataGrid } from "@mui/x-data-grid"; +import { useEffect, useState } from "react"; +import { useLambdasApi } from "src/hooks/use-api"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import strings from "src/localization/strings"; +import sprintViewTasksColumns from "./sprint-tasks-columns"; +import { errorAtom } from "src/atoms/error"; +import { useSetAtom } from "jotai"; + +/** + * Component properties + */ +interface Props { + project: Projects; + loggedInPersonId?: number; + filter?: string; +} + +/** + * Task table component + * + * @param props component properties + */ +const TaskTable = ({ project, loggedInPersonId, filter }: Props) => { + const { tasksApi, timeEntriesApi } = useLambdasApi(); + const [tasks, setTasks] = useState([]); + const [timeEntries, setTimeEntries] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const columns = sprintViewTasksColumns({ tasks, timeEntries }); + const setError = useSetAtom(errorAtom); + + /** + * Gather tasks and time entries when project is available and update reload state + */ + useEffect(() => { + if (project && open) { + getTasksAndTimeEntries(); + } + }, [project, open, filter]); + + /** + * Handle loggenInPersonId change + */ + useEffect(() => { + setTasks([]); + setOpen(false); + }, [loggedInPersonId]); + + /** + * Get tasks and total time entries + */ + const getTasksAndTimeEntries = async () => { + setLoading(true); + if (!tasks.length || !timeEntries.length) { + try { + const fetchedTasks = !loggedInPersonId + ? await tasksApi.listProjectTasks({ projectId: project.id }) + : await tasksApi + .listProjectTasks({ projectId: project.id }) + .then((result) => + result.filter((task) => task.assignedPersons?.includes(loggedInPersonId || 0)) + ); + + setTasks(fetchedTasks); + const fetchedTimeEntries = await Promise.all( + fetchedTasks.map(async (task) => { + try { + if (project.id) { + const totalTimeEntries = await timeEntriesApi.listProjectTimeEntries({ + projectId: project.id, + taskId: task.id + }); + let totalMinutes = 0; + totalTimeEntries.forEach((timeEntry: TimeEntries) => { + if (loggedInPersonId && timeEntry.person === loggedInPersonId) { + totalMinutes += timeEntry.timeRegistered || 0; + } + if (!loggedInPersonId) { + totalMinutes += timeEntry.timeRegistered || 0; + } + }); + return totalMinutes; + } + } catch (error) { + const message: string = strings + .formatString( + strings.sprintRequestError.fetchTasksError, + task.id || `${strings.sprintRequestError.fetchTaskIdError}`, + error as string + ) + .toString(); + setError(message); + } + return 0; + }) + ); + setTimeEntries(fetchedTimeEntries); + } catch (error) { + setError(`${strings.sprintRequestError.fetchTimeEntriesError} ${error}`); + } + } + setLoading(false); + }; + + return ( + + setOpen(!open)}> + {open ? : } + + {project?.name} + {open && ( + <> + {loading ? ( + + + + ) : ( + + )} + + )} + + ); +}; + +export default TaskTable; diff --git a/src/components/sprint-view-table/user-avatars-component.tsx b/src/components/sprint-view-table/user-avatars-component.tsx new file mode 100644 index 00000000..de8f1f55 --- /dev/null +++ b/src/components/sprint-view-table/user-avatars-component.tsx @@ -0,0 +1,91 @@ +import { Avatar, AvatarGroup, Tooltip } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { avatarsAtom, personsAtom } from "src/atoms/person"; +import type { Person } from "src/generated/client"; +import type { UsersAvatars } from "src/generated/homeLambdasClient"; + +/** + * Component properties + */ +interface Props { + assignedPersons: number[]; +} + +/** + * List of avatars component + */ +const UserAvatars = ({ assignedPersons }: Props) => { + const persons: Person[] = useAtomValue(personsAtom); + const avatars: UsersAvatars[] = useAtomValue(avatarsAtom); + const maxAvatarsInLine = 3; + + return ( + + {renderAvatars(assignedPersons, persons, avatars, maxAvatarsInLine)} + + ); +}; + +/** + * Render Slack Avatars + * + * @param assignedPersons list of all persons + * @param avatars list of exsisting avatars + * @param persons list of persons assigned to the project + * @param maxAvatarsInLine avatars limitation per table cell + */ +const renderAvatars = ( + assignedPersons: number[], + persons: Person[], + avatars: UsersAvatars[], + maxAvatarsInLine: number +) => { + return assignedPersons.map((personId: number, index: number) => { + const avatar = avatars?.find((avatar) => avatar.personId === personId); + const person = persons?.find((person) => person.id === personId); + const numberOfAssignedPersons = assignedPersons.length; + const hiddenAssignedPersons = numberOfAssignedPersons - maxAvatarsInLine; + + if (index < maxAvatarsInLine) { + return ( + + + + ); + } + + if (index === maxAvatarsInLine && hiddenAssignedPersons > 0) { + const groupedPersons = assignedPersons.slice(maxAvatarsInLine); + let tooltipTitle = ""; + + groupedPersons.forEach((groupedPersonId: number) => { + const personFound = persons.find((person: { id: number }) => person.id === groupedPersonId); + if (personFound) { + tooltipTitle += `${personFound?.firstName} ${personFound?.lastName}, `; + } + }); + tooltipTitle = tooltipTitle.slice(0, tooltipTitle.length - 2); + if (hiddenAssignedPersons === 1) { + return ( + + + + ); + } + return ( + personId)}`} + title={tooltipTitle} + > + +{hiddenAssignedPersons} + + ); + } + }); +}; + +export default UserAvatars; diff --git a/src/components/vacation-requests-table/vacation-requests-table-toolbar/toolbar-form/toolbar-form-fields.tsx b/src/components/vacation-requests-table/vacation-requests-table-toolbar/toolbar-form/toolbar-form-fields.tsx index 43dcae14..c17b2505 100644 --- a/src/components/vacation-requests-table/vacation-requests-table-toolbar/toolbar-form/toolbar-form-fields.tsx +++ b/src/components/vacation-requests-table/vacation-requests-table-toolbar/toolbar-form/toolbar-form-fields.tsx @@ -13,7 +13,7 @@ import config from "src/app/config"; import { userProfileAtom } from "src/atoms/auth"; import { personsAtom } from "src/atoms/person"; import { VacationType, Person } from "src/generated/client"; -import { daysOfWeek } from "src/components/constants"; +import { DAYS_OF_WEEK } from "src/components/constants"; /** * Component properties @@ -88,10 +88,10 @@ const ToolbarFormFields = ({ * @param loggedInPerson Person */ const getWorkingWeek = (loggedInPerson?: Person) => { - const workingWeek = new Array(daysOfWeek.length).fill(false); + const workingWeek = new Array(DAYS_OF_WEEK.length).fill(false); if (!loggedInPerson) return workingWeek; - daysOfWeek.forEach((weekDay, index)=>{ + DAYS_OF_WEEK.forEach((weekDay, index)=>{ if (loggedInPerson[weekDay as keyof typeof loggedInPerson] !== 0) { workingWeek[index] = true; } diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts index 052ffe27..a4c26a20 100644 --- a/src/hooks/use-api.ts +++ b/src/hooks/use-api.ts @@ -2,4 +2,4 @@ import { useAtomValue } from "jotai"; import { apiClientAtom, apiLambdasClientAtom } from "../atoms/api"; export const useApi = () => useAtomValue(apiClientAtom); -export const useLambdasApi = () => useAtomValue(apiLambdasClientAtom); +export const useLambdasApi = () => useAtomValue(apiLambdasClientAtom); \ No newline at end of file diff --git a/src/localization/en.json b/src/localization/en.json index 3f2c7870..459bbdb1 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -59,7 +59,26 @@ }, "sprint": { "sprintview": "Sprint view", - "sprintviewScreen": "Sprint view Screen" + "myAllocation": "My allocations", + "allocation": "Allocation", + "timeEntries": "Time Entries", + "allocationLeft": "Allocations Left", + "assigned": "Assignee", + "taskStatus": "Status", + "taskPriority": "Priority", + "estimatedTime": "Estimate", + "taskName": "Tasks", + "showMyTasks": "My tasks", + "toDo": "To do", + "inProgress": "In progress", + "allTasks" : "All tasks", + "notFound" : "No result found", + "projectName": "Project", + "search": "Search for a task, project or assignee", + "unAllocated": "Unallocated Time:", + "sprintDate": "Sprint", + "completed": "Done", + "current": "Current: {0}-{1}" }, "errors": { "fetchFailedGeneral": "There was an error fetching data", @@ -137,6 +156,13 @@ "noVacationRequestsFound": "No vacation requests found", "nameNotFound": "Name not found" }, + "sprintRequestError": { + "fetchError": "Fetching sprint data requests has failed", + "fetchTimeEntriesError": "Error fetching time entries", + "fetchAllocationError": "Error fetching time entries for allocation {0}: {1}", + "fetchTasksError": "Error fetching time entries for task {0}: {1}", + "fetchTaskIdError": "Task id not found" + }, "form": { "submit": "Submit", "update": "Update" diff --git a/src/localization/fi.json b/src/localization/fi.json index a6753ece..c678cd9a 100644 --- a/src/localization/fi.json +++ b/src/localization/fi.json @@ -59,7 +59,26 @@ }, "sprint": { "sprintview": "Sprintinäkymä", - "sprintviewScreen": "Sprintinäkymä näyttö" + "myAllocation": "Minun allokointi", + "allocation": "Allokointi ", + "timeEntries": "Aikamerkinnät", + "allocationLeft": "Jäljellä oleva alkoatio", + "assigned": "Luovutuksensaaja", + "taskStatus": "Tila", + "taskPriority": "Prioriteetti", + "estimatedTime": "Arvio", + "taskName": "Tehtävä", + "showMyTasks": "Tehtäväni ", + "toDo": "Odotuksessa", + "inProgress": "Käynnissä", + "allTasks" : "Kaikki tehtävät", + "notFound" : "Tulosta ei löytynyt", + "projectName": "Projekti", + "search": "Hae tehtävää, projektia tai vastaanottajaa", + "unAllocated": "Kohdistamaton aika:", + "sprintDate": "Sprint", + "completed": "Valmis", + "current": "Nykyinen: {0}-{1}" }, "errors": { "fetchFailedGeneral": "Tietojen hakeminen epäonnistui", @@ -137,6 +156,13 @@ "noVacationRequestsFound": "Lomahakemuksia ei löydetty", "nameNotFound": "Nimeä ei löydetty" }, + "sprintRequestError": { + "fetchError": "Sprintin tietopyyntöjen hakeminen epäonnistui", + "fetchTimeEntriesError": "Virhe aikamerkintöjen hakemisessa", + "fetchAllocationError": "Virhe aikamerkintöjen hakemisessa jakamista varten {0}: {1}", + "fetchTasksError": "Tehtävän {0} aikamerkintöjen noutovirhe: {1}", + "fetchTaskIdError": "Tehtävän tunnusta ei löytynyt" + }, "form": { "submit": "Lähetä", "update": "Päivitä" diff --git a/src/localization/strings.ts b/src/localization/strings.ts index 7551ecb2..189914ae 100644 --- a/src/localization/strings.ts +++ b/src/localization/strings.ts @@ -85,7 +85,26 @@ export interface Localized extends LocalizedStringsMethods { */ sprint: { sprintview: string; - sprintviewScreen: string + myAllocation: string, + allocation: string, + timeEntries: string, + allocationLeft: string, + assigned: string, + taskStatus: string, + taskPriority: string, + estimatedTime: string, + taskName: string, + showMyTasks: string, + toDo: string, + inProgress: string, + allTasks: string, + notFound: string, + projectName: string, + search: string, + unAllocated: string, + sprintDate: string, + completed: string, + current: string, }; /** * General time-related expressions @@ -162,6 +181,16 @@ export interface Localized extends LocalizedStringsMethods { noVacationRequestsFound: string; nameNotFound: string; }; + /** + * Translations related to sprint requests errors + */ + sprintRequestError: { + fetchError: string; + fetchTimeEntriesError: string; + fetchAllocationError: string; + fetchTasksError: string; + fetchTaskIdError: string; + }; /** * Translations related to form */ diff --git a/src/utils/sprint-utils.ts b/src/utils/sprint-utils.ts new file mode 100644 index 00000000..d982becd --- /dev/null +++ b/src/utils/sprint-utils.ts @@ -0,0 +1,136 @@ +import { Person } from "src/generated/client"; +import { Allocations, Projects, Tasks } from "src/generated/homeLambdasClient"; + +/** + * Retrieve total time entries for an allocation + * + * @param allocation allocation + * @param allocations list of allocations + * @param timeEntries list of total time entries associated with allocations + */ +export const getTotalTimeEntriesAllocations = ( + allocation: Allocations, + allocations: Allocations[], + timeEntries: number[] +) => { + if (timeEntries.length) { + return timeEntries[allocations.indexOf(allocation)] || 0; + } + return 0; +}; + +/** + * Retrieve total time entries for a task + * + * @param task task of allocated project + * @param tasks list of tasks related to the project + * @param timeEntries list of total time associated with tasks + */ +export const getTotalTimeEntriesTasks = (task: Tasks, tasks: Tasks[], timeEntries: number[]) => { + if (timeEntries.length) { + return timeEntries[tasks.indexOf(task)] || 0; + } + return 0; +}; + +/** + * Get project name + * + * @param allocation allocation + * @param allocations list of allocations + * @param projects list of project associated with the allocations + */ +export const getProjectName = ( + allocation: Allocations, + allocations: Allocations[], + projects: Projects[] +) => { + if (projects.length) { + return projects[allocations.indexOf(allocation)]?.name || ""; + } + return ""; +}; + +/** + * Get project color + * + * @param allocation allocation + * @param allocations list of allocations + * @param projects list of projects associated with allocations + */ +export const getProjectColor = ( + allocation: Allocations, + allocations: Allocations[], + projects: Projects[] +) => { + if (projects.length) { + return projects[allocations.indexOf(allocation)]?.color || ""; + } + return ""; +}; + +/** + * Calculate total time allocated to the project for 2 week period + * + * @param allocation expected work load of user in minutes + */ +export const totalAllocations = (allocation: Allocations) => { + const totalMinutes = + (allocation.monday || 0) + + (allocation.tuesday || 0) + + (allocation.wednesday || 0) + + (allocation.thursday || 0) + + (allocation.friday || 0); + return totalMinutes * 2; +}; + +/** + * Calculate the remaining time of project completion + * + * @param allocation allocation + * @param allocations list of allocations + * @param projects list of projects associated with allocations + */ +export const timeLeft = ( + allocation: Allocations, + allocations: Allocations[], + timeEntries: number[] +) => { + return ( + totalAllocations(allocation) - + getTotalTimeEntriesAllocations(allocation, allocations, timeEntries) || 0 + ); +}; + +/** + * Calculate registered time for the user in the current 2 week period + * + * @param person user time spent on the project in minutes + */ +export const calculateWorkingLoad = (person?: Person) => { + if (!person) return 0; + + const totalMinutes = + (person.monday || 0) + + (person.tuesday || 0) + + (person.wednesday || 0) + + (person.thursday || 0) + + (person.friday || 0); + return totalMinutes * 2; +}; + +/** + * Filter allocations and projects if project is not running + * + * @param allocations allocations + * @param projects list of running projects + */ +export const filterAllocationsAndProjects = (allocations: Allocations[], projects: Projects[]) => { + const filteredProjects: Projects[] = []; + const filteredAllocations = allocations.filter(allocation => projects.find(project => allocation.project === project.id)); + filteredAllocations.map(allocation => { + const allocationProject = projects.find(project => allocation.project === project.id); + if (allocationProject) filteredProjects.push(allocationProject); + }) + return {filteredAllocations, filteredProjects}; +} diff --git a/src/utils/time-utils.ts b/src/utils/time-utils.ts index df1cd111..8e8cf314 100644 --- a/src/utils/time-utils.ts +++ b/src/utils/time-utils.ts @@ -171,4 +171,25 @@ const countWorkingWeekDaysInRange = (startWeekIndex: number, endWeekIndex: numbe if (workingWeek[i-1]) takenWorkingDays+=1; } return takenWorkingDays; +} + +/** + * Get sprint start date + * + * @param date string date + */ +export const getSprintStart = (date: string) => { + const weekIndex = DateTime.fromISO(date).localWeekNumber; + const weekDay = DateTime.fromISO(date).weekday; + const days = (weekIndex%2 === 1 ? 0 : 7) + weekDay; + return DateTime.fromISO(date).minus({days : days - 1}); +} + +/** + * Get sprint end date + * + * @param date string date + */ +export const getSprintEnd = (date: string) => { + return getSprintStart(date).plus({days : 11}); } \ No newline at end of file