diff --git a/.cspell.json b/.cspell.json index 7281b6e66..500360476 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,6 +4,7 @@ "caseSensitive": false, "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", "words": [ + "nivo", "accepte", "Accordian", "adipiscing", @@ -349,7 +350,8 @@ "Xlarge", "xlcard", "xlight", - "yellowbox" + "yellowbox", + "vhidden" ], "useGitignore": true, "ignorePaths": [ diff --git a/apps/mobile/app/helpers/date.ts b/apps/mobile/app/helpers/date.ts index b46ea0a6f..8f16fc44c 100644 --- a/apps/mobile/app/helpers/date.ts +++ b/apps/mobile/app/helpers/date.ts @@ -20,6 +20,7 @@ export function addHours(numOfHours: number, date = new Date()) { return date; } + export function secondsToTime(secs: number) { const hours = Math.floor(secs / (60 * 60)); diff --git a/apps/mobile/yarn.lock b/apps/mobile/yarn.lock index 03cbdb298..3490bbf7a 100644 --- a/apps/mobile/yarn.lock +++ b/apps/mobile/yarn.lock @@ -6915,9 +6915,9 @@ fast-loops@^1.1.3: integrity sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg== fast-xml-parser@^4.0.12: - version "4.3.2" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz#761e641260706d6e13251c4ef8e3f5694d4b0d79" - integrity sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg== + version "4.4.1" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" + integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== dependencies: strnum "^1.0.5" diff --git a/apps/web/app/[locale]/all-teams/component.tsx b/apps/web/app/[locale]/all-teams/component.tsx index 544c4f9b4..3e572b9cf 100644 --- a/apps/web/app/[locale]/all-teams/component.tsx +++ b/apps/web/app/[locale]/all-teams/component.tsx @@ -28,8 +28,8 @@ function AllTeamsPage() { ]; /* If the user is not a manager in any team or if he's - manager in only one team, then redirect him to the home page - */ + manager in only one team, then redirect him to the home page + */ if (userManagedTeams.length < 2) return ; return ( diff --git a/apps/web/app/[locale]/auth/passcode/component.tsx b/apps/web/app/[locale]/auth/passcode/component.tsx index 87676b98d..598e61876 100644 --- a/apps/web/app/[locale]/auth/passcode/component.tsx +++ b/apps/web/app/[locale]/auth/passcode/component.tsx @@ -423,9 +423,8 @@ export function WorkSpaceComponent(props: IWorkSpace) { {props.workspaces?.map((worksace, index) => (
diff --git a/apps/web/app/[locale]/page-component.tsx b/apps/web/app/[locale]/page-component.tsx index 18ba41fa7..03bc54d81 100644 --- a/apps/web/app/[locale]/page-component.tsx +++ b/apps/web/app/[locale]/page-component.tsx @@ -3,14 +3,14 @@ 'use client'; import React, { useEffect, useState } from 'react'; -import { useDailyPlan, useOrganizationTeams, useUserProfilePage } from '@app/hooks'; +import { useOrganizationTeams } from '@app/hooks'; import { clsxm } from '@app/utils'; import NoTeam from '@components/pages/main/no-team'; import { withAuthentication } from 'lib/app/authenticator'; import { Breadcrumb, Card } from 'lib/components'; import { AuthUserTaskInput, TeamInvitations, TeamMembers, Timer, UnverifiedEmail } from 'lib/features'; import { MainLayout } from 'lib/layout'; -import { DAILY_PLAN_SHOW_MODAL, IssuesView } from '@app/constants'; +import { IssuesView } from '@app/constants'; import { useNetworkState } from '@uidotdev/usehooks'; import Offline from '@components/pages/offline'; import { useTranslations } from 'next-intl'; @@ -31,21 +31,10 @@ import { PeoplesIcon } from 'assets/svg'; import TeamMemberHeader from 'lib/features/team-member-header'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@components/ui/resizable'; import { TeamOutstandingNotifications } from 'lib/features/team/team-outstanding-notifications'; -import { DailyPlanCompareEstimatedModal } from 'lib/features/daily-plan'; function MainPage() { const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false) - const { todayPlan } = useDailyPlan(); - const profile = useUserProfilePage(); const [headerSize, setHeaderSize] = useState(10); - const plan = todayPlan.find((plan) => plan.date?.toString()?.startsWith(new Date()?.toISOString().split('T')[0])); - - const defaultOpenPopup = - typeof window !== 'undefined' - ? (window.localStorage.getItem(DAILY_PLAN_SHOW_MODAL)) || null - : new Date().toISOString().split('T')[0]; - const { isTeamMember, isTrackingEnabled, activeTeam } = useOrganizationTeams(); const [fullWidth, setFullWidth] = useRecoilState(fullWidthState); const [view, setView] = useRecoilState(headerTabs); @@ -63,14 +52,6 @@ function MainPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, setView]); - useEffect(() => { - window.localStorage.setItem(DAILY_PLAN_SHOW_MODAL, new Date().toISOString().split('T')[0]); - if (defaultOpenPopup !== new Date().toISOString().split('T')[0] || defaultOpenPopup === null) { - setIsOpen(true) - } - }, [defaultOpenPopup, plan]) - - React.useEffect(() => { window && window?.localStorage.getItem('conf-fullWidth-mode'); setFullWidth(JSON.parse(window?.localStorage.getItem('conf-fullWidth-mode') || 'true')); @@ -81,10 +62,6 @@ function MainPage() { } return ( <> - setIsOpen((prev) => { - window.localStorage.setItem(DAILY_PLAN_SHOW_MODAL, new Date().toISOString().split('T')[0]); - return !prev; - })} todayPlan={todayPlan} profile={profile} />
{/*
*/} diff --git a/apps/web/app/[locale]/profile/[memberId]/page.tsx b/apps/web/app/[locale]/profile/[memberId]/page.tsx index e0afccf3c..5e1860dbd 100644 --- a/apps/web/app/[locale]/profile/[memberId]/page.tsx +++ b/apps/web/app/[locale]/profile/[memberId]/page.tsx @@ -23,6 +23,7 @@ import { AppsTab } from 'lib/features/activity/apps'; import { VisitedSitesTab } from 'lib/features/activity/visited-sites'; import { activityTypeState } from '@app/stores/activity-type'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@components/ui/resizable'; +import { ActivityCalendar } from 'lib/features/activity/calendar'; export type FilterTab = 'Tasks' | 'Screenshots' | 'Apps' | 'Visited Sites'; @@ -136,6 +137,9 @@ const Profile = React.memo(function ProfilePage({ params }: { params: { memberId {/* TaskFilter */} +
+ +
diff --git a/apps/web/app/constants.ts b/apps/web/app/constants.ts index d58c076eb..501d5d9a6 100644 --- a/apps/web/app/constants.ts +++ b/apps/web/app/constants.ts @@ -2,8 +2,8 @@ import { JitsuOptions } from '@jitsu/jitsu-react/dist/useJitsu'; import { I_SMTPRequest } from './interfaces/ISmtp'; import { getNextPublicEnv } from './env'; import enLanguage from '../locales/en.json'; -// import { } from 'country-flag-icons/react/3x2' import { BG, CN, DE, ES, FR, IS, IT, NL, PL, PT, RU, SA, US } from 'country-flag-icons/react/1x1'; +import { ManualTimeReasons } from './interfaces/timer/IManualTimeReasons'; export const API_BASE_URL = '/api'; export const DEFAULT_APP_PATH = '/auth/passcode'; export const DEFAULT_MAIN_PATH = '/'; @@ -266,9 +266,9 @@ export const languagesFlags = [ // Local storage keys export const LAST_WORSPACE_AND_TEAM = 'last-workspace-and-team'; export const USER_SAW_OUTSTANDING_NOTIFICATION = 'user-saw-notif'; -export const TODAY_PLAN_ALERT_SHOWN_DATE = 'last-today-plan-alert-date'; -export const ESTIMATE_POPUP_SHOWN_DATE = 'last-estimate-popup-date'; -export const DAILY_PLAN_SHOW_MODAL = 'daily-plan-modal'; +export const DAILY_PLAN_SUGGESTION_MODAL_DATE = 'daily-plan-suggestion-modal-date'; +export const TASKS_ESTIMATE_HOURS_MODAL_DATE = 'tasks-estimate-hours-modal-date'; +export const DAILY_PLAN_ESTIMATE_HOURS_MODAL_DATE = 'daily-plan-estimate-hours-modal'; // OAuth providers keys @@ -298,3 +298,14 @@ export const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET; export const TWITTER_CLIENT_ID = process.env.TWITTER_CLIENT_ID; export const TWITTER_CLIENT_SECRET = process.env.TWITTER_CLIENT_SECRET; + +// Add manual timer reason + +export const manualTimeReasons: ManualTimeReasons[] = [ + 'LOST_ELECTRICITY', + 'LOST_INTERNET', + 'FORGOT_TO_START_TIMER', + 'ERROR', + 'UNPLANNED_WORK', + 'TESTED_TIMER' +]; diff --git a/apps/web/app/helpers/daily-plan-estimated.ts b/apps/web/app/helpers/daily-plan-estimated.ts index 498648ed7..1c18fb348 100644 --- a/apps/web/app/helpers/daily-plan-estimated.ts +++ b/apps/web/app/helpers/daily-plan-estimated.ts @@ -1,29 +1,48 @@ -"use client" - import { IDailyPlan } from "@app/interfaces"; +import { convertHourToSeconds } from "./date"; -export const dailyPlanCompareEstimated = (plans: IDailyPlan[]) => { - - const plan = plans.find((plan) => plan.date?.toString()?.startsWith(new Date()?.toISOString().split('T')[0])); +export interface IDailyPlanCompareEstimated { + difference: boolean, + workTimePlanned?: number, + estimated?: boolean[] | undefined, + plan?: IDailyPlan | undefined +} - const times = plan?.tasks?.map((task) => task?.estimate).filter((time): time is number => typeof time === 'number') ?? []; - const estimated = plan?.tasks?.map((task) => task.estimate! > 0); +export const dailyPlanCompareEstimated = (plans: IDailyPlan[]): IDailyPlanCompareEstimated => { + const plan = plans.find((plan) => plan.date?.toString()?.startsWith(new Date().toISOString().split('T')[0])); - let estimatedTime = 0; - if (times.length > 0) estimatedTime = times.reduce((acc, cur) => acc + cur, 0) ?? 0; + if (!plan) { + return { + difference: false, + workTimePlanned: 0, + estimated: [], + plan: undefined + }; + } - const workedTimes = - plan?.tasks?.map((task) => task.totalWorkedTime).filter((time): time is number => typeof time === 'number') ?? - []; + const workTimePlanned = convertHourToSeconds(plan.workTimePlanned!); + const times = plan.tasks?.map((task) => task.estimate).filter((time): time is number => typeof time === 'number') ?? []; + const estimated = plan.tasks?.map((task) => task.estimate! > 0); - let totalWorkTime = 0; - if (workedTimes?.length > 0) totalWorkTime = workedTimes.reduce((acc, cur) => acc + cur, 0) ?? 0; + let estimatedTime = 0; + if (times.length > 0) { + estimatedTime = times.reduce((acc, cur) => acc + cur, 0) ?? 0; + } - const result = estimated?.every(Boolean) ? estimatedTime - totalWorkTime : null; + const difference = dailyPlanSubtraction(estimatedTime, workTimePlanned); return { - result, - totalWorkTime, - estimatedTime - } + workTimePlanned, + estimated, + difference, + plan + }; +} + +export function dailyPlanSubtraction( + estimatedTime: number, + workTimePlanned: number +): boolean { + const difference = Math.abs(estimatedTime - workTimePlanned) / (60 * 2); + return difference >= -1 && difference <= 1; } diff --git a/apps/web/app/helpers/date.ts b/apps/web/app/helpers/date.ts index 5aa529fd2..6078cf3ac 100644 --- a/apps/web/app/helpers/date.ts +++ b/apps/web/app/helpers/date.ts @@ -163,3 +163,8 @@ export const isTestDateRange = (itemDate: Date, from?: Date, to?: Date) => { return true; // or false, depending on your default logic } } + + +export function convertHourToSeconds(hours: number) { + return hours * 60 * 60; +} diff --git a/apps/web/app/helpers/plan-day-badge.ts b/apps/web/app/helpers/plan-day-badge.ts index 70c1f92ed..1bd23d610 100644 --- a/apps/web/app/helpers/plan-day-badge.ts +++ b/apps/web/app/helpers/plan-day-badge.ts @@ -28,3 +28,27 @@ export const planBadgeContent = ( return null; } }; + + +export const planBadgeContPast = ( + dailyPlan: IDailyPlan[], + taskId: ITeamTask['id'] +): string | null => { + const today = new Date().toISOString().split('T')[0]; + const dailyPlanDataPast = dailyPlan.filter(plan => new Date(plan.date) < new Date(today)); + const allTasks = dailyPlanDataPast.flatMap(plan => plan.tasks); + const taskCount: { [key: string]: number } = allTasks?.reduce((acc, task) => { + if (task && task.id) { acc[task.id] = (acc[task.id] || 0) + 1; } + return acc; + }, {} as { [key: string]: number }); + + const dailyPlanPast = allTasks?.filter(task => task && taskCount[task.id] === 1); + const filterDailyPlan = dailyPlanPast.filter((plan) => plan?.id === taskId); + if (filterDailyPlan.length > 0) { + return 'Planned'; + } else { + return null; + } + + +} diff --git a/apps/web/app/hooks/auth/useAuthenticationPasscode.ts b/apps/web/app/hooks/auth/useAuthenticationPasscode.ts index e51746f1e..901e8d771 100644 --- a/apps/web/app/hooks/auth/useAuthenticationPasscode.ts +++ b/apps/web/app/hooks/auth/useAuthenticationPasscode.ts @@ -244,13 +244,14 @@ export function useAuthenticationPasscode() { useEffect(() => { if (queryEmail && queryCode && !loginFromQuery.current) { setScreen('passcode'); - verifyPasscodeRequest({ - email: queryEmail as string, - code: queryCode as string - }); + verifySignInEmailConfirmRequest({ email: queryEmail, code: queryCode }); + // verifyPasscodeRequest({ + // email: queryEmail as string, + // code: queryCode as string + // }); loginFromQuery.current = true; } - }, [query, verifyPasscodeRequest, queryEmail, queryCode]); + }, [query, verifySignInEmailConfirmRequest, queryEmail, queryCode]); // deepscan-disable-line /** * send a fresh auth request handler diff --git a/apps/web/app/hooks/features/useStartStopTimerHandler.ts b/apps/web/app/hooks/features/useStartStopTimerHandler.ts new file mode 100644 index 000000000..99ee3e566 --- /dev/null +++ b/apps/web/app/hooks/features/useStartStopTimerHandler.ts @@ -0,0 +1,149 @@ +import { useCallback, useMemo } from 'react'; +import { useModal } from '../useModal'; +import { useTeamTasks } from './useTeamTasks'; +import { useTimer } from './useTimer'; +import { + DAILY_PLAN_SUGGESTION_MODAL_DATE, + TASKS_ESTIMATE_HOURS_MODAL_DATE, + DAILY_PLAN_ESTIMATE_HOURS_MODAL_DATE +} from '@app/constants'; + +export function useStartStopTimerHandler() { + const { + isOpen: isEnforceTaskModalOpen, + closeModal: enforceTaskCloseModal, + openModal: openEnforcePlannedTaskModal + } = useModal(); + + const { + isOpen: isDailyPlanWorkHoursModalOpen, + closeModal: dailyPlanWorkHoursCloseModal, + openModal: openAddDailyPlanWorkHoursModal + } = useModal(); + + const { + isOpen: isTasksEstimationHoursModalOpen, + closeModal: tasksEstimationHoursCloseModal, + openModal: openAddTasksEstimationHoursModal + } = useModal(); + + const { + isOpen: isSuggestDailyPlanModalOpen, + closeModal: suggestDailyPlanCloseModal, + openModal: openSuggestDailyPlanModal + } = useModal(); + + const { timerStatus, timerStatusFetching, startTimer, stopTimer, hasPlan, canRunTimer, activeTeamTask } = + useTimer(); + + const { activeTeam } = useTeamTasks(); + + const requirePlan = useMemo(() => activeTeam?.requirePlanToTrack, [activeTeam?.requirePlanToTrack]); + + const hasWorkedHours = useMemo( + () => hasPlan?.workTimePlanned && hasPlan?.workTimePlanned > 0, + [hasPlan?.workTimePlanned] + ); + const areAllTasksEstimated = useMemo( + () => hasPlan?.tasks?.every((el) => typeof el?.estimate === 'number' && el?.estimate > 0), + [hasPlan?.tasks] + ); + + const isActiveTaskPlaned = useMemo( + () => hasPlan?.tasks?.some((task) => task.id === activeTeamTask?.id), + [activeTeamTask?.id, hasPlan?.tasks] + ); + + const startStopTimerHandler = useCallback(() => { + const currentDate = new Date().toISOString().split('T')[0]; + const dailyPlanSuggestionModalDate = window && window?.localStorage.getItem(DAILY_PLAN_SUGGESTION_MODAL_DATE); + const tasksEstimateHoursModalDate = window && window?.localStorage.getItem(TASKS_ESTIMATE_HOURS_MODAL_DATE); + const dailyPlanEstimateHoursModalDate = + window && window?.localStorage.getItem(DAILY_PLAN_ESTIMATE_HOURS_MODAL_DATE); + + /** + * Handle missing working hour for a daily plN + */ + const handleMissingDailyPlanWorkHour = () => { + if (!hasWorkedHours) { + openAddDailyPlanWorkHoursModal(); + } else { + startTimer(); + } + }; + + /** + * Handler function to start or stop the timer based on various conditions. + * Shows appropriate modals and starts or stops the timer as needed. + */ + if (timerStatusFetching || !canRunTimer) return; + if (timerStatus?.running) { + stopTimer(); + } else if (requirePlan && !isActiveTaskPlaned) { + openEnforcePlannedTaskModal(); + } else { + if ( + dailyPlanSuggestionModalDate == currentDate && + tasksEstimateHoursModalDate == currentDate && + dailyPlanEstimateHoursModalDate == currentDate + ) { + startTimer(); + } else { + if (dailyPlanSuggestionModalDate != currentDate) { + openSuggestDailyPlanModal(); + } else if (tasksEstimateHoursModalDate != currentDate) { + if (areAllTasksEstimated) { + if (dailyPlanEstimateHoursModalDate != currentDate) { + handleMissingDailyPlanWorkHour(); + } else { + startTimer(); + } + } else { + openAddTasksEstimationHoursModal(); + } + } else if (dailyPlanEstimateHoursModalDate != currentDate) { + if (areAllTasksEstimated) { + handleMissingDailyPlanWorkHour(); + } else { + startTimer(); + } + } else { + // Default action to start the timer + startTimer(); + } + } + } + }, [ + areAllTasksEstimated, + canRunTimer, + hasWorkedHours, + isActiveTaskPlaned, + openAddDailyPlanWorkHoursModal, + openAddTasksEstimationHoursModal, + openEnforcePlannedTaskModal, + openSuggestDailyPlanModal, + requirePlan, + startTimer, + stopTimer, + timerStatus?.running, + timerStatusFetching + ]); + + return { + modals: { + isEnforceTaskModalOpen, + enforceTaskCloseModal, + openEnforcePlannedTaskModal, + isDailyPlanWorkHoursModalOpen, + dailyPlanWorkHoursCloseModal, + openAddDailyPlanWorkHoursModal, + isTasksEstimationHoursModalOpen, + tasksEstimationHoursCloseModal, + openAddTasksEstimationHoursModal, + isSuggestDailyPlanModalOpen, + suggestDailyPlanCloseModal, + openSuggestDailyPlanModal + }, + startStopTimerHandler + }; +} diff --git a/apps/web/app/hooks/features/useTaskEstimation.ts b/apps/web/app/hooks/features/useTaskEstimation.ts index 3e5c02724..8dedea653 100644 --- a/apps/web/app/hooks/features/useTaskEstimation.ts +++ b/apps/web/app/hooks/features/useTaskEstimation.ts @@ -56,7 +56,7 @@ export function useTaskEstimation(task?: Nullable) { setValue((oldVa) => { return { ...oldVa, - hours: oldVa.hours !== '0' ? oldVa.hours : '' + hours: oldVa.hours ? oldVa.hours : '' }; }); setEditableMode(true); @@ -66,7 +66,7 @@ export function useTaskEstimation(task?: Nullable) { setValue((oldVa) => { return { ...oldVa, - hours: oldVa.hours !== '' ? oldVa.hours : '0' + hours: oldVa.hours ? oldVa.hours : '' }; }); }; @@ -75,7 +75,7 @@ export function useTaskEstimation(task?: Nullable) { setValue((oldVa) => { return { ...oldVa, - minutes: oldVa.minutes !== '' ? pad(+oldVa.minutes) : pad(0) + minutes: oldVa.minutes ? pad(+oldVa.minutes) : '' }; }); }; @@ -139,6 +139,7 @@ export function useTaskEstimation(task?: Nullable) { value, handleSubmit, task: $task, + setEditableMode, updateLoading }; } diff --git a/apps/web/app/hooks/features/useTimeLogs.ts b/apps/web/app/hooks/features/useTimeLogs.ts new file mode 100644 index 000000000..eecfc6c64 --- /dev/null +++ b/apps/web/app/hooks/features/useTimeLogs.ts @@ -0,0 +1,52 @@ +import { useRecoilState } from 'recoil'; +import { getTimerLogsDailyReportRequestAPI } from '@app/services/client/api/activity/time-logs'; +import { useAuthenticateUser } from './useAuthenticateUser'; +import { timerLogsDailyReportState } from '@app/stores/time-logs'; +import { useQuery } from '../useQuery'; +import { useCallback, useEffect } from 'react'; +import moment from 'moment'; + +export function useTimeLogs() { + const { user } = useAuthenticateUser(); + const [timerLogsDailyReport, setTimerLogsDailyReport] = useRecoilState(timerLogsDailyReportState); + + const { loading: timerLogsDailyReportLoading, queryCall: queryTimerLogsDailyReport } = useQuery( + getTimerLogsDailyReportRequestAPI + ); + + const getTimerLogsDailyReport = useCallback( + (startDate: Date = moment().startOf('year').toDate(), endDate: Date = moment().endOf('day').toDate()) => { + queryTimerLogsDailyReport({ + tenantId: user?.tenantId ?? '', + organizationId: user?.employee.organizationId ?? '', + employeeId: user?.employee.id ?? '', + startDate, + endDate + }) + .then((response) => { + if (response.data && Array.isArray(response.data)) { + setTimerLogsDailyReport(response.data); + } + }) + .catch((error) => { + console.log(error); + }); + }, + [ + queryTimerLogsDailyReport, + setTimerLogsDailyReport, + user?.employee.id, + user?.employee.organizationId, + user?.tenantId + ] + ); + + useEffect(() => { + getTimerLogsDailyReport(); + }, [getTimerLogsDailyReport]); + + return { + timerLogsDailyReport, + timerLogsDailyReportLoading + }; +} diff --git a/apps/web/app/interfaces/timer/IManualTimeReasons.ts b/apps/web/app/interfaces/timer/IManualTimeReasons.ts new file mode 100644 index 000000000..0567ccb8c --- /dev/null +++ b/apps/web/app/interfaces/timer/IManualTimeReasons.ts @@ -0,0 +1,7 @@ +export type ManualTimeReasons = + | 'LOST_ELECTRICITY' + | 'LOST_INTERNET' + | 'FORGOT_TO_START_TIMER' + | 'ERROR' + | 'UNPLANNED_WORK' + | 'TESTED_TIMER'; diff --git a/apps/web/app/interfaces/timer/ITimerLogs.ts b/apps/web/app/interfaces/timer/ITimerLogs.ts new file mode 100644 index 000000000..73e1f3a88 --- /dev/null +++ b/apps/web/app/interfaces/timer/ITimerLogs.ts @@ -0,0 +1,13 @@ +export interface ITimerLogsDailyReportRequest { + tenantId: string; + organizationId: string; + employeeId: string; + startDate: Date; + endDate: Date; +} + +export interface ITimerLogsDailyReport { + activity: number; + date: string; // '2024-07-19' + sum: number; // in seconds +} diff --git a/apps/web/app/services/client/api/activity/time-logs.ts b/apps/web/app/services/client/api/activity/time-logs.ts new file mode 100644 index 000000000..617ea1936 --- /dev/null +++ b/apps/web/app/services/client/api/activity/time-logs.ts @@ -0,0 +1,23 @@ +import { get } from '@app/services/client/axios'; +import { ITimerLogsDailyReportRequest, ITimerLogsDailyReport } from '@app/interfaces/timer/ITimerLogs'; +import qs from 'qs'; + +export async function getTimerLogsDailyReportRequestAPI({ + tenantId, + organizationId, + employeeId, + startDate, + endDate +}: ITimerLogsDailyReportRequest) { + const params = { + tenantId: tenantId, + organizationId: organizationId, + employeeId, + todayEnd: startDate.toISOString(), + todayStart: endDate.toISOString() + }; + + const query = qs.stringify(params); + + return get(`/timesheet/time-log/report/daily?${query}`); +} diff --git a/apps/web/app/services/client/api/tasks.ts b/apps/web/app/services/client/api/tasks.ts index 5e34439f0..db681e9c0 100644 --- a/apps/web/app/services/client/api/tasks.ts +++ b/apps/web/app/services/client/api/tasks.ts @@ -150,9 +150,9 @@ export async function tasksTimesheetStatisticsAPI( if (GAUZY_API_BASE_SERVER_URL.value) { const employeesParams = employeeId ? [employeeId].reduce((acc: any, v, i) => { - acc[`employeeIds[${i}]`] = v; - return acc; - }) + acc[`employeeIds[${i}]`] = v; + return acc; + }) : {}; const commonParams = { tenantId, @@ -189,6 +189,8 @@ export async function tasksTimesheetStatisticsAPI( `/timer/timesheet/statistics-tasks${employeeId ? '?employeeId=' + employeeId : ''}` ); } + + } export async function activeTaskTimesheetStatisticsAPI( @@ -200,9 +202,9 @@ export async function activeTaskTimesheetStatisticsAPI( if (GAUZY_API_BASE_SERVER_URL.value) { const employeesParams = employeeId ? [employeeId].reduce((acc: any, v, i) => { - acc[`employeeIds[${i}]`] = v; - return acc; - }) + acc[`employeeIds[${i}]`] = v; + return acc; + }) : {}; const commonParams = { tenantId, diff --git a/apps/web/app/stores/time-logs.ts b/apps/web/app/stores/time-logs.ts new file mode 100644 index 000000000..c0019445b --- /dev/null +++ b/apps/web/app/stores/time-logs.ts @@ -0,0 +1,7 @@ +import { ITimerLogsDailyReport } from '@app/interfaces/timer/ITimerLogs'; +import { atom } from 'recoil'; + +export const timerLogsDailyReportState = atom({ + key: 'timerLogsDailyReportState', + default: [] +}); diff --git a/apps/web/assets/common/loading.svg b/apps/web/assets/common/loading.svg new file mode 100644 index 000000000..19022fb83 --- /dev/null +++ b/apps/web/assets/common/loading.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/assets/svg.ts b/apps/web/assets/svg.ts index 08f99fe5f..13d22254f 100644 --- a/apps/web/assets/svg.ts +++ b/apps/web/assets/svg.ts @@ -32,6 +32,7 @@ export { default as SearchStatusIcon } from './common/search-status.svg'; export { default as SettingFilterIcon } from './common/setting-filter.svg'; export { default as SettingGearIcon } from './common/setting-gear.svg'; export { default as SettingOutlineIcon } from './common/setting-outline.svg'; +export { default as LoadingIcon } from './common/loading.svg'; // checkbox export { default as CheckCircleOutlineIcon } from './common/tick/circle-outline.svg'; diff --git a/apps/web/components/shared/timer/timer-card.tsx b/apps/web/components/shared/timer/timer-card.tsx index 1a100e3f5..985053ddb 100644 --- a/apps/web/components/shared/timer/timer-card.tsx +++ b/apps/web/components/shared/timer/timer-card.tsx @@ -1,13 +1,18 @@ -import { ESTIMATE_POPUP_SHOWN_DATE, TODAY_PLAN_ALERT_SHOWN_DATE } from '@app/constants'; import { pad } from '@app/helpers/number'; -import { useModal } from '@app/hooks'; +import { useTeamTasks } from '@app/hooks'; +import { useStartStopTimerHandler } from '@app/hooks/features/useStartStopTimerHandler'; import { useTaskStatistics } from '@app/hooks/features/useTaskStatistics'; import { useTimer } from '@app/hooks/features/useTimer'; import { ProgressBar } from '@components/ui/progress-bar'; import { PauseIcon } from '@components/ui/svgs/pause-icon'; import { PlayIcon } from '@components/ui/svgs/play-icon'; -import { AddWorkTimeAndEstimatesToPlan } from 'lib/features/daily-plan/plans-work-time-and-estimate'; +import { + AddTasksEstimationHoursModal, + AddDailyPlanWorkHourModal, + EnforcePlanedTaskModal +} from 'lib/features/daily-plan'; import { useTranslations } from 'next-intl'; +import { useMemo } from 'react'; const Timer = () => { const t = useTranslations(); @@ -15,34 +20,18 @@ const Timer = () => { fomatedTimeCounter: { hours, minutes, seconds, ms_p }, timerStatus, timerStatusFetching, - startTimer, - stopTimer, canRunTimer, - isPlanVerified, hasPlan, timerSeconds } = useTimer(); const { activeTaskEstimation } = useTaskStatistics(timerSeconds); - const { closeModal, isOpen, openModal } = useModal(); + const { modals, startStopTimerHandler } = useStartStopTimerHandler(); - const timerHanlder = () => { - const currentDate = new Date().toISOString().split('T')[0]; - const lastPopupDate = window && window?.localStorage.getItem(TODAY_PLAN_ALERT_SHOWN_DATE); - const lastPopupEstimates = window && window?.localStorage.getItem(ESTIMATE_POPUP_SHOWN_DATE); + const { activeTeam, activeTeamTask } = useTeamTasks(); - if (timerStatusFetching || !canRunTimer) return; - if (timerStatus?.running) { - stopTimer(); - } else { - if (!isPlanVerified || lastPopupDate !== currentDate || lastPopupEstimates !== currentDate) { - openModal(); - } else { - startTimer(); - } - } - }; + const requirePlan = useMemo(() => activeTeam?.requirePlanToTrack, [activeTeam?.requirePlanToTrack]); return ( <> @@ -56,17 +45,35 @@ const Timer = () => {
{timerStatus?.running ? : }
- + {hasPlan && hasPlan.tasks && ( + + )} + + {hasPlan && ( + + )} + + {requirePlan && hasPlan && activeTeamTask && ( + + )} ); }; diff --git a/apps/web/components/ui/calendar.tsx b/apps/web/components/ui/calendar.tsx index 999eabc53..ac069e2b3 100644 --- a/apps/web/components/ui/calendar.tsx +++ b/apps/web/components/ui/calendar.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { DayPicker } from 'react-day-picker'; +import { DayPicker, DropdownProps } from 'react-day-picker'; import { cn } from 'lib/utils'; import { buttonVariants } from 'components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; +import { ScrollArea } from './scroll-bar'; export type CalendarProps = React.ComponentProps; @@ -15,8 +17,10 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C classNames={{ months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0', month: 'space-y-4', + vhidden: 'vhidden hidden', caption: 'flex justify-center pt-1 relative items-center', caption_label: 'text-sm font-medium', + caption_dropdowns: cn('flex justify-between gap-1', props.captionLayout === 'dropdown' && 'w-full'), nav: 'space-x-1 flex items-center', nav_button: cn( buttonVariants({ variant: 'outline' }), @@ -40,6 +44,42 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C ...classNames }} components={{ + Dropdown: ({ value, onChange, children, ...props }: DropdownProps) => { + const options = React.Children.toArray(children) as React.ReactElement< + React.HTMLProps + >[]; + const selected = options.find((child) => child.props.value === value); + const handleChange = (value: string) => { + const changeEvent = { + target: { value } + } as React.ChangeEvent; + onChange?.(changeEvent); + }; + return ( + + ); + }, IconLeft, IconRight }} diff --git a/apps/web/lib/components/custom-select/index.tsx b/apps/web/lib/components/custom-select/index.tsx new file mode 100644 index 000000000..50e16257b --- /dev/null +++ b/apps/web/lib/components/custom-select/index.tsx @@ -0,0 +1,77 @@ +import { Button } from "@components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover"; +import { cn } from "lib/utils"; +import { useState } from "react"; +import { MdOutlineKeyboardArrowDown } from "react-icons/md"; + +interface SelectItemsProps { + items: T[]; + onValueChange?: (value: string) => void; + itemToString: (item: T) => string; + itemId: (item: T) => string; + triggerClassName?: string; + popoverClassName?: string; + renderItem?: (item: T, onClick: () => void) => JSX.Element; +} + +export function SelectItems({ + items, + onValueChange, + itemToString, + itemId, + triggerClassName = '', + popoverClassName = '', + renderItem +}: SelectItemsProps) { + const [selectedItem, setSelectedItem] = useState(null); + const [isPopoverOpen, setPopoverOpen] = useState(false); + + const onClick = (item: T) => { + setSelectedItem(item); + setPopoverOpen(false); + if (onValueChange) { + onValueChange(itemId(item)); + } + } + + return ( + + + + + +
+ {items.map((item) => ( + renderItem ? renderItem(item, () => onClick(item)) : ( + onClick(item)} + key={itemId(item)} + className="truncate hover:cursor-pointer hover:bg-slate-50 w-full text-[13px] hover:rounded-lg p-1 hover:font-bold dark:text-white dark:hover:bg-primary" + style={{ textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden' }} + > + {itemToString(item)} + + ) + ))} +
+
+
+ ); +} diff --git a/apps/web/lib/components/emoji-picker.tsx b/apps/web/lib/components/emoji-picker.tsx index b92d8a5eb..5e1396a56 100644 --- a/apps/web/lib/components/emoji-picker.tsx +++ b/apps/web/lib/components/emoji-picker.tsx @@ -72,9 +72,8 @@ export const EmojiPicker = ({ as="div" >
@@ -99,7 +98,7 @@ export const EmojiPicker = ({ }} className={`outline-none `} > - +
)} diff --git a/apps/web/lib/components/index.ts b/apps/web/lib/components/index.ts index 38faad35d..7c3a6edda 100644 --- a/apps/web/lib/components/index.ts +++ b/apps/web/lib/components/index.ts @@ -22,6 +22,7 @@ export * from './color-picker'; export * from './no-data'; export * from './pagination'; export * from './time-picker' +export * from './custom-select' export * from './inputs/input'; export * from './inputs/auth-code-input'; diff --git a/apps/web/lib/components/inputs/input.tsx b/apps/web/lib/components/inputs/input.tsx index aa5e4aa16..5637296db 100644 --- a/apps/web/lib/components/inputs/input.tsx +++ b/apps/web/lib/components/inputs/input.tsx @@ -161,24 +161,31 @@ export const InputField = forwardRef( {leadingNode &&
{leadingNode}
}
{inputElement}
{emojis && ( -
{clickInput && <> - setShowEmoji(true)} className={clsxm('mr-3')} /> - {showEmoji && ( -
| MutableRefObject)[] +
+ {clickInput && ( + <> + setShowEmoji(true)} className={clsxm('mr-3')} /> + {showEmoji && ( +
+ | MutableRefObject + )[] + )} + className="absolute right-1 z-50" + > + +
)} - className="absolute right-1 z-50" - > - -
- )}} + + )}
)} {trailingNode &&
{trailingNode}
} diff --git a/apps/web/lib/components/keyboard-shortcuts.tsx b/apps/web/lib/components/keyboard-shortcuts.tsx index b11de95f4..78317dd60 100644 --- a/apps/web/lib/components/keyboard-shortcuts.tsx +++ b/apps/web/lib/components/keyboard-shortcuts.tsx @@ -40,7 +40,7 @@ export function KeyboardShortcuts() { {HostKeysMapping.map((item, index) => (

- {t(`hotkeys.${item.heading}` as DottedLanguageObjectStringPaths)} + {t(`hotkeys.${item.heading}` as DottedLanguageObjectStringPaths)}

{item.keySequence.map((keySeq, keySeqIndex) => ( diff --git a/apps/web/lib/components/toggler.tsx b/apps/web/lib/components/toggler.tsx index 6fe058b74..27459fe10 100644 --- a/apps/web/lib/components/toggler.tsx +++ b/apps/web/lib/components/toggler.tsx @@ -153,7 +153,7 @@ export function DataSyncModeToggler({ className }: IClassName) { className={clsxm( 'flex flex-row justify-center items-center p-2 w-8 h-8 rounded-[60px] ml-[-2px]', dataSyncMode == 'REAL_TIME' && - 'bg-white text-primary shadow-md dark:bg-transparent dark:bg-[#3B4454]' + 'bg-white text-primary shadow-md dark:bg-transparent dark:bg-[#3B4454]' )} > diff --git a/apps/web/lib/features/activity/calendar.tsx b/apps/web/lib/features/activity/calendar.tsx new file mode 100644 index 000000000..b250ccf6b --- /dev/null +++ b/apps/web/lib/features/activity/calendar.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useTimeLogs } from '@app/hooks/features/useTimeLogs'; +import { useEffect, useState } from 'react'; +import { CalendarDatum, ResponsiveCalendar } from '@nivo/calendar'; +import Skeleton from 'react-loading-skeleton'; +import moment from 'moment'; + +export function ActivityCalendar() { + const { timerLogsDailyReport, timerLogsDailyReportLoading } = useTimeLogs(); + const [calendarData, setCalendarData] = useState([]); + + useEffect(() => { + setCalendarData( + timerLogsDailyReport.map((el) => ({ value: Number((el.sum / 3600).toPrecision(2)), day: el.date })) + ); + }, [timerLogsDailyReport]); + + return ( +
+ {timerLogsDailyReportLoading ? ( + + ) : ( + d.toLocaleString('en-US', { month: 'short' })} + /> + )} +
+ ); +} + +// Skeletons +function ActivityCalendarSkeleton() { + const { innerWidth: deviceWith } = window; + + const skeletons = Array.from(Array(12)); + + return ( +
+ {skeletons.map((_, index) => ( + + ))} +
+ ); +} diff --git a/apps/web/lib/features/daily-plan/add-daily-plan-work-hours-modal.tsx b/apps/web/lib/features/daily-plan/add-daily-plan-work-hours-modal.tsx new file mode 100644 index 000000000..115b7c694 --- /dev/null +++ b/apps/web/lib/features/daily-plan/add-daily-plan-work-hours-modal.tsx @@ -0,0 +1,88 @@ +import { Card, InputField, Modal, Text } from 'lib/components'; +import { Button } from '@components/ui/button'; +import { useCallback, useMemo, useState } from 'react'; +import { DAILY_PLAN_ESTIMATE_HOURS_MODAL_DATE } from '@app/constants'; +import { IDailyPlan } from '@app/interfaces'; +import { useDailyPlan, useTeamTasks, useTimerView } from '@app/hooks'; +import { useTranslations } from 'next-intl'; + +interface IAddDailyPlanWorkHoursModalProps { + closeModal: () => void; + isOpen: boolean; + plan: IDailyPlan; +} + +export function AddDailyPlanWorkHourModal(props: IAddDailyPlanWorkHoursModalProps) { + const { closeModal, isOpen, plan } = props; + + const t = useTranslations(); + const { updateDailyPlan } = useDailyPlan(); + const { startTimer } = useTimerView(); + const { activeTeam } = useTeamTasks(); + + const [workTimePlanned, setworkTimePlanned] = useState(plan.workTimePlanned); + const currentDate = useMemo(() => new Date().toISOString().split('T')[0], []); + const requirePlan = useMemo(() => activeTeam?.requirePlanToTrack, [activeTeam?.requirePlanToTrack]); + const hasWorkHours = useMemo(() => plan.workTimePlanned && plan.workTimePlanned > 0, [plan.workTimePlanned]); + + const handleCloseModal = useCallback(() => { + localStorage.setItem(DAILY_PLAN_ESTIMATE_HOURS_MODAL_DATE, currentDate); + closeModal(); + }, [closeModal, currentDate]); + + const handleSubmit = useCallback(() => { + updateDailyPlan({ workTimePlanned }, plan.id ?? ''); + startTimer(); + handleCloseModal(); + }, [handleCloseModal, plan.id, startTimer, updateDailyPlan, workTimePlanned]); + + return ( + + +
+
+ + {t('timer.todayPlanSettings.TITLE')} + +
+ + {t('timer.todayPlanSettings.WORK_TIME_PLANNED')} * + + + setworkTimePlanned(parseFloat(e.target.value))} + required + min={0} + value={workTimePlanned} + defaultValue={plan.workTimePlanned ?? 0} + /> +
+
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/lib/features/daily-plan/add-task-estimation-hours-modal.tsx b/apps/web/lib/features/daily-plan/add-task-estimation-hours-modal.tsx new file mode 100644 index 000000000..f415c0413 --- /dev/null +++ b/apps/web/lib/features/daily-plan/add-task-estimation-hours-modal.tsx @@ -0,0 +1,119 @@ +import { TASKS_ESTIMATE_HOURS_MODAL_DATE } from '@app/constants'; +import { useMemo, useCallback, useState } from 'react'; +import { PiWarningCircleFill } from 'react-icons/pi'; +import { Card, InputField, Modal, Text, VerticalSeparator } from 'lib/components'; +import { Button } from '@components/ui/button'; +import { useTranslations } from 'next-intl'; +import { useDailyPlan, useTeamTasks, useTimerView } from '@app/hooks'; +import { TaskNameInfoDisplay } from '../task/task-displays'; +import { TaskEstimate } from '../task/task-estimate'; +import { IDailyPlan, ITeamTask } from '@app/interfaces'; + +interface IAddTasksEstimationHoursModalProps { + closeModal: () => void; + isOpen: boolean; + plan: IDailyPlan; + tasks: ITeamTask[]; +} + +export function AddTasksEstimationHoursModal(props: IAddTasksEstimationHoursModalProps) { + const { isOpen, closeModal, plan, tasks } = props; + + const t = useTranslations(); + const { updateDailyPlan } = useDailyPlan(); + const { startTimer } = useTimerView(); + const { activeTeam } = useTeamTasks(); + + const [workTimePlanned, setworkTimePlanned] = useState(plan.workTimePlanned); + const currentDate = useMemo(() => new Date().toISOString().split('T')[0], []); + const requirePlan = useMemo(() => activeTeam?.requirePlanToTrack, [activeTeam?.requirePlanToTrack]); + + const handleCloseModal = useCallback(() => { + localStorage.setItem(TASKS_ESTIMATE_HOURS_MODAL_DATE, currentDate); + closeModal(); + }, [closeModal, currentDate]); + + const handleSubmit = useCallback(() => { + updateDailyPlan({ workTimePlanned }, plan.id ?? ''); + startTimer(); + handleCloseModal(); + }, [handleCloseModal, plan.id, startTimer, updateDailyPlan, workTimePlanned]); + + return ( + + +
+
+ + {t('timer.todayPlanSettings.TITLE')} + +
+ + {t('timer.todayPlanSettings.WORK_TIME_PLANNED')} * + + + setworkTimePlanned(parseFloat(e.target.value))} + required + min={0} + value={workTimePlanned} + defaultValue={plan.workTimePlanned ?? 0} + /> +
+
+
+ + {t('timer.todayPlanSettings.TASKS_WITH_NO_ESTIMATIONS')}{' '} + * + +
+ {tasks.map((task, index) => ( + + ))} +
+
+
+ +

{t('timer.todayPlanSettings.WARNING_PLAN_ESTIMATION')}

+
+
+
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx b/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx index 8491d203a..a483882c7 100644 --- a/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx +++ b/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx @@ -1,16 +1,13 @@ -import { useCallback, useState } from 'react'; +import { Dispatch, memo, SetStateAction, useCallback, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { DailyPlanStatusEnum, IDailyPlanMode, IOrganizationTeamList, OT_Member } from '@app/interfaces'; import { useAuthenticateUser, useDailyPlan, useOrganizationTeams } from '@app/hooks'; import { Avatar, Card, Modal, Text } from 'lib/components'; import { imgTitle, tomorrowDate } from '@app/helpers'; -import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover'; -import { cn } from 'lib/utils'; -import { CalendarIcon, ReloadIcon } from '@radix-ui/react-icons'; +import { ReloadIcon } from '@radix-ui/react-icons'; import moment from 'moment'; import { Calendar } from '@components/ui/calendar'; import { Button } from '@components/ui/button'; - import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@components/ui/command'; import { ScrollArea } from '@components/ui/scroll-bar'; import { clsxm, isValidUrl } from '@app/utils'; @@ -37,9 +34,15 @@ export function CreateDailyPlanFormModal({ const { activeTeam, activeTeamManagers } = useOrganizationTeams(); const { createDailyPlan, createDailyPlanLoading, profileDailyPlans } = useDailyPlan(); - const existingPlanDates = profileDailyPlans.items.map((plan) => new Date(plan.date)); + const existingPlanDates = useMemo( + () => profileDailyPlans.items.map((plan) => new Date(plan.date)), + [profileDailyPlans.items] + ); - const isManagerConnectedUser = activeTeamManagers.find((member) => member.employee?.user?.id == user?.id); + const isManagerConnectedUser = useMemo( + () => activeTeamManagers.find((member) => member.employee?.user?.id === user?.id), + [activeTeamManagers, user?.id] + ); const [date, setDate] = useState(new Date(tomorrowDate)); const [selectedEmployee, setSelectedEmployee] = useState(isManagerConnectedUser); @@ -82,18 +85,17 @@ export function CreateDailyPlanFormModal({ closeModal ] ); + return ( -
+
{/* Form header */} -
- - CREATE A DAY PLAN +
+ + Plan this task for {moment(date).format('DD.MM.YYYY').toString()} - - You are creating a new plan
{/* Form Fields */} @@ -116,44 +118,33 @@ export function CreateDailyPlanFormModal({ /> */} {planMode === 'custom' && ( - - - - - - setDate(day ? day : new Date(tomorrowDate))} - initialFocus - disabled={[ - ...existingPlanDates, - { from: new Date(1970, 1, 1), to: tomorrowDate } - ]} - /> - - +
+ +
)} - - +
+ + +
@@ -162,6 +153,33 @@ export function CreateDailyPlanFormModal({ ); } +const CustomCalendar = memo(function CustomCalendar({ + date, + setDate, + existingPlanDates +}: { + date: Date; + setDate: Dispatch>; + existingPlanDates: Date[]; +}) { + return ( + setDate(day ? day : new Date(tomorrowDate))} + initialFocus + disabled={[...existingPlanDates, { from: new Date(1970, 1, 1), to: tomorrowDate }]} + modifiers={{ + booked: existingPlanDates + }} + modifiersClassNames={{ booked: 'bg-primary text-white' }} + fromYear={new Date().getUTCFullYear()} + toYear={new Date().getUTCFullYear() + 5} + /> + ); +}); + function MembersList({ activeTeam, handleMemberClick, diff --git a/apps/web/lib/features/daily-plan/daily-plan-compare-estimate-modal.tsx b/apps/web/lib/features/daily-plan/daily-plan-compare-estimate-modal.tsx index 5e40b0d9d..38786bd7a 100644 --- a/apps/web/lib/features/daily-plan/daily-plan-compare-estimate-modal.tsx +++ b/apps/web/lib/features/daily-plan/daily-plan-compare-estimate-modal.tsx @@ -1,168 +1,205 @@ -"use client" +'use client'; -import { Card, Modal, Text, Button, TimePicker, TimePickerValue } from 'lib/components' -import { PiWarningCircleFill } from "react-icons/pi"; -import React, { useState } from 'react' +import { Card, Modal, Text, Button, TimePicker, TimePickerValue } from 'lib/components'; +import { PiWarningCircleFill } from 'react-icons/pi'; +import React, { useState } from 'react'; import Separator from '@components/ui/separator'; import { IDailyPlan, ITeamTask } from '@app/interfaces'; import { TaskNameInfoDisplay } from '../task/task-displays'; import { clsxm } from '@app/utils'; import { TaskEstimateInput } from '../team/user-team-card/task-estimate'; -import { useTeamMemberCard, useTimer, useTMCardTaskEdit } from '@app/hooks'; +import { useDailyPlan, useTeamMemberCard, useTimer, useTMCardTaskEdit } from '@app/hooks'; import { dailyPlanCompareEstimated } from '@app/helpers/daily-plan-estimated'; import { secondsToTime } from '@app/helpers'; +import { DAILY_PLAN_ESTIMATE_HOURS_MODAL_DATE } from '@app/constants'; - +export interface IDailyPlanCompareEstimated { + difference?: boolean; + workTimePlanned?: number; + estimated?: boolean[] | undefined; + plan?: IDailyPlan | undefined; +} export function DailyPlanCompareEstimatedModal({ - open, - closeModal, - todayPlan, - profile -}: { open: boolean, closeModal: () => void, todayPlan: IDailyPlan[], profile: any }) { + open, + closeModal, + todayPlan, + profile +}: { + open: boolean; + closeModal: () => void; + todayPlan: IDailyPlan[]; + profile: any; +}) { + const { difference, workTimePlanned, estimated, plan } = dailyPlanCompareEstimated(todayPlan); + const { updateDailyPlan, updateDailyPlanLoading } = useDailyPlan(); + const { h: dh, m: dm } = secondsToTime(workTimePlanned || 0); + const { startTimer } = useTimer(); + const hour = dh.toString()?.padStart(2, '0'); + const minute = dm.toString()?.padStart(2, '0'); + const [times, setTimes] = useState({ + hours: (workTimePlanned! / 3600).toString(), + meridiem: 'PM', + minute: '--' + }); - const { estimatedTime } = dailyPlanCompareEstimated(todayPlan); - const { h: dh, m: dm } = secondsToTime(estimatedTime || 0); - const { startTimer } = useTimer() - const hour = dh.toString()?.padStart(2, '0'); - const minute = dm.toString()?.padStart(2, '0'); - const [times, setTimes] = useState({ - hours: '--', - meridiem: 'PM', - minute: '--' - }) - const onClick = () => { - startTimer(); - window.localStorage.setItem('daily-plan-modal', new Date().toISOString().split('T')[0]); - } - return ( - -
- -
- -
-
- { - setTimes(value); - console.log(times) + const onClick = () => { + updateDailyPlan({ workTimePlanned: parseInt(times.hours) }, plan?.id ?? ''); + if (!updateDailyPlanLoading) { + startTimer(); + closeModal(); + window.localStorage.setItem(DAILY_PLAN_ESTIMATE_HOURS_MODAL_DATE, new Date().toISOString().split('T')[0]); + } + }; - }} - /> - -
-
- {todayPlan.map((plan, i) => { - return
- {plan.tasks?.map((data, index) => { - return - })} -
- })} -
-
-
- - Please correct planned work hours or re-estimate task(s) -
- -
-
-
-
- ) + return ( + +
+ +
+ +
+
+ setTimes(value)} + /> + +
+
+ {todayPlan.map((plan, i) => { + return ( +
+ {plan.tasks?.map((data, index) => { + return ( +
+ +
+ ); + })} +
+ ); + })} +
+
+
+ {!difference && !estimated?.every(Boolean) && ( + <> + + Please correct planned work hours or re-estimate task(s) + + )} +
+ 0 ? false : true)} + /> +
+
+
+
+ ); } -export function DailyPlanTask({ task, profile }: { task?: ITeamTask, profile: any }) { - const taskEdition = useTMCardTaskEdit(task); - const member = task?.selectedTeam?.members.find((member) => { - return member?.employee?.user?.id === profile?.userProfile?.id - }); +export function DailyPlanTask({ task, profile }: { task?: ITeamTask; profile: any }) { + const taskEdition = useTMCardTaskEdit(task); + const member = task?.selectedTeam?.members.find((member) => { + return member?.employee?.user?.id === profile?.userProfile?.id; + }); - const memberInfo = useTeamMemberCard(member); - return ( -
-
- -
- -
- -
-
- ); + const memberInfo = useTeamMemberCard(member); + return ( +
+
+ +
+ +
+ +
+
+ ); } - -export function DailyPlanCompareActionButton({ closeModal, onClick, loading }: { closeModal?: () => void, onClick?: () => void, loading?: boolean }) { - return ( -
- - -
- ) +export function DailyPlanCompareActionButton({ + closeModal, + onClick, + loading, + disabled +}: { + closeModal?: () => void; + onClick?: () => void; + loading?: boolean; + disabled: boolean; +}) { + return ( +
+ + +
+ ); } - export function DailyPlanCompareHeader() { - return ( - <> -
- - TODAY'S PLAN - -
-
-
- - Add planned working hours - - * - -
-
- - ) + return ( + <> +
+ + TODAY'S PLAN + +
+
+
+ + Add planned working hours + + + * + +
+
+ + ); } - export function DailyPlanWorkTimeInput() { - return ( - <> -
- - Tasks with no time estimations - - - * - -
- - ) + return ( + <> +
+ + Tasks with no time estimations + + + * + +
+ + ); } diff --git a/apps/web/lib/features/daily-plan/enforce-planed-task-modal.tsx b/apps/web/lib/features/daily-plan/enforce-planed-task-modal.tsx new file mode 100644 index 000000000..ca3ec01bf --- /dev/null +++ b/apps/web/lib/features/daily-plan/enforce-planed-task-modal.tsx @@ -0,0 +1,60 @@ +import { useAuthenticateUser, useDailyPlan, useTimerView } from '@app/hooks'; +import { IDailyPlan, ITeamTask } from '@app/interfaces'; +import { Button, Card, Modal, Text } from 'lib/components'; +import { useTranslations } from 'next-intl'; +import { useCallback } from 'react'; + +interface IEnforcePlannedTaskModalProps { + open: boolean; + closeModal: () => void; + task: ITeamTask; + plan: IDailyPlan; +} + +export function EnforcePlanedTaskModal(props: IEnforcePlannedTaskModalProps) { + const { closeModal, task, open, plan } = props; + const t = useTranslations(); + const { addTaskToPlan, addTaskToPlanLoading } = useDailyPlan(); + const { startTimer } = useTimerView(); + const { user } = useAuthenticateUser(); + + const handleAddTaskToPlan = useCallback(() => { + if (user?.employee && task && plan.id) { + addTaskToPlan({ employeeId: user.employee.id, taskId: task.id }, plan.id).then(() => { + closeModal(); + startTimer(); + }); + } + }, [addTaskToPlan, closeModal, plan.id, startTimer, task, user?.employee]); + + return ( + + +
+ + {t('dailyPlan.SUGGESTS_TO_ADD_TASK_TO_TODAY_PLAN')} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/lib/features/daily-plan/index.ts b/apps/web/lib/features/daily-plan/index.ts index 198a4d36b..af0141f2a 100644 --- a/apps/web/lib/features/daily-plan/index.ts +++ b/apps/web/lib/features/daily-plan/index.ts @@ -1,4 +1,7 @@ -export * from './plans-work-time-and-estimate'; export * from './add-task-to-plan'; -export * from './daily-plan-compare-estimate-modal' -export * from './create-daily-plan-form-modal' +export * from './daily-plan-compare-estimate-modal'; +export * from './create-daily-plan-form-modal'; +export * from './enforce-planed-task-modal'; +export * from './add-daily-plan-work-hours-modal'; +export * from './suggest-daily-plan-modal'; +export * from './add-task-estimation-hours-modal'; diff --git a/apps/web/lib/features/daily-plan/plans-work-time-and-estimate.tsx b/apps/web/lib/features/daily-plan/plans-work-time-and-estimate.tsx deleted file mode 100644 index cfd0bb1d5..000000000 --- a/apps/web/lib/features/daily-plan/plans-work-time-and-estimate.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { PiWarningCircleFill } from 'react-icons/pi'; -import { IDailyPlan, ITeamTask, IUser } from '@app/interfaces'; -import { Card, InputField, Modal, Text, VerticalSeparator } from 'lib/components'; -import { useTranslations } from 'use-intl'; -import { TaskNameInfoDisplay } from '../task/task-displays'; -import { Button } from '@components/ui/button'; -import { TaskEstimate } from '../task/task-estimate'; -import { useAuthenticateUser, useAuthTeamTasks, useDailyPlan, useTeamTasks } from '@app/hooks'; -import { ReloadIcon } from '@radix-ui/react-icons'; -import { ESTIMATE_POPUP_SHOWN_DATE, TODAY_PLAN_ALERT_SHOWN_DATE } from '@app/constants'; - -export function AddWorkTimeAndEstimatesToPlan({ - open, - closeModal, - plan, - startTimer, - hasPlan -}: { - open: boolean; - closeModal: () => void; - startTimer: () => void; - hasPlan: boolean; - plan?: IDailyPlan; -}) { - const t = useTranslations(); - const [workTimePlanned, setworkTimePlanned] = useState(plan?.workTimePlanned); - - useEffect(() => { - if (typeof workTimePlanned === 'string') setworkTimePlanned(parseFloat(workTimePlanned)); - }, [workTimePlanned]); - const { user } = useAuthenticateUser(); - - const { updateDailyPlan, todayPlan: hasPlanToday } = useDailyPlan(); - - const { tasks: $tasks, activeTeam } = useTeamTasks(); - const requirePlan = activeTeam?.requirePlanToTrack; - const currentUser = activeTeam?.members?.find((member) => member.employee.userId === user?.id); - - const tasksEstimated = plan?.tasks?.some((t) => typeof t?.estimate === 'number' && t?.estimate <= 0); - - const tasks = $tasks.filter((task) => { - return plan?.tasks?.some((t) => task?.id === t.id && typeof task?.estimate === 'number' && task?.estimate <= 0); - }); - - const currentDate = new Date().toISOString().split('T')[0]; - const lastPopupDate = window && window?.localStorage.getItem(TODAY_PLAN_ALERT_SHOWN_DATE); - const lastPopupEstimates = window && window?.localStorage.getItem(ESTIMATE_POPUP_SHOWN_DATE); - - const hasWorkedToday = currentUser?.totalTodayTasks.reduce( - (previousValue, currentValue) => previousValue + currentValue.duration, - 0 - ); - - const handleSubmit = () => { - if (requirePlan) { - if (workTimePlanned === 0 || typeof workTimePlanned !== 'number') return; - if (tasks.some((task) => task.estimate === 0)) return; - } - - updateDailyPlan({ workTimePlanned }, plan?.id ?? ''); - startTimer(); - closeModal(); - }; - - const handleCloseModal = useCallback(() => { - closeModal(); - // startTimer(); - localStorage.setItem(ESTIMATE_POPUP_SHOWN_DATE, currentDate); - }, [closeModal, currentDate]); - - const Content = () => { - if (hasWorkedToday && hasWorkedToday > 0) { - if ((!hasPlanToday || hasPlanToday.length === 0) && (!lastPopupDate || lastPopupDate !== currentDate)) { - return ; - } else { - if ( - (tasksEstimated || !plan?.workTimePlanned || plan?.workTimePlanned <= 0) && - (!lastPopupEstimates || lastPopupEstimates !== currentDate) - ) { - return ( -
-
- - {t('timer.todayPlanSettings.TITLE')} - - {hasPlan && plan?.workTimePlanned && plan?.workTimePlanned <= 0 && ( -
- - {t('timer.todayPlanSettings.WORK_TIME_PLANNED')}{' '} - * - - - setworkTimePlanned(parseFloat(e.target.value))} - required - defaultValue={plan?.workTimePlanned ?? 0} - /> -
- )} - {tasksEstimated && ( -
- - -
- -

{t('timer.todayPlanSettings.WARNING_PLAN_ESTIMATION')}

-
-
- )} -
-
- - -
-
- ); - } - } - } - return null; - }; - - return ( - - - - - - ); -} - -function UnEstimatedTasks({ - requirePlan, - hasPlan, - dailyPlan, - user -}: { - requirePlan: boolean; - hasPlan: boolean; - dailyPlan?: IDailyPlan; - user?: IUser; -}) { - const t = useTranslations(); - - const { tasks: $tasks } = useTeamTasks(); - const { assignedTasks } = useAuthTeamTasks(user); - - let tasks: ITeamTask[] = []; - if (hasPlan) { - tasks = $tasks.filter((task) => - dailyPlan?.tasks?.some( - (t) => task?.id === t.id && typeof task?.estimate === 'number' && task?.estimate <= 0 - ) - ); - } else { - if (!requirePlan) { - tasks = assignedTasks.filter((task) => typeof task?.estimate === 'number' && task?.estimate <= 0); - } - } - - return ( -
- {tasks?.length > 0 && ( -
- - {t('timer.todayPlanSettings.TASKS_WITH_NO_ESTIMATIONS')} * - -
- {tasks && tasks?.map((task) => )} -
-
- )} -
- ); -} - -export function UnEstimatedTask({ task }: { task: ITeamTask }) { - return ( - - ); -} - -export function CreateTodayPlanPopup({ closeModal, currentDate }: { closeModal: () => void; currentDate: string }) { - const t = useTranslations(); - const { createDailyPlanLoading } = useDailyPlan(); - - const handleCloseModal = useCallback(() => { - closeModal(); - // startTimer(); - localStorage.setItem(TODAY_PLAN_ALERT_SHOWN_DATE, currentDate); - }, [closeModal, currentDate]); - - return ( - -
-
- - {t('dailyPlan.CREATE_A_PLAN_FOR_TODAY')} - - - {t('dailyPlan.TODAY_PLAN_SUB_TITLE')} - {t('dailyPlan.DAILY_PLAN_DESCRIPTION')} -
-
- -
-
-
- ); -} diff --git a/apps/web/lib/features/daily-plan/suggest-daily-plan-modal.tsx b/apps/web/lib/features/daily-plan/suggest-daily-plan-modal.tsx new file mode 100644 index 000000000..bebc987d5 --- /dev/null +++ b/apps/web/lib/features/daily-plan/suggest-daily-plan-modal.tsx @@ -0,0 +1,51 @@ +import { Modal, Card, Text } from 'lib/components'; +import { Button } from '@components/ui/button'; +import { useCallback, useMemo } from 'react'; +import { DAILY_PLAN_SUGGESTION_MODAL_DATE } from '@app/constants'; +import { useTranslations } from 'next-intl'; + +interface ISuggestDailyPlanModalProps { + closeModal: () => void; + isOpen: boolean; +} + +export function SuggestDailyPlanModal(props: ISuggestDailyPlanModalProps) { + const { isOpen, closeModal } = props; + + const t = useTranslations(); + + const currentDate = useMemo(() => new Date().toISOString().split('T')[0], []); + + const handleCloseModal = useCallback(() => { + localStorage.setItem(DAILY_PLAN_SUGGESTION_MODAL_DATE, currentDate); + closeModal(); + }, [closeModal, currentDate]); + + return ( + + +
+
+ + {t('dailyPlan.CREATE_A_PLAN_FOR_TODAY')} + + + {t('dailyPlan.TODAY_PLAN_SUB_TITLE')} + + {t('dailyPlan.DAILY_PLAN_DESCRIPTION')} + +
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/lib/features/manual-time/add-manual-time-modal.tsx b/apps/web/lib/features/manual-time/add-manual-time-modal.tsx new file mode 100644 index 000000000..61b3271d8 --- /dev/null +++ b/apps/web/lib/features/manual-time/add-manual-time-modal.tsx @@ -0,0 +1,290 @@ +import '../../../styles/style.css'; +import { useOrganizationTeams, useTeamTasks } from '@app/hooks'; +import api from '@app/services/client/axios'; +import { clsxm } from '@app/utils'; +import { DatePicker } from '@components/ui/DatePicker'; +import { PencilSquareIcon } from '@heroicons/react/20/solid'; +import { format } from 'date-fns'; +import { useState, useEffect, FormEvent, useCallback } from 'react'; +import { Button, SelectItems, Modal } from 'lib/components'; +import { FaRegCalendarAlt } from 'react-icons/fa'; +import { HiMiniClock } from 'react-icons/hi2'; +import { manualTimeReasons } from '@app/constants'; +import { useTranslations } from 'next-intl'; + +interface IAddManualTimeModalProps { + isOpen: boolean; + closeModal: () => void; +} + +export function AddManualTimeModal(props: IAddManualTimeModalProps) { + const { closeModal, isOpen } = props; + const t = useTranslations(); + const [isBillable, setIsBillable] = useState(false); + const [description, setDescription] = useState(''); + const [reason, setReason] = useState(''); + const [errorMsg, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [endTime, setEndTime] = useState(''); + const [date, setDate] = useState(new Date()); + const [startTime, setStartTime] = useState(''); + const [teamId, setTeamId] = useState(''); + const [taskId, setTaskId] = useState(''); + const [timeDifference, setTimeDifference] = useState(''); + const { activeTeamTask, tasks, activeTeamId } = useTeamTasks(); + const { teams } = useOrganizationTeams(); + + useEffect(() => { + const now = new Date(); + const currentTime = now.toTimeString().slice(0, 5); + + setDate(now); + setStartTime(currentTime); + setEndTime(currentTime); + }, []); + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + const timeObject = { + date, + isBillable, + startTime, + endTime, + teamId, + taskId, + description, + reason, + timeDifference + }; + + if (date && startTime && endTime && teamId && taskId) { + setLoading(true); + setError(''); + const postData = async () => { + try { + const response = await api.post('/add_time', timeObject); + if (response.data.message) { + setLoading(false); + closeModal(); + } + } catch (err) { + setError('Failed to post data'); + setLoading(false); + } + }; + + postData(); + } else { + setError(`Please complete all required fields with a ${'*'}`); + } + }, + [closeModal, date, description, endTime, isBillable, reason, startTime, taskId, teamId, timeDifference] + ); + + const calculateTimeDifference = useCallback(() => { + const [startHours, startMinutes] = startTime.split(':').map(Number); + const [endHours, endMinutes] = endTime.split(':').map(Number); + + const startTotalMinutes = startHours * 60 + startMinutes; + const endTotalMinutes = endHours * 60 + endMinutes; + + const diffMinutes = endTotalMinutes - startTotalMinutes; + if (diffMinutes < 0) { + return; + } + + const hours = Math.floor(diffMinutes / 60); + const minutes = diffMinutes % 60; + setTimeDifference(`${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m`); + }, [endTime, startTime]); + + useEffect(() => { + calculateTimeDifference(); + }, [calculateTimeDifference, endTime, startTime]); + + useEffect(() => { + if (activeTeamTask) { + setTaskId(activeTeamTask.id); + } + if (activeTeamId) { + setTeamId(activeTeamId); + } + }, [activeTeamTask, activeTeamId]); + + return ( + + +
+ +
+ + {date ? ( +
+ + {format(date, 'PPP')} +
+ ) : ( + + )} +
+ } + selected={date} + onSelect={(value) => { + value && setDate(value); + }} + mode={'single'} + /> +
+
+ +
+ +
setIsBillable(!isBillable)} + style={ + isBillable + ? { background: 'linear-gradient(to right, #9d91efb7, #8a7bedb7)' } + : { background: '#6c57f4b7' } + } + > +
+
+
+
+
+ + setStartTime(e.target.value)} + className="w-full p-2 border font-bold border-slate-100 dark:border-slate-600 dark:bg-dark--theme-light rounded-md" + required + /> +
+ +
+ + + setEndTime(e.target.value)} + className="w-full p-2 border font-bold border-slate-100 dark:border-slate-600 dark:bg-dark--theme-light rounded-md" + required + /> +
+
+ +
+ +
+
+ +
+ {timeDifference} +
+
+ +
+ + setTeamId(value)} + itemId={(team) => team.id} + itemToString={(team) => team.name} + triggerClassName="border-slate-100 dark:border-slate-600" + /> +
+ +
+ + setTaskId(value)} + itemId={(task) => task.id} + itemToString={(task) => task.title} + triggerClassName="border-slate-100 dark:border-slate-600" + /> +
+ +
+ +