diff --git a/.cspell.json b/.cspell.json index 7bdabe23a..be2df850e 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,6 +4,8 @@ "caseSensitive": false, "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", "words": [ + " X", + " X ", "accepte", "Accordian", "adipiscing", @@ -38,6 +40,7 @@ "asel", "Authentificate", "authjs", + "autorun", "barcodes", "billrate", "binutils", @@ -74,6 +77,7 @@ "Codacy", "codecov", "Codementor", + "collapsable", "Combox", "combx", "commitlint", @@ -114,6 +118,7 @@ "Efate", "eiusmod", "electronmon", + "Elipssis", "elit", "ellipsize", "embla", @@ -125,9 +130,12 @@ "Environtment", "errr", "everco", + "evereq", "everteamsdesktop", + "everteamswebserver", "excalidraw", "exclamationcircleo", + "expanded", "exposdk", "extramenu", "Fakaofo", @@ -146,6 +154,7 @@ "Galery", "gauzystage", "gcloud", + "gitops", "Gitter", "GlobalSkeleton", "gradlew", @@ -226,6 +235,7 @@ "Loadtasks", "localstorage", "locatio", + "locutus", "loglevel", "longpress", "Lorem", @@ -253,6 +263,8 @@ "Northflank", "Notif", "nsis", + "nums", + "offcanvas", "Opena", "opentelemetry", "Ordereds", @@ -343,12 +355,14 @@ "tailess", "Tailess", "tailwindcss", + "timesheet-viewMode", "tanstack", "taskid", "taskstatus", "tblr", "teamsupercell", "teamtask", + "TEAMTASKS", "tempor", "testid", "timegrid", @@ -364,6 +378,7 @@ "TRANSFERT", "Transpiles", "tsbuildinfo", + "twing", "typeof", "uicolors", "uidotdev", @@ -394,29 +409,14 @@ "worksace", "Worspace", "X", + "X ", "xcodebuild", "xcodeproj", "xcworkspace", "Xlarge", "xlcard", "xlight", - "yellowbox", - "twing", - "gitops", - "autorun", - "locutus", - "X", - "offcanvas", - "nums", - "Elipssis", - "evereq", - "everteamswebserver", - "expanded", - "expanded", - " X", - " X ", - "X ", - "collapsable" + "yellowbox" ], "useGitignore": true, "ignorePaths": [ diff --git a/.vscode/settings.json b/.vscode/settings.json index 170edab21..49b03091f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,11 +18,14 @@ "source.fixAll": "explicit", "source.organizeImports": "never", "source.sortMembers": "never", - "organizeImports": "never" + "organizeImports": "never", + "source.removeUnusedImports": "always" }, "vsicons.presets.angular": true, "deepscan.enable": true, - "cSpell.words": ["Timepicker"], + "cSpell.words": [ + "Timepicker" + ], "files.exclude": { "**/.git": true, "**/.DS_Store": true, diff --git a/apps/web/app/[locale]/timesheet/components/CalendarView.tsx b/apps/web/app/[locale]/timesheet/components/CalendarView.tsx new file mode 100644 index 000000000..b7a4fd146 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/components/CalendarView.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +export function CalendarView() { + return ( +
+ +
+ ) +} diff --git a/apps/web/app/[locale]/timesheet/components/FilterWithStatus.tsx b/apps/web/app/[locale]/timesheet/components/FilterWithStatus.tsx new file mode 100644 index 000000000..ed38c2d23 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/components/FilterWithStatus.tsx @@ -0,0 +1,35 @@ +import React, { HTMLAttributes } from 'react'; +import { Button } from 'lib/components'; +import { clsxm } from '@app/utils'; + +export type FilterStatus = "All Tasks" | "Pending" | "Approved" | "Rejected"; +export function FilterWithStatus({ + activeStatus, + onToggle, + className +}: { + activeStatus: FilterStatus; + onToggle: (status: FilterStatus) => void; + className?: HTMLAttributes +}) { + const buttonData: { label: FilterStatus; count: number; icon: React.ReactNode }[] = [ + { label: 'All Tasks', count: 46, icon: }, + { label: 'Pending', count: 12, icon: }, + { label: 'Approved', count: 28, icon: }, + { label: 'Rejected', count: 6, icon: }, + ]; + + return ( +
+ {buttonData.map(({ label, count, icon }, index) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/[locale]/timesheet/components/FrequencySelect.tsx b/apps/web/app/[locale]/timesheet/components/FrequencySelect.tsx new file mode 100644 index 000000000..656e63aa0 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/components/FrequencySelect.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/ui/select" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@components/ui/dropdown-menu" +import { Button } from "lib/components/button"; + +export function FrequencySelect() { + const [selectedValue, setSelectedValue] = React.useState(undefined); + + const handleSelectChange = (value: string) => { + setSelectedValue(value); + }; + + return ( + + ); +} + + + + +export const FilterTaskActionMenu = () => { + // const handleCopyPaymentId = () => navigator.clipboard.writeText(idTasks); + return ( + + + + + + + Today + + + Last 7 days + + + Last 30 days + + + This year (2024) + {/* ({new Date().getFullYear()}) */} + + {/* */} + + + + ); +}; + +export const CustomDateRange = () => { + return ( + + + Custom Date Range + + + + +
+
+ Calendar +
+
+
+
+
+ ) +} diff --git a/apps/web/app/[locale]/timesheet/components/TimesheetCard.tsx b/apps/web/app/[locale]/timesheet/components/TimesheetCard.tsx new file mode 100644 index 000000000..687473020 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/components/TimesheetCard.tsx @@ -0,0 +1,65 @@ + +import { clsxm } from '@app/utils'; +import { ArrowRightIcon } from 'assets/svg'; +import { Button, Card } from 'lib/components'; +import React, { ReactNode } from 'react' + +interface ITimesheetCard { + title?: string; + date?: string + description?: string; + hours?: string; + count?: number; + color?: string; + icon?: ReactNode; + classNameIcon?: string + onClick?: () => void; +} + + +export function TimesheetCard({ ...props }: ITimesheetCard) { + const { icon, title, date, description, hours, count, onClick, classNameIcon } = props; + return ( + +
+
+

{hours ?? count}

+

{title}

+ {date ?? description} +
+ +
+ +
+ ) +} diff --git a/apps/web/app/[locale]/timesheet/components/TimesheetFilter.tsx b/apps/web/app/[locale]/timesheet/components/TimesheetFilter.tsx new file mode 100644 index 000000000..179d5ccc8 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/components/TimesheetFilter.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { FilterWithStatus } from './FilterWithStatus' +import { FrequencySelect } from '.'; +import { Button } from 'lib/components'; +import { SettingFilterIcon } from 'assets/svg'; + +export function TimesheetFilter() { + return ( +
+
+ { + console.log(label) + }} + /> +
+
+
+
+ + + +
+
+
+ + ) +} diff --git a/apps/web/app/[locale]/timesheet/components/TimesheetView.tsx b/apps/web/app/[locale]/timesheet/components/TimesheetView.tsx new file mode 100644 index 000000000..91b1d73d5 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/components/TimesheetView.tsx @@ -0,0 +1,10 @@ +import { DataTableTimeSheet } from 'lib/features/integrations/calendar' +import React from 'react' + +export function TimesheetView() { + return ( +
+ +
+ ) +} diff --git a/apps/web/app/[locale]/timesheet/components/index.tsx b/apps/web/app/[locale]/timesheet/components/index.tsx new file mode 100644 index 000000000..12cf616a7 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/components/index.tsx @@ -0,0 +1,6 @@ +export * from './TimesheetCard'; +export * from './TimesheetView'; +export * from './CalendarView'; +export * from './TimesheetFilter'; +export * from './FrequencySelect'; +export * from './FilterWithStatus'; diff --git a/apps/web/app/[locale]/timesheet/page.tsx b/apps/web/app/[locale]/timesheet/page.tsx new file mode 100644 index 000000000..c278f7fa5 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/page.tsx @@ -0,0 +1,162 @@ +"use client" +import React, { useMemo } from 'react'; +import { useParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; + +import { withAuthentication } from 'lib/app/authenticator'; +import { Breadcrumb, Container, Divider } from 'lib/components'; +import { Footer, MainLayout } from 'lib/layout'; + +import { useLocalStorageState, useOrganizationTeams } from '@app/hooks'; +import { clsxm } from '@app/utils'; +import { fullWidthState } from '@app/stores/fullWidth'; +import { useAtomValue } from 'jotai'; +import { ArrowLeftIcon } from 'assets/svg'; +import { CalendarView, TimesheetCard, TimesheetFilter, TimesheetView } from './components'; +import { CalendarDaysIcon, Clock, User2 } from 'lucide-react'; +import { GrTask } from "react-icons/gr"; +import { GoSearch } from "react-icons/go"; + +type TimesheetViewMode = "ListView" | "CalendarView" +type ViewToggleButtonProps = { + mode: TimesheetViewMode; + active: boolean; + icon: React.ReactNode; + onClick: () => void; +}; + +function TimeSheetPage() { + const t = useTranslations(); + const [timesheetNavigator, setTimesheetNavigator] = useLocalStorageState('timesheet-viewMode', 'ListView'); + + const fullWidth = useAtomValue(fullWidthState); + const { isTrackingEnabled, activeTeam } = useOrganizationTeams(); + + const params = useParams<{ locale: string }>(); + const currentLocale = params ? params.locale : null; + const breadcrumbPath = useMemo( + () => [ + { title: JSON.parse(t('pages.home.BREADCRUMB')), href: '/' }, + { title: activeTeam?.name || '', href: '/' }, + { title: 'Timesheet', href: `/${currentLocale}/timesheet` } + ], + [activeTeam?.name, currentLocale, t] + ); + return ( + < > + +
+ +
+
+ + +
+
+
+
+
+ +
+
+

Good morning, Ruslan !

+ This is your personal timesheet dashboard, showing you what needs your attention now. +
+
+ } + classNameIcon='bg-[#FBB650] shadow-[#fbb75095]' + /> + } + classNameIcon='bg-[#3D5A80] shadow-[#3d5a809c] ' + /> + } + classNameIcon='bg-[#30B366] shadow-[#30b3678f]' + /> +
+
+
+
+ } + mode='ListView' + active={timesheetNavigator === 'ListView'} + onClick={() => setTimesheetNavigator('ListView')} + /> + } + mode='CalendarView' + active={timesheetNavigator === 'CalendarView'} + onClick={() => setTimesheetNavigator('CalendarView')} + /> +
+
+ + +
+
+ {/* */} +
+ +
+ {timesheetNavigator === 'ListView' ? + + : + } +
+
+
+
+
+ + + ) +} + +export default withAuthentication(TimeSheetPage, { displayName: 'TimeSheet' }); + + +const FooterTimeSheet = ({ fullWidth }: { fullWidth: boolean }) => { + return ( +
+ +
+
+ ) +} +const ViewToggleButton: React.FC = ({ + mode, + active, + icon, + onClick +}) => ( + +); diff --git a/apps/web/app/hooks/features/useActiveTeam.ts b/apps/web/app/hooks/features/useActiveTeam.ts new file mode 100644 index 000000000..6736537dd --- /dev/null +++ b/apps/web/app/hooks/features/useActiveTeam.ts @@ -0,0 +1,44 @@ +'use client'; + +import { useOrganizationTeams, useTimer } from '@app/hooks'; +import { useToast } from '@components/ui/use-toast'; +import { useCallback } from 'react'; +import { TeamItem } from '@/lib/features/team/team-item'; +import { useTranslations } from 'next-intl'; + +export const useActiveTeam = () => { + const { activeTeam, setActiveTeam } = useOrganizationTeams(); + const { timerStatus, stopTimer } = useTimer(); + const t = useTranslations(); + const { toast } = useToast(); + const onChangeActiveTeam = useCallback( + (item: TeamItem) => { + if (item.data) { + /** + * If timer started in Teams and user switches the Team, stop the timer + */ + if ( + timerStatus && + timerStatus?.running && + timerStatus.lastLog && + timerStatus.lastLog.organizationTeamId && + timerStatus.lastLog.source === 'TEAMS' && + activeTeam && + activeTeam?.id && + timerStatus.lastLog.organizationTeamId === activeTeam?.id + ) { + toast({ + variant: 'default', + title: t('timer.TEAM_SWITCH.STOPPED_TIMER_TOAST_TITLE'), + description: t('timer.TEAM_SWITCH.STOPPED_TIMER_TOAST_DESCRIPTION') + }); + stopTimer(); + } + + setActiveTeam(item.data); + } + }, + [setActiveTeam, timerStatus, stopTimer, activeTeam, toast, t] + ); + return { activeTeam, setActiveTeam, onChangeActiveTeam }; +}; diff --git a/apps/web/app/hooks/features/useFavoritesTask.ts b/apps/web/app/hooks/features/useFavoritesTask.ts new file mode 100644 index 000000000..565accf3e --- /dev/null +++ b/apps/web/app/hooks/features/useFavoritesTask.ts @@ -0,0 +1,47 @@ +import { useTeamTasks } from '@/app/hooks/features/useTeamTasks'; +import { ITeamTask } from '@/app/interfaces/ITask'; +import { useAtom } from 'jotai'; +import { favoriteTasksStorageAtom } from '@/app/stores/team-tasks'; +import { useCallback } from 'react'; +/** + * A React hook that manages a list of favorite tasks for a team. + * + * The `useFavoritesTask` hook returns an object with the following properties: + * + * - `tasks`: The list of all tasks for the team, obtained from the `useTeamTasks` hook. + * - `favoriteTasks`: The list of favorite tasks. + * - `toggleFavorite`: A function that toggles the favorite status of a given task. + * - `isFavorite`: A function that checks if a given task is a favorite. + * - `addFavorite`: A function that adds a task to the list of favorites. + */ + +export const useFavoritesTask = () => { + const { tasks } = useTeamTasks(); + const [favoriteTasks, setFavoriteTasks] = useAtom(favoriteTasksStorageAtom); + + const toggleFavorite = useCallback((task: ITeamTask) => { + if (!task?.id) { + console.warn('Invalid task provided to toggleFavorite'); + return; + } + setFavoriteTasks((prev) => + prev.some((t) => t.id === task.id) ? prev.filter((t) => t.id !== task.id) : [...prev, task] + ); + }, []); + + const isFavorite = useCallback((task: ITeamTask) => favoriteTasks.some((t) => t.id === task.id), [favoriteTasks]); + + const addFavorite = useCallback((task: ITeamTask) => { + if (!isFavorite(task)) { + setFavoriteTasks((prev) => [...prev, task]); + } + }, []); + + return { + tasks, + favoriteTasks, + toggleFavorite, + isFavorite, + addFavorite + }; +}; diff --git a/apps/web/app/hooks/features/useOrganizationTeamManagers.ts b/apps/web/app/hooks/features/useOrganizationTeamManagers.ts index 4a1450465..32a0143c4 100644 --- a/apps/web/app/hooks/features/useOrganizationTeamManagers.ts +++ b/apps/web/app/hooks/features/useOrganizationTeamManagers.ts @@ -3,56 +3,67 @@ import { useAuthenticateUser } from './useAuthenticateUser'; import { useOrganizationTeams } from './useOrganizationTeams'; import { filterValue } from '@app/stores/all-teams'; import { useMemo } from 'react'; - +/** + * Provides a hook that returns the teams managed by the authenticated user, along with the ability to filter those teams based on the timer status of their members. + * + * @returns An object with two properties: + * - `userManagedTeams`: An array of teams that the authenticated user manages. + * - `filteredTeams`: An array of teams that the authenticated user manages, filtered based on the `filterValue` atom. + */ export function useOrganizationAndTeamManagers() { - const { user } = useAuthenticateUser(); - const { teams } = useOrganizationTeams(); - const { value: filtered } = useAtomValue(filterValue); + const { user } = useAuthenticateUser(); + const { teams } = useOrganizationTeams(); + const { value: filtered } = useAtomValue(filterValue); - const userManagedTeams = useMemo(() => { - return teams.filter((team) => - team.members.some( - (member) => - member.employee?.user?.id === user?.id && - member.role?.name === 'MANAGER' - ) - ); - }, [teams, user]); + /** + * Filters the teams managed by the authenticated user. + * + * @returns An array of teams that the authenticated user manages, where the authenticated user has the 'MANAGER' role for at least one member of the team. + */ + const userManagedTeams = useMemo(() => { + return teams.filter((team) => + team.members.some((member) => member.employee?.user?.id === user?.id && member.role?.name === 'MANAGER') + ); + }, [teams, user]); - const filteredTeams = useMemo(() => { - return filtered === 'all' - ? userManagedTeams - : filtered === 'pause' - ? userManagedTeams.map((team) => ({ - ...team, - members: team.members.filter( - (member) => member.timerStatus === 'pause' - ) - })) - : filtered === 'running' - ? userManagedTeams.map((team) => ({ - ...team, - members: team.members.filter( - (member) => member.timerStatus === 'running' - ) - })) - : filtered === 'suspended' - ? userManagedTeams.map((team) => ({ - ...team, - members: team.members.filter( - (member) => member.timerStatus === 'suspended' - ) - })) - : filtered === 'invited' - ? userManagedTeams.map((team) => ({ - ...team, - members: team.members.filter((member) => member.employee.acceptDate) - })) - : userManagedTeams; - }, [filtered, userManagedTeams]); + /** + * Filters the teams managed by the authenticated user based on the `filterValue` atom. + * + * @returns An array of teams that the authenticated user manages, filtered based on the `filterValue` atom. The filtering options include: + * - 'all': Returns all teams managed by the authenticated user. + * - 'pause': Returns teams where at least one member has a timer status of 'pause'. + * - 'running': Returns teams where at least one member has a timer status of 'running'. + * - 'suspended': Returns teams where at least one member has a timer status of 'suspended'. + * - 'invited': Returns teams where at least one member has an `acceptDate` value. + */ + const filteredTeams = useMemo(() => { + return filtered === 'all' + ? userManagedTeams + : filtered === 'pause' + ? userManagedTeams.map((team) => ({ + ...team, + members: team.members.filter((member) => member.timerStatus === 'pause') + })) + : filtered === 'running' + ? userManagedTeams.map((team) => ({ + ...team, + members: team.members.filter((member) => member.timerStatus === 'running') + })) + : filtered === 'suspended' + ? userManagedTeams.map((team) => ({ + ...team, + members: team.members.filter((member) => member.timerStatus === 'suspended') + })) + : filtered === 'invited' + ? userManagedTeams.map((team) => ({ + ...team, + members: team.members.filter((member) => member.employee.acceptDate) + })) + : userManagedTeams; + }, [filtered, userManagedTeams]); - return { - userManagedTeams, - filteredTeams - }; + return { + userManagedTeams, + filteredTeams + }; } diff --git a/apps/web/app/hooks/features/useOrganizationTeams.ts b/apps/web/app/hooks/features/useOrganizationTeams.ts index 2c3dad5cd..c44a89972 100644 --- a/apps/web/app/hooks/features/useOrganizationTeams.ts +++ b/apps/web/app/hooks/features/useOrganizationTeams.ts @@ -1,36 +1,32 @@ 'use client'; import { - getActiveTeamIdCookie, - setActiveProjectIdCookie, - setActiveTeamIdCookie, - setOrganizationIdCookie + getActiveTeamIdCookie, + setActiveProjectIdCookie, + setActiveTeamIdCookie, + setOrganizationIdCookie } from '@app/helpers/cookies'; +import { IOrganizationTeamList, IOrganizationTeamUpdate, IOrganizationTeamWithMStatus } from '@app/interfaces'; import { - IOrganizationTeamList, - IOrganizationTeamUpdate, - IOrganizationTeamWithMStatus -} from '@app/interfaces'; -import { - createOrganizationTeamAPI, - deleteOrganizationTeamAPI, - editOrganizationTeamAPI, - getOrganizationTeamAPI, - getOrganizationTeamsAPI, - removeUserFromAllTeamAPI, - updateOrganizationTeamAPI + createOrganizationTeamAPI, + deleteOrganizationTeamAPI, + editOrganizationTeamAPI, + getOrganizationTeamAPI, + getOrganizationTeamsAPI, + removeUserFromAllTeamAPI, + updateOrganizationTeamAPI } from '@app/services/client/api'; import { - activeTeamIdState, - activeTeamManagersState, - activeTeamState, - isTeamMemberJustDeletedState, - isTeamMemberState, - organizationTeamsState, - teamsFetchingState, - timerStatusState + activeTeamIdState, + activeTeamManagersState, + activeTeamState, + isTeamMemberJustDeletedState, + isTeamMemberState, + organizationTeamsState, + teamsFetchingState, + timerStatusState } from '@app/stores'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import isEqual from 'lodash/isEqual'; import { useFirstLoad } from '../useFirstLoad'; @@ -48,25 +44,25 @@ import { LAST_WORSPACE_AND_TEAM } from '@app/constants'; * setTeamUpdate: A function that can be used to update the teams state. */ function useTeamsState() { - const [teams, setTeams] = useAtom(organizationTeamsState); - const teamsRef = useSyncRef(teams); - - const setTeamsUpdate = useCallback( - (team: IOrganizationTeamWithMStatus) => { - // Update active teams fields with from team Status API - setTeams((tms) => { - return [...tms.filter((t) => t.id != team.id), team]; - }); - }, - [setTeams] - ); - - return { - teams, - setTeams, - setTeamsUpdate, - teamsRef - }; + const [teams, setTeams] = useAtom(organizationTeamsState); + const teamsRef = useSyncRef(teams); + + const setTeamsUpdate = useCallback( + (team: IOrganizationTeamWithMStatus) => { + // Update active teams fields with from team Status API + setTeams((tms) => { + return [...tms.filter((t) => t.id != team.id), team]; + }); + }, + [setTeams] + ); + + return { + teams, + setTeams, + setTeamsUpdate, + teamsRef + }; } /** @@ -76,398 +72,455 @@ function useTeamsState() { * 2. loading: A boolean value. */ function useCreateOrganizationTeam() { - const { loading, queryCall } = useQuery(createOrganizationTeamAPI); - const [teams, setTeams] = useAtom(organizationTeamsState); - const teamsRef = useSyncRef(teams); - const setActiveTeamId = useSetAtom(activeTeamIdState); - const { refreshToken, $user } = useAuthenticateUser(); - const [isTeamMember, setIsTeamMember] = useAtom(isTeamMemberState); - - const createOrganizationTeam = useCallback( - (name: string) => { - const teams = teamsRef.current; - const $name = name.trim(); - const exits = teams.find( - (t) => t.name.toLowerCase() === $name.toLowerCase() - ); - - if (exits || $name.length < 2 || !$user.current) { - return Promise.reject(new Error('Invalid team name !')); - } - - return queryCall($name, $user.current).then(async (res) => { - const dt = res.data?.items || []; - setTeams(dt); - const created = dt.find((t) => t.name === $name); - if (created) { - setActiveTeamIdCookie(created.id); - setOrganizationIdCookie(created.organizationId); - // This must be called at the end (Update store) - setActiveTeamId(created.id); - if (!isTeamMember) { - setIsTeamMember(true); - } - - /** - * DO NOT REMOVE - * Refresh Token needed for the first time when new Organization is created, As in backend permissions are getting updated - * */ - await refreshToken(); - } - return res; - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - isTeamMember, - queryCall, - refreshToken, - setActiveTeamId, - setIsTeamMember, - setTeams, - teamsRef - ] - ); - - return { - createOrganizationTeam, - loading - }; + const { loading, queryCall } = useQuery(createOrganizationTeamAPI); + const [teams, setTeams] = useAtom(organizationTeamsState); + const teamsRef = useSyncRef(teams); + const setActiveTeamId = useSetAtom(activeTeamIdState); + const { refreshToken, $user } = useAuthenticateUser(); + const [isTeamMember, setIsTeamMember] = useAtom(isTeamMemberState); + + const createOrganizationTeam = useCallback( + (name: string) => { + const teams = teamsRef.current; + const $name = name.trim(); + const exits = teams.find((t) => t.name.toLowerCase() === $name.toLowerCase()); + + if (exits || $name.length < 2 || !$user.current) { + return Promise.reject(new Error('Invalid team name !')); + } + + return queryCall($name, $user.current).then(async (res) => { + const dt = res.data?.items || []; + setTeams(dt); + const created = dt.find((t) => t.name === $name); + if (created) { + setActiveTeamIdCookie(created.id); + setOrganizationIdCookie(created.organizationId); + // This must be called at the end (Update store) + setActiveTeamId(created.id); + if (!isTeamMember) { + setIsTeamMember(true); + } + + /** + * DO NOT REMOVE + * Refresh Token needed for the first time when new Organization is created, As in backend permissions are getting updated + * */ + await refreshToken(); + } + return res; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isTeamMember, queryCall, refreshToken, setActiveTeamId, setIsTeamMember, setTeams, teamsRef] + ); + + return { + createOrganizationTeam, + loading + }; } /** * It takes a team and an optional data object and updates the team with the data */ function useUpdateOrganizationTeam() { - const { loading, queryCall } = useQuery(updateOrganizationTeamAPI); - const { setTeamsUpdate } = useTeamsState(); - - const updateOrganizationTeam = useCallback( - ( - team: IOrganizationTeamList, - data: Partial = {} - ) => { - const members = team.members; - - const body: Partial = { - id: team.id, - memberIds: members - .map((t) => t.employee.id) - .filter((value, index, array) => array.indexOf(value) === index), // To make the array Unique list of ids - managerIds: members - .filter((m) => m.role && m.role.name === 'MANAGER') - .map((t) => t.employee.id) - .filter((value, index, array) => array.indexOf(value) === index), // To make the array Unique list of ids - name: team.name, - tenantId: team.tenantId, - organizationId: team.organizationId, - tags: [], - ...data - }; - - /* Updating the team state with the data from the API. */ - queryCall(team.id, body).then((res) => { - setTeamsUpdate(res.data); - }); - }, - [queryCall, setTeamsUpdate] - ); - - return { updateOrganizationTeam, loading }; + const { loading, queryCall } = useQuery(updateOrganizationTeamAPI); + const { setTeamsUpdate } = useTeamsState(); + + const updateOrganizationTeam = useCallback( + (team: IOrganizationTeamList, data: Partial = {}) => { + const members = team.members; + + const body: Partial = { + id: team.id, + memberIds: members + .map((t) => t.employee.id) + .filter((value, index, array) => array.indexOf(value) === index), // To make the array Unique list of ids + managerIds: members + .filter((m) => m.role && m.role.name === 'MANAGER') + .map((t) => t.employee.id) + .filter((value, index, array) => array.indexOf(value) === index), // To make the array Unique list of ids + name: team.name, + tenantId: team.tenantId, + organizationId: team.organizationId, + tags: [], + ...data + }; + + /* Updating the team state with the data from the API. */ + queryCall(team.id, body).then((res) => { + setTeamsUpdate(res.data); + }); + }, + [queryCall, setTeamsUpdate] + ); + + return { updateOrganizationTeam, loading }; } - /** - * It returns an object with all the data and functions needed to manage the teams in the organization + * A powerful hook for managing organization teams with complete CRUD operations and state management. + * This hook centralizes all team-related operations and states in one place. + * + * @returns {Object} An object containing the following properties and methods: + * + * @property {() => Promise} loadTeamsData + * Function that fetches and synchronizes the latest teams data. It handles: + * - Loading the initial teams data + * - Updating the active team + * - Managing team cookies + * - Syncing with local storage + * + * @property {boolean} loading + * Global loading state for team operations + * + * @property {IOrganizationTeamList[]} teams + * Array containing all teams in the organization. Each team includes: + * - Team details (id, name, etc.) + * - Member information + * - Projects associated + * - Roles and permissions + * + * @property {boolean} teamsFetching + * Specific loading state for team fetching operations + * + * @property {IOrganizationTeamList} activeTeam + * Currently selected team with all its details + * + * @property {(team: IOrganizationTeamList) => void} setActiveTeam + * Sets the active team and handles: + * - Cookie updates + * - Local storage sync + * - Organization ID updates + * - Project ID updates + * + * @property {(name: string) => Promise} createOrganizationTeam + * Creates a new team with validation: + * - Checks for duplicate names + * - Validates name length + * - Updates necessary cookies + * - Refreshes authentication token + * + * @property {boolean} createOTeamLoading + * Loading state for team creation + * + * @property {any} firstLoadTeamsData + * Initial data loaded when the hook is first initialized + * + * @property {(data: IOrganizationTeamUpdate) => Promise} editOrganizationTeam + * Updates existing team information with full validation + * + * @property {boolean} editOrganizationTeamLoading + * Loading state for team editing operations + * + * @property {(id: string) => Promise} deleteOrganizationTeam + * Deletes a team and handles cleanup operations + * + * @property {boolean} deleteOrganizationTeamLoading + * Loading state for team deletion + * + * @property {ITeamManager[]} activeTeamManagers + * List of managers for the active team with their roles and permissions + * + * @property {(team: IOrganizationTeamList, data?: Partial) => void} updateOrganizationTeam + * Updates team details with partial data support + * + * @property {boolean} updateOTeamLoading + * Loading state for team updates + * + * @property {(teams: IOrganizationTeamList[]) => void} setTeams + * Updates the entire teams list with proper state management + * + * @property {boolean} isTeamMember + * Indicates if current user is a team member + * + * @property {boolean} removeUserFromAllTeamLoading + * Loading state for user removal operations + * + * @property {(userId: string) => Promise} removeUserFromAllTeam + * Removes user from all teams with proper cleanup: + * - Updates user permissions + * - Refreshes authentication + * - Updates team states + * + * @property {boolean} loadingTeam + * Loading state for single team operations + * + * @property {boolean} isTrackingEnabled + * Indicates if time tracking is enabled for current user + * + * @property {string | null} memberActiveTaskId + * ID of current user's active task, null if no active task + * + * @property {boolean} isTeamMemberJustDeleted + * Flag indicating recent member deletion + * + * @property {boolean} isTeamManager + * If the active user is a team manager + * + * @property {(value: boolean) => void} setIsTeamMemberJustDeleted + * Updates the member deletion state + * + * @example + * ```typescript + * const { + * teams, + * activeTeam, + * createOrganizationTeam, + * updateOrganizationTeam + * } = useOrganizationTeams(); + * + * // Create new team + * await createOrganizationTeam("New Team Name"); + * + * // Update team + * await updateOrganizationTeam(activeTeam, { name: "Updated Name" }); + * ``` */ + export function useOrganizationTeams() { - const { loading, queryCall, loadingRef } = useQuery(getOrganizationTeamsAPI); - const { - loading: loadingTeam, - queryCall: queryCallTeam, - loadingRef: loadingRefTeam - } = useQuery(getOrganizationTeamAPI); - const { teams, setTeams, setTeamsUpdate, teamsRef } = useTeamsState(); - const activeTeam = useAtomValue(activeTeamState); - - const activeTeamManagers = useAtomValue(activeTeamManagersState); - - const loadingTeamsRef = useSyncRef(loading); - - const [activeTeamId, setActiveTeamId] = useAtom(activeTeamIdState); - const [teamsFetching, setTeamsFetching] = useAtom(teamsFetchingState); - const [isTeamMemberJustDeleted, setIsTeamMemberJustDeleted] = useAtom( - isTeamMemberJustDeletedState - ); - // const [isTeamJustDeleted, setIsTeamJustDeleted] = useAtom(isTeamJustDeletedState); - const { firstLoad, firstLoadData: firstLoadTeamsData } = useFirstLoad(); - const [isTeamMember, setIsTeamMember] = useAtom(isTeamMemberState); - const { updateUserFromAPI, refreshToken, user } = useAuthenticateUser(); - const { updateAvatar: updateUserLastTeam } = useSettings(); - const timerStatus = useAtomValue(timerStatusState); - - // const setMemberActiveTaskId = useSetAtom(memberActiveTaskIdState); - - const currentUser = activeTeam?.members?.find( - (member) => member.employee.userId === user?.id - ); - - const memberActiveTaskId = - (timerStatus?.running && timerStatus?.lastLog?.taskId) || - currentUser?.activeTaskId || - null; - - const isTrackingEnabled = activeTeam?.members?.find( - (member) => member.employee.userId === user?.id && member.isTrackingEnabled - ) - ? true - : false; - - // useEffect(() => { - // setMemberActiveTaskId(memberActiveTaskId); - // }, [setMemberActiveTaskId, memberActiveTaskId]); - - // Updaters - const { - createOrganizationTeam, - loading: createOTeamLoading - } = useCreateOrganizationTeam(); - - const { - updateOrganizationTeam, - loading: updateOTeamLoading - } = useUpdateOrganizationTeam(); - - const { - loading: editOrganizationTeamLoading, - queryCall: editQueryCall - } = useQuery(editOrganizationTeamAPI); - - const { - loading: deleteOrganizationTeamLoading, - queryCall: deleteQueryCall - } = useQuery(deleteOrganizationTeamAPI); - - const { - loading: removeUserFromAllTeamLoading, - queryCall: removeUserFromAllTeamQueryCall - } = useQuery(removeUserFromAllTeamAPI); - - useEffect(() => { - setTeamsFetching(loading); - }, [loading, setTeamsFetching]); - - const setActiveTeam = useCallback( - (team: typeof teams[0]) => { - setActiveTeamIdCookie(team?.id); - setOrganizationIdCookie(team?.organizationId); - // This must be called at the end (Update store) - setActiveTeamId(team?.id); - - // Set Project Id to cookie - // TODO: Make it dynamic when we add Dropdown in Navbar - if (team && team?.projects && team.projects.length) { - setActiveProjectIdCookie(team.projects[0].id); - } - window && window?.localStorage.setItem(LAST_WORSPACE_AND_TEAM, team.id); - if (user) updateUserLastTeam({ id: user.id, lastTeamId: team.id }); - }, - [setActiveTeamId, updateUserLastTeam, user] - ); - - const loadTeamsData = useCallback(() => { - if ( - loadingRef.current || - loadingRefTeam.current || - !user?.employee.organizationId || - !user?.employee.tenantId - ) { - return; - } - - let teamId = getActiveTeamIdCookie(); - setActiveTeamId(teamId); - - return queryCall( - user?.employee.organizationId, - user?.employee.tenantId - ).then((res) => { - if (res.data?.items && res.data?.items?.length === 0) { - setIsTeamMember(false); - setIsTeamMemberJustDeleted(true); - } - const latestTeams = res.data?.items || []; - - const latestTeamsSorted = latestTeams - .slice() - .sort((a: any, b: any) => a.name.localeCompare(b.name)); - - const teamsRefSorted = teamsRef.current - .slice() - .sort((a, b) => a.name.localeCompare(b.name)); - - /** - * Check deep equality, - * No need to update state if all the Team details are same - * (It prevents unnecessary re-rendering) - * - * Use teamsRef to make we always get the lastest value - */ - if (!teamId && !isEqual(latestTeamsSorted, teamsRefSorted)) { - setTeams(latestTeams); - } - - // Handle case where user might Remove Account from all teams, - // In such case need to update active team with Latest list of Teams - if ( - !latestTeams.find((team: any) => team.id === teamId) && - latestTeams.length - ) { - setIsTeamMemberJustDeleted(true); - setActiveTeam(latestTeams[0]); - } else if (!latestTeams.length) { - teamId = ''; - } - - teamId && - user?.employee.organizationId && - user?.employee.tenantId && - queryCallTeam( - teamId, - user?.employee.organizationId, - user?.employee.tenantId - ).then((res) => { - const newTeam = res.data; - - /** - * Check deep equality, - * No need to update state if all the Team details are same - * (It prevents unnecessary re-rendering) - */ - if (!isEqual(latestTeamsSorted, teamsRefSorted)) { - setTeams([ - newTeam, - ...latestTeams.filter((team: any) => team.id !== newTeam.id) - ]); - - // Set Project Id to cookie - // TODO: Make it dynamic when we add Dropdown in Navbar - if (newTeam && newTeam.projects && newTeam.projects.length) { - setActiveProjectIdCookie(newTeam.projects[0].id); - } - } - }); - - return res; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - queryCall, - queryCallTeam, - setActiveTeam, - setActiveTeamId, - setIsTeamMember, - setTeams - ]); - - /** - * Get active team profile from api - */ - useEffect(() => { - if ( - activeTeamId && - firstLoad && - user?.employee.organizationId && - user?.employee.tenantId - ) { - getOrganizationTeamAPI( - activeTeamId, - user?.employee.organizationId, - user?.employee.tenantId - ).then((res) => { - !loadingTeamsRef.current && setTeamsUpdate(res.data); - }); - } - }, [ - activeTeamId, - firstLoad, - loadingTeamsRef, - setTeams, - setTeamsUpdate, - user?.employee?.organizationId, - user?.employee?.tenantId - ]); - - const editOrganizationTeam = useCallback( - (data: IOrganizationTeamUpdate) => { - return editQueryCall(data).then((res) => { - setTeamsUpdate(res.data); - return res; - }); - }, - [editQueryCall, setTeamsUpdate] - ); - - const deleteOrganizationTeam = useCallback( - (id: string) => { - return deleteQueryCall(id).then((res) => { - loadTeamsData(); - return res; - }); - }, - [deleteQueryCall, loadTeamsData] - ); - - const removeUserFromAllTeam = useCallback( - (userId: string) => { - return removeUserFromAllTeamQueryCall(userId).then((res) => { - loadTeamsData(); - refreshToken().then(() => { - updateUserFromAPI(); - }); - - return res; - }); - }, - [ - loadTeamsData, - removeUserFromAllTeamQueryCall, - refreshToken, - updateUserFromAPI - ] - ); - - useEffect(() => { - if (activeTeam?.projects && activeTeam?.projects?.length) { - setActiveProjectIdCookie(activeTeam?.projects[0]?.id); - } - }, [activeTeam]); - - return { - loadTeamsData, - loading, - teams, - teamsFetching, - activeTeam, - setActiveTeam, - createOrganizationTeam, - createOTeamLoading, - firstLoadTeamsData, - editOrganizationTeam, - editOrganizationTeamLoading, - deleteOrganizationTeam, - deleteOrganizationTeamLoading, - activeTeamManagers, - updateOrganizationTeam, - updateOTeamLoading, - setTeams, - isTeamMember, - removeUserFromAllTeamLoading, - removeUserFromAllTeamQueryCall, - removeUserFromAllTeam, - loadingTeam, - isTrackingEnabled, - memberActiveTaskId, - isTeamMemberJustDeleted, - setIsTeamMemberJustDeleted - }; + const { loading, queryCall, loadingRef } = useQuery(getOrganizationTeamsAPI); + const { + loading: loadingTeam, + queryCall: queryCallTeam, + loadingRef: loadingRefTeam + } = useQuery(getOrganizationTeamAPI); + const { teams, setTeams, setTeamsUpdate, teamsRef } = useTeamsState(); + const activeTeam = useAtomValue(activeTeamState); + + const activeTeamManagers = useAtomValue(activeTeamManagersState); + + const loadingTeamsRef = useSyncRef(loading); + + const [activeTeamId, setActiveTeamId] = useAtom(activeTeamIdState); + const [teamsFetching, setTeamsFetching] = useAtom(teamsFetchingState); + const [isTeamMemberJustDeleted, setIsTeamMemberJustDeleted] = useAtom(isTeamMemberJustDeletedState); + // const [isTeamJustDeleted, setIsTeamJustDeleted] = useAtom(isTeamJustDeletedState); + const { firstLoad, firstLoadData: firstLoadTeamsData } = useFirstLoad(); + const [isTeamMember, setIsTeamMember] = useAtom(isTeamMemberState); + const { updateUserFromAPI, refreshToken, user } = useAuthenticateUser(); + const { updateAvatar: updateUserLastTeam } = useSettings(); + const timerStatus = useAtomValue(timerStatusState); + + const [isTeamManager, setIsTeamManager] = useState(false); + // const setMemberActiveTaskId = useSetAtom(memberActiveTaskIdState); + + const members = activeTeam?.members || []; + const currentUser = activeTeam?.members?.find((member) => member.employee.userId === user?.id); + + const memberActiveTaskId = + (timerStatus?.running && timerStatus?.lastLog?.taskId) || currentUser?.activeTaskId || null; + + const isTrackingEnabled = activeTeam?.members?.find( + (member) => member.employee.userId === user?.id && member.isTrackingEnabled + ) + ? true + : false; + + // useEffect(() => { + // setMemberActiveTaskId(memberActiveTaskId); + // }, [setMemberActiveTaskId, memberActiveTaskId]); + + // Updaters + const { createOrganizationTeam, loading: createOTeamLoading } = useCreateOrganizationTeam(); + + const { updateOrganizationTeam, loading: updateOTeamLoading } = useUpdateOrganizationTeam(); + + const { loading: editOrganizationTeamLoading, queryCall: editQueryCall } = useQuery(editOrganizationTeamAPI); + + const { loading: deleteOrganizationTeamLoading, queryCall: deleteQueryCall } = useQuery(deleteOrganizationTeamAPI); + + const { loading: removeUserFromAllTeamLoading, queryCall: removeUserFromAllTeamQueryCall } = + useQuery(removeUserFromAllTeamAPI); + + const isManager = useCallback(() => { + const $u = user; + const isM = members.find((member) => { + const isUser = member.employee.userId === $u?.id; + return isUser && member.role && member.role.name === 'MANAGER'; + }); + setIsTeamManager(!!isM); + }, [user, members]); + useEffect(() => { + setTeamsFetching(loading); + }, [loading, setTeamsFetching]); + + const setActiveTeam = useCallback( + (team: (typeof teams)[0]) => { + setActiveTeamIdCookie(team?.id); + setOrganizationIdCookie(team?.organizationId); + // This must be called at the end (Update store) + setActiveTeamId(team?.id); + + // Set Project Id to cookie + // TODO: Make it dynamic when we add Dropdown in Navbar + if (team && team?.projects && team.projects.length) { + setActiveProjectIdCookie(team.projects[0].id); + } + window && window?.localStorage.setItem(LAST_WORSPACE_AND_TEAM, team.id); + if (user) updateUserLastTeam({ id: user.id, lastTeamId: team.id }); + }, + [setActiveTeamId, updateUserLastTeam, user] + ); + + const loadTeamsData = useCallback(() => { + if ( + loadingRef.current || + loadingRefTeam.current || + !user?.employee.organizationId || + !user?.employee.tenantId + ) { + return; + } + + let teamId = getActiveTeamIdCookie(); + setActiveTeamId(teamId); + + return queryCall(user?.employee.organizationId, user?.employee.tenantId).then((res) => { + if (res.data?.items && res.data?.items?.length === 0) { + setIsTeamMember(false); + setIsTeamMemberJustDeleted(true); + } + const latestTeams = res.data?.items || []; + + const latestTeamsSorted = latestTeams.slice().sort((a: any, b: any) => a.name.localeCompare(b.name)); + + const teamsRefSorted = teamsRef.current.slice().sort((a, b) => a.name.localeCompare(b.name)); + + /** + * Check deep equality, + * No need to update state if all the Team details are same + * (It prevents unnecessary re-rendering) + * + * Use teamsRef to make we always get the lastest value + */ + if (!teamId && !isEqual(latestTeamsSorted, teamsRefSorted)) { + setTeams(latestTeams); + } + + // Handle case where user might Remove Account from all teams, + // In such case need to update active team with Latest list of Teams + if (!latestTeams.find((team: any) => team.id === teamId) && latestTeams.length) { + setIsTeamMemberJustDeleted(true); + setActiveTeam(latestTeams[0]); + } else if (!latestTeams.length) { + teamId = ''; + } + + teamId && + user?.employee.organizationId && + user?.employee.tenantId && + queryCallTeam(teamId, user?.employee.organizationId, user?.employee.tenantId).then((res) => { + const newTeam = res.data; + + /** + * Check deep equality, + * No need to update state if all the Team details are same + * (It prevents unnecessary re-rendering) + */ + if (!isEqual(latestTeamsSorted, teamsRefSorted)) { + setTeams([newTeam, ...latestTeams.filter((team: any) => team.id !== newTeam.id)]); + + // Set Project Id to cookie + // TODO: Make it dynamic when we add Dropdown in Navbar + if (newTeam && newTeam.projects && newTeam.projects.length) { + setActiveProjectIdCookie(newTeam.projects[0].id); + } + } + }); + + return res; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryCall, queryCallTeam, setActiveTeam, setActiveTeamId, setIsTeamMember, setTeams]); + + /** + * Get active team profile from api + */ + useEffect(() => { + if (activeTeamId && firstLoad && user?.employee.organizationId && user?.employee.tenantId) { + getOrganizationTeamAPI(activeTeamId, user?.employee.organizationId, user?.employee.tenantId).then((res) => { + !loadingTeamsRef.current && setTeamsUpdate(res.data); + }); + } + }, [ + activeTeamId, + firstLoad, + loadingTeamsRef, + setTeams, + setTeamsUpdate, + user?.employee?.organizationId, + user?.employee?.tenantId + ]); + + const editOrganizationTeam = useCallback( + (data: IOrganizationTeamUpdate) => { + return editQueryCall(data).then((res) => { + setTeamsUpdate(res.data); + return res; + }); + }, + [editQueryCall, setTeamsUpdate] + ); + + const deleteOrganizationTeam = useCallback( + (id: string) => { + return deleteQueryCall(id).then((res) => { + loadTeamsData(); + return res; + }); + }, + [deleteQueryCall, loadTeamsData] + ); + + const removeUserFromAllTeam = useCallback( + (userId: string) => { + return removeUserFromAllTeamQueryCall(userId).then((res) => { + loadTeamsData(); + refreshToken().then(() => { + updateUserFromAPI(); + }); + + return res; + }); + }, + [loadTeamsData, removeUserFromAllTeamQueryCall, refreshToken, updateUserFromAPI] + ); + + useEffect(() => { + if (activeTeam?.projects && activeTeam?.projects?.length) { + setActiveProjectIdCookie(activeTeam?.projects[0]?.id); + } + isManager(); + }, [activeTeam]); + + return { + loadTeamsData, + loading, + teams, + teamsFetching, + activeTeam, + setActiveTeam, + createOrganizationTeam, + createOTeamLoading, + firstLoadTeamsData, + editOrganizationTeam, + editOrganizationTeamLoading, + deleteOrganizationTeam, + deleteOrganizationTeamLoading, + activeTeamManagers, + updateOrganizationTeam, + updateOTeamLoading, + setTeams, + isTeamMember, + isTeamManager, + removeUserFromAllTeamLoading, + removeUserFromAllTeamQueryCall, + removeUserFromAllTeam, + loadingTeam, + isTrackingEnabled, + memberActiveTaskId, + isTeamMemberJustDeleted, + setIsTeamMemberJustDeleted + }; } diff --git a/apps/web/app/hooks/features/useTeamTasks.ts b/apps/web/app/hooks/features/useTeamTasks.ts index fda1bbfad..a85217c6c 100644 --- a/apps/web/app/hooks/features/useTeamTasks.ts +++ b/apps/web/app/hooks/features/useTeamTasks.ts @@ -2,41 +2,36 @@ /* eslint-disable no-mixed-spaces-and-tabs */ import { - getActiveTaskIdCookie, - getActiveUserTaskCookie, - setActiveTaskIdCookie, - setActiveUserTaskCookie + getActiveTaskIdCookie, + getActiveUserTaskCookie, + setActiveTaskIdCookie, + setActiveUserTaskCookie } from '@app/helpers'; +import { ITaskLabelsItemList, ITaskStatusField, ITaskStatusStack, ITeamTask } from '@app/interfaces'; import { - ITaskLabelsItemList, - ITaskStatusField, - ITaskStatusStack, - ITeamTask -} from '@app/interfaces'; -import { - createTeamTaskAPI, - deleteTaskAPI, - getTeamTasksAPI, - updateTaskAPI, - deleteEmployeeFromTasksAPI, - getTasksByIdAPI, - getTasksByEmployeeIdAPI, - getAllDayPlansAPI, - getMyDailyPlansAPI + createTeamTaskAPI, + deleteTaskAPI, + getTeamTasksAPI, + updateTaskAPI, + deleteEmployeeFromTasksAPI, + getTasksByIdAPI, + getTasksByEmployeeIdAPI, + getAllDayPlansAPI, + getMyDailyPlansAPI } from '@app/services/client/api'; import { - activeTeamState, - activeTeamTaskId, - dailyPlanListState, - detailedTaskState, - // employeeTasksState, - memberActiveTaskIdState, - myDailyPlanListState, - userState, - activeTeamTaskState, - tasksByTeamState, - tasksFetchingState, - teamTasksState + activeTeamState, + activeTeamTaskId, + dailyPlanListState, + detailedTaskState, + // employeeTasksState, + memberActiveTaskIdState, + myDailyPlanListState, + userState, + activeTeamTaskState, + tasksByTeamState, + tasksFetchingState, + teamTasksState } from '@app/stores'; import isEqual from 'lodash/isEqual'; import { useCallback, useEffect } from 'react'; @@ -48,510 +43,511 @@ import { useOrganizationEmployeeTeams } from './useOrganizatioTeamsEmployee'; import { useAuthenticateUser } from './useAuthenticateUser'; import { useTaskStatus } from './useTaskStatus'; +/** + * A React hook that provides functionality for managing team tasks, including creating, updating, deleting, and fetching tasks. + * + * @returns {Object} An object containing various functions and state related to team tasks. + * @property {ITeamTask[]} tasks - The list of team tasks. + * @property {boolean} loading - Indicates whether the tasks are currently being loaded. + * @property {boolean} tasksFetching - Indicates whether the tasks are currently being fetched. + * @property {(task: ITeamTask) => Promise} deleteTask - A function to delete a task. + * @property {boolean} deleteLoading - Indicates whether a task is currently being deleted. + * @property {(taskData: { taskName: string; issueType?: string; status?: string; taskStatusId: string; priority?: string; size?: string; tags?: ITaskLabelsItemList[]; description?: string | null; }, members?: { id: string }[]) => Promise} createTask - A function to create a new task. + * @property {boolean} createLoading - Indicates whether a task is currently being created. + * @property {(task: Partial & { id: string }) => Promise} updateTask - A function to update an existing task. + * @property {boolean} updateLoading - Indicates whether a task is currently being updated. + * @property {(task: ITeamTask | null) => void} setActiveTask - A function to set the active task. + * @property {ITeamTask | null} activeTeamTask - The currently active team task. + * @property {any} firstLoadTasksData - Data related to the first load of tasks. + * @property {(newTitle: string, task?: ITeamTask | null, loader?: boolean) => Promise} updateTitle - A function to update the title of a task. + * @property {(newDescription: string, task?: ITeamTask | null, loader?: boolean) => Promise} updateDescription - A function to update the description of a task. + * @property {(publicity: boolean, task?: ITeamTask | null, loader?: boolean) => Promise} updatePublicity - A function to update the publicity of a task. + * @property {(status: ITaskStatusStack[T], field: T, taskStatusId: ITeamTask['taskStatusId'], task?: ITeamTask | null, loader?: boolean) => Promise} handleStatusUpdate - A function to update the status of a task. + * @property {(employeeId: string, organizationTeamId: string) => void} getTasksByEmployeeId - A function to fetch tasks by employee ID. + * @property {boolean} getTasksByEmployeeIdLoading - Indicates whether tasks are currently being fetched by employee ID. + * @property {ITeamTask['organizationId']} activeTeamId - The ID of the active team. + * @property {() => void} unassignAuthActiveTask - A function to unassign the active task of the authenticated user. + * @property {(tasks: ITeamTask[]) => void} setAllTasks - A function to set all the tasks. + * @property {(deepCheck?: boolean) => Promise} loadTeamTasksData - A function to load the team tasks data. + * @property {(employeeId: string, organizationTeamId: string) => void} deleteEmployeeFromTasks - A function to delete an employee from tasks. + * @property {boolean} deleteEmployeeFromTasksLoading - Indicates whether an employee is currently being deleted from tasks. + * @property {(taskId: string) => Promise} getTaskById - A function to fetch a task by its ID. + * @property {boolean} getTasksByIdLoading - Indicates whether a task is currently being fetched by its ID. + * @property {ITeamTask | null} detailedTask - The detailed task. + */ + export function useTeamTasks() { - const { - updateOrganizationTeamEmployeeActiveTask - } = useOrganizationEmployeeTeams(); - const { user, $user } = useAuthenticateUser(); - - const setAllTasks = useSetAtom(teamTasksState); - const tasks = useAtomValue(tasksByTeamState); - const [detailedTask, setDetailedTask] = useAtom(detailedTaskState); - // const allTaskStatistics = useAtomValue(allTaskStatisticsState); - const tasksRef = useSyncRef(tasks); - - const [tasksFetching, setTasksFetching] = useAtom(tasksFetchingState); - const authUser = useSyncRef(useAtomValue(userState)); - const memberActiveTaskId = useAtomValue(memberActiveTaskIdState); - const $memberActiveTaskId = useSyncRef(memberActiveTaskId); - // const [employeeState, setEmployeeState] = useAtom(employeeTasksState); - const { taskStatus } = useTaskStatus(); - const activeTeam = useAtomValue(activeTeamState); - const activeTeamRef = useSyncRef(activeTeam); - - const [activeTeamTask, setActiveTeamTask] = useAtom(activeTeamTaskState); - - const { firstLoad, firstLoadData: firstLoadTasksData } = useFirstLoad(); - - const setDailyPlan = useSetAtom(dailyPlanListState); - const setMyDailyPlans = useSetAtom(myDailyPlanListState); - - // Queries hooks - const { queryCall, loading, loadingRef } = useQuery(getTeamTasksAPI); - const { - queryCall: getTasksByIdQueryCall, - loading: getTasksByIdLoading - } = useQuery(getTasksByIdAPI); - const { - queryCall: getTasksByEmployeeIdQueryCall, - loading: getTasksByEmployeeIdLoading - } = useQuery(getTasksByEmployeeIdAPI); - - const { queryCall: deleteQueryCall, loading: deleteLoading } = useQuery( - deleteTaskAPI - ); - - const { queryCall: createQueryCall, loading: createLoading } = useQuery( - createTeamTaskAPI - ); - - const { queryCall: updateQueryCall, loading: updateLoading } = useQuery( - updateTaskAPI - ); - - const { queryCall: getAllQueryCall } = useQuery(getAllDayPlansAPI); - const { queryCall: getMyDailyPlansQueryCall } = useQuery(getMyDailyPlansAPI); - - const { - queryCall: deleteEmployeeFromTasksQueryCall, - loading: deleteEmployeeFromTasksLoading - } = useQuery(deleteEmployeeFromTasksAPI); - - const getAllDayPlans = useCallback(async () => { - const response = await getAllQueryCall(); - - if (response.data.items.length) { - const { items, total } = response.data; - setDailyPlan({ items, total }); - } - }, [getAllQueryCall, setDailyPlan]); - - const getMyDailyPlans = useCallback(async () => { - const response = await getMyDailyPlansQueryCall(); - - if (response.data.items.length) { - const { items, total } = response.data; - setMyDailyPlans({ items, total }); - } - }, [getMyDailyPlansQueryCall, setMyDailyPlans]); - - const getTaskById = useCallback( - (taskId: string) => { - tasksRef.current.forEach((task) => { - if (task.id === taskId) { - setDetailedTask(task); - } - }); - - return getTasksByIdQueryCall(taskId).then((res) => { - setDetailedTask(res?.data || null); - return res; - }); - }, - [getTasksByIdQueryCall, setDetailedTask, tasksRef] - ); - - const getTasksByEmployeeId = useCallback( - (employeeId: string, organizationTeamId: string) => { - return getTasksByEmployeeIdQueryCall(employeeId, organizationTeamId).then( - (res) => { - // setEmployeeState(res?.data || []); - return res.data; - } - ); - }, - [getTasksByEmployeeIdQueryCall] - ); - - const deepCheckAndUpdateTasks = useCallback( - (responseTasks: ITeamTask[], deepCheck?: boolean) => { - if (responseTasks && responseTasks.length) { - responseTasks.forEach((task) => { - if (task.tags && task.tags?.length) { - task.label = task.tags[0].name; - } - }); - } - - /** - * When deepCheck enabled, - * then update the tasks store only when active-team tasks have an update - */ - if (deepCheck) { - const latestActiveTeamTasks = responseTasks - .filter((task) => { - return task.teams.some((tm) => { - return tm.id === activeTeamRef.current?.id; - }); - }) - .sort((a, b) => a.title.localeCompare(b.title)); - - const activeTeamTasks = tasksRef.current - .slice() - .sort((a, b) => a.title.localeCompare(b.title)); - - if (!isEqual(latestActiveTeamTasks, activeTeamTasks)) { - // Fetch plans with updated task(s) - getMyDailyPlans(); - getAllDayPlans(); - setAllTasks(responseTasks); - } - } else { - setAllTasks(responseTasks); - } - }, - [activeTeamRef, getAllDayPlans, getMyDailyPlans, setAllTasks, tasksRef] - ); - - const loadTeamTasksData = useCallback( - (deepCheck?: boolean) => { - if (loadingRef.current || !user || !activeTeamRef.current?.id) { - return new Promise((response) => { - response(true); - }); - } - - return queryCall( - user?.employee.organizationId, - user?.employee.tenantId, - activeTeamRef.current?.projects && - activeTeamRef.current?.projects.length - ? activeTeamRef.current?.projects[0].id - : '', - activeTeamRef.current?.id || '' - ).then((res) => { - deepCheckAndUpdateTasks(res?.data?.items || [], deepCheck); - return res; - }); - }, - [queryCall, deepCheckAndUpdateTasks, loadingRef, user, activeTeamRef] - ); - - // Global loading state - useEffect(() => { - if (firstLoad) { - setTasksFetching(loading); - } - }, [loading, firstLoad, setTasksFetching]); - - const setActiveUserTaskCookieCb = useCallback( - (task: ITeamTask | null) => { - if (task?.id && authUser.current?.id) { - setActiveUserTaskCookie({ - taskId: task?.id, - userId: authUser.current?.id - }); - } else { - setActiveUserTaskCookie({ - taskId: '', - userId: '' - }); - } - }, - [authUser] - ); - - // Reload tasks after active team changed - useEffect(() => { - if (activeTeam?.id && firstLoad) { - loadTeamTasksData(); - } - }, [activeTeam?.id, firstLoad, loadTeamTasksData]); - const setActive = useSetAtom(activeTeamTaskId); - - // Get the active task from cookie and put on global store - useEffect(() => { - if (firstLoad) { - const active_user_task = getActiveUserTaskCookie(); - const active_taskid = - active_user_task?.userId === authUser.current?.id - ? active_user_task?.taskId - : getActiveTaskIdCookie() || ''; - - setActiveTeamTask(tasks.find((ts) => ts.id === active_taskid) || null); - } - }, [setActiveTeamTask, tasks, firstLoad, authUser]); - - // Queries calls - const deleteTask = useCallback( - (task: typeof tasks[0]) => { - return deleteQueryCall(task.id).then((res) => { - const affected = res.data?.affected || 0; - if (affected > 0) { - setAllTasks((ts) => { - return ts.filter((t) => t.id !== task.id); - }); - } - return res; - }); - }, - [deleteQueryCall, setAllTasks] - ); - - const createTask = useCallback( - ( - { - taskName, - issueType, - taskStatusId, - status = taskStatus[0]?.name, - priority, - size, - tags, - description - }: { - taskName: string; - issueType?: string; - status?: string; - taskStatusId: string; - priority?: string; - size?: string; - tags?: ITaskLabelsItemList[]; - description?: string | null; - }, - members?: { id: string }[] - ) => { - return createQueryCall( - { - title: taskName, - issueType, - status, - priority, - size, - tags, - // Set Project Id to cookie - // TODO: Make it dynamic when we add Dropdown in Navbar - ...(activeTeam?.projects && activeTeam?.projects.length > 0 - ? { - projectId: activeTeam.projects[0].id - } - : {}), - ...(description ? { description: `

${description}

` } : {}), - ...(members ? { members } : {}), - taskStatusId: taskStatusId - }, - $user.current - ).then((res) => { - deepCheckAndUpdateTasks(res?.data?.items || [], true); - return res; - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [createQueryCall, deepCheckAndUpdateTasks, activeTeam] - ); - - const updateTask = useCallback( - (task: Partial & { id: string }) => { - return updateQueryCall(task.id, task).then((res) => { - setActive({ - id: '' - }); - const updatedTasks = res?.data?.items || []; - deepCheckAndUpdateTasks(updatedTasks, true); - - if (detailedTask) { - getTaskById(detailedTask.id); - } - - return res; - }); - }, - [ - updateQueryCall, - setActive, - deepCheckAndUpdateTasks, - detailedTask, - getTaskById - ] - ); - - const updateTitle = useCallback( - (newTitle: string, task?: ITeamTask | null, loader?: boolean) => { - if (task && newTitle !== task.title) { - loader && setTasksFetching(true); - return updateTask({ - ...task, - title: newTitle - }).then((res) => { - setTasksFetching(false); - return res; - }); - } - return Promise.resolve(); - }, - [updateTask, setTasksFetching] - ); - - const updateDescription = useCallback( - (newDescription: string, task?: ITeamTask | null, loader?: boolean) => { - if (task && newDescription !== task.description) { - loader && setTasksFetching(true); - return updateTask({ - ...task, - description: newDescription - }).then((res) => { - setTasksFetching(false); - return res; - }); - } - return Promise.resolve(); - }, - [updateTask, setTasksFetching] - ); - - const updatePublicity = useCallback( - (publicity: boolean, task?: ITeamTask | null, loader?: boolean) => { - if (task && publicity !== task.public) { - loader && setTasksFetching(true); - return updateTask({ - ...task, - public: publicity - }).then((res) => { - setTasksFetching(false); - return res; - }); - } - return Promise.resolve(); - }, - [updateTask, setTasksFetching] - ); - - const handleStatusUpdate = useCallback( - ( - status: ITaskStatusStack[T], - field: T, - taskStatusId: ITeamTask['taskStatusId'], - task?: ITeamTask | null, - loader?: boolean - ) => { - if (task && status !== task[field]) { - loader && setTasksFetching(true); - - if (field === 'status' && status === 'closed') { - const active_user_task = getActiveUserTaskCookie(); - if (active_user_task?.taskId === task.id) { - setActiveUserTaskCookie({ - taskId: '', - userId: '' - }); - } - const active_task_id = getActiveTaskIdCookie(); - if (active_task_id === task.id) { - setActiveTaskIdCookie(''); - } - } - - return updateTask({ - ...task, - taskStatusId: taskStatusId ?? task.taskStatusId, - [field]: status - }).then((res) => { - setTasksFetching(false); - return res; - }); - } - - return Promise.resolve(); - }, - [updateTask, setTasksFetching] - ); - - /** - * Change active task - */ - const setActiveTask = useCallback( - (task: ITeamTask | null) => { - /** - * Unassign previous active task - */ - if ($memberActiveTaskId.current && $user.current) { - const _task = tasksRef.current.find( - (t) => t.id === $memberActiveTaskId.current - ); - - if (_task) { - updateTask({ - ..._task, - members: _task.members.filter( - (m) => m.id !== $user.current?.employee.id - ) - }); - } - } - - setActiveTaskIdCookie(task?.id || ''); - setActiveTeamTask(task); - setActiveUserTaskCookieCb(task); - - if (task) { - // Update Current user's active task to sync across multiple devices - const currentEmployeeDetails = activeTeam?.members.find( - (member) => member.employeeId === authUser.current?.employee?.id - ); - - if (currentEmployeeDetails && currentEmployeeDetails.id) { - updateOrganizationTeamEmployeeActiveTask(currentEmployeeDetails.id, { - organizationId: task.organizationId, - activeTaskId: task.id, - organizationTeamId: activeTeam?.id, - tenantId: activeTeam?.tenantId - }); - } - } - }, - [ - setActiveTeamTask, - setActiveUserTaskCookieCb, - updateOrganizationTeamEmployeeActiveTask, - activeTeam, - authUser, - $memberActiveTaskId, - $user, - tasksRef, - updateTask - ] - ); - - const deleteEmployeeFromTasks = useCallback( - (employeeId: string, organizationTeamId: string) => { - deleteEmployeeFromTasksQueryCall(employeeId, organizationTeamId); - }, - [deleteEmployeeFromTasksQueryCall] - ); - - const unassignAuthActiveTask = useCallback(() => { - setActiveTaskIdCookie(''); - setActiveTeamTask(null); - }, [setActiveTeamTask]); - - useEffect(() => { - const memberActiveTask = tasks.find( - (item) => item.id === memberActiveTaskId - ); - if (memberActiveTask) { - setActiveTeamTask(memberActiveTask); - } - }, [activeTeam, tasks, memberActiveTaskId, setActiveTeamTask]); - - return { - tasks, - loading, - tasksFetching, - deleteTask, - deleteLoading, - createTask, - createLoading, - updateTask, - updateLoading, - setActiveTask, - activeTeamTask, - firstLoadTasksData, - updateTitle, - updateDescription, - updatePublicity, - handleStatusUpdate, - // employeeState, - getTasksByEmployeeId, - getTasksByEmployeeIdLoading, - activeTeam, - activeTeamId: activeTeam?.id, - unassignAuthActiveTask, - setAllTasks, - loadTeamTasksData, - deleteEmployeeFromTasks, - deleteEmployeeFromTasksLoading, - getTaskById, - getTasksByIdLoading, - detailedTask - }; + const { updateOrganizationTeamEmployeeActiveTask } = useOrganizationEmployeeTeams(); + const { user, $user } = useAuthenticateUser(); + + const setAllTasks = useSetAtom(teamTasksState); + const tasks = useAtomValue(tasksByTeamState); + const [detailedTask, setDetailedTask] = useAtom(detailedTaskState); + // const allTaskStatistics = useAtomValue(allTaskStatisticsState); + const tasksRef = useSyncRef(tasks); + + const [tasksFetching, setTasksFetching] = useAtom(tasksFetchingState); + const authUser = useSyncRef(useAtomValue(userState)); + const memberActiveTaskId = useAtomValue(memberActiveTaskIdState); + const $memberActiveTaskId = useSyncRef(memberActiveTaskId); + // const [employeeState, setEmployeeState] = useAtom(employeeTasksState); + const { taskStatus } = useTaskStatus(); + const activeTeam = useAtomValue(activeTeamState); + const activeTeamRef = useSyncRef(activeTeam); + + const [activeTeamTask, setActiveTeamTask] = useAtom(activeTeamTaskState); + + const { firstLoad, firstLoadData: firstLoadTasksData } = useFirstLoad(); + + const setDailyPlan = useSetAtom(dailyPlanListState); + const setMyDailyPlans = useSetAtom(myDailyPlanListState); + + // Queries hooks + const { queryCall, loading, loadingRef } = useQuery(getTeamTasksAPI); + const { queryCall: getTasksByIdQueryCall, loading: getTasksByIdLoading } = useQuery(getTasksByIdAPI); + const { queryCall: getTasksByEmployeeIdQueryCall, loading: getTasksByEmployeeIdLoading } = + useQuery(getTasksByEmployeeIdAPI); + + const { queryCall: deleteQueryCall, loading: deleteLoading } = useQuery(deleteTaskAPI); + + const { queryCall: createQueryCall, loading: createLoading } = useQuery(createTeamTaskAPI); + + const { queryCall: updateQueryCall, loading: updateLoading } = useQuery(updateTaskAPI); + + const { queryCall: getAllQueryCall } = useQuery(getAllDayPlansAPI); + const { queryCall: getMyDailyPlansQueryCall } = useQuery(getMyDailyPlansAPI); + + const { queryCall: deleteEmployeeFromTasksQueryCall, loading: deleteEmployeeFromTasksLoading } = + useQuery(deleteEmployeeFromTasksAPI); + + const getAllDayPlans = useCallback(async () => { + const response = await getAllQueryCall(); + + if (response.data.items.length) { + const { items, total } = response.data; + setDailyPlan({ items, total }); + } + }, [getAllQueryCall, setDailyPlan]); + + const getMyDailyPlans = useCallback(async () => { + const response = await getMyDailyPlansQueryCall(); + + if (response.data.items.length) { + const { items, total } = response.data; + setMyDailyPlans({ items, total }); + } + }, [getMyDailyPlansQueryCall, setMyDailyPlans]); + + const getTaskById = useCallback( + (taskId: string) => { + tasksRef.current.forEach((task) => { + if (task.id === taskId) { + setDetailedTask(task); + } + }); + + return getTasksByIdQueryCall(taskId).then((res) => { + setDetailedTask(res?.data || null); + return res; + }); + }, + [getTasksByIdQueryCall, setDetailedTask, tasksRef] + ); + + const getTasksByEmployeeId = useCallback( + (employeeId: string, organizationTeamId: string) => { + return getTasksByEmployeeIdQueryCall(employeeId, organizationTeamId).then((res) => { + // setEmployeeState(res?.data || []); + return res.data; + }); + }, + [getTasksByEmployeeIdQueryCall] + ); + + const deepCheckAndUpdateTasks = useCallback( + (responseTasks: ITeamTask[], deepCheck?: boolean) => { + if (responseTasks && responseTasks.length) { + responseTasks.forEach((task) => { + if (task.tags && task.tags?.length) { + task.label = task.tags[0].name; + } + }); + } + + /** + * When deepCheck enabled, + * then update the tasks store only when active-team tasks have an update + */ + if (deepCheck) { + const latestActiveTeamTasks = responseTasks + .filter((task) => { + return task.teams.some((tm) => { + return tm.id === activeTeamRef.current?.id; + }); + }) + .sort((a, b) => a.title.localeCompare(b.title)); + + const activeTeamTasks = tasksRef.current.slice().sort((a, b) => a.title.localeCompare(b.title)); + + if (!isEqual(latestActiveTeamTasks, activeTeamTasks)) { + // Fetch plans with updated task(s) + getMyDailyPlans(); + getAllDayPlans(); + setAllTasks(responseTasks); + } + } else { + setAllTasks(responseTasks); + } + }, + [activeTeamRef, getAllDayPlans, getMyDailyPlans, setAllTasks, tasksRef] + ); + + const loadTeamTasksData = useCallback( + (deepCheck?: boolean) => { + if (loadingRef.current || !user || !activeTeamRef.current?.id) { + return new Promise((response) => { + response(true); + }); + } + + return queryCall( + user?.employee.organizationId, + user?.employee.tenantId, + activeTeamRef.current?.projects && activeTeamRef.current?.projects.length + ? activeTeamRef.current?.projects[0].id + : '', + activeTeamRef.current?.id || '' + ).then((res) => { + deepCheckAndUpdateTasks(res?.data?.items || [], deepCheck); + return res; + }); + }, + [queryCall, deepCheckAndUpdateTasks, loadingRef, user, activeTeamRef] + ); + + // Global loading state + useEffect(() => { + if (firstLoad) { + setTasksFetching(loading); + } + }, [loading, firstLoad, setTasksFetching]); + + const setActiveUserTaskCookieCb = useCallback( + (task: ITeamTask | null) => { + if (task?.id && authUser.current?.id) { + setActiveUserTaskCookie({ + taskId: task?.id, + userId: authUser.current?.id + }); + } else { + setActiveUserTaskCookie({ + taskId: '', + userId: '' + }); + } + }, + [authUser] + ); + + // Reload tasks after active team changed + useEffect(() => { + if (activeTeam?.id && firstLoad) { + loadTeamTasksData(); + } + }, [activeTeam?.id, firstLoad, loadTeamTasksData]); + const setActive = useSetAtom(activeTeamTaskId); + + // Get the active task from cookie and put on global store + useEffect(() => { + if (firstLoad) { + const active_user_task = getActiveUserTaskCookie(); + const active_taskid = + active_user_task?.userId === authUser.current?.id + ? active_user_task?.taskId + : getActiveTaskIdCookie() || ''; + + setActiveTeamTask(tasks.find((ts) => ts.id === active_taskid) || null); + } + }, [setActiveTeamTask, tasks, firstLoad, authUser]); + + // Queries calls + const deleteTask = useCallback( + (task: (typeof tasks)[0]) => { + return deleteQueryCall(task.id).then((res) => { + const affected = res.data?.affected || 0; + if (affected > 0) { + setAllTasks((ts) => { + return ts.filter((t) => t.id !== task.id); + }); + } + return res; + }); + }, + [deleteQueryCall, setAllTasks] + ); + + const createTask = useCallback( + ( + { + taskName, + issueType, + taskStatusId, + status = taskStatus[0]?.name, + priority, + size, + tags, + description + }: { + taskName: string; + issueType?: string; + status?: string; + taskStatusId: string; + priority?: string; + size?: string; + tags?: ITaskLabelsItemList[]; + description?: string | null; + }, + members?: { id: string }[] + ) => { + return createQueryCall( + { + title: taskName, + issueType, + status, + priority, + size, + tags, + // Set Project Id to cookie + // TODO: Make it dynamic when we add Dropdown in Navbar + ...(activeTeam?.projects && activeTeam?.projects.length > 0 + ? { + projectId: activeTeam.projects[0].id + } + : {}), + ...(description ? { description: `

${description}

` } : {}), + ...(members ? { members } : {}), + taskStatusId: taskStatusId + }, + $user.current + ).then((res) => { + deepCheckAndUpdateTasks(res?.data?.items || [], true); + return res; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [createQueryCall, deepCheckAndUpdateTasks, activeTeam] + ); + + const updateTask = useCallback( + (task: Partial & { id: string }) => { + return updateQueryCall(task.id, task).then((res) => { + setActive({ + id: '' + }); + const updatedTasks = res?.data?.items || []; + deepCheckAndUpdateTasks(updatedTasks, true); + + if (detailedTask) { + getTaskById(detailedTask.id); + } + + return res; + }); + }, + [updateQueryCall, setActive, deepCheckAndUpdateTasks, detailedTask, getTaskById] + ); + + const updateTitle = useCallback( + (newTitle: string, task?: ITeamTask | null, loader?: boolean) => { + if (task && newTitle !== task.title) { + loader && setTasksFetching(true); + return updateTask({ + ...task, + title: newTitle + }).then((res) => { + setTasksFetching(false); + return res; + }); + } + return Promise.resolve(); + }, + [updateTask, setTasksFetching] + ); + + const updateDescription = useCallback( + (newDescription: string, task?: ITeamTask | null, loader?: boolean) => { + if (task && newDescription !== task.description) { + loader && setTasksFetching(true); + return updateTask({ + ...task, + description: newDescription + }).then((res) => { + setTasksFetching(false); + return res; + }); + } + return Promise.resolve(); + }, + [updateTask, setTasksFetching] + ); + + const updatePublicity = useCallback( + (publicity: boolean, task?: ITeamTask | null, loader?: boolean) => { + if (task && publicity !== task.public) { + loader && setTasksFetching(true); + return updateTask({ + ...task, + public: publicity + }).then((res) => { + setTasksFetching(false); + return res; + }); + } + return Promise.resolve(); + }, + [updateTask, setTasksFetching] + ); + + const handleStatusUpdate = useCallback( + ( + status: ITaskStatusStack[T], + field: T, + taskStatusId: ITeamTask['taskStatusId'], + task?: ITeamTask | null, + loader?: boolean + ) => { + if (task && status !== task[field]) { + loader && setTasksFetching(true); + + if (field === 'status' && status === 'closed') { + const active_user_task = getActiveUserTaskCookie(); + if (active_user_task?.taskId === task.id) { + setActiveUserTaskCookie({ + taskId: '', + userId: '' + }); + } + const active_task_id = getActiveTaskIdCookie(); + if (active_task_id === task.id) { + setActiveTaskIdCookie(''); + } + } + + return updateTask({ + ...task, + taskStatusId: taskStatusId ?? task.taskStatusId, + [field]: status + }).then((res) => { + setTasksFetching(false); + return res; + }); + } + + return Promise.resolve(); + }, + [updateTask, setTasksFetching] + ); + + /** + * Change active task + */ + const setActiveTask = useCallback( + (task: ITeamTask | null) => { + /** + * Unassign previous active task + */ + if ($memberActiveTaskId.current && $user.current) { + const _task = tasksRef.current.find((t) => t.id === $memberActiveTaskId.current); + + if (_task) { + updateTask({ + ..._task, + members: _task.members.filter((m) => m.id !== $user.current?.employee.id) + }); + } + } + + setActiveTaskIdCookie(task?.id || ''); + setActiveTeamTask(task); + setActiveUserTaskCookieCb(task); + + if (task) { + // Update Current user's active task to sync across multiple devices + const currentEmployeeDetails = activeTeam?.members.find( + (member) => member.employeeId === authUser.current?.employee?.id + ); + + if (currentEmployeeDetails && currentEmployeeDetails.id) { + updateOrganizationTeamEmployeeActiveTask(currentEmployeeDetails.id, { + organizationId: task.organizationId, + activeTaskId: task.id, + organizationTeamId: activeTeam?.id, + tenantId: activeTeam?.tenantId + }); + } + } + }, + [ + setActiveTeamTask, + setActiveUserTaskCookieCb, + updateOrganizationTeamEmployeeActiveTask, + activeTeam, + authUser, + $memberActiveTaskId, + $user, + tasksRef, + updateTask + ] + ); + + const deleteEmployeeFromTasks = useCallback( + (employeeId: string, organizationTeamId: string) => { + deleteEmployeeFromTasksQueryCall(employeeId, organizationTeamId); + }, + [deleteEmployeeFromTasksQueryCall] + ); + + const unassignAuthActiveTask = useCallback(() => { + setActiveTaskIdCookie(''); + setActiveTeamTask(null); + }, [setActiveTeamTask]); + + useEffect(() => { + const memberActiveTask = tasks.find((item) => item.id === memberActiveTaskId); + if (memberActiveTask) { + setActiveTeamTask(memberActiveTask); + } + }, [activeTeam, tasks, memberActiveTaskId, setActiveTeamTask]); + + return { + tasks, + loading, + tasksFetching, + deleteTask, + deleteLoading, + createTask, + createLoading, + updateTask, + updateLoading, + setActiveTask, + activeTeamTask, + firstLoadTasksData, + updateTitle, + updateDescription, + updatePublicity, + handleStatusUpdate, + // employeeState, + getTasksByEmployeeId, + getTasksByEmployeeIdLoading, + activeTeam, + activeTeamId: activeTeam?.id, + unassignAuthActiveTask, + setAllTasks, + loadTeamTasksData, + deleteEmployeeFromTasks, + deleteEmployeeFromTasksLoading, + getTaskById, + getTasksByIdLoading, + detailedTask + }; } diff --git a/apps/web/app/hooks/useQuery.ts b/apps/web/app/hooks/useQuery.ts index d073fdbfd..8cdaf47e9 100644 --- a/apps/web/app/hooks/useQuery.ts +++ b/apps/web/app/hooks/useQuery.ts @@ -2,6 +2,16 @@ import { useCallback, useRef, useState } from 'react'; +/** + * A custom React hook that provides a way to execute a query function and manage its loading state. + * + * @param queryFunction - A function that returns a Promise and takes any number of parameters. + * @returns An object containing the following properties: + * - `queryCall`: A memoized version of the `queryFunction` that manages the loading state. + * - `loading`: A boolean indicating whether the query is currently loading. + * - `infiniteLoading`: A ref that can be used to track whether the query is part of an infinite loading scenario. + * - `loadingRef`: A ref that can be used to access the current loading state. + */ export function useQuery Promise>(queryFunction: T) { const [loading, setLoading] = useState(false); const loadingRef = useRef(false); diff --git a/apps/web/app/stores/team-tasks.ts b/apps/web/app/stores/team-tasks.ts index 5a0c1d22e..cd5103cec 100644 --- a/apps/web/app/stores/team-tasks.ts +++ b/apps/web/app/stores/team-tasks.ts @@ -2,12 +2,13 @@ import moment from 'moment'; import { ITeamTask } from '@app/interfaces/ITask'; import { ITasksTimesheet } from '@app/interfaces/ITimer'; import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; export const teamTasksState = atom([]); export const activeTeamTaskState = atom(null); export const activeTeamTaskId = atom<{ id: string }>({ - id: '' + id: '' }); export const tasksFetchingState = atom(false); @@ -19,29 +20,31 @@ export const detailedTaskState = atom(null); // }); export const tasksByTeamState = atom((get) => { - const tasks = get(teamTasksState); + const tasks = get(teamTasksState); - return tasks - .filter(() => { - return true; - }) - .sort((a, b) => moment(b.createdAt).diff(a.createdAt)); + return tasks + .filter(() => { + return true; + }) + .sort((a, b) => moment(b.createdAt).diff(a.createdAt)); }); export const tasksStatisticsState = atom<{ - all: ITasksTimesheet[]; - today: ITasksTimesheet[]; + all: ITasksTimesheet[]; + today: ITasksTimesheet[]; }>({ - all: [], - today: [] + all: [], + today: [] }); +export const favoriteTasksAtom = atom([]); +export const favoriteTasksStorageAtom = atomWithStorage('favoriteTasks', []); export const activeTaskStatisticsState = atom<{ - total: ITasksTimesheet | null; - today: ITasksTimesheet | null; + total: ITasksTimesheet | null; + today: ITasksTimesheet | null; }>({ - total: null, - today: null + total: null, + today: null }); export const allTaskStatisticsState = atom([]); diff --git a/apps/web/app/stores/user.ts b/apps/web/app/stores/user.ts index e09c7752e..7d5285e7e 100644 --- a/apps/web/app/stores/user.ts +++ b/apps/web/app/stores/user.ts @@ -3,3 +3,4 @@ import { atom } from 'jotai'; export const userState = atom(null); export const userDetailAccordion = atom(''); +export const stayOpen = atom(false) diff --git a/apps/web/components/app-sidebar.tsx b/apps/web/components/app-sidebar.tsx index 7a035ef9e..8465d56c1 100644 --- a/apps/web/components/app-sidebar.tsx +++ b/apps/web/components/app-sidebar.tsx @@ -1,16 +1,16 @@ import * as React from 'react'; import { - AudioWaveform, MonitorSmartphone, LayoutDashboard, Heart, FolderKanban, SquareActivity, - Command, - GalleryVerticalEnd, - Files + PlusIcon, + Files, + X } from 'lucide-react'; +import { TeamItem } from '@/lib/features/team/team-item'; import { EverTeamsLogo, SymbolAppLogo } from '@/lib/components/svgs'; import { NavMain } from '@/components/nav-main'; import { @@ -22,180 +22,289 @@ import { SidebarMenuItem, SidebarRail, SidebarTrigger, - useSidebar + useSidebar, + SidebarMenuSubButton } from '@/components/ui/sidebar'; import Link from 'next/link'; import { cn } from '@/lib/utils'; - -// This is sample data. -const data = { - user: { - name: 'evereq', - email: 'evereq@ever.co', - avatar: '/assets/svg/profile.svg' - }, - teams: [ - { - name: 'Strive Team', - logo: GalleryVerticalEnd, - plan: 'Enterprise' - }, - { - name: 'Ever Websites', - logo: AudioWaveform, - plan: 'Startup' - }, - { - name: 'Ever Team.', - logo: Command, - plan: 'Free' - } - ], - navMain: [ - { - title: 'Dashboard', - url: '/', - icon: LayoutDashboard, - isActive: true - }, - { - title: 'Favorites', - url: '#', - icon: Heart, - items: [ - { - title: 'Working on UI Design ...', - url: '#' - }, - { - title: 'As a team manager, I ...', - url: '#' - }, - { - title: 'As a team manager, I ...', - url: '#' - } - ] - }, - { - title: 'Tasks', - url: '#', - icon: Files, - items: [ - { - title: "Team's Tasks", - url: '#' - }, - { - title: 'My Tasks', - url: '#' - } - ] - }, - { - title: 'Projects', - url: '#', - icon: FolderKanban, - items: [ - { - title: 'Teams', - url: '#' - }, - { - title: 'Gauzy', - url: '#' - }, - { - title: 'IQ', - url: '#' - } - ] - }, - { - title: 'My Works', - url: '#', - icon: MonitorSmartphone, - items: [ - { - title: 'Time & Activity', - url: '#' - }, - { - title: 'Work Diary', - url: '#' - } - ] +import { useOrganizationAndTeamManagers } from '@/app/hooks/features/useOrganizationTeamManagers'; +import { useAuthenticateUser, useModal, useOrganizationTeams } from '@/app/hooks'; +import { useActiveTeam } from '@/app/hooks/features/useActiveTeam'; +import { SettingOutlineIcon } from '@/assets/svg'; +import { useFavoritesTask } from '@/app/hooks/features/useFavoritesTask'; +import { Button } from '@/lib/components/button'; +import { CreateTeamModal, TaskIssueStatus } from '@/lib/features'; +import { useTranslations } from 'next-intl'; +type AppSidebarProps = React.ComponentProps & { publicTeam: boolean | undefined }; +export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { + const { userManagedTeams } = useOrganizationAndTeamManagers(); + const { user } = useAuthenticateUser(); + const username = user?.name || user?.firstName || user?.lastName || user?.username; + const { isTeamManager } = useOrganizationTeams(); + const { favoriteTasks, toggleFavorite } = useFavoritesTask(); + const { state } = useSidebar(); + const { onChangeActiveTeam, activeTeam } = useActiveTeam(); + const { isOpen, closeModal, openModal } = useModal(); + const t = useTranslations(); + // This is sample data. + const data = { + user: { + name: 'evereq', + email: 'evereq@ever.co', + avatar: '/assets/svg/profile.svg' }, - { - title: 'Reports', - url: '#', - icon: SquareActivity, - items: [ - { - title: 'Timesheets', - url: '#' - }, - { - title: 'Manual Time Edit', - url: '#' - }, - { - title: 'Weekly Limit', - url: '#' - }, - { - title: 'Actual & Expected Hours', - url: '#' - }, - { - title: 'Payments Due', - url: '#' - }, - { - title: 'Project Budget', - url: '#' - }, - { - title: 'Time & Activity', - url: '#' - } - ] - } - ] -}; + navMain: [ + { + title: t('sidebar.DASHBOARD'), + url: '/', + icon: LayoutDashboard, + isActive: true, + label: 'dashboard' + }, + { + title: t('sidebar.FAVORITES'), + url: '#', + icon: Heart, + label: 'favorites', + items: + favoriteTasks && favoriteTasks.length > 0 + ? favoriteTasks + .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())) + .map((task, index) => ({ + title: task?.title, + url: '#', + component: ( + + + + {task && ( + // Show task issue and task number + + )} + + + #{task?.taskNumber} + + + {task?.title} + + + + toggleFavorite(task)} + /> + + + ) + })) + : [ + { + title: t('common.NO_FAVORITE_TASK'), + url: '#', + label: 'no-task' + } + ] + }, + { + title: t('sidebar.TASKS'), + url: '#', + icon: Files, + label: 'tasks', + items: [ + { + title: t('sidebar.TEAMTASKS'), + url: '/' + }, + { + title: t('sidebar.MY_TASKS'), + url: `/profile/${user?.id}?name=${username || ''}` + } + ] + }, + ...(userManagedTeams && userManagedTeams.length > 0 + ? [ + { + title: t('sidebar.PROJECTS'), + label: 'projects', + url: '#', + icon: FolderKanban, + items: [ + ...userManagedTeams + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + .map((team, index) => ({ + title: team.name, + url: '#', + component: ( + + + + ) + })), + { + title: t('common.NO_PROJECT'), + url: '#', + component: ( + + + + ) + } + ] + } + ] + : []), + { + title: t('sidebar.MY_WORKS'), + url: '#', + icon: MonitorSmartphone, + items: [ + { + title: t('sidebar.TIME_AND_ACTIVITY'), + url: '#' + }, + { + title: t('sidebar.WORK_DIARY'), + url: '#' + } + ] + }, + ...(isTeamManager + ? [ + { + title: t('sidebar.REPORTS'), + url: '#', + icon: SquareActivity, + items: [ + { + title: t('sidebar.TIMESHEETS'), + url: '#' + }, + { + title: t('sidebar.MANUAL_TIME_EDIT'), + url: '#' + }, + { + title: t('sidebar.WEEKLY_LIMIT'), + url: '#' + }, + { + title: t('sidebar.ACTUAL_AND_EXPECTED_HOURS'), + url: '#' + }, + { + title: t('sidebar.PAYMENTS_DUE'), + url: '#' + }, + { + title: t('sidebar.PROJECT_BUDGET'), + url: '#' + }, + { + title: t('sidebar.TIME_AND_ACTIVITY'), + url: '#' + } + ] + } + ] + : []) + ] + }; -export function AppSidebar({ ...props }: React.ComponentProps) { - const { state } = useSidebar(); return ( - - - - - - - -
- -
- {state === 'expanded' && } - -
-
-
-
- - - - -
+ <> + + + + + + + +
+ +
+ {state === 'expanded' && } + +
+
+
+
+ + + + +
+ + {!publicTeam && } + ); } diff --git a/apps/web/components/nav-main.tsx b/apps/web/components/nav-main.tsx index eca0bf8a3..207f42fa0 100644 --- a/apps/web/components/nav-main.tsx +++ b/apps/web/components/nav-main.tsx @@ -27,6 +27,7 @@ export function NavMain({ items?: { title: string; url: string; + component?: JSX.Element; }[]; }[]; }>) { @@ -38,7 +39,10 @@ export function NavMain({ @@ -64,25 +68,27 @@ export function NavMain({ - {item.items?.map((subItem) => ( - - - - - {subItem.title} - - - + {item.items.map((subItem, key) => ( + + {subItem?.component || ( + + + + {subItem.title} + + + + )} ))} diff --git a/apps/web/components/pages/task/title-block/task-title-block.tsx b/apps/web/components/pages/task/title-block/task-title-block.tsx index ac1edc7ce..c5f0e10a6 100644 --- a/apps/web/components/pages/task/title-block/task-title-block.tsx +++ b/apps/web/components/pages/task/title-block/task-title-block.tsx @@ -1,11 +1,7 @@ import { useModal, useTeamTasks } from '@app/hooks'; import { ITeamTask } from '@app/interfaces'; import { detailedTaskState } from '@app/stores'; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger -} from '@components/ui/hover-card'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@components/ui/hover-card'; import { useToast } from '@components/ui/use-toast'; import { Button, CopyTooltip } from 'lib/components'; import { ActiveTaskIssuesDropdown } from 'lib/features'; @@ -13,319 +9,322 @@ import Image from 'next/image'; import { CheckSimpleIcon, CopyRoundIcon } from 'assets/svg'; import Link from 'next/link'; -import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { ChangeEvent, useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { useAtom } from 'jotai'; import CreateParentTask from '../ParentTask'; import TitleLoader from './title-loader'; import { useTranslations } from 'next-intl'; import { XMarkIcon } from '@heroicons/react/20/solid'; import { clsxm } from '@app/utils'; +import { useFavoritesTask } from '@/app/hooks/features/useFavoritesTask'; +import { Heart } from 'lucide-react'; const TaskTitleBlock = () => { - const { updateTitle, updateLoading } = useTeamTasks(); - const { toast } = useToast(); - const t = useTranslations(); + const { updateTitle, updateLoading } = useTeamTasks(); + const { toast } = useToast(); + const t = useTranslations(); - //DOM elements - const titleDOM = useRef(null); - const saveButton = useRef(null); - const cancelButton = useRef(null); - const editButton = useRef(null); - const titleContainerRef = useRef(null); + //DOM elements + const titleDOM = useRef(null); + const saveButton = useRef(null); + const cancelButton = useRef(null); + const editButton = useRef(null); + const titleContainerRef = useRef(null); - //States - const [edit, setEdit] = useState(false); - const [task] = useAtom(detailedTaskState); - const [title, setTitle] = useState(''); + //States + const [edit, setEdit] = useState(false); + const [task] = useAtom(detailedTaskState); + const [title, setTitle] = useState(''); - //Hooks and functions - useEffect(() => { - if (!edit) { - task && !updateLoading && setTitle(task?.title); - } - }, [task, edit, updateLoading]); + const { toggleFavorite, isFavorite } = useFavoritesTask(); + const isFavoriteTask = useMemo(() => (task ? isFavorite(task) : false), [task, isFavorite]); + //Hooks and functions + useEffect(() => { + if (!edit) { + task && !updateLoading && setTitle(task?.title); + } + }, [task, edit, updateLoading]); - useEffect(() => { - autoTextAreaHeight(); - }, [title]); + useEffect(() => { + autoTextAreaHeight(); + }, [title]); - useEffect(() => { - titleDOM?.current?.focus(); - }, [edit]); + useEffect(() => { + titleDOM?.current?.focus(); + }, [edit]); - const saveTitle = useCallback( - (newTitle: string) => { - if (newTitle.length > 255) { - toast({ - variant: 'destructive', - title: t('pages.taskDetails.TASK_TITLE_CHARACTER_LIMIT_ERROR_TITLE'), - description: t( - 'pages.taskDetails.TASK_TITLE_CHARACTER_LIMIT_ERROR_DESCRIPTION' - ) - }); - return; - } + const saveTitle = useCallback( + (newTitle: string) => { + if (newTitle.length > 255) { + toast({ + variant: 'destructive', + title: t('pages.taskDetails.TASK_TITLE_CHARACTER_LIMIT_ERROR_TITLE'), + description: t('pages.taskDetails.TASK_TITLE_CHARACTER_LIMIT_ERROR_DESCRIPTION') + }); + return; + } - updateTitle(newTitle, task, true); - setEdit(false); - }, - [task, updateTitle, toast, t] - ); + updateTitle(newTitle, task, true); + setEdit(false); + }, + [task, updateTitle, toast, t] + ); - const saveOnEnter = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' && edit) { - saveTitle(title); - setEdit(false); - } - }; + const saveOnEnter = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && edit) { + saveTitle(title); + setEdit(false); + } + }; - useEffect(() => { - const handleOutsideClick = (event: MouseEvent) => { - if ( - edit && - titleContainerRef.current && - !titleContainerRef.current.contains(event.target as Node) - ) { - saveTitle(title); - } - }; + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if (edit && titleContainerRef.current && !titleContainerRef.current.contains(event.target as Node)) { + saveTitle(title); + } + }; - document.addEventListener('mousedown', handleOutsideClick); + document.addEventListener('mousedown', handleOutsideClick); - return () => { - document.removeEventListener('mousedown', handleOutsideClick); - }; - }, [edit, saveTitle, title]); + return () => { + document.removeEventListener('mousedown', handleOutsideClick); + }; + }, [edit, saveTitle, title]); - const cancelEdit = () => { - task && setTitle(task?.title); - setEdit(false); - }; + const cancelEdit = () => { + task && setTitle(task?.title); + setEdit(false); + }; - const autoTextAreaHeight = () => { - titleDOM.current?.style.setProperty('height', 'auto'); - titleDOM.current?.style.setProperty( - 'height', - titleDOM.current.scrollHeight + 'px' - ); - }; + const autoTextAreaHeight = () => { + titleDOM.current?.style.setProperty('height', 'auto'); + titleDOM.current?.style.setProperty('height', titleDOM.current.scrollHeight + 'px'); + }; - const handleTaskTitleChange = (event: ChangeEvent) => { - setTitle(event.target.value); - }; + const handleTaskTitleChange = (event: ChangeEvent) => { + setTitle(event.target.value); + }; - return ( -
- {task ? ( -
-