Skip to content

Commit

Permalink
[Feat]: Timesheet Calendar View UI (#3369)
Browse files Browse the repository at this point in the history
* feat:timesheet calendar view ui init

* feat:timesheet calendar view ui daily

* fix:cspell

* fix:cspell

* fix:coderabbitai

---------

Co-authored-by: Ruslan Konviser <evereq@gmail.com>
  • Loading branch information
Innocent-Akim and evereq authored Nov 27, 2024
1 parent 897ad04 commit aab335d
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 12 deletions.
140 changes: 138 additions & 2 deletions apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,143 @@
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 { TranslationHooks, useTranslations } from "next-intl";
import React from "react";
import { EmployeeAvatar } from "./CompactTimesheetComponent";
import { formatDate } from "@/app/helpers";
import { ClockIcon } from "lucide-react";

export function CalendarView() {
export function CalendarView({ data }: { data?: GroupedTimesheet[] }) {
const t = useTranslations();
return (
<div className='grow h-full w-full bg-[#FFFFFF] dark:bg-dark--theme'>
<div className="grow h-full w-full bg-[#FFFFFF] dark:bg-dark--theme">
{data ? (
data.length > 0 ? (
<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>
</div>
)
) : (
<div className="flex items-center justify-center h-full">
<p>{t('pages.timesheet.LOADING')}</p>
</div>
)}
</div>
);
}

const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: TranslationHooks }) => {
const { getStatusTimesheet } = useTimesheet({});

return (
<div className="w-full dark:bg-dark--theme">
<div className="rounded-md">
{data?.map((plan, index) => {
return <div key={index}>
<div
className={clsxm(
'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'
)}>
<div className='flex gap-x-3'>
<span>{formatDate(plan.date)}</span>
</div>
<div className="flex items-center gap-x-1">
<span className="text-[#868687]">Total{" : "}</span>

<TotalDurationByDate
timesheetLog={plan.tasks}
createdAt={formatDate(plan.date)}
className="text-black dark:text-gray-500 text-sm"
/>
</div>
</div>
<Accordion type="single" collapsible>
{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={clsxm(
'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="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-[14px]">({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">
{rows.map((task) => (
<div
key={task.id}
style={{
backgroundColor: statusColor(status).bgOpacity,
borderLeftColor: statusColor(status).border

}}
className={clsxm(
'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
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={clsxm(
'shadow-[0px_0px_15px_0px_#e2e8f0] dark:shadow-transparent'
)}
taskTitleClassName={clsxm(
'text-sm text-ellipsis overflow-hidden !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>
}
)}
</div>

</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
></path>
</svg>
);
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ export * from './TimesheetFilter';
export * from './FrequencySelect';
export * from './FilterWithStatus';
export * from './TimesheetFilterDate';
export * from './TimeSheetFilterPopover'
export * from './TimeSheetFilterPopover';
export * from './TimesheetAction';
export * from './RejectSelectedModal';
export * from './EditTaskModal';
export * from './TimesheetLoader'
export * from './CompactTimesheetComponent';
export * from './TimesheetLoader';

2 changes: 1 addition & 1 deletion apps/web/app/[locale]/timesheet/[memberId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb
<TimesheetView data={filterDataTimesheet}
loading={loadingTimesheet} />
) : (
<CalendarView />
<CalendarView data={filterDataTimesheet} />
)}
</div>
</Container>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/lib/components/types.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,45 @@
export type IVariant = 'primary' | 'outline' | 'ghost' | 'light' | 'dark';
type StatusColorScheme = {
bg: string;
border: string,
text: string;
bgOpacity: string;
};

const STATUS_COLORS: Record<string, StatusColorScheme> = {
PENDING: {
bg: 'bg-[#FBB650]',
border: 'rgb(251, 182, 80)',
text: 'text-[#FBB650]',
bgOpacity: 'rgba(251, 182, 80, 0.1)',
},
APPROVED: {
bg: 'bg-[#30B366]',
border: 'rgba(48, 179, 102)',
text: 'text-[#30B366]',
bgOpacity: 'rgba(48, 179, 102, 0.1)',
},
DENIED: {
bg: 'bg-[#dc2626]',
border: 'rgba(220, 38, 38)',
text: 'text-[#dc2626]',
bgOpacity: 'rgba(220, 38, 38, 0.1)',
},
DRAFT: {
bg: 'bg-gray-300',
border: 'rgba(220, 220, 220)',
text: 'text-gray-500',
bgOpacity: 'rgba(220, 220, 220, 0.1)',
},
'IN REVIEW': {
bg: 'bg-blue-500',
border: 'rgba(59, 130, 246)',
text: 'text-blue-500',
bgOpacity: 'rgba(59, 130, 246, 0.1)',
},
DEFAULT: {
bg: 'bg-gray-100',
border: 'rgba(243, 244, 246)',
text: 'text-gray-400',
bgOpacity: 'rgba(243, 244, 246, 0.1)',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
RejectSelectedModal,
StatusAction,
StatusType,
EmployeeAvatar,
getTimesheetButtons,
statusTable
} from '@/app/[locale]/timesheet/[memberId]/components';
Expand Down Expand Up @@ -335,10 +336,8 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) {
</div>
<span className="flex-1">{task.project && task.project.name}</span>
<div className="flex items-center flex-1 gap-x-2">
<img
className="w-8 h-8 rounded-full"
src={task.employee.user.imageUrl!}
alt=""
<EmployeeAvatar
imageUrl={task.employee.user.imageUrl!}
/>
<span className="flex-1 font-medium">{task.employee.fullName}</span>
</div>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/lib/features/task/task-displays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const DisplayTimeForTimesheet = ({ duration }: { duration: number }) => {
return (
<div className='flex items-center font-medium gap-x-1'>
<ClockIcon className='text-green-400 text-[14px] h-4 w-4' />
<div className='flex items-center'>
<div className='flex items-center text-[#282048] dark:text-[#9b8ae1]'>
{formatTime(hours, minute)}
</div>
</div>
Expand All @@ -104,15 +104,15 @@ TotalTimeDisplay.displayName = 'TotalTimeDisplay';


export const TotalDurationByDate = React.memo(
({ timesheetLog, createdAt }: { timesheetLog: TimesheetLog[]; createdAt: Date | string }) => {
({ timesheetLog, createdAt, className }: { timesheetLog: TimesheetLog[]; createdAt: Date | string, className?: string }) => {
const targetDateISO = new Date(createdAt).toISOString();
const filteredLogs = timesheetLog.filter(
(item) => formatDate(item.timesheet.createdAt) === formatDate(targetDateISO));
const totalDurationInSeconds = filteredLogs.reduce(
(total, log) => total + (log.timesheet?.duration || 0), 0);
const { h: hours, m: minutes } = secondsToTime(totalDurationInSeconds);
return (
<div className="flex items-center text-[#868688]">
<div className={clsxm("flex items-center text-[#868688]", className)}>
{formatTime(hours, minutes)}
</div>
);
Expand Down

0 comments on commit aab335d

Please sign in to comment.