Skip to content

Commit

Permalink
[Feat]: Timesheet Statistics api types (#3557)
Browse files Browse the repository at this point in the history
* refactor: improve timesheet statistics types and interfaces

- Move ITimesheetStatisticsCounts interface to ITimerLog.ts
- Use existing TimeLogType instead of custom LogType
- Update type references across timesheet API endpoints
- Improve type validation for log types

Technical Details:
- Replace custom LogType with existing TimeLogType from ITimer
- Relocate statistics interface to proper timer interfaces file
- Update return types in statistics request function
- Refactor type guard to use TimeLogType

Related files:
- app/interfaces/timer/ITimerLog.ts
- app/services/server/requests/timesheet.ts
- app/api/timesheet/statistics/counts/route.ts

* feat: add timesheet statistics API with formatting utilities

* feat: improve team stats grid UI

- Add loading spinner instead of dots
- Enhance stats display with default values
- Add smooth transitions for progress bars
- Fix layout and styling issues
- Improve error handling for null values

* feat: refine team stats grid display

- Remove progress bars from Total Hours and Members worked
- Use secondsToTime helper for duration formatting
- Add showProgress property to control progress bar visibility
- Clean up and organize stats properties

* refactor: optimize team stats grid component

- Add StatItem interface for better type safety
- Extract formatTime helper function
- Use useMemo for stats array to prevent unnecessary recalculations
- Reduce code repetition with shared timeValue
- Remove unnecessary fragment
- Improve code organization and readability

* fix: style team chart
  • Loading branch information
Innocent-Akim authored Feb 2, 2025
1 parent 39b5eda commit 99b69a8
Show file tree
Hide file tree
Showing 11 changed files with 574 additions and 323 deletions.
Original file line number Diff line number Diff line change
@@ -1,70 +1,117 @@
"use client";
'use client';

import { Card } from "@/components/ui/card";
import { secondsToTime } from '@/app/helpers';
import { ITimesheetStatisticsData } from '@/app/interfaces';
import { Card } from '@/components/ui/card';
import { Loader2 } from 'lucide-react';
import { useMemo } from 'react';

const stats = [
{
title: "Members worked",
value: "17",
type: "number"
},
{
title: "Tracked",
value: "47:23",
type: "time",
color: "text-blue-500",
progress: 70,
progressColor: "bg-blue-500"
},
{
title: "Manual",
value: "18:33",
type: "time",
color: "text-red-500",
progress: 30,
progressColor: "bg-red-500"
},
{
title: "Idle",
value: "05:10",
type: "time",
color: "text-yellow-500",
progress: 10,
progressColor: "bg-yellow-500"
},
{
title: "Total Hours",
value: "70:66",
type: "time"
}
];
function formatPercentage(value: number | undefined): number {
if (!value) return 0;
return Math.round(value);
}

export function TeamStatsGrid() {
return (
<>
function formatTime(hours: number, minutes: number, seconds: number): string {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats.map((stat) => (
<Card key={stat.title} className="p-6 dark:bg-dark--theme-light">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500">{stat.title}</span>
<span className={`text-2xl font-semibold mt-2 ${stat.color || "text-gray-900 dark:text-white"}`}>
{stat.value}
</span>
{stat.progress && (
<div className="mt-4">
<div className="w-full h-2 bg-gray-100 rounded-full">
<div
className={`h-full rounded-full ${stat.progressColor}`}
style={{ width: `${stat.progress}%` }}
/>
</div>
</div>
interface StatItem {
title: string;
value: string;
type: 'number' | 'time';
color?: string;
progress?: number;
progressColor?: string;
showProgress: boolean;
}

export function TeamStatsGrid({
statisticsCounts,
loadingTimesheetStatisticsCounts
}: {
statisticsCounts: ITimesheetStatisticsData | null;
loadingTimesheetStatisticsCounts: boolean;
}) {
const { h: hours, m: minutes, s: seconds } = secondsToTime(statisticsCounts?.weekDuration || 0);
const timeValue = formatTime(hours, minutes, seconds);
const progress = formatPercentage(statisticsCounts?.weekActivities);

const stats: StatItem[] = useMemo(
() => [
{
title: 'Members worked',
value: statisticsCounts?.employeesCount?.toString() || '0',
type: 'number',
showProgress: false
},
{
title: 'Tracked',
value: timeValue,
type: 'time',
color: 'text-blue-500',
progress,
progressColor: 'bg-blue-500',
showProgress: true
},
{
title: 'Manual',
value: timeValue,
type: 'time',
color: 'text-red-500',
progress,
progressColor: 'bg-red-500',
showProgress: true
},
{
title: 'Idle',
value: timeValue,
type: 'time',
color: 'text-yellow-500',
progress,
progressColor: 'bg-yellow-500',
showProgress: true
},
{
title: 'Total Hours',
value: timeValue,
type: 'time',
color: 'text-green-500',
showProgress: false
}
],
[timeValue, progress, statisticsCounts?.employeesCount]
);

return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats.map((stat) => (
<Card key={stat.title} className="p-6 dark:bg-dark--theme-light">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500">{stat.title}</span>
<div className="mt-2 h-9">
{loadingTimesheetStatisticsCounts ? (
<Loader2 className="w-6 h-6 text-gray-500 animate-spin" />
) : (
<span className={`text-2xl font-semibold ${stat.color || 'text-gray-900 dark:text-white'}`}>
{stat.value}
</span>
)}
</div>
</Card>
))}
</div>
</>
{stat.showProgress && (
<div className="mt-4">
<div className="w-full h-2 bg-gray-100 rounded-full dark:bg-gray-700">
<div
className={`h-full rounded-full ${stat.progressColor} transition-all duration-300`}
style={{
width: `${loadingTimesheetStatisticsCounts ? 0 : stat.progress}%`
}}
/>
</div>
</div>
)}
</div>
</Card>
))}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function TeamStatsTable({
}

return (
<div className="space-y-4 min-h-[400px] w-full dark:bg-dark--theme-light">
<div className="min-h-[400px] w-full dark:bg-dark--theme-light">
<div className="relative rounded-md border">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
Expand Down
19 changes: 10 additions & 9 deletions apps/web/app/[locale]/dashboard/team-dashboard/[teamId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Card } from '@/components/ui/card';
import { ArrowLeftIcon } from 'lucide-react';
import { TeamStatsChart } from './components/team-stats-chart';
Expand All @@ -16,24 +15,22 @@ import { cn } from '@/lib/utils';
import { useAtomValue } from 'jotai';
import { fullWidthState } from '@app/stores/fullWidth';
import { withAuthentication } from '@/lib/app/authenticator';
import { useReportActivity } from '@app/hooks/features/useReportActivity';
import { useReportActivity } from '@/app/hooks/features/useReportActivity';

function TeamDashboard() {
const { activeTeam, isTrackingEnabled } = useOrganizationTeams();
const { rapportChartActivity, updateDateRange, updateFilters, loadingTimeLogReportDailyChart, rapportDailyActivity, loadingTimeLogReportDaily } = useReportActivity();
const { rapportChartActivity, updateDateRange, updateFilters, loadingTimeLogReportDailyChart, rapportDailyActivity, loadingTimeLogReportDaily, statisticsCounts,loadingTimesheetStatisticsCounts} = useReportActivity();
const router = useRouter();
const t = useTranslations();
const fullWidth = useAtomValue(fullWidthState);
const paramsUrl = useParams<{ locale: string }>();
const currentLocale = paramsUrl?.locale;

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, t]
[activeTeam?.name, currentLocale]
);
return (
<MainLayout
Expand All @@ -57,8 +54,12 @@ function TeamDashboard() {
onUpdateDateRange={updateDateRange}
onUpdateFilters={updateFilters}
/>
<TeamStatsGrid />
<Card className="w-full">
<TeamStatsGrid
statisticsCounts={statisticsCounts}
loadingTimesheetStatisticsCounts={loadingTimesheetStatisticsCounts}
/>

<Card className="w-full dark:bg-dark--theme-light">
<TeamStatsChart
rapportChartActivity={rapportChartActivity}
isLoading={loadingTimeLogReportDailyChart}
Expand All @@ -70,7 +71,7 @@ function TeamDashboard() {
}
>
<Container fullWidth={fullWidth} className={cn('flex flex-col gap-8 py-6 w-full')}>
<Card className="w-full">
<Card className="w-full dark:bg-dark--theme-light min-h-[400px]">
<TeamStatsTable
rapportDailyActivity={rapportDailyActivity}
isLoading={loadingTimeLogReportDaily}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
import { authenticatedGuard } from '@app/services/server/guards/authenticated-guard-app';
import { NextResponse } from 'next/server';
import { getTimeLogReportDailyChartRequest } from '@app/services/server/requests';
import { getTimesheetStatisticsCountsRequest } from '@app/services/server/requests';
import { TimeLogType } from '@/app/interfaces';

const isValidLogType = (type: string): type is TimeLogType => {
return ['TRACKED', 'MANUAL', 'IDLE'].includes(type as TimeLogType);
};

export async function GET(req: Request) {
const res = new NextResponse();
const { searchParams } = new URL(req.url);

// Get required parameters from the URL
const activityLevelStart = searchParams.get('activityLevel[start]');
const activityLevelEnd = searchParams.get('activityLevel[end]');
const organizationId = searchParams.get('organizationId');
const tenantId = searchParams.get('tenantId');
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
const timeZone = searchParams.get('timeZone');
const groupBy = searchParams.get('groupBy');

// Get log types from URL and validate them
const logTypes = Array.from({ length: 10 }, (_, i) => searchParams.get(`logType[${i}]`))
.filter((logType): logType is string => logType !== null)
.filter(isValidLogType);

// Validate required parameters
if (!startDate || !endDate || !organizationId || !tenantId) {
return NextResponse.json(
{
Expand All @@ -22,28 +35,39 @@ export async function GET(req: Request) {
);
}

if (logTypes.length === 0) {
return NextResponse.json(
{
error: 'At least one valid logType is required (TRACKED, MANUAL, or IDLE)'
},
{ status: 400 }
);
}

// Authenticate the request
const { $res, user, access_token } = await authenticatedGuard(req, res);
if (!user) return $res('Unauthorized');

try {
const { data } = await getTimeLogReportDailyChartRequest({
// Make the request to get statistics counts
const { data } = await getTimesheetStatisticsCountsRequest({
activityLevel: {
start: parseInt(activityLevelStart || '0'),
end: parseInt(activityLevelEnd || '100')
},
logType: logTypes,
organizationId,
tenantId,
startDate,
endDate,
timeZone: timeZone || 'Etc/UTC',
groupBy: groupBy || 'date'
timeZone: timeZone || 'Etc/UTC'
}, access_token);

return NextResponse.json(data);
} catch (error) {
console.error('Error fetching daily chart data:', error);
console.error('Error fetching timesheet statistics counts:', error);
return NextResponse.json(
{ error: 'Failed to fetch daily chart data' },
{ error: 'Failed to fetch timesheet statistics counts' },
{ status: 500 }
);
}
Expand Down
Loading

0 comments on commit 99b69a8

Please sign in to comment.