Skip to content

Commit

Permalink
feat: Add MonthlyCalendarDataView component with dynamic calendar gen…
Browse files Browse the repository at this point in the history
…eration and timesheet grouping
  • Loading branch information
Innocent-Akim committed Dec 3, 2024
1 parent 1965f0b commit 2d58bfb
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 18 deletions.
134 changes: 116 additions & 18 deletions apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
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 { 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";


export function CalendarView({ data, loading }: { data?: GroupedTimesheet[], loading: boolean }) {
const t = useTranslations();
const { timesheetGroupByDays } = useTimelogFilterOptions();
// "Daily" | "Weekly" | "Monthly"
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} /> :
timesheetGroupByDays === 'Daily' ?
<CalendarDataView data={data} t={t} /> : null}
</>
) : (
<div className="flex items-center justify-center h-full min-h-[280px]">
<p>{t('pages.timesheet.NO_ENTRIES_FOUND')}</p>
Expand All @@ -40,7 +49,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 +72,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 +104,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,10 +121,10 @@ 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}
Expand All @@ -142,3 +148,95 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
</div>
)
}


const MonthlyCalendarDataView = ({ data }: { data: GroupedTimesheet[] }) => {
const { getStatusTimesheet } = useTimesheet({});
return (
<MonthlyTimesheetCalendar data={data}
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-[100px] !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>
<span className="flex-1">{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>
)}
</>
}} />
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { useMemo, useState, useCallback } from "react";
import { format, addMonths, eachDayOfInterval, startOfMonth, endOfMonth, addDays, Locale } from "date-fns";
import { GroupedTimesheet } from "@/app/hooks/features/useTimesheet";
import { fr, enGB } from 'date-fns/locale';
import { cn } from "@/lib/utils";
import { TotalDurationByDate } from "@/lib/features";
import { formatDate } from "@/app/helpers";

type MonthlyCalendarDataViewProps = {
data?: GroupedTimesheet[];
onDateClick?: (date: Date) => void;
renderDayContent?: (date: Date, plan?: GroupedTimesheet) => React.ReactNode;
locale?: Locale;
daysLabels?: string[];
noDataText?: string;
classNames?: {
container?: string;
header?: string;
grid?: string;
day?: string;
noData?: string;
};
};

const defaultDaysLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

const generateFullCalendar = (currentMonth: Date) => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const startDate = addDays(monthStart, -monthStart.getDay());
const endDate = addDays(monthEnd, 6 - monthEnd.getDay());
return eachDayOfInterval({ start: startDate, end: endDate });
};

const MonthlyTimesheetCalendar: React.FC<MonthlyCalendarDataViewProps> = ({
data = [],
onDateClick,
renderDayContent,
locale = enGB,
daysLabels = defaultDaysLabels,
noDataText = "No Data",
classNames = {}
}) => {
const [currentMonth, setCurrentMonth] = useState(new Date());
const calendarDates = useMemo(() => generateFullCalendar(currentMonth), [currentMonth]);
const groupedData = useMemo(
() => new Map(data.map((plan) => [format(new Date(plan.date), "yyyy-MM-dd"), plan])),
[data]
);

const handlePreviousMonth = useCallback(() => setCurrentMonth((prev) => addMonths(prev, -1)), []);
const handleNextMonth = useCallback(() => setCurrentMonth((prev) => addMonths(prev, 1)), []);

return (
<div className={classNames.container || "p-4 w-full"}>
{/* Header */}
<div className={classNames.header || "flex items-center justify-between mb-4"}>
<button
onClick={handlePreviousMonth}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 dark:bg-primary-light hover:dark:bg-primary-light"
>
Previous
</button>
<h2 className="text-xl font-bold">
{format(currentMonth, "MMMM yyyy", { locale: locale })}
</h2>
<button
onClick={handleNextMonth}
className="px-4 py-2 bg-gray-200 dark:bg-primary-light rounded hover:bg-gray-300 hover:dark:bg-primary-light"
>
Next
</button>
</div>

{/* Grid */}
<div className={classNames.grid || "grid grid-cols-7 text-center font-semibold text-gray-600"}>
{daysLabels.map((day) => (
<div key={day}>{day}</div>
))}
</div>
<div className="grid grid-cols-7 mt-2 w-full">
{calendarDates.map((date) => {
const formattedDate = format(date, "yyyy-MM-dd");
const plan = groupedData.get(formattedDate);
return (
<div
key={formattedDate}
className={cn(
classNames.day,
"border flex flex-col gap-2 relative shadow-sm rounded min-h-[150px] sm:w-[250px] md:w-[300px] lg:w-[350px] max-w-full", {
"bg-gray-100 dark:bg-gray-900": date.getMonth() !== currentMonth.getMonth(),
}
)}
onClick={() => onDateClick?.(date)}
>
<div className="px-2 flex items-center justify-between">
<span className="block text-gray-500 text-sm font-medium">
{format(date, "dd MMM yyyy")}
</span>
<div className="flex items-center gap-x-1 text-gray-500 text-sm font-medium">
<span className="text-[#868687]">Total{" : "}</span>
{plan && <TotalDurationByDate
timesheetLog={plan.tasks}
createdAt={formatDate(plan.date)}
className="text-black dark:text-gray-500 text-sm"
/>}
</div>
</div>
{renderDayContent ? (
renderDayContent(date, plan)
) : plan ? (
<div>{JSON.stringify(plan)}</div>
) : (
<div className={classNames.noData || "text-gray-400 text-sm"}>
{noDataText}
</div>
)}
</div>
);
})}
</div>
</div>
);
};

export default MonthlyTimesheetCalendar;
1 change: 1 addition & 0 deletions apps/web/lib/features/task/task-displays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ TotalTimeDisplay.displayName = 'TotalTimeDisplay';

export const TotalDurationByDate = React.memo(
({ timesheetLog, createdAt, className }: { timesheetLog: TimesheetLog[]; createdAt: Date | string, className?: string }) => {
console.log("========================>", createdAt)
const targetDateISO = new Date(createdAt).toISOString();
const filteredLogs = timesheetLog.filter(
(item) => formatDate(item.timesheet.createdAt) === formatDate(targetDateISO));
Expand Down

0 comments on commit 2d58bfb

Please sign in to comment.