From df7689f4897951421ac9c3fb139e6ce9b8c4d722 Mon Sep 17 00:00:00 2001 From: "Thierry CH." Date: Mon, 3 Feb 2025 22:47:55 +0200 Subject: [PATCH 01/11] refactor: implement the new design for visited sites view (#3582) --- apps/web/app/helpers/date.ts | 15 +-- apps/web/lib/features/activity/apps.tsx | 10 +- ...{app-visited-Item.tsx => visited-Item.tsx} | 9 +- ...skeleton.tsx => visited-item-skeleton.tsx} | 2 +- .../lib/features/activity/visited-sites.tsx | 107 +++++++++++------- apps/web/locales/ar.json | 4 +- apps/web/locales/bg.json | 4 +- apps/web/locales/de.json | 4 +- apps/web/locales/en.json | 4 +- apps/web/locales/es.json | 4 +- apps/web/locales/fr.json | 4 +- apps/web/locales/he.json | 4 +- apps/web/locales/it.json | 4 +- apps/web/locales/nl.json | 4 +- apps/web/locales/pl.json | 4 +- apps/web/locales/pt.json | 4 +- apps/web/locales/ru.json | 4 +- apps/web/locales/zh.json | 4 +- 18 files changed, 124 insertions(+), 71 deletions(-) rename apps/web/lib/features/activity/components/{app-visited-Item.tsx => visited-Item.tsx} (86%) rename apps/web/lib/features/activity/components/{app-visited-skeleton.tsx => visited-item-skeleton.tsx} (94%) diff --git a/apps/web/app/helpers/date.ts b/apps/web/app/helpers/date.ts index 71d8fc6d3..43093d086 100644 --- a/apps/web/app/helpers/date.ts +++ b/apps/web/app/helpers/date.ts @@ -78,7 +78,6 @@ export function differenceBetweenHours(startedAt: Date, stoppedAt: Date): number return 0; } - /** * Converts a given date string to a time string in the format HH:mm. * @@ -91,7 +90,7 @@ export function differenceBetweenHours(startedAt: Date, stoppedAt: Date): number * @returns {string} The formatted time string */ export const formatTimeFromDate = (date: string | Date | undefined) => { - if (!date) return ""; + if (!date) return ''; const dateObject = date instanceof Date ? date : new Date(date); const hours = dateObject.getHours().toString().padStart(2, '0'); const minutes = dateObject.getMinutes().toString().padStart(2, '0'); @@ -109,9 +108,7 @@ export const formatTimeFromDate = (date: string | Date | undefined) => { * @param {Date | string} date - The date input, which can be either a Date object or a string. * @returns {Date} The corresponding Date object. */ -export const toDate = (date: Date | string) => - (date instanceof Date ? date : new Date(date)); - +export const toDate = (date: Date | string) => (date instanceof Date ? date : new Date(date)); export function convertMsToTime(milliseconds: number) { let seconds = Math.floor(milliseconds / 1000); @@ -176,7 +173,6 @@ export const tomorrowDate = moment().add(1, 'days').toDate(); export const yesterdayDate = moment().subtract(1, 'days').toDate(); - export const formatDayPlanDate = (dateString: string | Date, format?: string) => { if (dateString.toString().length > 10) { dateString = dateString.toString().split('T')[0]; @@ -261,7 +257,7 @@ export const getGreeting = (t: TranslationHooks) => { MORNING_START: 5, AFTERNOON_START: 12, EVENING_START: 18 - } as const + } as const; const currentHour = new Date().getHours(); if (currentHour >= GREETING_TIMES.MORNING_START && currentHour < GREETING_TIMES.AFTERNOON_START) { @@ -271,7 +267,7 @@ export const getGreeting = (t: TranslationHooks) => { } else { return t('pages.timesheet.GREETINGS.GOOD_EVENING'); } -} +}; export const formatDate = (dateStr: string | Date): string => { try { @@ -280,8 +276,7 @@ export const formatDate = (dateStr: string | Date): string => { console.error('Invalid date format:', error); return ''; } -} - +}; export function toLocal(date: string | Date | moment.Moment): moment.Moment { const localDate = moment(date); diff --git a/apps/web/lib/features/activity/apps.tsx b/apps/web/lib/features/activity/apps.tsx index 0381a00c4..6e02d71c4 100644 --- a/apps/web/lib/features/activity/apps.tsx +++ b/apps/web/lib/features/activity/apps.tsx @@ -1,8 +1,8 @@ import { useTimeDailyActivity } from '@app/hooks/features/useTimeDailyActivity'; -import { AppVisitedSkeleton } from './components/app-visited-skeleton'; +import { VisitedItemSkeleton } from './components/visited-item-skeleton'; import { groupAppsByHour } from '@app/helpers/array-data'; import { useTranslations } from 'next-intl'; -import AppVisitedItem from './components/app-visited-Item'; +import VisitedItem from './components/visited-Item'; import { useMemo } from 'react'; // import { AppVisitedModal } from './components/app-visited-details'; @@ -49,7 +49,7 @@ export function AppsTab() { {app?.apps?.map((item, i) => (
{/* */} - + {/* */}
))} @@ -63,8 +63,8 @@ export function AppsTab() { )} {loading && visitedApps?.length < 1 && ( <> - - + + )} diff --git a/apps/web/lib/features/activity/components/app-visited-Item.tsx b/apps/web/lib/features/activity/components/visited-Item.tsx similarity index 86% rename from apps/web/lib/features/activity/components/app-visited-Item.tsx rename to apps/web/lib/features/activity/components/visited-Item.tsx index 273fa7b5e..e780a2b07 100644 --- a/apps/web/lib/features/activity/components/app-visited-Item.tsx +++ b/apps/web/lib/features/activity/components/visited-Item.tsx @@ -4,7 +4,7 @@ import { ProgressBar } from 'lib/components'; import Link from 'next/link'; import React, { useMemo } from 'react'; -const AppVisitedItem = ({ +const VisitedItem = ({ app, totalMilliseconds, type @@ -49,9 +49,12 @@ const AppVisitedItem = ({

-

{`${h}:${m}:${s}`}

+

{`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`}

); }; -export default AppVisitedItem; +export default VisitedItem; diff --git a/apps/web/lib/features/activity/components/app-visited-skeleton.tsx b/apps/web/lib/features/activity/components/visited-item-skeleton.tsx similarity index 94% rename from apps/web/lib/features/activity/components/app-visited-skeleton.tsx rename to apps/web/lib/features/activity/components/visited-item-skeleton.tsx index 02eb9fbbc..2d29e10b0 100644 --- a/apps/web/lib/features/activity/components/app-visited-skeleton.tsx +++ b/apps/web/lib/features/activity/components/visited-item-skeleton.tsx @@ -1,4 +1,4 @@ -export function AppVisitedSkeleton() { +export function VisitedItemSkeleton() { return (
diff --git a/apps/web/lib/features/activity/visited-sites.tsx b/apps/web/lib/features/activity/visited-sites.tsx index c2e960ec1..7a5cb63ed 100644 --- a/apps/web/lib/features/activity/visited-sites.tsx +++ b/apps/web/lib/features/activity/visited-sites.tsx @@ -1,51 +1,80 @@ import { useTimeDailyActivity } from '@app/hooks/features/useTimeDailyActivity'; -import { AppVisitedSkeleton } from './components/app-visited-skeleton'; +import { VisitedItemSkeleton } from './components/visited-item-skeleton'; import { groupAppsByHour } from '@app/helpers/array-data'; import { useTranslations } from 'next-intl'; -import AppVisitedItem from './components/app-visited-Item'; -import React from 'react'; +import VisitedItem from './components/visited-Item'; +import { useMemo } from 'react'; +// import { AppVisitedModal } from './components/app-visited-details'; -export const VisitedSitesTab = React.memo(function VisitedSitesT() { - const { visitedSites, loading } = useTimeDailyActivity('URL'); +export function VisitedSitesTab() { + const { visitedSites, loading } = useTimeDailyActivity('SITE'); const t = useTranslations(); - const sites = groupAppsByHour(visitedSites); + const sites = groupAppsByHour(visitedSites ?? []); + + const headers = useMemo( + () => [ + { + title: t('timer.VISITED_SITES'), + width: '20%' + }, + { + title: t('timer.VISITED_DATES'), + width: '25%' + }, + { + title: t('timer.PERCENT_USED'), + width: '40%' + }, + { + title: t('timer.TIME_SPENT_IN_HOURS'), + width: '15%' + } + ], + [t] + ); return (
{/* TODO: Filters components */}
-
-

{t('timer.APPS')}

-

{t('timer.VISITED_DATES')}

-

{t('timer.PERCENT_USED')}

-

{t('timer.TIME_SPENT_IN_HOURS')}

-
-
- {sites.map((site, i) => ( -
-

{site.hour}

-
- {site.apps?.map((item, i) => ( -
- -
- ))} +
+
+ {headers.map((header, i) => ( +
+ {header.title} +
+ ))} +
+
+ {sites?.map((site, i) => ( +
+
+ {site?.apps?.map((item, i) => ( +
+ +
+ ))} +
+ ))} +
+ {visitedSites?.length < 1 && !loading && ( +
+

{t('timer.NO_VISITED_SITE_MESSAGE')}

- ))} -
- {visitedSites.length < 1 && !loading && ( -
-

{t('timer.THERE_IS_NO_APPS_VISITED')}

-
- )} - {loading && visitedSites.length < 1 && ( - <> - - - - )} + )} + {loading && visitedSites?.length < 1 && ( + <> + + + + )} +
); -}); +} diff --git a/apps/web/locales/ar.json b/apps/web/locales/ar.json index 083f63108..588ed1887 100644 --- a/apps/web/locales/ar.json +++ b/apps/web/locales/ar.json @@ -712,7 +712,9 @@ "START_WORKING_BUTTON": "ابدأ العمل", "TIMER_RUNNING": "المؤقت قيد التشغيل بالفعل", "WARNING_PLAN_ESTIMATION": "رجى تصحيح ساعات العمل المخطط لها أو إعادة تقدير المهمة (المهام)" - } + }, + "VISITED_SITES": "المواقع التي تمت زيارتها", + "NO_VISITED_SITE_MESSAGE": "لا يوجد مواقع تمت زيارتها" }, "task": { "TITLE": "المهمة", diff --git a/apps/web/locales/bg.json b/apps/web/locales/bg.json index a95458b6b..09887212c 100644 --- a/apps/web/locales/bg.json +++ b/apps/web/locales/bg.json @@ -712,7 +712,9 @@ "START_WORKING_BUTTON": "Започнете работа", "TIMER_RUNNING": "Таймерът вече работи", "WARNING_PLAN_ESTIMATION": "Моля, коригирайте планираните работни часове или преоценете задачата(ите) " - } + }, + "VISITED_SITES": "Посетени сайтове", + "NO_VISITED_SITE_MESSAGE": "Няма посетени сайтове" }, "task": { "TITLE": "Задача", diff --git a/apps/web/locales/de.json b/apps/web/locales/de.json index af51d0af4..f0f81761c 100644 --- a/apps/web/locales/de.json +++ b/apps/web/locales/de.json @@ -712,7 +712,9 @@ "START_WORKING_BUTTON": "Mit der Arbeit beginnen", "TIMER_RUNNING": "Der Timer läuft bereits", "WARNING_PLAN_ESTIMATION": "Bitte korrigieren Sie die geplanten Arbeitsstunden oder schätzen Sie die Aufgabe(n) neu ein." - } + }, + "VISITED_SITES": "Besuchte Seiten", + "NO_VISITED_SITE_MESSAGE": "Es gibt keine besuchten Seiten" }, "task": { "TITLE": "Aufgabe", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 8a0a00bfc..443fb814c 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -713,7 +713,9 @@ "START_WORKING_BUTTON": "Start working", "TIMER_RUNNING": "The timer is already running", "WARNING_PLAN_ESTIMATION": "Please correct planned work hours or re-estimate task(s)" - } + }, + "VISITED_SITES": "Visited sites", + "NO_VISITED_SITE_MESSAGE": "There are no visited sites" }, "task": { "TITLE": "Task", diff --git a/apps/web/locales/es.json b/apps/web/locales/es.json index 6db4e51af..c6f4149c2 100644 --- a/apps/web/locales/es.json +++ b/apps/web/locales/es.json @@ -712,7 +712,9 @@ "START_WORKING_BUTTON": "Empezar a trabajar", "TIMER_RUNNING": "El temporizador ya está en ejecución", "WARNING_PLAN_ESTIMATION": "Por favor, corrija las horas de trabajo previstas o reevalúe la(s) tarea(s)" - } + }, + "VISITED_SITES": "Sitios visitados", + "NO_VISITED_SITE_MESSAGE": "No hay sitios visitados" }, "task": { "TITLE": "Tarea", diff --git a/apps/web/locales/fr.json b/apps/web/locales/fr.json index 010a70a74..6a7489529 100644 --- a/apps/web/locales/fr.json +++ b/apps/web/locales/fr.json @@ -712,7 +712,9 @@ "ACTIVE": "Actif", "INACTIVE": "Inactif", "ARCHIVED": "Archivé", - "NOT_ARCHIVED": "Non archivé" + "NOT_ARCHIVED": "Non archivé", + "VISITED_SITES": "Sites visités", + "NO_VISITED_SITE_MESSAGE": "Il n'y a pas de sites visités" }, "task": { "TITLE": "Tâche", diff --git a/apps/web/locales/he.json b/apps/web/locales/he.json index 0585c26a2..66b50a7a8 100644 --- a/apps/web/locales/he.json +++ b/apps/web/locales/he.json @@ -712,7 +712,9 @@ "START_WORKING_BUTTON": "התחל לעבוד", "TIMER_RUNNING": "הטיימר כבר פועל", "WARNING_PLAN_ESTIMATION": "נא לתקן את שעות העבודה המתוכננות או להעריך מחדש את המשימות" - } + }, + "VISITED_SITES": "אתרים שביקרת בהם", + "NO_VISITED_SITE_MESSAGE": "אין אתרים שביקרת בהם" }, "task": { "TITLE": "משימה", diff --git a/apps/web/locales/it.json b/apps/web/locales/it.json index bc5cfffae..a3705a141 100644 --- a/apps/web/locales/it.json +++ b/apps/web/locales/it.json @@ -712,7 +712,9 @@ "ACTIVE": "Attivo", "INACTIVE": "Inattivo", "ARCHIVED": "Archiviato", - "NOT_ARCHIVED": "Non archiviato" + "NOT_ARCHIVED": "Non archiviato", + "VISITED_SITES": "Siti visitati", + "NO_VISITED_SITE_MESSAGE": "Non ci sono siti visitati" }, "task": { "TITLE": "Compito", diff --git a/apps/web/locales/nl.json b/apps/web/locales/nl.json index 74f3f8ef3..bf1e458ed 100644 --- a/apps/web/locales/nl.json +++ b/apps/web/locales/nl.json @@ -712,7 +712,9 @@ "START_WORKING_BUTTON": "Begin met werken", "TIMER_RUNNING": "De timer loopt al", "WARNING_PLAN_ESTIMATION": "Corrigeer de geplande werkuren of schat de taak(en) opnieuw in" - } + }, + "VISITED_SITES": "Bezochte sites", + "NO_VISITED_SITE_MESSAGE": "Er zijn geen bezochte sites" }, "task": { "TITLE": "Taak", diff --git a/apps/web/locales/pl.json b/apps/web/locales/pl.json index ac6b4c0bb..7e4031e89 100644 --- a/apps/web/locales/pl.json +++ b/apps/web/locales/pl.json @@ -712,7 +712,9 @@ "ACTIVE": "Aktywny", "INACTIVE": "Nieaktywny", "ARCHIVED": "Zarchiwizowany", - "NOT_ARCHIVED": "Niezarchiwizowany" + "NOT_ARCHIVED": "Niezarchiwizowany", + "VISITED_SITES": "Odwiedzone strony", + "NO_VISITED_SITE_MESSAGE": "Brak odwiedzonych stron" }, "task": { "TITLE": "Zadanie", diff --git a/apps/web/locales/pt.json b/apps/web/locales/pt.json index 18ece4aa3..d90b2329e 100644 --- a/apps/web/locales/pt.json +++ b/apps/web/locales/pt.json @@ -713,7 +713,9 @@ "ACTIVE": "Ativo", "INACTIVE": "Inativo", "ARCHIVED": "Arquivado", - "NOT_ARCHIVED": "Não arquivado" + "NOT_ARCHIVED": "Não arquivado", + "VISITED_SITES": "Sites visitados", + "NO_VISITED_SITE_MESSAGE": "Não há sites visitados" }, "task": { "TITLE": "Tarefa", diff --git a/apps/web/locales/ru.json b/apps/web/locales/ru.json index 3f3ce5ef0..8b0d4e13b 100644 --- a/apps/web/locales/ru.json +++ b/apps/web/locales/ru.json @@ -712,7 +712,9 @@ "ACTIVE": "Активно", "INACTIVE": "Неактивно", "ARCHIVED": "Архивировано", - "NOT_ARCHIVED": "Не архивировано" + "NOT_ARCHIVED": "Не архивировано", + "VISITED_SITES": "Посещенные сайты", + "NO_VISITED_SITE_MESSAGE": "Нет посещенных сайтов" }, "task": { "TITLE": "Задача", diff --git a/apps/web/locales/zh.json b/apps/web/locales/zh.json index 1b02827d3..67c371f61 100644 --- a/apps/web/locales/zh.json +++ b/apps/web/locales/zh.json @@ -712,7 +712,9 @@ "START_WORKING_BUTTON": "开始工作", "TIMER_RUNNING": "计时器已经在运行", "WARNING_PLAN_ESTIMATION": "请更正计划工时或重新估算任务工时" - } + }, + "VISITED_SITES": "访问过的网站", + "NO_VISITED_SITE_MESSAGE": "没有访问过的网站" }, "task": { "TITLE": "任务", From 5726bf3ba2bb42345707ffa46b8f03773387e345 Mon Sep 17 00:00:00 2001 From: sv7000 Date: Tue, 4 Feb 2025 11:29:46 +0530 Subject: [PATCH 02/11] Correctly positioning search bar --- apps/web/lib/features/task/task-filters.tsx | 2 +- apps/web/lib/features/user-profile-tasks.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/features/task/task-filters.tsx b/apps/web/lib/features/task/task-filters.tsx index 30c3f2b0e..d9035c94a 100644 --- a/apps/web/lib/features/task/task-filters.tsx +++ b/apps/web/lib/features/task/task-filters.tsx @@ -544,7 +544,7 @@ export function TaskNameFilter({ }; return ( -
+
+
{tabFiltered.tab === 'worked' && (profile.member?.employee?.isTrackingTime || (profile.isAuthUser && timerStatus?.running)) && otherTasks.length > 0 && ( From 27d75b6a726f56fadb040ccc6ab3ba674ef16dd1 Mon Sep 17 00:00:00 2001 From: sv7000 Date: Tue, 4 Feb 2025 20:50:33 +0530 Subject: [PATCH 03/11] Label Button Light Theme Border Correct --- apps/web/lib/features/auth-user-task-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/features/auth-user-task-input.tsx b/apps/web/lib/features/auth-user-task-input.tsx index 49a412208..e440ffda1 100644 --- a/apps/web/lib/features/auth-user-task-input.tsx +++ b/apps/web/lib/features/auth-user-task-input.tsx @@ -53,7 +53,7 @@ export function AuthUserTaskInput({ className }: IClassName) { task={activeTeamTask} className="lg:max-w-[170px] grow text-xs" forDetails={false} - taskStatusClassName="dark:bg-[#1B1D22] text-xs dark:border dark:border-[#fff]" + taskStatusClassName="dark:bg-[#1B1D22] text-xs border dark:border-[#fff]" /> {activeTeamTask && ( Date: Wed, 5 Feb 2025 15:38:18 +0530 Subject: [PATCH 04/11] Correct confirm dropdown visiblility --- apps/web/lib/components/dropdown.tsx | 2 +- apps/web/lib/features/task/task-input.tsx | 2 +- apps/web/lib/features/task/task-item.tsx | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/lib/components/dropdown.tsx b/apps/web/lib/components/dropdown.tsx index fe93d301d..956fdb249 100644 --- a/apps/web/lib/components/dropdown.tsx +++ b/apps/web/lib/components/dropdown.tsx @@ -182,7 +182,7 @@ export function ConfirmDropdown({
    -
  • +
  • {confirmText} diff --git a/apps/web/lib/features/task/task-input.tsx b/apps/web/lib/features/task/task-input.tsx index cc8955162..13f1b2c05 100644 --- a/apps/web/lib/features/task/task-input.tsx +++ b/apps/web/lib/features/task/task-input.tsx @@ -675,7 +675,7 @@ function TaskCard({ task={task} selected={active} onClick={onItemClick} - className="cursor-pointer" + className="cursor-pointer overflow-y-auto" /> {!last && } diff --git a/apps/web/lib/features/task/task-item.tsx b/apps/web/lib/features/task/task-item.tsx index 850e2234b..6dca32a2c 100644 --- a/apps/web/lib/features/task/task-item.tsx +++ b/apps/web/lib/features/task/task-item.tsx @@ -33,7 +33,7 @@ export function TaskItem({ task, selected, onClick, className }: Props) { return (
    onClick && task && task.status !== 'closed' && onClick(task)} >
    e.stopPropagation()}> {task?.status !== 'closed' && ( - handleChange('closed')} confirmText={'Confirm'}> - {updateLoading ? : } + handleChange('closed')} confirmText={'Confirm'} className='fixed z-50'> + {updateLoading ? : } )} From b7d70860cc92795e64760c3f00d026e21a63aca3 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Thu, 6 Feb 2025 23:56:32 +0200 Subject: [PATCH 05/11] [Feat]: Add pagination size dropdown (#3593) * feat: add pagination size control to team stats table - Replace custom pagination select with PaginationDropdown component - Improve layout and styling of pagination controls - Move pagination size control next to entries count - Update text color to #111827 for better visibility * feat: make pagination options dynamic based on data size * fix: dropdown --- .../[teamId]/components/team-stats-table.tsx | 39 ++++++++++++------- apps/web/lib/settings/page-dropdown.tsx | 30 ++++++++++++-- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx index 076ac9fc0..984583aff 100644 --- a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx +++ b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx @@ -3,6 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { PaginationDropdown } from '@/lib/settings/page-dropdown'; import { format } from 'date-fns'; import { ITimerLogGrouped } from '@/app/interfaces'; import { Spinner } from '@/components/ui/loaders/spinner'; @@ -34,8 +35,6 @@ const formatPercentage = (value: number) => { return `${Math.round(value)}%`; }; -const ITEMS_PER_PAGE = 10; - export function TeamStatsTable({ rapportDailyActivity, isLoading @@ -44,9 +43,10 @@ export function TeamStatsTable({ isLoading?: boolean; }) { const [currentPage, setCurrentPage] = useState(1); - const totalPages = rapportDailyActivity ? Math.ceil(rapportDailyActivity.length / ITEMS_PER_PAGE) : 0; - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; + const [pageSize, setPageSize] = useState(10); + const totalPages = rapportDailyActivity ? Math.ceil(rapportDailyActivity.length / pageSize) : 0; + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; const { openModal, closeModal, isOpen } = useModal(); const paginatedData = rapportDailyActivity?.slice(startIndex, endIndex); @@ -68,7 +68,11 @@ export function TeamStatsTable({ } if (!rapportDailyActivity?.length) { - return
    No data available
    ; + return ( +
    + No data available +
    + ); } return ( @@ -94,9 +98,7 @@ export function TeamStatsTable({ {paginatedData?.map((dayData) => ( - + {format(new Date(dayData.date), 'EEEE dd MMM yyyy')} @@ -188,11 +190,7 @@ export function TeamStatsTable({
-
-
- Showing {startIndex + 1} to {Math.min(endIndex, rapportDailyActivity.length)} of{' '} - {rapportDailyActivity.length} entries -
+
+
+ { + setPageSize(value); + setCurrentPage(1); + }} + total={rapportDailyActivity?.length} + /> +
+ Showing {startIndex + 1} to {Math.min(endIndex, rapportDailyActivity?.length || 0)} of{' '} + {rapportDailyActivity?.length || 0} entries +
+
); diff --git a/apps/web/lib/settings/page-dropdown.tsx b/apps/web/lib/settings/page-dropdown.tsx index 2ec58e82e..0899827b5 100644 --- a/apps/web/lib/settings/page-dropdown.tsx +++ b/apps/web/lib/settings/page-dropdown.tsx @@ -10,11 +10,29 @@ import { ChevronDownIcon } from 'lucide-react'; export const PaginationDropdown = ({ setValue, - active + active, + total }: { setValue: Dispatch>; active?: IPagination | null; + total?: number; }) => { + const calculatePaginationOptions = useCallback((total = 0) => { + const baseOptions = [10, 20, 30,40, 50]; + + if (total > 50) { + const nextOption = Math.ceil(total / 10) * 10; + if (!baseOptions.includes(nextOption)) { + baseOptions.push(nextOption); + } + } + baseOptions.sort((a, b) => a - b); + + return baseOptions.map(size => ({ + title: size.toString() + })); + }, []); + const [paginationList, setPagination] = useState([ { title: '10' @@ -33,6 +51,12 @@ export const PaginationDropdown = ({ } ]); + useEffect(() => { + if (total) { + setPagination(calculatePaginationOptions(total)); + } + }, [total, calculatePaginationOptions]); + const items: PaginationItems[] = useMemo(() => mappaginationItems(paginationList), [paginationList]); const [open, setOpen] = useState(false); const [paginationItem, setPaginationItem] = useState(); @@ -72,9 +96,9 @@ export const PaginationDropdown = ({ onClick={() => setOpen(!open)} className={clsxm( 'input-border', - 'w-full flex justify-between rounded-xl px-3 py-2 text-sm items-center', + 'flex justify-between items-center px-3 py-2 w-full text-sm rounded-xl', 'font-normal outline-none', - 'py-0 font-medium h-[45px] w-[145px] z-10 outline-none dark:bg-dark--theme-light' + 'z-10 py-0 font-medium outline-none h-[45px] w-[145px] dark:bg-dark--theme-light' )} > {paginationItem?.selectedLabel || (paginationItem?.Label && )}{' '} From dc2dbe200aec49260afa155b6b5c2706dfbbc650 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Thu, 6 Feb 2025 23:58:13 +0200 Subject: [PATCH 06/11] [Feat]: Date picker settings init (#3586) * feat: implement date picker settings initialization - Replace settings button with Button component - Add click handler to initialize current month - Set date range to current month on settings click - Improve button styling and accessibility * feat: disable today and future dates in date picker - Use subDays to include today in disabled range - Only allow selection of past dates * feat: improve future dates disabling in calendar - Use startOfDay to properly disable all future dates - Replace static disabled range with dynamic date comparison - Ensure consistent behavior across timezones * feat: reduce date picker interface size - Reduce calendar to single month view - Make buttons and spacing more compact - Decrease text size and button heights - Add shadow for better visual hierarchy - Optimize layout for smaller screens * feat: set current month as default date range - Initialize date range with current month instead of last month - Set calendar view to current month by default - Remove unnecessary lastMonth calculation --- .../[teamId]/components/date-range-picker.tsx | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx index 1ff0fb4cf..842649d82 100644 --- a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx +++ b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Button } from '@/components/ui/button'; import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { format, @@ -17,11 +17,13 @@ import { subMonths, isSameMonth, isSameYear, - isEqual + isEqual, + startOfDay } from 'date-fns'; import { DateRange } from 'react-day-picker'; import { useTranslations } from 'next-intl'; import { SettingsIcon } from './team-icon'; +import { CalendarIcon } from '@radix-ui/react-icons'; interface DateRangePickerProps { className?: string; @@ -30,12 +32,15 @@ interface DateRangePickerProps { export function DateRangePicker({ className, onDateRangeChange }: DateRangePickerProps) { const t = useTranslations(); - const [dateRange, setDateRange] = React.useState({ - from: new Date(), - to: new Date() + const [dateRange, setDateRange] = React.useState(() => { + const today = new Date(); + return { + from: startOfMonth(today), + to: endOfMonth(today) + }; }); const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); - const [currentMonth, setCurrentMonth] = React.useState(new Date()); + const [currentMonth, setCurrentMonth] = React.useState(() => new Date()); const handleDateRangeChange = (range: DateRange | undefined) => { try { @@ -166,36 +171,61 @@ export function DateRangePicker({ className, onDateRangeChange }: DateRangePicke className )} > + {dateRange ? formatDateRange(dateRange) : t('common.SELECT')} - +
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onChange={(e) => e.stopPropagation()} - className="p-0 w-auto dark:bg-dark--theme-light dark:border-[#2D2D2D] border border-[#E4E4E7] rounded-md" + className="p-0 w-auto dark:bg-dark--theme-light dark:border-[#2D2D2D] border border-[#E4E4E7] rounded-md shadow-lg" align="center" > -
-
+
+
+ date >= startOfDay(new Date())} + /> +
+
{predefinedRanges.map((range) => (
-
- -
-
+
- +
@@ -61,13 +61,12 @@ export const TeamDashboardFilter = React.memo(function TeamDashboardFilter() { className={cn( 'text-primary/10', allteamsState.length > 0 && 'text-primary dark:text-primary-light' - )} - > - {t('common.CLEAR')} + )}> + {t('common.CLEAR')} ({allteamsState.length}) team?.name ?? ''} @@ -87,10 +86,12 @@ export const TeamDashboardFilter = React.memo(function TeamDashboardFilter() { alluserState.length > 0 && 'text-primary dark:text-primary-light' )} > - {t('common.CLEAR')} + {t('common.CLEAR')} ({alluserState.length}) { const members = team.members ?? []; return members.filter((member) => member && member.employee); diff --git a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-chart.tsx b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-chart.tsx index 4834529d0..5f8033b5d 100644 --- a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-chart.tsx +++ b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-chart.tsx @@ -48,7 +48,7 @@ export function TeamStatsChart({ rapportChartActivity, isLoading }: TeamStatsCha idle: true, resumed: true }); - const [groupBy,setGroupBy] = useState('daily'); + const [groupBy, setGroupBy] = useState('daily'); const toggleLine = (line: keyof typeof visibleLines) => { setVisibleLines((prev) => ({ @@ -57,6 +57,25 @@ export function TeamStatsChart({ rapportChartActivity, isLoading }: TeamStatsCha })); }; + const baseButtonClass = 'gap-[10px] px-3 py-1.5 w-[87px] h-[25px] rounded-[16px] text-xs font-normal'; + const buttonColors = { + tracked: { + base: 'bg-[#0088FE]', + hover: 'hover:bg-[#0088FE]/80' + }, + manual: { + base: 'bg-[#DC2626]', + hover: 'hover:bg-[#DC2626]/80' + }, + idle: { + base: 'bg-[#EAB308]', + hover: 'hover:bg-[#EAB308]/80' + }, + resumed: { + base: 'bg-[#22C55E]', + hover: 'hover:bg-[#22C55E]/80' + } + }; const groupData = (data: ITimerDailyLog[], grouping: GroupBy) => { if (grouping === 'daily') return data; @@ -239,39 +258,31 @@ export function TeamStatsChart({ rapportChartActivity, isLoading }: TeamStatsCha
diff --git a/apps/web/app/hooks/features/useReportActivity.ts b/apps/web/app/hooks/features/useReportActivity.ts index 9da2183b5..0f48c31ac 100644 --- a/apps/web/app/hooks/features/useReportActivity.ts +++ b/apps/web/app/hooks/features/useReportActivity.ts @@ -1,179 +1,238 @@ import { useCallback, useEffect, useState, useMemo } from 'react'; -import { ITimeLogReportDailyChartProps} from '@/app/interfaces/timer/ITimerLog'; -import { getTimeLogReportDaily, getTimeLogReportDailyChart, getTimesheetStatisticsCounts } from '@/app/services/client/api/timer/timer-log'; +import { ITimeLogReportDailyChartProps } from '@/app/interfaces/timer/ITimerLog'; +import { + getTimeLogReportDaily, + getTimeLogReportDailyChart, + getTimesheetStatisticsCounts +} from '@/app/services/client/api/timer/timer-log'; import { useAuthenticateUser } from './useAuthenticateUser'; import { useQuery } from '../useQuery'; import { useAtom } from 'jotai'; import { timeLogsRapportChartState, timeLogsRapportDailyState, timesheetStatisticsCountsState } from '@/app/stores'; import { TimeLogType } from '@/app/interfaces'; - -export interface UseReportActivityProps extends Omit { - logType?: TimeLogType[]; - activityLevel: { - start: number; - end: number; - }; - start?: number; - end?: number; +import { useTimelogFilterOptions } from './useTimelogFilterOptions'; + +export interface UseReportActivityProps + extends Omit { + logType?: TimeLogType[]; + activityLevel: { + start: number; + end: number; + }; + start?: number; + end?: number; + projectIds?: string[]; + employeeIds?: string[]; + teamIds?: string[]; } -const defaultProps: Required> = { - startDate: new Date().toISOString().split('T')[0], - endDate: new Date().toISOString().split('T')[0], - groupBy: 'date', - activityLevel: { - start: 0, - end: 100 - }, - logType: [TimeLogType.TRACKED], - start: 0, - end: 100 +const defaultProps: Required< + Pick< + UseReportActivityProps, + | 'startDate' + | 'endDate' + | 'groupBy' + | 'activityLevel' + | 'logType' + | 'start' + | 'end' + | 'employeeIds' + | 'projectIds' + | 'teamIds' + > +> = { + startDate: new Date().toISOString().split('T')[0], + endDate: new Date().toISOString().split('T')[0], + groupBy: 'date', + activityLevel: { + start: 0, + end: 100 + }, + logType: [TimeLogType.TRACKED], + start: 0, + end: 100, + employeeIds: [], + projectIds: [], + teamIds: [] }; export function useReportActivity() { - const { user } = useAuthenticateUser(); - const [rapportChartActivity, setRapportChartActivity] = useAtom(timeLogsRapportChartState); - const [rapportDailyActivity, setRapportDailyActivity] = useAtom(timeLogsRapportDailyState); - const [statisticsCounts, setStatisticsCounts] = useAtom(timesheetStatisticsCountsState); - const { loading: loadingTimeLogReportDailyChart, queryCall: queryTimeLogReportDailyChart } = useQuery(getTimeLogReportDailyChart); - const { loading: loadingTimeLogReportDaily, queryCall: queryTimeLogReportDaily } = useQuery(getTimeLogReportDaily); - const { loading: loadingTimesheetStatisticsCounts, queryCall: queryTimesheetStatisticsCounts } = useQuery(getTimesheetStatisticsCounts); - - const [currentFilters, setCurrentFilters] = useState>(defaultProps); - - // Memoize the merged props to avoid recalculation - const getMergedProps = useMemo(() => { - if (!user?.employee.organizationId) { - return null; - } - - return (customProps?: Partial) => { - const merged = { - ...defaultProps, - ...currentFilters, - ...(customProps || {}), - organizationId: user.employee.organizationId, - tenantId: user.tenantId ?? '', - logType: (customProps?.logType || currentFilters.logType || defaultProps.logType) as TimeLogType[], - startDate: (customProps?.startDate || currentFilters.startDate || defaultProps.startDate) as string, - endDate: (customProps?.endDate || currentFilters.endDate || defaultProps.endDate) as string, - activityLevel: { - start: customProps?.activityLevel?.start ?? currentFilters.activityLevel?.start ?? defaultProps.activityLevel.start, - end: customProps?.activityLevel?.end ?? currentFilters.activityLevel?.end ?? defaultProps.activityLevel.end - }, - start: customProps?.start ?? currentFilters.start ?? defaultProps.start, - end: customProps?.end ?? currentFilters.end ?? defaultProps.end - }; - return merged as Required; - }; - }, [user?.employee.organizationId, user?.tenantId, currentFilters]); - - // Generic fetch function to reduce code duplication - const fetchReport = useCallback(async ( - queryFn: typeof queryTimeLogReportDailyChart | typeof queryTimeLogReportDaily | typeof queryTimesheetStatisticsCounts, - setData: ((data: T[]) => void) | null, - customProps?: Partial - ) => { - if (!user || !getMergedProps) { - if (setData) { - setData([]); - } - return; - } - - try { - const mergedProps = getMergedProps(customProps); - const response = await queryFn(mergedProps); - - if (setData && Array.isArray(response.data)) { - setData(response.data as T[]); - } - - if (customProps) { - setCurrentFilters(prev => ({ - ...prev, - ...customProps - })); - } - } catch (err) { - console.error('Failed to fetch report:', err); - if (setData) { - setData([]); - } - } - }, [user, getMergedProps]); - - const fetchReportActivity = useCallback((customProps?: Partial) => - fetchReport(queryTimeLogReportDailyChart, setRapportChartActivity, customProps), - [fetchReport, queryTimeLogReportDailyChart, setRapportChartActivity]); - - const fetchDailyReport = useCallback((customProps?: Partial) => - fetchReport(queryTimeLogReportDaily, setRapportDailyActivity, customProps), - [fetchReport, queryTimeLogReportDaily, setRapportDailyActivity]); - - - - const fetchStatisticsCounts = useCallback(async (customProps?: Partial) => { - if (!user || !getMergedProps) { - return; - } - try { - const mergedProps = getMergedProps(customProps); - const response = await queryTimesheetStatisticsCounts({...mergedProps, logType:[TimeLogType.TRACKED]}); - setStatisticsCounts(response.data); - if (customProps) { - setCurrentFilters(prev => ({ - ...prev, - ...customProps - })); - } - } catch (error) { - console.error('Error fetching statistics:', error); - setStatisticsCounts(null); - } - }, [user, getMergedProps, queryTimesheetStatisticsCounts, setStatisticsCounts]); - - const updateDateRange = useCallback((startDate: Date, endDate: Date) => { - const newProps = { - startDate: startDate.toISOString().split('T')[0], - endDate: endDate.toISOString().split('T')[0] - }; - - Promise.all([ - fetchReportActivity(newProps), - fetchDailyReport(newProps), - fetchStatisticsCounts(newProps) - ]).catch(console.error); - }, [fetchReportActivity, fetchDailyReport, fetchStatisticsCounts]); - - const updateFilters = useCallback((newFilters: Partial) => { - Promise.all([ - fetchReportActivity(newFilters), - fetchDailyReport(newFilters), - fetchStatisticsCounts(newFilters) - ]).catch(console.error); - }, [fetchReportActivity, fetchDailyReport, fetchStatisticsCounts]); - - useEffect(() => { - if (user) { - Promise.all([ - fetchReportActivity(), - fetchDailyReport(), - fetchStatisticsCounts() - ]).catch(console.error); - } - }, [user, fetchReportActivity, fetchDailyReport, fetchStatisticsCounts]); - - return { - loadingTimeLogReportDailyChart, - loadingTimeLogReportDaily, - loadingTimesheetStatisticsCounts, - rapportChartActivity, - rapportDailyActivity, - statisticsCounts, - updateDateRange, - updateFilters, - currentFilters, - setStatisticsCounts, - }; + const { user } = useAuthenticateUser(); + const [rapportChartActivity, setRapportChartActivity] = useAtom(timeLogsRapportChartState); + const [rapportDailyActivity, setRapportDailyActivity] = useAtom(timeLogsRapportDailyState); + const [statisticsCounts, setStatisticsCounts] = useAtom(timesheetStatisticsCountsState); + const { allteamsState, alluserState } = useTimelogFilterOptions(); + + const { loading: loadingTimeLogReportDailyChart, queryCall: queryTimeLogReportDailyChart } = + useQuery(getTimeLogReportDailyChart); + const { loading: loadingTimeLogReportDaily, queryCall: queryTimeLogReportDaily } = useQuery(getTimeLogReportDaily); + const { loading: loadingTimesheetStatisticsCounts, queryCall: queryTimesheetStatisticsCounts } = + useQuery(getTimesheetStatisticsCounts); + + const [currentFilters, setCurrentFilters] = useState>(defaultProps); + + // Memoize the merged props to avoid recalculation + const getMergedProps = useMemo(() => { + if (!user?.employee.organizationId) { + return null; + } + + return (customProps?: Partial) => { + const merged = { + ...defaultProps, + ...currentFilters, + ...(customProps || {}), + organizationId: user.employee.organizationId, + teamId: customProps?.teamId || currentFilters.teamId, + userId: customProps?.userId || currentFilters.userId, + tenantId: user.tenantId ?? '', + logType: (customProps?.logType || currentFilters.logType || defaultProps.logType) as TimeLogType[], + startDate: (customProps?.startDate || currentFilters.startDate || defaultProps.startDate) as string, + endDate: (customProps?.endDate || currentFilters.endDate || defaultProps.endDate) as string, + projectIds: (customProps?.projectIds || + currentFilters.projectIds || + defaultProps.projectIds) as string[], + employeeIds: alluserState?.map(({ employee: { id } }) => id).filter(Boolean), + teamIds:allteamsState?.map(({ id }) => id).filter(Boolean), + activityLevel: { + start: + customProps?.activityLevel?.start ?? + currentFilters.activityLevel?.start ?? + defaultProps.activityLevel.start, + end: + customProps?.activityLevel?.end ?? + currentFilters.activityLevel?.end ?? + defaultProps.activityLevel.end + }, + start: customProps?.start ?? currentFilters.start ?? defaultProps.start, + end: customProps?.end ?? currentFilters.end ?? defaultProps.end + }; + return merged as Required; + }; + }, [user?.employee.organizationId, user?.tenantId, currentFilters, alluserState, allteamsState]); + + // Generic fetch function to reduce code duplication + const fetchReport = useCallback( + async ( + queryFn: + | typeof queryTimeLogReportDailyChart + | typeof queryTimeLogReportDaily + | typeof queryTimesheetStatisticsCounts, + setData: ((data: T[]) => void) | null, + customProps?: Partial + ) => { + if (!user || !getMergedProps) { + if (setData) { + setData([]); + } + return; + } + + try { + const mergedProps = getMergedProps(customProps); + const response = await queryFn(mergedProps); + + if (setData && Array.isArray(response.data)) { + setData(response.data as T[]); + } + + if (customProps) { + setCurrentFilters((prev) => ({ + ...prev, + ...customProps + })); + } + } catch (err) { + console.error('Failed to fetch report:', err); + if (setData) { + setData([]); + } + } + }, + [user, getMergedProps] + ); + + const fetchReportActivity = useCallback( + (customProps?: Partial) => + fetchReport(queryTimeLogReportDailyChart, setRapportChartActivity, customProps), + [fetchReport, queryTimeLogReportDailyChart, setRapportChartActivity] + ); + + const fetchDailyReport = useCallback( + (customProps?: Partial) => + fetchReport(queryTimeLogReportDaily, setRapportDailyActivity, customProps), + [fetchReport, queryTimeLogReportDaily, setRapportDailyActivity] + ); + + const fetchStatisticsCounts = useCallback( + async (customProps?: Partial) => { + if (!user || !getMergedProps) { + return; + } + try { + const mergedProps = getMergedProps(customProps); + const response = await queryTimesheetStatisticsCounts({ + ...mergedProps, + logType: [TimeLogType.TRACKED] + }); + setStatisticsCounts(response.data); + if (customProps) { + setCurrentFilters((prev) => ({ + ...prev, + ...customProps + })); + } + } catch (error) { + console.error('Error fetching statistics:', error); + setStatisticsCounts(null); + } + }, + [user, getMergedProps, queryTimesheetStatisticsCounts, setStatisticsCounts] + ); + + const updateDateRange = useCallback( + (startDate: Date, endDate: Date) => { + const newProps = { + startDate: startDate.toISOString().split('T')[0], + endDate: endDate.toISOString().split('T')[0] + }; + + Promise.all([ + fetchReportActivity(newProps), + fetchDailyReport(newProps), + fetchStatisticsCounts(newProps) + ]).catch(console.error); + }, + [fetchReportActivity, fetchDailyReport, fetchStatisticsCounts] + ); + + const updateFilters = useCallback( + (newFilters: Partial) => { + Promise.all([ + fetchReportActivity(newFilters), + fetchDailyReport(newFilters), + fetchStatisticsCounts(newFilters) + ]).catch(console.error); + }, + [fetchReportActivity, fetchDailyReport, fetchStatisticsCounts] + ); + + useEffect(() => { + if (user) { + Promise.all([fetchReportActivity(), fetchDailyReport(), fetchStatisticsCounts()]).catch(console.error); + } + }, [user, fetchReportActivity, fetchDailyReport, fetchStatisticsCounts]); + + return { + loadingTimeLogReportDailyChart, + loadingTimeLogReportDaily, + loadingTimesheetStatisticsCounts, + rapportChartActivity, + rapportDailyActivity, + statisticsCounts, + updateDateRange, + updateFilters, + currentFilters, + setStatisticsCounts + }; } diff --git a/apps/web/app/hooks/features/useTimelogFilterOptions.ts b/apps/web/app/hooks/features/useTimelogFilterOptions.ts index b93b3c2b0..54097af5b 100644 --- a/apps/web/app/hooks/features/useTimelogFilterOptions.ts +++ b/apps/web/app/hooks/features/useTimelogFilterOptions.ts @@ -71,7 +71,7 @@ export function useTimelogFilterOptions() { React.useEffect(() => { return () => setSelectTimesheetId([]); - }, []); + }, [setSelectTimesheetId]); return { statusState, diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index c8a38cfe6..e36101eff 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -2,148 +2,163 @@ import { useAuthenticateUser } from './useAuthenticateUser'; import { useAtom } from 'jotai'; import { timesheetRapportState } from '@/app/stores/time-logs'; import { useQuery } from '../useQuery'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { deleteTaskTimesheetLogsApi, getTaskTimesheetLogsApi, updateStatusTimesheetFromApi, createTimesheetFromApi, updateTimesheetFromAPi } from '@/app/services/client/api/timer/timer-log'; +import { useCallback, useEffect, useMemo } from 'react'; +import { + deleteTaskTimesheetLogsApi, + getTaskTimesheetLogsApi, + updateStatusTimesheetFromApi, + createTimesheetFromApi, + updateTimesheetFromAPi +} from '@/app/services/client/api/timer/timer-log'; import moment from 'moment'; import { ID, TimesheetLog, TimesheetStatus, UpdateTimesheet } from '@/app/interfaces'; import { useTimelogFilterOptions } from './useTimelogFilterOptions'; import axios from 'axios'; interface TimesheetParams { - startDate?: Date | string; - endDate?: Date | string; - timesheetViewMode?: 'ListView' | 'CalendarView' - inputSearch?: string + startDate?: Date | string; + endDate?: Date | string; + timesheetViewMode?: 'ListView' | 'CalendarView'; + inputSearch?: string; } export interface GroupedTimesheet { - date: string; - tasks: TimesheetLog[]; + date: string; + tasks: TimesheetLog[]; } - const groupByDate = (items: TimesheetLog[]): GroupedTimesheet[] => { - if (!items?.length) return []; - - // First, group by timesheetId - const groupedByTimesheet = items.reduce((acc, item) => { - if (!item?.timesheet?.id || !item?.timesheet.createdAt) { - console.warn('Skipping item with missing timesheet or createdAt:', item); - return acc; - } - const timesheetId = item.timesheet.id; - if (!acc[timesheetId]) { - acc[timesheetId] = []; - } - acc[timesheetId].push(item); - return acc; - }, {} as Record); - - // Then, for each timesheet group, group by date and merge all results - const result: GroupedTimesheet[] = []; - Object.values(groupedByTimesheet).forEach(timesheetLogs => { - const groupedByDate = timesheetLogs.reduce((acc, item) => { - try { - const date = new Date(item.timesheet.createdAt).toISOString().split('T')[0]; - if (!acc[date]) acc[date] = []; - acc[date].push(item); - } catch (error) { - console.error( - `Failed to process date for timesheet ${item.timesheet.id}:`, - { createdAt: item.timesheet.createdAt, error } - ); - } - return acc; - }, {} as Record); - - // Convert grouped dates to array format and add to results - Object.entries(groupedByDate).forEach(([date, tasks]) => { - result.push({ date, tasks }); - }); - }); - - // Sort by date in descending order - return result.sort((a, b) => b.date.localeCompare(a.date)); + if (!items?.length) return []; + + // First, group by timesheetId + const groupedByTimesheet = items.reduce( + (acc, item) => { + if (!item?.timesheet?.id || !item?.timesheet.createdAt) { + console.warn('Skipping item with missing timesheet or createdAt:', item); + return acc; + } + const timesheetId = item.timesheet.id; + if (!acc[timesheetId]) { + acc[timesheetId] = []; + } + acc[timesheetId].push(item); + return acc; + }, + {} as Record + ); + + // Then, for each timesheet group, group by date and merge all results + const result: GroupedTimesheet[] = []; + Object.values(groupedByTimesheet).forEach((timesheetLogs) => { + const groupedByDate = timesheetLogs.reduce( + (acc, item) => { + try { + const date = new Date(item.timesheet.createdAt).toISOString().split('T')[0]; + if (!acc[date]) acc[date] = []; + acc[date].push(item); + } catch (error) { + console.error(`Failed to process date for timesheet ${item.timesheet.id}:`, { + createdAt: item.timesheet.createdAt, + error + }); + } + return acc; + }, + {} as Record + ); + + // Convert grouped dates to array format and add to results + Object.entries(groupedByDate).forEach(([date, tasks]) => { + result.push({ date, tasks }); + }); + }); + + // Sort by date in descending order + return result.sort((a, b) => b.date.localeCompare(a.date)); }; const getWeekYearKey = (date: Date): string => { - try { - const year = date.getFullYear(); - const startOfYear = new Date(year, 0, 1); - const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)); - const week = Math.ceil((days + startOfYear.getDay() + 1) / 7); - return `${year}-W${week.toString().padStart(2, '0')}`; - } catch (error) { - console.error('Error in getWeekYearKey:', error); - return ''; - } + try { + const year = date.getFullYear(); + const startOfYear = new Date(year, 0, 1); + const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)); + const week = Math.ceil((days + startOfYear.getDay() + 1) / 7); + return `${year}-W${week.toString().padStart(2, '0')}`; + } catch (error) { + console.error('Error in getWeekYearKey:', error); + return ''; + } }; const getMonthKey = (date: Date): string => { - try { - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - return `${year}-${month}`; - } catch (error) { - console.error('Error in getMonthKey:', error); - return ''; - } + try { + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + return `${year}-${month}`; + } catch (error) { + console.error('Error in getMonthKey:', error); + return ''; + } }; type GroupingKeyFunction = (date: Date) => string; -const createGroupingFunction = (getKey: GroupingKeyFunction) => (items: TimesheetLog[]): GroupedTimesheet[] => { - if (!items?.length) return []; - const groupedByDate = items.reduce((acc, item) => { - if (!item?.timesheet?.createdAt) { - console.warn('Skipping item with missing createdAt:', item); - return acc; - } - - try { - const date = new Date(item.timesheet.createdAt); - if (isNaN(date.getTime())) { - console.warn('Invalid date:', item.timesheet.createdAt); - return acc; - } - - const key = getKey(date); - if (!acc[key]) { - acc[key] = { - date: key, - timesheets: {} - }; - } - - const timesheetId = item.timesheet.id; - if (!acc[key].timesheets[timesheetId]) { - acc[key].timesheets[timesheetId] = []; - } - acc[key].timesheets[timesheetId].push(item); - - } catch (error) { - console.warn('Error processing date:', error); - } - return acc; - }, {} as Record }>); - - return Object.values(groupedByDate) - .map(({ date, timesheets }) => ({ - date, - tasks: Object.values(timesheets) - .flat() - .sort((a, b) => { - if (a.timesheet.id !== b.timesheet.id) { - return a.timesheet.id.localeCompare(b.timesheet.id); - } - const dateA = new Date(a.timesheet.createdAt); - const dateB = new Date(b.timesheet.createdAt); - return dateB.getTime() - dateA.getTime(); - }) - })) - .sort((a, b) => b.date.localeCompare(a.date)); -}; - -const groupByWeek = createGroupingFunction(date => getWeekYearKey(date)); +const createGroupingFunction = + (getKey: GroupingKeyFunction) => + (items: TimesheetLog[]): GroupedTimesheet[] => { + if (!items?.length) return []; + const groupedByDate = items.reduce( + (acc, item) => { + if (!item?.timesheet?.createdAt) { + console.warn('Skipping item with missing createdAt:', item); + return acc; + } + + try { + const date = new Date(item.timesheet.createdAt); + if (isNaN(date.getTime())) { + console.warn('Invalid date:', item.timesheet.createdAt); + return acc; + } + + const key = getKey(date); + if (!acc[key]) { + acc[key] = { + date: key, + timesheets: {} + }; + } + + const timesheetId = item.timesheet.id; + if (!acc[key].timesheets[timesheetId]) { + acc[key].timesheets[timesheetId] = []; + } + acc[key].timesheets[timesheetId].push(item); + } catch (error) { + console.warn('Error processing date:', error); + } + return acc; + }, + {} as Record }> + ); + + return Object.values(groupedByDate) + .map(({ date, timesheets }) => ({ + date, + tasks: Object.values(timesheets) + .flat() + .sort((a, b) => { + if (a.timesheet.id !== b.timesheet.id) { + return a.timesheet.id.localeCompare(b.timesheet.id); + } + const dateA = new Date(a.timesheet.createdAt); + const dateB = new Date(b.timesheet.createdAt); + return dateB.getTime() - dateA.getTime(); + }) + })) + .sort((a, b) => b.date.localeCompare(a.date)); + }; + +const groupByWeek = createGroupingFunction((date) => getWeekYearKey(date)); const groupByMonth = createGroupingFunction(getMonthKey); /** @@ -177,347 +192,359 @@ const groupByMonth = createGroupingFunction(getMonthKey); * @prop {function} groupByDate - Callable to group timesheet logs by date. * @prop {boolean} isManage - Whether the user is authorized to manage the timesheet. */ -export function useTimesheet({ - startDate = moment().startOf('month').toDate(), - endDate = moment().endOf('month').toDate(), - timesheetViewMode, - inputSearch -}: TimesheetParams) { - const { user } = useAuthenticateUser(); - const [timesheet, setTimesheet] = useAtom(timesheetRapportState); - const { employee, project, task, statusState, timesheetGroupByDays, puTimesheetStatus, isUserAllowedToAccess, normalizeText, setSelectTimesheetId, selectTimesheetId, handleSelectRowByStatusAndDate, handleSelectRowTimesheet } = useTimelogFilterOptions(); - const { loading: loadingTimesheet, queryCall: queryTimesheet } = useQuery(getTaskTimesheetLogsApi); - const { loading: loadingDeleteTimesheet, queryCall: queryDeleteTimesheet } = useQuery(deleteTaskTimesheetLogsApi); - const { loading: loadingUpdateTimesheetStatus, queryCall: queryUpdateTimesheetStatus } = useQuery(updateStatusTimesheetFromApi) - const { loading: loadingCreateTimesheet, queryCall: queryCreateTimesheet } = useQuery(createTimesheetFromApi); - const { loading: loadingUpdateTimesheet, queryCall: queryUpdateTimesheet } = useQuery(updateTimesheetFromAPi); - const isManage = user && isUserAllowedToAccess(user); - - const [createTimesheetResponse, setCreateTimesheetResponse] = useState(null); - - /** - * Memoized date range with fallback to defaults - * Ensures all dates are converted to Date objects - */ - const currentDateRange = useMemo(() => { - const defaultStart = moment().startOf('month').toDate(); - const defaultEnd = moment().endOf('month').toDate(); - - return { - startDate: startDate ? moment(startDate).toDate() : defaultStart, - endDate: endDate ? moment(endDate).toDate() : defaultEnd - }; - }, [startDate, endDate]); - - /** - * Format date to YYYY-MM-DD ensuring valid date input - * @param date - Input date (optional) - * @param defaultDate - Default date if input is undefined - */ - const formatDate = useCallback((date: Date | string | undefined, defaultDate: Date): string => { - try { - return moment(date || defaultDate).format('YYYY-MM-DD'); - } catch (error) { - console.warn('Invalid date provided, using default date'); - return moment(defaultDate).format('YYYY-MM-DD'); - } - }, []); - - const getTaskTimesheet = useCallback( - ({ startDate, endDate }: TimesheetParams) => { - if (!user) return; - - const from = formatDate(startDate, currentDateRange.startDate); - const to = formatDate(endDate, currentDateRange.endDate); - queryTimesheet({ - startDate: from, - endDate: to, - organizationId: user.employee?.organizationId, - tenantId: user.tenantId ?? '', - timeZone: user.timeZone?.split('(')[0].trim() || 'UTC', - employeeIds: isManage - ? employee?.map(({ employee: { id } }) => id).filter(Boolean) - : [user.employee.id], - projectIds: project?.map((project) => project.id).filter((id) => id !== undefined), - taskIds: task?.map((task) => task.id).filter((id) => id !== undefined), - status: statusState?.map((status) => status.value).filter((value) => value !== undefined) - }).then((response) => { - setTimesheet(response.data); - }).catch((error) => { - console.error('Error fetching timesheet:', error); - }); - }, - [user, formatDate, currentDateRange.startDate, currentDateRange.endDate, queryTimesheet, isManage, employee, project, task, statusState, setTimesheet] - ); - - const createTimesheet = useCallback( - async ({ ...timesheetParams }: UpdateTimesheet) => { - if (!user) { - throw new Error("User not authenticated"); - } - try { - const response = await queryCreateTimesheet(timesheetParams); - setCreateTimesheetResponse(response.data); - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - console.error('Axios Error:', { - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data - }); - throw new Error(`Request failed: ${error.message}`); - } - console.error('Error:', error instanceof Error ? error.message : error); - throw error; - } - }, - [queryCreateTimesheet, user] - ); - - const updateTimesheet = useCallback( - async (timesheet: UpdateTimesheet) => { - if (!user) { - console.warn("User not authenticated!"); - return; - } - try { - const response = await queryUpdateTimesheet(timesheet); - if (response?.data?.id) { - setTimesheet((prevTimesheet) => - prevTimesheet.map((item) => - item.id === response.data.id - ? { ...item, ...response.data } - : item - ) - ); - } else { - console.warn( - "Unexpected structure of the response. No update performed.", - response - ); - } - } catch (error) { - console.error("Error updating the timesheet:", error); - throw error; - } - }, - [queryUpdateTimesheet, setTimesheet, user] - ); - - - - const updateTimesheetStatus = useCallback( - async ({ status, ids }: { status: TimesheetStatus; ids: ID[] | ID }) => { - if (!user) return; - const idsArray = Array.isArray(ids) ? ids : [ids]; - try { - const response = await queryUpdateTimesheetStatus({ ids: idsArray, status }); - const responseMap = new Map(response.data.map(item => [item.id, item])); - setTimesheet(prevTimesheet => - prevTimesheet.map(item => { - const updatedItem = responseMap.get(item.timesheet.id); - if (updatedItem) { - return { - ...item, - timesheet: { - ...item.timesheet, - status: updatedItem.status - } - }; - } - return item; - }) - ); - console.log('Timesheet status updated successfully!'); - } catch (error) { - console.error('Error updating timesheet status:', error); - } - }, - [queryUpdateTimesheetStatus, setTimesheet, user] - ); - - const getStatusTimesheet = (items: TimesheetLog[] = []) => { - const STATUS_MAP: Record = { - PENDING: [], - APPROVED: [], - DENIED: [], - DRAFT: [], - 'IN REVIEW': [] - }; - - return items.reduce((acc, item) => { - const status = item.timesheet.status; - if (isTimesheetStatus(status)) { - acc[status].push(item); - } else { - console.warn(`Invalid timesheet status: ${status}`); - } - return acc; - }, STATUS_MAP); - } - - // Type guard - function isTimesheetStatus(status: unknown): status is TimesheetStatus { - const timesheetStatusValues: TimesheetStatus[] = [ - "DRAFT", - "PENDING", - "IN REVIEW", - "DENIED", - "APPROVED" - ]; - return Object.values(timesheetStatusValues).includes(status as TimesheetStatus); - } - - - const deleteTaskTimesheet = useCallback(async ({ logIds }: { logIds: string[] }) => { - if (!user) { - throw new Error('User not authenticated'); - } - if (!logIds.length) { - throw new Error('No timesheet IDs provided for deletion'); - } - try { - await queryDeleteTimesheet({ - organizationId: user.employee.organizationId, - tenantId: user.tenantId ?? "", - logIds - }); - setTimesheet(prevTimesheet => - prevTimesheet.filter(item => !logIds.includes(item.id)) - ); - - } catch (error) { - console.error('Failed to delete timesheets:', error); - throw error; - } - }, [user, queryDeleteTimesheet, setTimesheet]); - - const groupedByTimesheetIds = ({ rows }: { rows: TimesheetLog[] }): Record => { - if (!rows) { - return {}; - } - return rows.reduce((acc, row) => { - if (!row) { - return acc; - } - const timesheetId = row.timesheetId ?? 'unassigned'; - if (!acc[timesheetId]) { - acc[timesheetId] = []; - } - acc[timesheetId].push(row); - return acc; - }, {} as Record); - } - - - const filterDataTimesheet = useMemo(() => { - if (!timesheet || !inputSearch) { - return timesheet; - } - const searchTerms = normalizeText(inputSearch).split(/\s+/).filter(Boolean); - if (searchTerms.length === 0) { - return timesheet; - } - return timesheet.filter((task) => { - const searchableContent = { - title: normalizeText(task.task?.title), - employee: normalizeText(task.employee?.fullName), - project: normalizeText(task.project?.name) - }; - return searchTerms.every(term => - Object.values(searchableContent).some(content => - content.includes(term) - ) - ); - }); - }, [timesheet, inputSearch, normalizeText]); - - const reGroupByDate = (groupedTimesheets: GroupedTimesheet[]): GroupedTimesheet[] => { - return groupedTimesheets.reduce((acc, { date, tasks }) => { - const existingGroup = acc.find(group => group.date === date); - if (existingGroup) { - existingGroup.tasks = existingGroup.tasks.concat(tasks); - } else { - acc.push({ date, tasks }); - } - return acc; - }, [] as GroupedTimesheet[]); - }; - - const timesheetElementGroup = useMemo(() => { - if (!timesheet) { - return []; - } - - if (timesheetViewMode === 'ListView') { - const groupedTimesheets = groupByDate(filterDataTimesheet); - const reGroupedByDate = reGroupByDate(groupedTimesheets); - switch (timesheetGroupByDays) { - case 'Daily': - return reGroupedByDate; - case 'Weekly': - return groupByWeek(filterDataTimesheet); - case 'Monthly': - return groupByMonth(filterDataTimesheet); - default: - return reGroupedByDate; - } - } - return reGroupByDate(groupByDate(filterDataTimesheet)); - }, [timesheet, timesheetViewMode, filterDataTimesheet, timesheetGroupByDays]); - - const rowsToObject = (rows: TimesheetLog[]): Record => { - return rows.reduce((acc, row) => { - acc[row.timesheet.id] = { task: row, status: row.timesheet.status } - return acc; - }, {} as Record); - }; - - - /** - * Combined effect for fetching timesheet data - * Handles both initial load and updates after timesheet creation - */ - useEffect(() => { - if (createTimesheetResponse) { - getTaskTimesheet(currentDateRange); - setCreateTimesheetResponse(null); - } else { - getTaskTimesheet(currentDateRange); - } - }, [ - getTaskTimesheet, - currentDateRange, - createTimesheetResponse, - timesheetGroupByDays, - inputSearch - ]); - - return { - loadingTimesheet, - timesheet: timesheetElementGroup, - getTaskTimesheet, - loadingDeleteTimesheet, - deleteTaskTimesheet, - getStatusTimesheet, - timesheetGroupByDays, - statusTimesheet: getStatusTimesheet(filterDataTimesheet.flat()), - updateTimesheetStatus, - loadingUpdateTimesheetStatus, - puTimesheetStatus, - createTimesheet, - loadingCreateTimesheet, - updateTimesheet, - loadingUpdateTimesheet, - groupByDate, - isManage, - normalizeText, - setSelectTimesheetId, - selectTimesheetId, - handleSelectRowByStatusAndDate, - handleSelectRowTimesheet, - groupedByTimesheetIds, - rowsToObject - }; +export function useTimesheet({ startDate, endDate, timesheetViewMode, inputSearch }: TimesheetParams) { + const { user } = useAuthenticateUser(); + const [timesheet, setTimesheet] = useAtom(timesheetRapportState); + const { + employee, + project, + task, + statusState, + timesheetGroupByDays, + puTimesheetStatus, + isUserAllowedToAccess, + normalizeText, + setSelectTimesheetId, + selectTimesheetId, + handleSelectRowByStatusAndDate, + handleSelectRowTimesheet + } = useTimelogFilterOptions(); + const { loading: loadingTimesheet, queryCall: queryTimesheet } = useQuery(getTaskTimesheetLogsApi); + const { loading: loadingDeleteTimesheet, queryCall: queryDeleteTimesheet } = useQuery(deleteTaskTimesheetLogsApi); + const { loading: loadingUpdateTimesheetStatus, queryCall: queryUpdateTimesheetStatus } = + useQuery(updateStatusTimesheetFromApi); + const { loading: loadingCreateTimesheet, queryCall: queryCreateTimesheet } = useQuery(createTimesheetFromApi); + const { loading: loadingUpdateTimesheet, queryCall: queryUpdateTimesheet } = useQuery(updateTimesheetFromAPi); + const isManage = user && isUserAllowedToAccess(user); + + /** + * Memoized date range with fallback to defaults + * Ensures all dates are converted to Date objects + */ + const currentDateRange = useMemo(() => { + const now = moment(); + const defaultStart = now.clone().startOf('month').toDate(); + const defaultEnd = now.clone().endOf('month').toDate(); + + const parseDate = (date: Date | string | undefined, defaultValue: Date) => { + if (!date) return defaultValue; + const parsed = moment(date); + return parsed.isValid() ? parsed.toDate() : defaultValue; + }; + + return { + startDate: parseDate(startDate, defaultStart), + endDate: parseDate(endDate, defaultEnd) + }; + }, [startDate, endDate]); + + /** + * Format date to YYYY-MM-DD ensuring valid date input + * @param date - Input date (optional) + * @returns Formatted date string + */ + const formatDate = useCallback((date: Date | string | undefined): string => { + if (!date) { + return moment().format('YYYY-MM-DD'); + } + + if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + return date; + } + + try { + const parsedDate = moment(date); + if (!parsedDate.isValid()) { + console.warn('Invalid date provided, using current date'); + return moment().format('YYYY-MM-DD'); + } + return parsedDate.format('YYYY-MM-DD'); + } catch (error) { + console.warn('Error parsing date:', error); + return moment().format('YYYY-MM-DD'); + } + }, []); + + const getTaskTimesheet = useCallback( + ({ startDate, endDate }: TimesheetParams) => { + if (!user) return; + + const from = moment(startDate).format('YYYY-MM-DD'); + const to = moment(endDate).format('YYYY-MM-DD'); + + queryTimesheet({ + startDate: from, + endDate: to, + organizationId: user.employee?.organizationId, + tenantId: user.tenantId ?? '', + timeZone: user.timeZone?.split('(')[0].trim() || 'UTC', + employeeIds: isManage + ? employee?.map(({ employee: { id } }) => id).filter(Boolean) + : [user.employee.id], + projectIds: project?.map((project) => project.id).filter((id) => id !== undefined), + taskIds: task?.map((task) => task.id).filter((id) => id !== undefined), + status: statusState?.map((status) => status.value).filter((value) => value !== undefined) + }) + .then((response) => { + setTimesheet(response.data); + }) + .catch((error) => { + console.error('Error fetching timesheet:', error); + }); + }, + [user, queryTimesheet, isManage, employee, project, task, statusState, setTimesheet] + ); + + useEffect(() => { + if (startDate || endDate) { + getTaskTimesheet({ startDate, endDate }); + } + }, [startDate, endDate, getTaskTimesheet]); + + const createTimesheet = useCallback( + async ({ ...timesheetParams }: UpdateTimesheet) => { + if (!user) { + throw new Error('User not authenticated'); + } + try { + const response = queryCreateTimesheet(timesheetParams).then((res) => { + return res.data; + }); + return response; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error('Axios Error:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data + }); + throw new Error(`Request failed: ${error.message}`); + } + console.error('Error:', error instanceof Error ? error.message : error); + throw error; + } + }, + [queryCreateTimesheet, user] + ); + + const updateTimesheet = useCallback( + async (timesheet: UpdateTimesheet) => { + if (!user) { + console.warn('User not authenticated!'); + return; + } + try { + const response = await queryUpdateTimesheet(timesheet); + if (response?.data?.id) { + setTimesheet((prevTimesheet) => + prevTimesheet.map((item) => + item.id === response.data.id ? { ...item, ...response.data } : item + ) + ); + } else { + console.warn('Unexpected structure of the response. No update performed.', response); + } + } catch (error) { + console.error('Error updating the timesheet:', error); + throw error; + } + }, + [queryUpdateTimesheet, setTimesheet, user] + ); + + const updateTimesheetStatus = useCallback( + async ({ status, ids }: { status: TimesheetStatus; ids: ID[] | ID }) => { + if (!user) return; + const idsArray = Array.isArray(ids) ? ids : [ids]; + try { + const response = await queryUpdateTimesheetStatus({ ids: idsArray, status }); + const responseMap = new Map(response.data.map((item) => [item.id, item])); + setTimesheet((prevTimesheet) => + prevTimesheet.map((item) => { + const updatedItem = responseMap.get(item.timesheet.id); + if (updatedItem) { + return { + ...item, + timesheet: { + ...item.timesheet, + status: updatedItem.status + } + }; + } + return item; + }) + ); + console.log('Timesheet status updated successfully!'); + } catch (error) { + console.error('Error updating timesheet status:', error); + } + }, + [queryUpdateTimesheetStatus, setTimesheet, user] + ); + + const getStatusTimesheet = (items: TimesheetLog[] = []) => { + const STATUS_MAP: Record = { + PENDING: [], + APPROVED: [], + DENIED: [], + DRAFT: [], + 'IN REVIEW': [] + }; + + return items.reduce((acc, item) => { + const status = item.timesheet.status; + if (isTimesheetStatus(status)) { + acc[status].push(item); + } else { + console.warn(`Invalid timesheet status: ${status}`); + } + return acc; + }, STATUS_MAP); + }; + + // Type guard + function isTimesheetStatus(status: unknown): status is TimesheetStatus { + const timesheetStatusValues: TimesheetStatus[] = ['DRAFT', 'PENDING', 'IN REVIEW', 'DENIED', 'APPROVED']; + return Object.values(timesheetStatusValues).includes(status as TimesheetStatus); + } + + const deleteTaskTimesheet = useCallback( + async ({ logIds }: { logIds: string[] }) => { + if (!user) { + throw new Error('User not authenticated'); + } + if (!logIds.length) { + throw new Error('No timesheet IDs provided for deletion'); + } + try { + await queryDeleteTimesheet({ + organizationId: user.employee.organizationId, + tenantId: user.tenantId ?? '', + logIds + }); + setTimesheet((prevTimesheet) => prevTimesheet.filter((item) => !logIds.includes(item.id))); + } catch (error) { + console.error('Failed to delete timesheets:', error); + throw error; + } + }, + [user, queryDeleteTimesheet, setTimesheet] + ); + + const groupedByTimesheetIds = ({ rows }: { rows: TimesheetLog[] }): Record => { + if (!rows) { + return {}; + } + return rows.reduce( + (acc, row) => { + if (!row) { + return acc; + } + const timesheetId = row.timesheetId ?? 'unassigned'; + if (!acc[timesheetId]) { + acc[timesheetId] = []; + } + acc[timesheetId].push(row); + return acc; + }, + {} as Record + ); + }; + + const filterDataTimesheet = useMemo(() => { + if (!timesheet || !inputSearch) { + return timesheet; + } + const searchTerms = normalizeText(inputSearch).split(/\s+/).filter(Boolean); + if (searchTerms.length === 0) { + return timesheet; + } + return timesheet.filter((task) => { + const searchableContent = { + title: normalizeText(task.task?.title), + employee: normalizeText(task.employee?.fullName), + project: normalizeText(task.project?.name) + }; + return searchTerms.every((term) => + Object.values(searchableContent).some((content) => content.includes(term)) + ); + }); + }, [timesheet, inputSearch, normalizeText]); + + const reGroupByDate = (groupedTimesheets: GroupedTimesheet[]): GroupedTimesheet[] => { + return groupedTimesheets.reduce((acc, { date, tasks }) => { + const existingGroup = acc.find((group) => group.date === date); + if (existingGroup) { + existingGroup.tasks = existingGroup.tasks.concat(tasks); + } else { + acc.push({ date, tasks }); + } + return acc; + }, [] as GroupedTimesheet[]); + }; + + const timesheetElementGroup = useMemo(() => { + if (!timesheet) { + return []; + } + + if (timesheetViewMode === 'ListView') { + const groupedTimesheets = groupByDate(filterDataTimesheet); + const reGroupedByDate = reGroupByDate(groupedTimesheets); + switch (timesheetGroupByDays) { + case 'Daily': + return reGroupedByDate; + case 'Weekly': + return groupByWeek(filterDataTimesheet); + case 'Monthly': + return groupByMonth(filterDataTimesheet); + default: + return reGroupedByDate; + } + } + return reGroupByDate(groupByDate(filterDataTimesheet)); + }, [timesheet, timesheetViewMode, filterDataTimesheet, timesheetGroupByDays]); + + const rowsToObject = (rows: TimesheetLog[]): Record => { + return rows.reduce( + (acc, row) => { + acc[row.timesheet.id] = { task: row, status: row.timesheet.status }; + return acc; + }, + {} as Record + ); + }; + + useEffect(() => { + getTaskTimesheet({ startDate, endDate }); + }, [getTaskTimesheet, startDate, endDate, timesheetGroupByDays, inputSearch]); + + return { + loadingTimesheet, + timesheet: timesheetElementGroup, + getTaskTimesheet, + loadingDeleteTimesheet, + deleteTaskTimesheet, + getStatusTimesheet, + timesheetGroupByDays, + statusTimesheet: getStatusTimesheet(filterDataTimesheet.flat()), + updateTimesheetStatus, + loadingUpdateTimesheetStatus, + puTimesheetStatus, + createTimesheet, + loadingCreateTimesheet, + updateTimesheet, + loadingUpdateTimesheet, + groupByDate, + isManage, + normalizeText, + setSelectTimesheetId, + selectTimesheetId, + handleSelectRowByStatusAndDate, + handleSelectRowTimesheet, + groupedByTimesheetIds, + rowsToObject, + formatDate, + currentDateRange + }; } diff --git a/apps/web/app/interfaces/timer/ITimerLog.ts b/apps/web/app/interfaces/timer/ITimerLog.ts index 451d71497..858f38298 100644 --- a/apps/web/app/interfaces/timer/ITimerLog.ts +++ b/apps/web/app/interfaces/timer/ITimerLog.ts @@ -179,6 +179,8 @@ export interface ITimeLogReportDailyChartProps { logType?: TimeLogType[]; teamIds?: string[]; groupBy?: string; + teamId?: string; + userId?: string; } export interface IOrganizationContact { diff --git a/yarn.lock b/yarn.lock index 7187db4b6..5a33a032e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1207,6 +1207,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.17.9": + version "7.26.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.7.tgz#f4e7fe527cd710f8dc0618610b61b4b060c3c341" + integrity sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.20.7": version "7.24.6" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.6.tgz" @@ -2760,6 +2767,19 @@ "@hapi/hoek" "^9.0.0" "@hapi/topo" "^5.0.0" +"@hcaptcha/loader@^1.2.1": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@hcaptcha/loader/-/loader-1.2.4.tgz#541714395a82e27ec0f0e8bd80ef1a0bea141cc3" + integrity sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw== + +"@hcaptcha/react-hcaptcha@^1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.11.1.tgz#cc0dd0b180b3458ca7b0f8cf1446f09afca2ec55" + integrity sha512-g6TwatNIzBtOR3RM4mxzvTUQGs5T9HMN+4fcNGHn7wUVThvmazThUs0vImI836bSkGpJS8n0rOYvv1UZ47q8Vw== + dependencies: + "@babel/runtime" "^7.17.9" + "@hcaptcha/loader" "^1.2.1" + "@headlessui/react@^1.7.7": version "1.7.17" resolved "https://registry.npmjs.org/@headlessui/react/-/react-1.7.17.tgz" @@ -12399,6 +12419,11 @@ csstype@^3.0.2: resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz" integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== +custom-electron-titlebar@^4.2.8: + version "4.2.8" + resolved "https://registry.yarnpkg.com/custom-electron-titlebar/-/custom-electron-titlebar-4.2.8.tgz#8b0d024cf372b10c9758cc512ca55498b69616da" + integrity sha512-JEFiOKJdSZtMh90FO90FeEqCc463ZZhuh78JxILvO9Nc0H8I9MfKekgCf6jZH6xccd3/tm1OcYOoUJi3JbXR/w== + cypress-file-upload@^5.0.8: version "5.0.8" resolved "https://registry.npmjs.org/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz" @@ -23045,6 +23070,11 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" +react-turnstile@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/react-turnstile/-/react-turnstile-1.1.4.tgz#0c23b2f4b55f83b929407ae9bfbd211fbe5df362" + integrity sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ== + react@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz" From 9451b4c7b372d6b327a64c05992abbba6200a705 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Fri, 7 Feb 2025 08:34:12 +0200 Subject: [PATCH 08/11] [Feat]: Add rows per page (#3595) * feat: improve TimesheetPagination component UI * feat: improve TimesheetPagination component UI --- .../components/TimesheetPagination.tsx | 170 +++++++++++------- .../[locale]/timesheet/[memberId]/page.tsx | 15 +- 2 files changed, 117 insertions(+), 68 deletions(-) diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetPagination.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetPagination.tsx index 89d3c4720..5b6947993 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetPagination.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetPagination.tsx @@ -1,17 +1,26 @@ -import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink } from '@components/ui/pagination' -import React from 'react' +import React from 'react'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink +} from '@components/ui/pagination'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@components/ui/select'; import { MdKeyboardDoubleArrowLeft, MdKeyboardDoubleArrowRight } from 'react-icons/md'; interface TimesheetPaginationProps { - totalPages?: number; - onPageChange?: (page: number) => void; - nextPage?: () => void; - previousPage?: () => void; - goToPage: (page: number) => void; - currentPage?: number; - getPageNumbers: () => (number | string)[]; - dates?: string[]; - totalGroups?: number - + totalPages?: number; + onPageChange?: (page: number) => void; + nextPage?: () => void; + previousPage?: () => void; + goToPage: (page: number) => void; + currentPage?: number; + getPageNumbers: () => (number | string)[]; + dates?: string[]; + totalGroups?: number; + pageSize?: number; + pageSizeOptions?: number[]; + onPageSizeChange?: (size: number) => void; } /** @@ -28,58 +37,89 @@ interface TimesheetPaginationProps { * * @returns {React.ReactElement} - The component element. */ -function TimesheetPagination({ totalPages, onPageChange, goToPage, nextPage, previousPage, currentPage, getPageNumbers, dates, totalGroups }: TimesheetPaginationProps) { - return ( - // totalPages > 1 - <> - {totalPages && totalPages > 1 && ( - - -
- Page {currentPage} of {totalPages} ({dates?.length} items of {totalGroups}) -
- - - - - - {getPageNumbers().map((pageNumber, index) => ( - - {pageNumber === '...' ? ( - - ) : ( - goToPage(pageNumber as number)}> - {pageNumber} - - )} - - ))} - - - - -
- ) - } - - ) - +function TimesheetPagination({ + totalPages, + goToPage, + nextPage, + previousPage, + currentPage, + getPageNumbers, + dates, + totalGroups, + pageSize = 10, + pageSizeOptions = [10, 20, 30, 50], + onPageSizeChange +}: TimesheetPaginationProps) { + return ( + // totalPages > 1 + <> + {totalPages && totalPages > 1 && ( + +
+
+ {dates?.length || 0} of {totalGroups || 0} row(s) selected +
+
+ Rows per page + +
+
+ + + + + {getPageNumbers().map((pageNumber, index) => ( + + {pageNumber === '...' ? ( + + ) : ( + goToPage(pageNumber as number)} + > + {pageNumber} + + )} + + ))} + + + + +
+ Page {currentPage} of {totalPages} +
+
+ )} + + ); } -export default TimesheetPagination +export default TimesheetPagination; diff --git a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx index 4edb11365..3aa815d26 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx @@ -34,8 +34,6 @@ import { useTimesheetViewData } from '@/app/hooks/features/useTimesheetViewData' type TimesheetViewMode = 'ListView' | 'CalendarView'; export type TimesheetDetailMode = 'Pending' | 'MenHours' | 'MemberWork'; -const TIMESHEET_PAGE_SIZE = 10; - type ViewToggleButtonProps = { mode: TimesheetViewMode; active: boolean; @@ -47,6 +45,14 @@ type ViewToggleButtonProps = { const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memberId: string } }) { const t = useTranslations(); const { user } = useAuthenticateUser(); + const [pageSize, setPageSize] = useState(10); + + const getPageSizeOptions = (total: number) => { + if (total <= 10) return [10]; + if (total <= 20) return [10, 20]; + if (total <= 30) return [10, 20, 30]; + return [10, 20, 30, 50]; + }; const { getOrganizationProjects } = useOrganizationProjects(); const { isTrackingEnabled, activeTeam } = useOrganizationTeams(); @@ -105,7 +111,7 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb dates } = useTimesheetPagination({ data: filterDataTimesheet, - pageSize: TIMESHEET_PAGE_SIZE + pageSize }); const viewData = useTimesheetViewData({ timesheetNavigator, @@ -330,6 +336,9 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb previousPage={previousPage} dates={dates} totalGroups={totalGroups} + pageSize={pageSize} + onPageSizeChange={setPageSize} + pageSizeOptions={getPageSizeOptions(totalGroups || 0)} /> )}
From 1c673505e09c1602b3704c08d757462af8c0e0f8 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Fri, 7 Feb 2025 08:34:48 +0200 Subject: [PATCH 09/11] [Feat]: Task date picker improvements (#3596) * feat: enhance task date picker with data indicators - Add visual indicators for dates with available data - Improve date picker type safety with IDailyPlan interface - Optimize date filtering and validation logic - Add dot indicators to highlight dates with tasks - Ensure proper date range selection behavior * feat: enhance task date picker with data indicators - Add visual indicators for dates with available data - Improve date picker type safety with IDailyPlan interface - Optimize date filtering and validation logic - Add dot indicators to highlight dates with tasks - Ensure proper date range selection behavior * fix: deepscan --- apps/web/app/hooks/useDateRange.ts | 10 +++--- apps/web/app/stores/daily-plan.ts | 9 +++++ .../web/lib/features/task/task-date-range.tsx | 33 ++++++++++++----- apps/web/lib/features/task/task-filters.tsx | 2 +- apps/web/lib/features/user-profile-plans.tsx | 36 +++++++++---------- 5 files changed, 58 insertions(+), 32 deletions(-) diff --git a/apps/web/app/hooks/useDateRange.ts b/apps/web/app/hooks/useDateRange.ts index 3c7b75723..60050a275 100644 --- a/apps/web/app/hooks/useDateRange.ts +++ b/apps/web/app/hooks/useDateRange.ts @@ -2,23 +2,23 @@ import { dateRangeAllPlanState, dateRangeFuturePlanState, dateRangePastPlanState, - getFirstAndLastDateState + getPlanState } from '@app/stores'; import { useAtom, useAtomValue } from 'jotai'; export const useDateRange = (tab: string | any) => { - const itemsDate = useAtomValue(getFirstAndLastDateState); + const itemsPlans = useAtomValue(getPlanState); const [dateFuture, setDateFuture] = useAtom(dateRangeFuturePlanState); const [dateAllPlan, setDateAllPlan] = useAtom(dateRangeAllPlanState); const [datePastPlan, setDatePastPlan] = useAtom(dateRangePastPlanState); switch (tab) { case 'Future Tasks': - return { date: dateFuture, setDate: setDateFuture, data: itemsDate }; + return { date: dateFuture, setDate: setDateFuture, data: itemsPlans }; case 'Past Tasks': - return { date: datePastPlan, setDate: setDatePastPlan, data: itemsDate }; + return { date: datePastPlan, setDate: setDatePastPlan, data: itemsPlans }; case 'All Tasks': default: - return { date: dateAllPlan, setDate: setDateAllPlan, data: itemsDate }; + return { date: dateAllPlan, setDate: setDateAllPlan, data: itemsPlans }; } }; diff --git a/apps/web/app/stores/daily-plan.ts b/apps/web/app/stores/daily-plan.ts index d7c86a8d2..d973bd9e0 100644 --- a/apps/web/app/stores/daily-plan.ts +++ b/apps/web/app/stores/daily-plan.ts @@ -123,3 +123,12 @@ export const getFirstAndLastDateState = atom((get) => { to: new Date(sortedData[sortedData.length - 1]?.date) }; }); + + +export const getPlanState = atom((get) => { + const itemsData = get(dataDailyPlanState); + if (!itemsData?.length) return { data: [] }; + return { + data: itemsData + }; +}); diff --git a/apps/web/lib/features/task/task-date-range.tsx b/apps/web/lib/features/task/task-date-range.tsx index 096735849..abdf79a9b 100644 --- a/apps/web/lib/features/task/task-date-range.tsx +++ b/apps/web/lib/features/task/task-date-range.tsx @@ -13,6 +13,7 @@ import { DateRange } from 'react-day-picker'; import { SetStateAction } from 'jotai'; import moment from 'moment'; import { SetAtom } from 'types'; +import { IDailyPlan } from '@/app/interfaces'; interface ITaskDatePickerWithRange { className?: string; @@ -20,7 +21,7 @@ interface ITaskDatePickerWithRange { onSelect?: SetAtom<[SetStateAction], void>; label?: string; - data?: any; + data?: IDailyPlan[]; } export function TaskDatePickerWithRange({ className, @@ -29,11 +30,14 @@ export function TaskDatePickerWithRange({ label, data }: ITaskDatePickerWithRange) { - const isDateDisabled = (dateToCheck: any) => { - const { from, to }: any = data; - const fromDate = new Date(moment(from)?.format('YYYY-MM-DD')); - const toDate = new Date(moment(to)?.format('YYYY-MM-DD')); - return dateToCheck < fromDate || dateToCheck > toDate; + const isDateDisabled = (dateToCheck: Date) => { + if (!data || !Array.isArray(data)) return true; + + const checkDate = moment(dateToCheck).format('YYYY-MM-DD'); + return !data.some(item => { + const itemDate = moment(item.date).format('YYYY-MM-DD'); + return itemDate === checkDate; + }); }; const handleDateSelect = (newDate: DateRange | undefined) => { if (onSelect) { @@ -52,7 +56,7 @@ export function TaskDatePickerWithRange({ !date && 'text-muted-foreground' )} > - + {date?.from ? ( date.to ? ( <> @@ -67,7 +71,7 @@ export function TaskDatePickerWithRange({ )} - + { + if (!data || !Array.isArray(data)) return false; + const checkDate = moment(date).format('YYYY-MM-DD'); + return data.some(item => { + const itemDate = moment(item.date).format('YYYY-MM-DD'); + return itemDate === checkDate; + }); + } + }} + modifiersClassNames={{ + hasData: 'relative before:absolute before:content-[""] before:w-1 before:h-1 before:bg-primary before:rounded-full before:bottom-1 before:left-1/2 before:-translate-x-1/2' + }} /> diff --git a/apps/web/lib/features/task/task-filters.tsx b/apps/web/lib/features/task/task-filters.tsx index d9035c94a..ff980728a 100644 --- a/apps/web/lib/features/task/task-filters.tsx +++ b/apps/web/lib/features/task/task-filters.tsx @@ -477,7 +477,7 @@ export function TaskStatusFilter({ hook, employeeId }: { hook: I_TaskFilter; emp {hook.tab === 'dailyplan' && } {['Future Tasks', 'Past Tasks', 'All Tasks'].includes(dailyPlanTab) && ( setDate(range)} label="Planned date" diff --git a/apps/web/lib/features/user-profile-plans.tsx b/apps/web/lib/features/user-profile-plans.tsx index b0471f39a..b2105e185 100644 --- a/apps/web/lib/features/user-profile-plans.tsx +++ b/apps/web/lib/features/user-profile-plans.tsx @@ -148,11 +148,11 @@ export function UserProfilePlans(props: IUserProfilePlansProps) { <> {profileDailyPlans?.items?.length > 0 ? ( -
+
-
+
{Object.keys(tabsScreens).map((filter, i) => ( -
+
{i !== 0 && }
))}
-
+
{currentTab === 'Today Tasks' && todayPlan[0] && ( <> {canSeeActivity ? ( @@ -238,10 +238,10 @@ export function UserProfilePlans(props: IUserProfilePlansProps) { } }} variant="destructive" - className="flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:bg-red-400" + className="flex justify-center items-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:bg-red-400" > {deleteDailyPlanLoading && ( - + )} {t('common.DELETE')} @@ -364,8 +364,8 @@ function AllPlans({ className="dark:border-slate-600 !border-none" > -
-
+
+
{formatDayPlanDate(plan.date.toString())} ({plan.tasks?.length})
@@ -518,7 +518,7 @@ export function PlanHeader({ plan, planMode }: { plan: IDailyPlan; planMode: Fil > {/* Planned Time */} -
+
{!editTime && !updateDailyPlanLoading ? ( <>
@@ -538,13 +538,13 @@ export function PlanHeader({ plan, planMode }: { plan: IDailyPlan; planMode: Fil min={0} type="number" className={clsxm( - 'outline-none p-0 bg-transparent border-b text-center max-w-[54px] text-xs font-medium' + 'p-0 text-xs font-medium text-center bg-transparent border-b outline-none max-w-[54px]' )} onChange={(e) => setTime(parseFloat(e.target.value))} /> {updateDailyPlanLoading ? ( - + ) : ( -
+
{t('dailyPlan.ESTIMATED_TIME')} : {formatIntegerToHour(estimatedTime / 3600)}
@@ -571,7 +571,7 @@ export function PlanHeader({ plan, planMode }: { plan: IDailyPlan; planMode: Fil {/* Total worked time for the plan */} {planMode !== 'Future Tasks' && ( -
+
{t('dailyPlan.TOTAL_TIME_WORKED')} : {formatIntegerToHour(totalWorkTime / 3600)}
@@ -582,15 +582,15 @@ export function PlanHeader({ plan, planMode }: { plan: IDailyPlan; planMode: Fil {/* Completed tasks */} {planMode !== 'Future Tasks' && (
-
+
{t('dailyPlan.COMPLETED_TASKS')} : {`${completedTasks}/${totalTasks}`}
-
+
{t('dailyPlan.READY')}: {readyTasks}
-
+
{t('dailyPlan.LEFT')}: {totalTasks - completedTasks - readyTasks}
@@ -602,7 +602,7 @@ export function PlanHeader({ plan, planMode }: { plan: IDailyPlan; planMode: Fil {/* Completion progress */} {planMode !== 'Future Tasks' && (
-
+
{t('dailyPlan.COMPLETION')}: {completionPercent}%
@@ -613,7 +613,7 @@ export function PlanHeader({ plan, planMode }: { plan: IDailyPlan; planMode: Fil {/* Future tasks total plan */} {planMode === 'Future Tasks' && (
-
+
{t('dailyPlan.PLANNED_TASKS')}: {totalTasks}
From 59ab50287319d9f803659c3fe0521d49801b9fd7 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Fri, 7 Feb 2025 08:36:12 +0200 Subject: [PATCH 10/11] refactor: optimize date range picker component (#3592) * refactor: optimize date range picker component - Extract PredefinedRanges into a separate component for better maintainability - Implement createRangeHelper factory function to reduce code duplication - Add memoization for predefined ranges to prevent unnecessary recalculations - Improve type safety with DateRangeGetter type - Standardize date comparison using startOfDay - Enhance UI with consistent button styling and layout Performance improvements: - Reduce redundant date calculations - Prevent unnecessary re-renders with useMemo - Centralize date range creation logic Technical improvements: - Better type safety - More maintainable code structure - Consistent date handling * deepscan * deepscan * deepscan * fix: memoize createRange function to prevent unnecessary re-renders in date-range-picker * deepscan * fix: conflit --- .cspell.json | 1 + .../[teamId]/components/date-range-picker.tsx | 235 +++++++++--------- .../components/TimesheetDetailModal.tsx | 28 +-- 3 files changed, 139 insertions(+), 125 deletions(-) diff --git a/.cspell.json b/.cspell.json index eb482a503..a650d982e 100644 --- a/.cspell.json +++ b/.cspell.json @@ -110,6 +110,7 @@ "deserunt", "digitaloceanspaces", "dimesions", + "Ipredefine", "discrepenancy", "dolor", "dolore", diff --git a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx index 842649d82..25ed7cbc0 100644 --- a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx +++ b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/date-range-picker.tsx @@ -23,6 +23,7 @@ import { import { DateRange } from 'react-day-picker'; import { useTranslations } from 'next-intl'; import { SettingsIcon } from './team-icon'; +import { TranslationHooks } from 'next-intl'; import { CalendarIcon } from '@radix-ui/react-icons'; interface DateRangePickerProps { @@ -51,100 +52,6 @@ export function DateRangePicker({ className, onDateRangeChange }: DateRangePicke } }; - const predefinedRanges = [ - { - label: t('common.TODAY'), - action: () => { - const today = new Date(); - handleDateRangeChange({ from: today, to: today }); - }, - isSelected: (range: DateRange | undefined) => { - if (!range?.from || !range?.to) return false; - const today = new Date(); - return isEqual(range.from, today) && isEqual(range.to, today); - } - }, - { - label: t('common.YESTERDAY'), - action: () => { - const yesterday = subDays(new Date(), 1); - handleDateRangeChange({ from: yesterday, to: yesterday }); - }, - isSelected: (range: DateRange | undefined) => { - if (!range?.from || !range?.to) return false; - const yesterday = subDays(new Date(), 1); - return isEqual(range.from, yesterday) && isEqual(range.to, yesterday); - } - }, - { - label: t('common.THIS_WEEK'), - action: () => { - const today = new Date(); - handleDateRangeChange({ - from: startOfWeek(today, { weekStartsOn: 1 }), - to: endOfWeek(today, { weekStartsOn: 1 }) - }); - }, - isSelected: (range: DateRange | undefined) => { - if (!range?.from || !range?.to) return false; - const today = new Date(); - const weekStart = startOfWeek(today, { weekStartsOn: 1 }); - const weekEnd = endOfWeek(today, { weekStartsOn: 1 }); - return isEqual(range.from, weekStart) && isEqual(range.to, weekEnd); - } - }, - { - label: t('common.LAST_WEEK'), - action: () => { - const lastWeek = subWeeks(new Date(), 1); - handleDateRangeChange({ - from: startOfWeek(lastWeek, { weekStartsOn: 1 }), - to: endOfWeek(lastWeek, { weekStartsOn: 1 }) - }); - }, - isSelected: (range: DateRange | undefined) => { - if (!range?.from || !range?.to) return false; - const lastWeek = subWeeks(new Date(), 1); - const weekStart = startOfWeek(lastWeek, { weekStartsOn: 1 }); - const weekEnd = endOfWeek(lastWeek, { weekStartsOn: 1 }); - return isEqual(range.from, weekStart) && isEqual(range.to, weekEnd); - } - }, - { - label: t('common.THIS_MONTH'), - action: () => { - const today = new Date(); - handleDateRangeChange({ - from: startOfMonth(today), - to: endOfMonth(today) - }); - }, - isSelected: (range: DateRange | undefined) => { - if (!range?.from || !range?.to) return false; - const today = new Date(); - const monthStart = startOfMonth(today); - const monthEnd = endOfMonth(today); - return isEqual(range.from, monthStart) && isEqual(range.to, monthEnd); - } - }, - { - label: t('common.LAST_MONTH'), - action: () => { - const lastMonth = subMonths(new Date(), 1); - handleDateRangeChange({ - from: startOfMonth(lastMonth), - to: endOfMonth(lastMonth) - }); - }, - isSelected: (range: DateRange | undefined) => { - if (!range?.from || !range?.to) return false; - const lastMonth = subMonths(new Date(), 1); - const monthStart = startOfMonth(lastMonth); - const monthEnd = endOfMonth(lastMonth); - return isEqual(range.from, monthStart) && isEqual(range.to, monthEnd); - } - } - ]; const formatDateRange = (range: DateRange) => { if (!range.from) return 'Select date range'; @@ -219,23 +126,8 @@ export function DateRangePicker({ className, onDateRangeChange }: DateRangePicke disabled={(date) => date >= startOfDay(new Date())} />
-
- {predefinedRanges.map((range) => ( - - ))} +
+
@@ -262,3 +154,124 @@ export function DateRangePicker({ className, onDateRangeChange }: DateRangePicke ); } +interface PredefinedRangeProps { + handleDateRangeChange: (range: DateRange | undefined) => void; + t: TranslationHooks; + dateRange: DateRange | undefined; +} + +type DateRangeGetter = () => { from: Date; to: Date }; + +const createRangeHelper = (handleDateRangeChange: (range: DateRange | undefined) => void) => { + return (getRange: DateRangeGetter) => { + const range = getRange(); + return { + action: () => { + const newRange = { + from: new Date(range.from), + to: new Date(range.to) + }; + handleDateRangeChange(newRange); + }, + isSelected: (currentRange: DateRange | undefined) => { + if (!currentRange?.from || !currentRange?.to) return false; + return isEqual( + startOfDay(currentRange.from), + startOfDay(range.from) + ) && isEqual( + startOfDay(currentRange.to), + startOfDay(range.to) + ); + } + }; + }; +}; + +const PredefinedRanges = ({ handleDateRangeChange, t, dateRange }: PredefinedRangeProps) => { + const weekOptions = { weekStartsOn: 1 as const }; + + const createRange = React.useMemo( + () => createRangeHelper(handleDateRangeChange), + [handleDateRangeChange] + ); + + const predefinedRanges = React.useMemo( + () => [ + { + label: t('common.TODAY'), + ...createRange(() => { + const today = new Date(); + return { from: today, to: today }; + }) + }, + { + label: t('common.YESTERDAY'), + ...createRange(() => { + const yesterday = subDays(new Date(), 1); + return { from: yesterday, to: yesterday }; + }) + }, + { + label: t('common.THIS_WEEK'), + ...createRange(() => { + const today = new Date(); + return { + from: startOfWeek(today, weekOptions), + to: endOfWeek(today, weekOptions) + }; + }) + }, + { + label: t('common.LAST_WEEK'), + ...createRange(() => { + const lastWeek = subWeeks(new Date(), 1); + return { + from: startOfWeek(lastWeek, weekOptions), + to: endOfWeek(lastWeek, weekOptions) + }; + }) + }, + { + label: t('common.THIS_MONTH'), + ...createRange(() => { + const today = new Date(); + return { + from: startOfMonth(today), + to: endOfMonth(today) + }; + }) + }, + { + label: t('common.LAST_MONTH'), + ...createRange(() => { + const lastMonth = subMonths(new Date(), 1); + return { + from: startOfMonth(lastMonth), + to: endOfMonth(lastMonth) + }; + }) + } + ], + [createRange, t] + ); + + return ( +
+ {predefinedRanges.map((range) => ( + + ))} +
+ ); +}; diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx index 33b3eeb05..f4f6c7e75 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx @@ -38,7 +38,7 @@ function TimesheetDetailModal({ closeModal, isOpen, timesheet, timesheetDetailMo showCloseIcon className="bg-light--theme-light dark:bg-dark--theme-light p-5 rounded w-full md:w-40 md:min-w-[35rem]" titleClass="font-bold flex justify-start w-full text-xl"> -
+
{(() => { switch (timesheetDetailMode) { @@ -89,13 +89,13 @@ const MembersWorkedCard = ({ element, t }: { element: TimesheetLog[], t: Transla
-
- +
+ {timesheet.element[0].employee.fullName}
-
+
-
+
{status === 'DENIED' ? 'REJECTED' : status} @@ -150,7 +150,7 @@ const MembersWorkedCard = ({ element, t }: { element: TimesheetLog[], t: Transla borderBottomColor: statusColor(status).bg }} className={cn( - 'flex items-center border-b border-b-gray-200 dark:border-b-gray-600 space-x-4 p-1 h-[60px]' + 'flex items-center p-1 space-x-4 border-b border-b-gray-200 dark:border-b-gray-600 h-[60px]' )} >
-
+
{items.project?.imageUrl && } {items.project?.name}
@@ -218,11 +218,11 @@ const MenHoursCard = ({ element, t }: MenHoursCardProps) => { style={{ backgroundColor: statusColor(timesheet.element[0].timesheet.status).bgOpacity }} type="button" className={cn( - 'flex flex-row-reverse justify-end items-center w-full h-[50px] rounded-sm gap-x-2 hover:no-underline px-2', + 'flex flex-row-reverse gap-x-2 justify-end items-center px-2 w-full rounded-sm h-[50px] hover:no-underline', )}>
-
+
{timesheet.element[0].timesheet.status === 'DENIED' ? 'REJECTED' : timesheet.element[0].timesheet.status}
@@ -249,10 +249,10 @@ const MenHoursCard = ({ element, t }: MenHoursCardProps) => { 'flex flex-row-reverse justify-end items-center w-full h-[50px] rounded-sm gap-x-2 hover:no-underline px-2', statusColor(status).text )}> -
+
-
+
{status === 'DENIED' ? 'REJECTED' : status} @@ -278,7 +278,7 @@ const MenHoursCard = ({ element, t }: MenHoursCardProps) => { borderBottomColor: statusColor(status).bg }} className={cn( - 'flex items-center border-b border-b-gray-200 dark:border-b-gray-600 space-x-4 p-1 h-[60px]' + 'flex items-center p-1 space-x-4 border-b border-b-gray-200 dark:border-b-gray-600 h-[60px]' )} >
{ taskNumberClassName="text-sm" />
-
+
{items.project?.imageUrl && } {items.project?.name}
From e71a27cf1caa06e892a15a6dce7510ef9bbaebfb Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Fri, 7 Feb 2025 08:40:54 +0200 Subject: [PATCH 11/11] [Feat]: Improve activity modal UI (#3585) * feat: improve activity modal and chart button UI - Fix type error in ActivityModal component - Improve modal styling and responsiveness - Replace custom button with Button component - Add accessibility attributes to chart button - Optimize modal rendering with null check * feat: improve activity modal and chart button UI - Fix type error in ActivityModal component - Improve modal styling and responsiveness - Replace custom button with Button component - Add accessibility attributes to chart button - Optimize modal rendering with null check * fix: conflit --- .../[teamId]/components/activity-modal.tsx | 6 +- .../[teamId]/components/team-stats-table.tsx | 301 ++++++++++-------- .../team-dashboard/[teamId]/page.tsx | 15 +- 3 files changed, 172 insertions(+), 150 deletions(-) diff --git a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/activity-modal.tsx b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/activity-modal.tsx index db7ba8699..f07325619 100644 --- a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/activity-modal.tsx +++ b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/activity-modal.tsx @@ -92,19 +92,19 @@ export const ActivityModal = ({ employeeLog, isOpen, closeModal }: ActivityModal
-

+

{employeeLog.employee.fullName}

diff --git a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx index 984583aff..c1766c0d6 100644 --- a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx +++ b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/components/team-stats-table.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { PaginationDropdown } from '@/lib/settings/page-dropdown'; import { format } from 'date-fns'; -import { ITimerLogGrouped } from '@/app/interfaces'; +import { ITimerEmployeeLog, ITimerLogGrouped } from '@/app/interfaces'; import { Spinner } from '@/components/ui/loaders/spinner'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import { Fragment, useState } from 'react'; @@ -42,6 +42,9 @@ export function TeamStatsTable({ rapportDailyActivity?: ITimerLogGrouped[]; isLoading?: boolean; }) { + + + const [employeeLog, setEmployeeLog] = useState(undefined); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); const totalPages = rapportDailyActivity ? Math.ceil(rapportDailyActivity.length / pageSize) : 0; @@ -61,7 +64,7 @@ export function TeamStatsTable({ if (isLoading) { return ( -
+
); @@ -76,149 +79,163 @@ export function TeamStatsTable({ } return ( -
-
-
-
-
- - - - Member - Total Time - Tracked - Manually Added - Active Time - Idle Time - Unknown Activity - Activity Level - - - - - {paginatedData?.map((dayData) => ( - - - - {format(new Date(dayData.date), 'EEEE dd MMM yyyy')} - - - {dayData.logs?.map( - (projectLog) => - projectLog.employeeLogs?.map((employeeLog) => ( - - -
- - - - {employeeLog.employee?.user?.name?.[0] || - 'U'} - - - - {employeeLog.employee?.user?.name || - 'Unknown User'} - -
-
- - {formatDuration(employeeLog.sum || 0)} - - - {formatPercentage(employeeLog.activity)} - - - {formatPercentage(0)} - - - {formatPercentage(100)} - - - {formatPercentage(0)} - - - {formatPercentage(0)} - - -
-
-
+ <> + {employeeLog && ( + + )} +
+
+
+
+
+
+ + + Member + Total Time + Tracked + Manually Added + Active Time + Idle Time + Unknown Activity + Activity Level + + + + + {paginatedData?.map((dayData) => ( + + + + {format(new Date(dayData.date), 'EEEE dd MMM yyyy')} + + + {dayData.logs?.map( + (projectLog) => + projectLog.employeeLogs?.map((employeeLog) => ( + + +
+ + + + {employeeLog.employee?.user + ?.name?.[0] || 'U'} + + + + {employeeLog.employee?.user?.name || + 'Unknown User'} +
- - {(employeeLog.activity || 0).toFixed(1)}% - - -
- - <> - - - - -
- )) || [] - ) || []} -
- ))} -
-
+ + + {formatDuration(employeeLog.sum || 0)} + + + {formatPercentage(employeeLog.activity)} + + + {formatPercentage(0)} + + + {formatPercentage(100)} + + + {formatPercentage(0)} + + + {formatPercentage(0)} + + +
+
+
+
+ + {(employeeLog.activity || 0).toFixed(1)}% + +
+ + + <> + + + + + )) || [] + ) || []} + + ))} + + +
-
-
-
- - -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - ))} +
+
+ + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} +
+ +
- - -
-
+
{ setPageSize(value); @@ -231,7 +248,9 @@ export function TeamStatsTable({ {rapportDailyActivity?.length || 0} entries
+
+
-
+ ); } diff --git a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/page.tsx b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/page.tsx index 467474126..c8ae6308b 100644 --- a/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/page.tsx +++ b/apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/page.tsx @@ -16,9 +16,11 @@ import { useAtomValue } from 'jotai'; import { fullWidthState } from '@app/stores/fullWidth'; import { withAuthentication } from '@/lib/app/authenticator'; import { useReportActivity } from '@/app/hooks/features/useReportActivity'; +import { useTranslations } from 'next-intl'; function TeamDashboard() { const { activeTeam, isTrackingEnabled } = useOrganizationTeams(); + const t=useTranslations(); const { rapportChartActivity, updateDateRange, updateFilters, loadingTimeLogReportDailyChart, rapportDailyActivity, loadingTimeLogReportDaily, statisticsCounts,loadingTimesheetStatisticsCounts} = useReportActivity(); const router = useRouter(); const fullWidth = useAtomValue(fullWidthState); @@ -27,11 +29,13 @@ function TeamDashboard() { const breadcrumbPath = useMemo( () => [ + { title: JSON.parse(t('pages.home.BREADCRUMB')), href: '/' }, { title: activeTeam?.name || '', href: '/' }, { title: 'Team-Dashboard', href: `/${currentLocale}/dashboard/team-dashboard` } ], - [activeTeam?.name, currentLocale] + [activeTeam?.name, currentLocale,t] ); + return (
-
+
- } - > - - + }> + +