Skip to content

Commit

Permalink
[Feat]: Add MonthlyCalendarDataView and WeeklyCalendarDataView for di…
Browse files Browse the repository at this point in the history
…splaying timesheet (#3388)

* feat: Add MonthlyCalendarDataView component with dynamic calendar generation and timesheet grouping

* fix: deepscan

* fix:coderabbitai

* fix:coderabbitai

* feat(calendar): add weekly view with navigation

* feat: coderabbitai

* refactor: Combine Monthly and Weekly views into a single component

* feat:feat: add  day  translation keys for localization
  • Loading branch information
Innocent-Akim authored Dec 4, 2024
1 parent 8db23c8 commit 88fc30c
Show file tree
Hide file tree
Showing 23 changed files with 619 additions and 58 deletions.
167 changes: 147 additions & 20 deletions apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,47 @@
import { GroupedTimesheet, useTimesheet } from "@/app/hooks/features/useTimesheet";
import { clsxm } from "@/app/utils";
import { statusColor } from "@/lib/components";
import { DisplayTimeForTimesheet, TaskNameInfoDisplay, TotalDurationByDate, TotalTimeDisplay } from "@/lib/features";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@components/ui/accordion";
import { Accordion } from "@radix-ui/react-accordion";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@components/ui/accordion";
import { TranslationHooks, useTranslations } from "next-intl";
import React from "react";
import { EmployeeAvatar } from "./CompactTimesheetComponent";
import { EmployeeAvatar, ProjectLogo } from "./CompactTimesheetComponent";
import { formatDate } from "@/app/helpers";
import { ClockIcon } from "lucide-react";
import { ClockIcon, CodeSquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import MonthlyTimesheetCalendar from "./MonthlyTimesheetCalendar";
import { useTimelogFilterOptions } from "@/app/hooks";
import WeeklyTimesheetCalendar from "./WeeklyTimesheetCalendar";
interface BaseCalendarDataViewProps {
data: GroupedTimesheet[];
daysLabels?: string[];
CalendarComponent: typeof MonthlyTimesheetCalendar | typeof WeeklyTimesheetCalendar;
}

export function CalendarView({ data, loading }: { data?: GroupedTimesheet[], loading: boolean }) {
const t = useTranslations();
const { timesheetGroupByDays } = useTimelogFilterOptions();
const defaultDaysLabels = [
t("common.DAYS.sun"),
t("common.DAYS.mon"),
t("common.DAYS.tue"),
t("common.DAYS.wed"),
t("common.DAYS.thu"),
t("common.DAYS.fri"),
t("common.DAYS.sat")
];
return (
<div className="grow h-full w-full bg-[#FFFFFF] dark:bg-dark--theme">
<div className="grow h-full bg-[#FFFFFF] dark:bg-dark--theme">
{data ? (
data.length > 0 ? (
<CalendarDataView data={data} t={t} />
<>
{timesheetGroupByDays === 'Monthly' ? (
<MonthlyCalendarDataView data={data} daysLabels={defaultDaysLabels} />
) : timesheetGroupByDays === 'Weekly' ? (
<WeeklyCalendarDataView data={data} daysLabels={defaultDaysLabels} />
) : (
<CalendarDataView data={data} t={t} />
)}
</>
) : (
<div className="flex items-center justify-center h-full min-h-[280px]">
<p>{t('pages.timesheet.NO_ENTRIES_FOUND')}</p>
Expand All @@ -40,7 +65,7 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
{data?.map((plan, index) => {
return <div key={index}>
<div
className={clsxm(
className={cn(
'h-[40px] flex justify-between items-center w-full',
'bg-[#ffffffcc] dark:bg-dark--theme rounded-md border-1',
'border-gray-400 px-5 text-[#71717A] font-medium'
Expand All @@ -63,18 +88,16 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
rows.length > 0 && status && <AccordionItem
key={status}
value={status === 'DENIED' ? 'REJECTED' : status}
className="p-1 rounded"
>
className="p-1 rounded" >
<AccordionTrigger
type="button"
className={clsxm(
className={cn(
'flex flex-row-reverse justify-end items-center w-full h-[30px] rounded-sm gap-x-2 hover:no-underline px-2',
statusColor(status).text
)}
>
)}>
<div className="flex items-center justify-between space-x-1 w-full">
<div className="flex items-center w-full gap-2">
<div className={clsxm('p-2 rounded', statusColor(status).bg)}></div>
<div className={cn('p-2 rounded', statusColor(status).bg)}></div>
<div className="flex items-center gap-x-1">
<span className="text-base font-normal text-gray-400 uppercase text-[12px]">
{status === 'DENIED' ? 'REJECTED' : status}
Expand All @@ -97,10 +120,9 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
borderLeftColor: statusColor(status).border

}}
className={clsxm(
className={cn(
'border-l-4 rounded-l flex flex-col p-2 gap-2 items-start space-x-4 h-[100px]',
)}
>
)}>
<div className="flex px-3 justify-between items-center w-full">
<div className="flex items-center gap-x-1">
<EmployeeAvatar
Expand All @@ -115,17 +137,18 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
</div>
<TaskNameInfoDisplay
task={task.task}
className={clsxm(
className={cn(
'shadow-[0px_0px_15px_0px_#e2e8f0] dark:shadow-transparent'
)}
taskTitleClassName={clsxm(
taskTitleClassName={cn(
'text-sm text-ellipsis overflow-hidden !text-[#293241] dark:!text-white '
)}
showSize={true}
dash
taskNumberClassName="text-sm"
/>
<div>
<div className="flex items-center gap-2">
{task.project && <ProjectLogo imageUrl={task.project.imageUrl as string} />}
<span className="flex-1">{task.project && task.project.name}</span>
</div>
</div>
Expand All @@ -142,3 +165,107 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
</div>
)
}

const BaseCalendarDataView = ({ data, daysLabels, CalendarComponent }: BaseCalendarDataViewProps) => {
const { getStatusTimesheet } = useTimesheet({});
return (
<CalendarComponent
data={data}
// locale={ }
daysLabels={daysLabels}
renderDayContent={(date, plan) => {
return <>
{plan ? (
<Accordion type="single" collapsible className="w-full">
{Object.entries(getStatusTimesheet(plan.tasks)).map(([status, rows]) => (
rows.length > 0 && status && <AccordionItem
key={status}
value={status === 'DENIED' ? 'REJECTED' : status}
className="p-1 rounded" >
<AccordionTrigger
type="button"
className={cn(
'flex flex-row-reverse justify-end items-center w-full !h-[20px] rounded-sm gap-x-2 hover:no-underline',
statusColor(status).text
)}>
<div className="flex items-center justify-between space-x-1 w-full">
<div className="flex items-center w-full gap-2">
<div className={cn('p-2 rounded', statusColor(status).bg)}></div>
<div className="flex items-center gap-x-1">
<span className="text-base font-normal text-gray-400 uppercase text-[12px]">
{status === 'DENIED' ? 'REJECTED' : status}
</span>
<span className="text-gray-400 text-[12px]">({rows.length})</span>
</div>
</div>
<div className="flex items-center space-x-2">
<ClockIcon className=' text-[12px] h-3 w-3' />
<TotalTimeDisplay timesheetLog={rows} />
</div>
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col w-full gap-y-2 overflow-auto">
{rows.map((task) => (
<div
key={task.id}
style={{
backgroundColor: statusColor(status).bgOpacity,
borderLeftColor: statusColor(status).border

}}
className={cn(
'border-l-4 rounded-l flex flex-col p-2 gap-2 items-start space-x-4 h-[110px] !w-full',
)}>
<div className="flex px-3 justify-between items-center w-full">
<div className="flex items-center gap-x-1">
<EmployeeAvatar
imageUrl={task.employee.user.imageUrl ?? ''}
/>
<span className=" font-normal text-[#3D5A80] dark:text-[#7aa2d8]">{task.employee.fullName}</span>
</div>
<DisplayTimeForTimesheet
duration={task.timesheet.duration}

/>
</div>
<TaskNameInfoDisplay
task={task.task}
className={cn(
'shadow-[0px_0px_15px_0px_#e2e8f0] dark:shadow-transparent'
)}
taskTitleClassName={cn(
'text-sm !text-ellipsis !overflow-hidden !truncate !text-[#293241] dark:!text-white '
)}
showSize={true}
dash
taskNumberClassName="text-sm"
/>
<div className="flex items-center gap-x-2">
{task.project && <ProjectLogo imageUrl={task.project.imageUrl as string} />}
<span className="flex-1 font-medium">{task.project && task.project.name}</span>
</div>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<div className="text-gray-400 text-sm flex items-center justify-center min-h-[150px] sm:w-[250px] md:w-[300px] lg:w-[350px] max-w-full gap-2">
<CodeSquareIcon />
<span>No Data</span>
</div>
)}
</>
}}
/>
);
};

const MonthlyCalendarDataView = (props: { data: GroupedTimesheet[], daysLabels?: string[] }) => (
<BaseCalendarDataView {...props} CalendarComponent={MonthlyTimesheetCalendar} />
);

const WeeklyCalendarDataView = (props: { data: GroupedTimesheet[], daysLabels?: string[] }) => (
<BaseCalendarDataView {...props} CalendarComponent={WeeklyTimesheetCalendar} />
);
Original file line number Diff line number Diff line change
@@ -1,27 +1,5 @@
import React from "react";

export const EmployeeAvatar = ({ imageUrl }: { imageUrl: string }) => {
const [isLoading, setIsLoading] = React.useState(true);

return (
<div className="relative w-6 h-6">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-200 rounded-full">
<LoadingSpinner className="w-4 h-4" />
</div>
)}
<img
className="w-6 h-6 rounded-full"
src={imageUrl}
alt="Employee"
onLoad={() => setIsLoading(false)}
onError={() => setIsLoading(false)}
/>
</div>
);
};


const LoadingSpinner = ({ className }: { className?: string }) => (
<svg
className={`animate-spin text-gray-500 ${className}`}
Expand All @@ -44,3 +22,33 @@ const LoadingSpinner = ({ className }: { className?: string }) => (
></path>
</svg>
);

const ImageWithLoader = ({ imageUrl, alt, className = "w-6 h-6 rounded-full" }:
{ imageUrl: string; alt: string; className?: string }) => {
const [isLoading, setIsLoading] = React.useState(true);
return (
<div className="relative w-6 h-6">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-200 rounded-full">
<LoadingSpinner className="w-4 h-4" />
</div>
)}
<img
className={className}
src={imageUrl}
alt={alt}
onLoad={() => setIsLoading(false)}
onError={() => setIsLoading(false)}
/>
</div>
);
};

export const EmployeeAvatar = ({ imageUrl }: { imageUrl: string }) => (
<ImageWithLoader imageUrl={imageUrl} alt="Employee" />
);


export const ProjectLogo = ({ imageUrl }: { imageUrl: string }) => (
<ImageWithLoader imageUrl={imageUrl} alt="Project Logo" />
);
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo
valueKey: 'id',
displayKey: 'name',
element: 'Project',
defaultValue: dataTimesheet.project.name
defaultValue: 'name'

},
];
Expand Down
Loading

0 comments on commit 88fc30c

Please sign in to comment.