Skip to content

Commit

Permalink
[Feat]: Timesheet Improvements (#3480)
Browse files Browse the repository at this point in the history
* fix(components): enhance CustomSelect component functionality and styling

* fix: correct time display format from AM/PM to 24h

* fix: correct time display format from AM/PM to 24h

* fix: deep scan

* fix: deep scan
  • Loading branch information
Innocent-Akim authored Dec 26, 2024
1 parent 05eaa04 commit 13e8403
Show file tree
Hide file tree
Showing 11 changed files with 152 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) {
}
await createTimesheet({
...payload,
startedAt,
stoppedAt,
startedAt: start,
stoppedAt: end,
});
})
);
Expand Down Expand Up @@ -182,12 +182,16 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) {
<span className="text-[#de5505e1] ml-1">*</span>:
</label>
<CustomSelect
classNameGroup='max-h-[40vh] !text-white '
valueKey='employeeId'
classNameGroup='max-h-[40vh] dark:!text-white '
ariaLabel='Task issues'
className='w-full font-medium text-white'
options={activeTeam?.members as any}
onChange={(value: any) => updateFormState('employeeId', value.id)}
renderOption={(option: any) => (
className='w-full font-medium dark:text-white'
options={activeTeam?.members || []}
onChange={(value) => {
console.log(value)
updateFormState('employeeId', value)
}}
renderOption={(option) => (
<div className="flex items-center gap-x-2">
<img className='h-6 w-6 rounded-full' src={option.employee.user.imageUrl} />
<span>{option.employee.fullName}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
</div>
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col w-full gap-y-2">
<AccordionContent className="flex flex-col w-full gap-y-2 ">
{rows.map((task) => (
<div
key={task.id}
Expand All @@ -134,12 +134,13 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati

}}
className={cn(
'border-l-4 rounded-l flex flex-col p-2 gap-2 items-start space-x-4 h-[100px]',
'border-l-4 rounded-l flex flex-col p-2 gap-2 items-start space-x-4 ',
)}>
<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 ?? ''}
className="w-[28px] h-[28px] drop-shadow-[0_4px_4px_rgba(0,0,0,0.25)] rounded-full"
/>
<span className=" font-normal text-[#3D5A80] dark:text-[#7aa2d8]">{task.employee.fullName}</span>
</div>
Expand All @@ -160,9 +161,16 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
dash
taskNumberClassName="text-sm"
/>
<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 className="flex flex-row items-center py-0 gap-2 flex-none order-2 self-stretch flex-grow-0">
{task.project?.imageUrl && (
<ProjectLogo
className="w-[28px] h-[28px] drop-shadow-[0_4px_4px_rgba(0,0,0,0.25)] rounded-[8px]"
imageUrl={task.project.imageUrl}
/>
)}
<span className="!text-ellipsis !overflow-hidden !truncate !text-[#3D5A80] dark:!text-[#7aa2d8] flex-1">
{task.project?.name ?? 'No Project'}
</span>
</div>
</div>
))}
Expand Down Expand Up @@ -258,7 +266,7 @@ const BaseCalendarDataView = ({ data, daysLabels, t, CalendarComponent }: BaseCa
<div className="flex flex-row items-center py-0 gap-2 flex-none order-2 self-stretch flex-grow-0">
{task.project?.imageUrl && (
<ProjectLogo
className="w-[28px] h-[28px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.15)] rounded-[8px]"
className="w-[28px] h-[28px] drop-shadow-[0_4px_4px_rgba(0,0,0,0.25)] rounded-[8px]"
imageUrl={task.project.imageUrl}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { Modal, statusColor } from "@/lib/components";
import { IoMdArrowDropdown } from "react-icons/io";
import { FaRegClock } from "react-icons/fa";
import { DatePickerFilter } from "./TimesheetFilterDate";
import { FormEvent, useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { clsxm } from "@/app/utils";
import { Item, ManageOrMemberComponent, getNestedValue } from "@/lib/features/manual-time/manage-member-component";
import { useOrganizationProjects } from "@/app/hooks";
import { useOrganizationProjects, useOrganizationTeams } from "@/app/hooks";
import { CustomSelect, TaskNameInfoDisplay } from "@/lib/features";
import { statusTable } from "./TimesheetAction";
import { TimesheetLog } from "@/app/interfaces";
import { secondsToTime } from "@/app/helpers";
import { differenceBetweenHours, formatTimeFromDate, secondsToTime, toDate } from "@/app/helpers";
import { useTimesheet } from "@/app/hooks/features/useTimesheet";
import { toast } from "@components/ui/use-toast";
import { ToastAction } from "@components/ui/toast";
import { ReloadIcon } from "@radix-ui/react-icons";
import { addMinutes, format, parseISO } from "date-fns";

export interface IEditTaskModalProps {
isOpen: boolean;
Expand All @@ -23,24 +23,27 @@ export interface IEditTaskModalProps {
}
export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskModalProps) {
const { organizationProjects } = useOrganizationProjects();
const { activeTeam } = useOrganizationTeams();
const t = useTranslations();
const { updateTimesheet, loadingUpdateTimesheet } = useTimesheet({})
const initialTimeRange = {
startTime: formatTimeFromDate(dataTimesheet.startedAt),
endTime: formatTimeFromDate(dataTimesheet.stoppedAt),
};

const [dateRange, setDateRange] = useState<{ date: Date | null }>({
date: dataTimesheet.timesheet?.startedAt ? new Date(dataTimesheet.timesheet.startedAt) : new Date(),
});
const seconds = differenceBetweenHours(toDate(dataTimesheet.startedAt), toDate(dataTimesheet.stoppedAt));
const { h: hours, m: minutes } = secondsToTime(seconds);

const { h: hours, m: minutes } = secondsToTime(dataTimesheet.timesheet.duration);

const [timeRange, setTimeRange] = useState<{ startTime: string; endTime: string }>({
startTime: dataTimesheet.timesheet?.startedAt
? dataTimesheet.timesheet.startedAt.toString().slice(0, 5)
: '',
endTime: dataTimesheet.timesheet?.stoppedAt
? dataTimesheet.timesheet.stoppedAt.toString().slice(0, 5)
: '',
});
const [timeRange, setTimeRange] = useState<{ startTime: string; endTime: string }>(initialTimeRange);

/**
* Updates the start or end time in the state based on the provided key and value.
* @param {string} key - The key of the time range to update. This can be either 'startTime' or 'endTime'.
* @param {string} value - The new value for the selected time range.
*/
const updateTime = (key: 'startTime' | 'endTime', value: string) => {
setTimeRange(prevState => ({
...prevState,
Expand All @@ -51,10 +54,16 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo
isBillable: dataTimesheet.isBillable ?? true,
projectId: dataTimesheet.project?.id || '',
notes: dataTimesheet.description || '',
employeeId: dataTimesheet.employeeId || '',
});
const memberItemsLists = {
Project: organizationProjects,
};
/**
* Updates the project id in the form state when a project is selected or deselected in the dropdown.
* @param {Object} values - An object with the selected values from the dropdown.
* @param {Item | null} values['Project'] - The selected project.
*/
const handleSelectedValuesChange = (values: { [key: string]: Item | null }) => {
setTimesheetData((prev) => ({
...prev,
Expand Down Expand Up @@ -96,10 +105,11 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo
...timeRange.endTime.split(':').map(Number)
)
);

const payload = {
id: dataTimesheet.id,
isBillable: timesheetData.isBillable,
employeeId: dataTimesheet.employeeId,
employeeId: timesheetData.employeeId,
logType: dataTimesheet.logType,
source: dataTimesheet.source,
startedAt,
Expand Down Expand Up @@ -150,6 +160,12 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo
const handleFromChange = (fromDate: Date | null) => {
setDateRange((prev) => ({ ...prev, date: fromDate }));
};
const getMinEndTime = (): string => {
if (!timeRange.startTime) return "00:00";
const startDate = parseISO(`2000-01-01T${timeRange.startTime}`);
return format(addMinutes(startDate, 5), 'HH:mm');
};

return (
<Modal
closeModal={closeModal}
Expand All @@ -170,16 +186,29 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo
/>
<div className="flex items-center gap-x-1 ">
<span className="text-gray-400">for</span>
<span className="text-primary dark:text-primary-light">{dataTimesheet.employee?.fullName ?? ""}</span>
<IoMdArrowDropdown className="cursor-pointer" />
<CustomSelect
defaultValue={dataTimesheet.employee.fullName}
placeholder={dataTimesheet.employee.fullName}
valueKey="employeeId"
className="border border-transparent hover:border-transparent dark:hover:border-transparent"
options={activeTeam?.members || []}
value={timesheetData.employeeId}
onChange={(value) => setTimesheetData({ ...timesheetData, employeeId: value.employeeId })}
renderOption={(option) => (
<div className="flex items-center gap-x-2">
<img className='h-6 w-6 rounded-full' src={option.employee.user.imageUrl} alt={option.employee.fullName} />
<span>{option.employee.fullName}</span>
</div>
)}
/>
</div>
</div>
<div className="flex items-start flex-col justify-center gap-4">
<div>
<span className="text-[#282048] dark:text-gray-500 ">{t('dailyPlan.TASK_TIME')}</span>
<span className="text-[#282048] dark:text-gray-500 capitalize ">{t('dailyPlan.TASK_TIME')}</span>
<div className="flex items-center gap-x-2 ">
<FaRegClock className="text-[#30B366]" />
<span>{hours}:{minutes} h</span>
<span>{String(hours).padStart(2, '0')}:{String(minutes).padStart(2, '0')} h</span>
</div>
</div>
<div className="flex items-center w-full">
Expand All @@ -189,13 +218,13 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo
<span className="text-[#de5505e1] ml-1">*</span>
</label>
<input
defaultValue={timeRange.startTime || "09:00"}
aria-label="Start time"
aria-describedby="start-time-error"
type="time"
min="00:00"
max="23:59"
pattern="[0-9]{2}:[0-9]{2}"
value={timeRange.startTime}
onChange={(e) => updateTime("startTime", e.target.value)}
className="w-full p-1 border font-normal border-slate-300 dark:border-slate-600 dark:bg-dark--theme-light rounded-md"
required
Expand All @@ -208,10 +237,11 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo
<span className="text-[#de5505e1] ml-1">*</span>
</label>
<input
defaultValue={timeRange.endTime || "10:00"}
aria-label="End time"
aria-describedby="end-time-error"
type="time"
value={timeRange.endTime}
min={getMinEndTime()}
onChange={(e) => updateTime('endTime', e.target.value)}
className="w-full p-1 border font-normal border-slate-300 dark:border-slate-600 dark:bg-dark--theme-light rounded-md"
required
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ export const TimesheetCardDetail = ({ data }: { data?: Record<TimesheetStatus, T
</div>
<Badge
variant={'outline'}
className="flex items-center gap-x-2 h-[25px] rounded-md bg-[#E4E4E7] dark:bg-gray-800"
className="box-border flex flex-row items-center px-2 py-1 gap-2 w-[108px] h-[30px] bg-[rgba(247,247,247,0.6)] border border-gray-300 rounded-lg flex-none order-1 flex-grow-0"
>
<span className="text-[#5f5f61]">{t('timer.TOTAL_HOURS')}</span>
<TotalTimeDisplay timesheetLog={rows} />
<span className="text-[#5f5f61]">{t('timer.TOTAL_HOURS').split(' ')[0]}{':'}</span>
<TotalTimeDisplay timesheetLog={rows} className='text-[#293241] text-[14px]' />
</Badge>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default TimesheetDetailModal




const MembersWorkedCard = ({ element, t }: { element: TimesheetLog[], t: TranslationHooks }) => {
const memberWork = groupBy(element, (items) => items.employeeId);
const memberWorkItems = Object.entries(memberWork)
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/[locale]/timesheet/[memberId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ const ViewToggleButton: React.FC<ViewToggleButtonProps> = ({ mode, active, icon,
<button
onClick={onClick}
className={clsxm(
'text-[#7E7991] font-medium w-[191px] h-[40px] flex items-center gap-x-4 text-[14px] px-2 rounded',
'box-border text-[#7E7991] font-medium w-[191px] h-[76px] flex items-center gap-x-4 text-[14px] px-2 py-6',
active &&
'border-b-primary text-primary border-b-2 dark:text-primary-light dark:border-b-primary-light bg-[#F1F5F9] dark:bg-gray-800 font-medium'
)}
Expand All @@ -280,3 +280,4 @@ const ViewToggleButton: React.FC<ViewToggleButtonProps> = ({ mode, active, icon,
<span>{mode === 'ListView' ? t('pages.timesheet.VIEWS.LIST') : t('pages.timesheet.VIEWS.CALENDAR')}</span>
</button>
);
ViewToggleButton.displayName = 'ViewToggleButton';
35 changes: 35 additions & 0 deletions apps/web/app/helpers/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,40 @@ export function differenceBetweenHours(startedAt: Date, stoppedAt: Date): number
}


/**
* Converts a given date string to a time string in the format HH:mm.
*
* This function takes an optional date string as input. If the input is not
* provided, the function returns an empty string. If the input is a valid date
* string, the function converts the string to a Date object, formats the time
* in the format HH:mm, and returns the result as a string.
*
* @param {string | undefined} dateString - The date string to format
* @returns {string} The formatted time string
*/
export const formatTimeFromDate = (date: string | Date | undefined) => {
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');

return `${hours}:${minutes}`;
};

/**
* Converts a given input to a Date object.
*
* This function accepts either a Date object or a string representation of a date.
* If the input is already a Date object, it returns the input as-is. If the input
* is a string, it converts the string to a Date object and returns the result.
*
* @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 function convertMsToTime(milliseconds: number) {
let seconds = Math.floor(milliseconds / 1000);
let minutes = Math.floor(seconds / 60);
Expand Down Expand Up @@ -142,6 +176,7 @@ 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];
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/hooks/features/useTimesheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function useTimesheet({
const response = await queryUpdateTimesheet(timesheet);
setTimesheet(prevTimesheet =>
prevTimesheet.map(item =>
item.timesheet?.id === response.data.id
item.id === response.data.id
? response.data
: item
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ export function ProjectDropDown(props: ITaskProjectDropdownProps) {
<>
<Listbox.Button
className={clsxm(
`cursor-pointer outline-none w-full flex
`cursor-pointer outline-none w-full flex dark:text-white
items-center justify-between px-4 h-full
border-solid border-color-[#F2F2F2]
dark:bg-[#1B1D22] dark:border dark:border-[#FFFFFF33] rounded-lg`,
Expand Down Expand Up @@ -408,7 +408,7 @@ export function ProjectDropDown(props: ITaskProjectDropdownProps) {
{organizationProjects.map((item, i) => {
return (
<Listbox.Option key={item.id} value={item} as={Fragment}>
<li className="relative border h-[2rem] flex items-center gap-1 px-2 rounded-lg outline-none cursor-pointer">
<li className="relative border h-[2rem] flex items-center gap-1 px-2 rounded-lg outline-none cursor-pointer dark:text-white">
<ProjectIcon width={14} height={14} />{' '}
<span className=" truncate">{item.name}</span>
</li>
Expand Down
Loading

0 comments on commit 13e8403

Please sign in to comment.