diff --git a/apps/extensions/yarn.lock b/apps/extensions/yarn.lock index 3e4478338..1ed1597fd 100644 --- a/apps/extensions/yarn.lock +++ b/apps/extensions/yarn.lock @@ -4042,9 +4042,9 @@ mz@^2.7.0: thenify-all "^1.0.0" nanoid@^3.3.6: - version "3.3.6" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" - integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== napi-build-utils@^1.0.1: version "1.0.2" diff --git a/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx b/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx index 032b830c9..5804fddd6 100644 --- a/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx +++ b/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx @@ -59,7 +59,7 @@ export function GroupBySelect({ defaultValues, onChange }: IProps) { return (
- +
{selected.map((option) => ( - + {options.map((option, index) => ( + className={({ active, selected }) => `relative cursor-default select-none py-2 pl-10 pr-4 ${ - active ? 'bg-primary/10 text-primary' : 'text-gray-900' - }` + active + ? 'bg-primary/10 text-primary dark:text-white dark:bg-primary/10' + : 'text-gray-900 dark:text-white' + } ${selected && 'dark:bg-primary/10'}` } value={option} > diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx index 3893337aa..7b9159c14 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx @@ -12,6 +12,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@c import { DatePickerFilter } from './TimesheetFilterDate'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@components/ui/select'; import { useTimesheet } from '@/app/hooks/features/useTimesheet'; +import { toUTC } from '@/app/helpers'; export interface IAddTaskModalProps { isOpen: boolean; closeModal: () => void; @@ -22,6 +23,18 @@ interface Shift { totalHours: string; dateFrom: Date | string, } +interface FormState { + isBillable: boolean; + notes: string; + projectId: string; + taskId: string; + employeeId: string; + shifts: { + dateFrom: Date; + startTime: string; + endTime: string; + }[]; +} export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { const { tasks } = useTeamTasks(); @@ -32,6 +45,7 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { const timeOptions = generateTimeOptions(5); const t = useTranslations(); + const [formState, setFormState] = React.useState({ notes: '', isBillable: true, @@ -77,7 +91,12 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { }, ]; - const handleAddTimesheet = async () => { + const createUtcDate = (baseDate: Date, time: string): Date => { + const [hours, minutes] = time.split(':').map(Number); + return new Date(Date.UTC(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), hours, minutes)); + }; + + const handleAddTimesheet = async (formState: FormState) => { const payload = { isBillable: formState.isBillable, description: formState.notes, @@ -85,29 +104,40 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { logType: TimeLogType.MANUAL as any, source: TimerSource.BROWSER as any, taskId: formState.taskId, - employeeId: formState.employeeId - } - const createUtcDate = (baseDate: Date, time: string): Date => { - const [hours, minutes] = time.split(':').map(Number); - return new Date(Date.UTC(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), hours, minutes)); + employeeId: formState.employeeId, + organizationContactId: null || "", + organizationTeamId: null, }; - try { - await Promise.all(formState.shifts.map(async (shift) => { - const baseDate = shift.dateFrom instanceof Date ? shift.dateFrom : new Date(shift.dateFrom ?? new Date()); - const startedAt = createUtcDate(baseDate, shift.startTime.toString().slice(0, 5)); - const stoppedAt = createUtcDate(baseDate, shift.endTime.toString().slice(0, 5)); - await createTimesheet({ - ...payload, - startedAt, - stoppedAt, - }); - })); - closeModal(); + if (!formState.shifts || formState.shifts.length === 0) { + throw new Error('No shifts provided.'); + } + await Promise.all( + formState.shifts.map(async (shift) => { + if (!shift.dateFrom || !shift.startTime || !shift.endTime) { + throw new Error('Incomplete shift data.'); + } + + const baseDate = shift.dateFrom instanceof Date ? shift.dateFrom : new Date(shift.dateFrom); + const start = createUtcDate(baseDate, shift.startTime); + const end = createUtcDate(baseDate, shift.endTime); + const startedAt = toUTC(start).toISOString(); + const stoppedAt = toUTC(end).toISOString(); + if (stoppedAt <= startedAt) { + throw new Error('End time must be after start time.'); + } + await createTimesheet({ + ...payload, + startedAt, + stoppedAt, + }); + }) + ); + console.log('Timesheets successfully created.'); } catch (error) { console.error('Failed to create timesheet:', error); } - } + }; return ( *: updateFormState('employeeId', value.id)} renderOption={(option: any) => (
+ {option.employee.fullName}
)} @@ -235,7 +266,7 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) {
- + {rows.map((task) => (
-
+
- {task.employee.fullName} + {task.employee.fullName}
-
- {task.project && } - {task.project && task.project.name} +
+ {task.project?.imageUrl && ( + + )} + + {task.project?.name ?? 'No Project'} +
))} diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx index 37cfebc81..b2a3232de 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx @@ -27,7 +27,7 @@ const ImageWithLoader = ({ imageUrl, alt, className = "w-6 h-6 rounded-full" }: { imageUrl: string; alt: string; className?: string }) => { const [isLoading, setIsLoading] = React.useState(true); return ( -
+
{isLoading && (
@@ -44,11 +44,11 @@ const ImageWithLoader = ({ imageUrl, alt, className = "w-6 h-6 rounded-full" }: ); }; -export const EmployeeAvatar = ({ imageUrl }: { imageUrl: string }) => ( - +export const EmployeeAvatar = ({ imageUrl, className }: { imageUrl: string, className?: string }) => ( + ); -export const ProjectLogo = ({ imageUrl }: { imageUrl: string }) => ( - +export const ProjectLogo = ({ imageUrl, className }: { imageUrl: string, className?: string }) => ( + ); diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/FilterWithStatus.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/FilterWithStatus.tsx index 48834ec8a..f4f41b715 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/FilterWithStatus.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/FilterWithStatus.tsx @@ -49,15 +49,15 @@ export function FilterWithStatus({ return (
{buttonData.map(({ label, count, icon }, index) => (
))} diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx index d02ca47ce..139fdb610 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetDetailModal.tsx @@ -1,35 +1,63 @@ import { TimesheetLog, TimesheetStatus } from '@/app/interfaces'; -import { Modal } from '@/lib/components'; +import { Modal, statusColor } from '@/lib/components'; import React from 'react' import { TimesheetCardDetail } from './TimesheetCard'; -import { useTranslations } from 'next-intl'; +import { TranslationHooks, useTranslations } from 'next-intl'; +import { TimesheetDetailMode } from '../page'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@components/ui/accordion'; +import { cn } from '@/lib/utils'; +import { useTimesheet } from '@/app/hooks/features/useTimesheet'; +import { Badge } from '@components/ui/badge'; +import { TaskNameInfoDisplay, TotalTimeDisplay } from '@/lib/features'; +import { EmployeeAvatar, ProjectLogo } from './CompactTimesheetComponent'; +import { groupBy } from '@/app/helpers/array-data'; export interface IAddTaskModalProps { isOpen: boolean; closeModal: () => void; timesheet?: Record + timesheetDetailMode?: TimesheetDetailMode } -function TimesheetDetailModal({ closeModal, isOpen, timesheet }: IAddTaskModalProps) { +function TimesheetDetailModal({ closeModal, isOpen, timesheet, timesheetDetailMode }: IAddTaskModalProps) { const t = useTranslations() + const titles = { + 'Pending': 'View Pending Details', + 'MemberWork': 'View Member Work Details', + }; + const title = titles[timesheetDetailMode as 'Pending' | 'MemberWork'] || 'View Men Hours Details'; + const timesheetDetail = Object.values(timesheet ?? {}).flat(); + + return ( + titleClass="font-bold flex justify-start w-full text-xl">
- { - timesheet?.PENDING.length === 0 ? ( -
-

{t('pages.timesheet.NO_ENTRIES_FOUND')}

-
- ) : - } + {(() => { + switch (timesheetDetailMode) { + case 'Pending': + return timesheet?.PENDING.length === 0 ? ( +
+

{t('pages.timesheet.NO_ENTRIES_FOUND')}

+
+ ) : ( + + ); + case 'MemberWork': + return ; + case 'MenHours': + return ; + default: + return null; + } + })()}
@@ -37,5 +65,251 @@ function TimesheetDetailModal({ closeModal, isOpen, timesheet }: IAddTaskModalPr ) } - export default TimesheetDetailModal + + + +const MembersWorkedCard = ({ element, t }: { element: TimesheetLog[], t: TranslationHooks }) => { + const memberWork = groupBy(element, (items) => items.employeeId); + const memberWorkItems = Object.entries(memberWork) + .map(([employeeId, element]) => ({ employeeId, element })) + .sort((a, b) => b.employeeId.localeCompare(a.employeeId)); + + const { getStatusTimesheet } = useTimesheet({}); + return ( +
+ {memberWorkItems.map((timesheet, index) => { + return ( + + + +
+
+ + {timesheet.element[0].employee.fullName} +
+ + {t('timer.TOTAL_HOURS').split(' ')[0]}: + + +
+
+ + + {Object.entries(getStatusTimesheet(timesheet.element)).map(([status, rows]) => { + return rows.length > 0 && status && ( + +
+
+
+
+ + {status === 'DENIED' ? 'REJECTED' : status} + + ({rows.length}) +
+ + {t('timer.TOTAL_HOURS').split(' ')[0]}: + + +
+ +
+
+ + {rows.map((items) => ( +
+
+ +
+
+ {items.project?.imageUrl && } + {items.project?.name} +
+
+ ))} +
+
+ ) + })} +
+
+
+
+ ) + }) + } +
+ ) +} + + +interface MenHoursCardProps { + element: TimesheetLog[]; + t: TranslationHooks; +} + +/** + * @param {MenHoursCardProps} props - the component props + * @returns {JSX.Element} - the rendered component + */ +const MenHoursCard = ({ element, t }: MenHoursCardProps) => { + const menHours = groupBy(element, (items) => items.timesheet.status); + const menHoursItems = Object.entries(menHours) + .map(([status, element]) => ({ status, element })) + .sort((a, b) => b.status.localeCompare(a.status)); + + const { getStatusTimesheet } = useTimesheet({}); + + return ( +
+ {menHoursItems.map((timesheet, index) => { + return ( + + + +
+
+
+ {timesheet.element[0].timesheet.status === 'DENIED' ? 'REJECTED' : timesheet.element[0].timesheet.status} +
+ + {t('timer.TOTAL_HOURS').split(' ')[0]}: + + +
+
+ + + {Object.entries(getStatusTimesheet(timesheet.element)).map(([status, rows]) => { + return rows.length > 0 && status && ( + +
+
+
+
+ + {status === 'DENIED' ? 'REJECTED' : status} + + ({rows.length}) +
+ + {t('timer.TOTAL_HOURS').split(' ')[0]}: + + +
+ +
+
+ + {rows.map((items) => ( +
+
+ +
+
+ {items.project?.imageUrl && } + {items.project?.name} +
+
+ ))} +
+
+ ) + })} +
+
+
+
+ ) + }) + } +
+ ); +}; diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx index 4cff8b6c1..d4cd7aba5 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx @@ -5,6 +5,7 @@ import { TranslationHooks } from 'next-intl'; import { AddTaskModal } from './AddTaskModal'; import { IUser, TimesheetLog, TimesheetStatus } from '@/app/interfaces'; import { useTimelogFilterOptions } from '@/app/hooks'; +import { PlusIcon } from './TimesheetIcons' interface ITimesheetFilter { isOpen: boolean, @@ -48,6 +49,7 @@ export function TimesheetFilter({ closeModal, isOpen, openModal, t, initDate, fi onClick={openModal} variant="outline" className="bg-primary/5 dark:bg-primary-light dark:border-transparent !h-[2.2rem] font-medium"> + {t('common.ADD_TIME')} diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetIcons.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetIcons.tsx new file mode 100644 index 000000000..654782aaa --- /dev/null +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetIcons.tsx @@ -0,0 +1,159 @@ + +/** + * MemberWorkIcon component + * + * A timesheet icon representing a member that has worked + * + * @example + * + */ +export const MemberWorkIcon = () => + + + + + + +export const MenHoursIcon = () => + + + + +/** + * PendingTaskIcon + * + * Renders a pending task icon. + * + * @returns {React.ReactElement} - pending task icon + */ +export const PendingTaskIcon = () => + + + + + + + + +/** + * ListViewIcon + * + * Renders a list view icon. + * + * @returns {React.ReactElement} - list view icon + */ +export const ListViewIcon = () => + + + + + + + + + + +/** + * CalendarViewIcon + * + * Renders a calendar view icon. + * + * @returns {React.ReactElement} - SVG element representing a calendar view icon + */ + +export const CalendarViewIcon = () => + + + + + + + + + +/** + * FilterIcon + * + * Renders a filter icon. + * + * @returns {React.ReactElement} - SVG element representing a filter icon + */ +export const FilterIcon = () => + + + + + + + + + + + + +/** + * SearchIcon + * + * Renders a search icon using SVG. + * + * @returns {React.ReactElement} - The rendered search icon component. + */ + +export const SearchIcon = () => + + + + +/** + * ApproveSelectedIcon + * + * Renders an approve selected icon using SVG. + * + * @returns {React.ReactElement} - The rendered approve selected icon component. + */ +export const ApproveSelectedIcon = ({ className }: { className?: string }) => + + + + + + +/** + * RejectSelectedIcon + * + * Renders a reject selected icon using SVG. + * + * @returns {React.ReactElement} - The rendered reject selected icon component. + */ +export const RejectSelectedIcon = ({ className }: { className?: string }) => + + + + + +/** + * DeleteSelectedIcon + * + * Renders a delete selected icon using SVG. + * + * @returns {React.ReactElement} - The rendered delete selected icon component. + */ + +export const DeleteSelectedIcon = ({ className }: { className?: string }) => + + + + + + +/** + * PlusIcon + * + * Renders a plus icon using SVG. + * + * @returns {React.ReactElement} - The rendered plus icon component. + */ +export const PlusIcon = () => + + + diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx index 3fe53b726..4719c2c39 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx @@ -7,7 +7,7 @@ import { TotalDurationByDate } from "@/lib/features"; import { formatDate } from "@/app/helpers"; import { TranslationHooks } from "next-intl"; -type WeeklyCalendarProps = { +export type WeeklyCalendarProps = { t: TranslationHooks data?: GroupedTimesheet[]; onDateClick?: (date: Date) => void; @@ -32,6 +32,24 @@ const generateWeek = (currentDate: Date) => { return eachDayOfInterval({ start: weekStart, end: weekEnd }); }; +/** + * A weekly calendar component for displaying timesheet data. + * + * The component is a grid of days in the week, with each day displaying the total duration of the tasks for that day. + * The component is also responsive and can be used in a variety of screen sizes. + * + * @param {WeeklyCalendarProps} props - The props for the component. + * @param {GroupedTimesheet[]} [props.data=[]] - The data to display in the calendar. + * @param {((date: Date) => void)} [props.onDateClick] - The function to call when a date is clicked. + * @param {((date: Date, plan?: GroupedTimesheet) => React.ReactNode)} [props.renderDayContent] - The function to call to render the content for each day. + * @param {Locale} [props.locale=enGB] - The locale to use for the dates. + * @param {string[]} [props.daysLabels=defaultDaysLabels] - The labels for the days of the week. + * @param {string} [props.noDataText="No Data"] - The text to display when there is no data for a day. + * @param {{ container?: string; header?: string; grid?: string; day?: string; noData?: string; }} [props.classNames={}] - The CSS class names to use for the component. + * @param {TranslationHooks} props.t - The translations to use for the component. + * + * @returns {React.ReactElement} The JSX element for the component. + */ const WeeklyTimesheetCalendar: React.FC = ({ data = [], onDateClick, @@ -117,12 +135,12 @@ const WeeklyTimesheetCalendar: React.FC = ({ {format(date, "dd MMM yyyy")}
- Total{" : "} + {/* Total{" : "} */} {plan && ( )}
diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/index.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/index.tsx index f8475978f..8b22c95d1 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/index.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/index.tsx @@ -12,3 +12,4 @@ export * from './EditTaskModal'; export * from './CompactTimesheetComponent'; export * from './TimesheetLoader'; export * from './SelectionBar' +export * from './TimesheetIcons'; diff --git a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx index f332dfec3..cf0f37493 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx @@ -14,17 +14,16 @@ import { fullWidthState } from '@app/stores/fullWidth'; import { useAtomValue } from 'jotai'; import { ArrowLeftIcon } from 'assets/svg'; -import { CalendarView, FilterStatus, TimesheetCard, TimesheetFilter, TimesheetView } from './components'; -import { CalendarDaysIcon, Clock, User2 } from 'lucide-react'; -import { GrTask } from 'react-icons/gr'; +import { CalendarView, CalendarViewIcon, FilterStatus, ListViewIcon, MemberWorkIcon, MenHoursIcon, PendingTaskIcon, TimesheetCard, TimesheetFilter, TimesheetView } from './components'; import { GoSearch } from 'react-icons/go'; -import { getGreeting, secondsToTime } from '@/app/helpers'; +import { differenceBetweenHours, getGreeting, secondsToTime } from '@/app/helpers'; import { useTimesheet } from '@/app/hooks/features/useTimesheet'; -import { startOfWeek, endOfWeek } from 'date-fns'; +import { endOfMonth, startOfMonth } from 'date-fns'; import TimesheetDetailModal from './components/TimesheetDetailModal'; type TimesheetViewMode = 'ListView' | 'CalendarView'; +export type TimesheetDetailMode = 'Pending' | 'MenHours' | 'MemberWork'; type ViewToggleButtonProps = { mode: TimesheetViewMode; @@ -42,15 +41,17 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb const { isTrackingEnabled, activeTeam } = useOrganizationTeams(); const [search, setSearch] = useState(''); const [filterStatus, setFilterStatus] = useLocalStorageState('timesheet-filter-status', 'All Tasks'); + const [timesheetDetailMode, setTimesheetDetailMode] = useLocalStorageState('timesheet-detail-mode', 'Pending'); const [timesheetNavigator, setTimesheetNavigator] = useLocalStorageState( 'timesheet-viewMode', 'ListView' ); const [dateRange, setDateRange] = React.useState<{ from: Date | null; to: Date | null }>({ - from: startOfWeek(new Date(), { weekStartsOn: 1 }), - to: endOfWeek(new Date(), { weekStartsOn: 1 }), + from: startOfMonth(new Date()), + to: endOfMonth(new Date()), }); + const { timesheet, statusTimesheet, loadingTimesheet, isManage } = useTimesheet({ startDate: dateRange.from!, endDate: dateRange.to!, @@ -91,14 +92,21 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb closeModal: closeTimesheetDetail } = useModal(); + const username = user?.name || user?.firstName || user?.lastName || user?.username; const totalDuration = Object.values(statusTimesheet) .flat() - .map(entry => entry.timesheet.duration) + .map(entry => { + return differenceBetweenHours( + entry.startedAt instanceof Date ? entry.startedAt : new Date(entry.startedAt), + entry.stoppedAt instanceof Date ? entry.stoppedAt : new Date(entry.stoppedAt) + ) + }) .reduce((total, current) => total + current, 0); const { h: hours, m: minute } = secondsToTime(totalDuration || 0); + const fullWidth = useAtomValue(fullWidthState); const paramsUrl = useParams<{ locale: string }>(); @@ -118,8 +126,8 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb closeModal={closeTimesheetDetail} isOpen={isTimesheetDetailOpen} timesheet={statusTimesheet} + timesheetDetailMode={timesheetDetailMode} />} - } + icon={} classNameIcon="bg-[#FBB650] shadow-[#fbb75095]" - onClick={() => openTimesheetDetail()} + onClick={() => { + setTimesheetDetailMode('Pending') + openTimesheetDetail() + }} /> } + icon={} classNameIcon="bg-[#3D5A80] shadow-[#3d5a809c] " + onClick={() => { + setTimesheetDetailMode('MenHours') + openTimesheetDetail() + }} /> {isManage && (} + icon={} classNameIcon="bg-[#30B366] shadow-[#30b3678f]" + onClick={() => { + setTimesheetDetailMode('MemberWork') + openTimesheetDetail() + }} />)}
} + icon={} mode="ListView" active={timesheetNavigator === 'ListView'} onClick={() => setTimesheetNavigator('ListView')} t={t} /> } + icon={} mode="CalendarView" active={timesheetNavigator === 'CalendarView'} onClick={() => setTimesheetNavigator('CalendarView')} @@ -254,7 +273,7 @@ const ViewToggleButton: React.FC = ({ mode, active, icon, className={clsxm( 'text-[#7E7991] font-medium w-[191px] h-[40px] flex items-center gap-x-4 text-[14px] px-2 rounded', 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-bold' + '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' )} > {icon} diff --git a/apps/web/app/helpers/array-data.ts b/apps/web/app/helpers/array-data.ts index d96877bf5..e59605540 100644 --- a/apps/web/app/helpers/array-data.ts +++ b/apps/web/app/helpers/array-data.ts @@ -74,3 +74,32 @@ const formatTime = (d: Date | string, addHour: boolean) => { return `${new Date(d).getHours() < 10 ? pad(new Date(d).getHours() + 1) : new Date(d).getHours() + 1}:00`; else return `${new Date(d).getHours() < 10 ? pad(new Date(d).getHours()) : new Date(d).getHours()}:00`; }; + + +/** + * Groups an array of items by the key returned by the `key` function. + * + * @example + * const items = [ + * { id: 1, name: 'John', group: 'A' }, + * { id: 2, name: 'Jane', group: 'A' }, + * { id: 3, name: 'Bob', group: 'B' }, + * ]; + * + * const groupedItems = groupBy(items, item => item.group); + * + * // groupedItems = { + * // A: [{ id: 1, name: 'John', group: 'A' }, { id: 2, name: 'Jane', group: 'A' }], + * // B: [{ id: 3, name: 'Bob', group: 'B' }] + * // } + * + * @param array The array of items to group. + * @param key A function that takes an item and returns a key to group by. + * @returns An object where the keys are the unique values of the key function and the values are arrays of items. + */ +export const groupBy = (array: T[], key: (item: T) => K): Record => + array.reduce((acc, item) => { + const groupKey = key(item); + (acc[groupKey] ||= []).push(item); + return acc; + }, {} as Record); diff --git a/apps/web/app/helpers/date.ts b/apps/web/app/helpers/date.ts index 436b6c5c4..922f3a5b1 100644 --- a/apps/web/app/helpers/date.ts +++ b/apps/web/app/helpers/date.ts @@ -58,6 +58,27 @@ export function secondsToTime(secs: number) { }; } +/** + * Calculates the difference in seconds between two Date objects. + * + * This function takes two Date objects, `startedAt` and `stoppedAt`, and computes + * the difference between them in seconds. If either of the dates is invalid, + * the function returns `undefined`. + * + * @param {Date} startedAt - The starting time + * @param {Date} stoppedAt - The stopping time + * @returns {number | undefined} The difference in seconds or `undefined` if dates are invalid + */ +export function differenceBetweenHours(startedAt: Date, stoppedAt: Date): number { + const started = new Date(startedAt); + const stopped = new Date(stoppedAt); + if (!isNaN(started.getTime()) && !isNaN(stopped.getTime())) { + return (stopped.getTime() - started.getTime()) / 1000; + } + return 0; +} + + export function convertMsToTime(milliseconds: number) { let seconds = Math.floor(milliseconds / 1000); let minutes = Math.floor(seconds / 60); @@ -225,3 +246,16 @@ export const formatDate = (dateStr: string | Date): string => { return ''; } } + + +export function toLocal(date: string | Date | moment.Moment): moment.Moment { + const localDate = moment(date); + if (!localDate.isValid()) { + throw new Error('Invalid date provided to toUTC'); + } + return localDate.utc(); +} + +export function toUTC(date: string | Date | moment.Moment): moment.Moment { + return moment(date).utc(); +} diff --git a/apps/web/app/hooks/features/useTimelogFilterOptions.ts b/apps/web/app/hooks/features/useTimelogFilterOptions.ts index ba00c9aae..995992ea4 100644 --- a/apps/web/app/hooks/features/useTimelogFilterOptions.ts +++ b/apps/web/app/hooks/features/useTimelogFilterOptions.ts @@ -1,4 +1,4 @@ -import { IUser, RoleNameEnum } from '@/app/interfaces'; +import { IUser, RoleNameEnum, TimesheetLog } from '@/app/interfaces'; import { timesheetDeleteState, timesheetGroupByDayState, timesheetFilterEmployeeState, timesheetFilterProjectState, timesheetFilterStatusState, timesheetFilterTaskState, timesheetUpdateStatus } from '@/app/stores'; import { useAtom } from 'jotai'; import React from 'react'; @@ -36,7 +36,7 @@ export function useTimelogFilterOptions() { const hour12 = hour24 % 12 || 12; // Convert to 12-hour format const minutes = (totalMinutes % 60).toString().padStart(2, '0'); const period = hour24 < 12 ? 'AM' : 'PM'; // Determine AM/PM - return `${hour12.toString().padStart(2, '0')}:${minutes} ${period}`; + return `${hour12.toString().padStart(2, '0')}:${minutes}:00 ${period}`; }); }; @@ -44,12 +44,16 @@ export function useTimelogFilterOptions() { setSelectTimesheetId((prev) => prev.includes(items) ? prev.filter((filter) => filter !== items) : [...prev, items]) } - const handleSelectRowByStatusAndDate = (status: string, date: string) => { - setSelectedItems((prev) => - prev.some((item) => item.status === status && item.date === date) - ? prev.filter((item) => !(item.status === status && item.date === date)) - : [...prev, { status, date }] - ); + const handleSelectRowByStatusAndDate = (logs: TimesheetLog[], isChecked: boolean) => { + setSelectTimesheetId((prev) => { + const logIds = logs.map((item) => item.id); + + if (isChecked) { + return [...new Set([...prev, ...logIds])]; + } else { + return prev.filter((id) => !logIds.includes(id)); + } + }); } @@ -61,6 +65,7 @@ export function useTimelogFilterOptions() { return { statusState, employee, + setSelectedItems, project, task, setEmployeeState, diff --git a/apps/web/app/hooks/features/useTimer.ts b/apps/web/app/hooks/features/useTimer.ts index 2a5de21ca..880d11bd8 100644 --- a/apps/web/app/hooks/features/useTimer.ts +++ b/apps/web/app/hooks/features/useTimer.ts @@ -104,7 +104,7 @@ function useLocalTimeCounter( const timerStatusDate = timerStatus?.lastLog?.createdAt ? moment(timerStatus?.lastLog?.createdAt).unix() * 1000 - - timerStatus?.lastLog?.duration + timerStatus?.lastLog?.duration : 0; timerStatus && @@ -257,8 +257,8 @@ export function useTimer() { // check if the today plan has working time planned and all the tasks into the plan are estimated const isPlanVerified = requirePlan ? hasPlan && - hasPlan?.workTimePlanned > 0 && - !!hasPlan?.tasks?.every((task) => task.estimate && task.estimate > 0) + hasPlan?.workTimePlanned > 0 && + !!hasPlan?.tasks?.every((task) => task.estimate && task.estimate > 0) : true; const canRunTimer = diff --git a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx index 8d0efecec..03f66bac9 100644 --- a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx +++ b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx @@ -1,18 +1,7 @@ 'use client'; import * as React from 'react'; -import { - ColumnDef, - ColumnFiltersState, - SortingState, - VisibilityState, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from '@tanstack/react-table'; -import { ArrowUpDownIcon, MoreHorizontal } from 'lucide-react'; +import { MoreHorizontal } from 'lucide-react'; import { Button } from '@components/ui/button'; import { DropdownMenu, @@ -35,14 +24,10 @@ import { SelectValue } from '@components/ui/select'; import { - MdKeyboardDoubleArrowLeft, - MdKeyboardDoubleArrowRight, - MdKeyboardArrowLeft, - MdKeyboardArrowRight, MdKeyboardArrowUp, MdKeyboardArrowDown } from 'react-icons/md'; -import { ConfirmStatusChange, StatusBadge, statusOptions, dataSourceTimeSheet, TimeSheet } from '.'; +import { ConfirmStatusChange, statusOptions } from '.'; import { useModal, useTimelogFilterOptions } from '@app/hooks'; import { Checkbox } from '@components/ui/checkbox'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@components/ui/accordion'; @@ -67,94 +52,6 @@ import { IUser, TimesheetLog, TimesheetStatus } from '@/app/interfaces'; import { toast } from '@components/ui/use-toast'; import { ToastAction } from '@components/ui/toast'; -export const columns: ColumnDef[] = [ - { - enableHiding: false, - id: 'select', - size: 50, - header: ({ table }) => ( -
- table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - Task -
- ), - cell: ({ row }) => ( -
- row.toggleSelected(!!value)} - aria-label="Select row" - /> - - {row.original.task} - -
- ) - }, - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) => - }, - { - accessorKey: 'employee', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- {row.original.employee} -
- ) - }, - { - accessorKey: 'status', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ; - } - }, - { - accessorKey: 'time', - header: () =>
Time
, - cell: ({ row }) => ( -
- {row.original.time} -
- ) - } -]; export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[], user?: IUser | undefined }) { const modal = useModal(); @@ -183,28 +80,7 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[], }; const t = useTranslations(); - const [sorting, setSorting] = React.useState([]); - const [columnFilters, setColumnFilters] = React.useState([]); - const [columnVisibility, setColumnVisibility] = React.useState({}); - const [rowSelection, setRowSelection] = React.useState({}); - const table = useReactTable({ - data: dataSourceTimeSheet, - columns, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setRowSelection, - state: { - sorting, - columnFilters, - columnVisibility, - rowSelection - } - }); + const handleSort = (key: string, order: SortOrder) => { console.log(`Sorting ${key} in ${order} order`); }; @@ -229,7 +105,6 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[], } }; - return (
-
+
{status === 'DENIED' ? 'REJECTED' : status} @@ -296,21 +172,21 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[],
- {t('timer.TOTAL_HOURS')} - + {t('timer.TOTAL_HOURS').split(' ')[0]}: +
- {isManage && getTimesheetButtons(status as StatusType, t, true, handleButtonClick)} + {isManage && getTimesheetButtons(status as StatusType, t, selectTimesheetId.length === 0, handleButtonClick)}
handleSelectRowByStatusAndDate(status, plan.date)} + () => handleSelectRowByStatusAndDate(rows, selectTimesheetId.length === 0)} data={rows} status={status} onSort={handleSort} @@ -328,7 +204,7 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[], )} > handleSelectRowTimesheet(task.id)} checked={selectTimesheetId.includes(task.id)} /> @@ -347,11 +223,12 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[], />
- {task.project?.imageUrl && } + {task.project?.imageUrl && } {task.project?.name}
{task.employee.fullName} @@ -364,7 +241,7 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[],
-
-
- {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length}{' '} - row(s) selected. -
-
- - Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} - - - - - -
-
); } @@ -545,19 +387,7 @@ const TaskActionMenu = ({ dataTimesheet, isManage, user }: { dataTimesheet: Time }; -const TaskDetails = ({ description, name }: { description: string; name: string }) => { - return ( -
-
- ever -
- - {name} - -
{description}
-
- ); -}; + export const StatusTask = ({ timesheet }: { timesheet: TimesheetLog }) => { const t = useTranslations(); diff --git a/apps/web/lib/features/multiple-select/index.tsx b/apps/web/lib/features/multiple-select/index.tsx index 94149c225..5ec9af5da 100644 --- a/apps/web/lib/features/multiple-select/index.tsx +++ b/apps/web/lib/features/multiple-select/index.tsx @@ -158,8 +158,8 @@ export function CustomSelect({ - {options.map((value) => ( - + {options.map((value, index) => ( + {renderOption ? renderOption(value) : value.charAt(0).toUpperCase() + value.slice(1)} ))} diff --git a/apps/web/lib/features/task/task-displays.tsx b/apps/web/lib/features/task/task-displays.tsx index 22132c12a..502d1a01c 100644 --- a/apps/web/lib/features/task/task-displays.tsx +++ b/apps/web/lib/features/task/task-displays.tsx @@ -2,10 +2,11 @@ import { ITeamTask, Nullable, TimesheetLog } from '@app/interfaces'; import { clsxm } from '@app/utils'; import { Tooltip } from 'lib/components'; import { TaskIssueStatus } from './task-issue'; -import { formatDate, secondsToTime } from '@/app/helpers'; +import { differenceBetweenHours, formatDate, secondsToTime } from '@/app/helpers'; import { ClockIcon } from "@radix-ui/react-icons" import React from 'react'; import { CalendarArrowDown, UserPlusIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; type Props = { task: Nullable; @@ -66,20 +67,29 @@ export function TaskNameInfoDisplay({ ); } -const formatTime = (hours: number, minutes: number) => ( -
+const formatTime = (hours: number, minutes: number, second?: number) => ( +
{String(hours).padStart(2, '0')} : {String(minutes).padStart(2, '0')} + {second !== undefined && ( + <> + : + {String(second).padStart(2, '0')} + + )} + h
); -export const DisplayTimeForTimesheet = ({ duration, logType }: { duration: number, logType?: 'TRACKED' | 'MANUAL' | 'IDLE' | undefined }) => { - if (duration < 0) { - console.warn('Negative duration provided to DisplayTimeForTimesheet'); - duration = 0; - } - const { h: hours, m: minute } = secondsToTime(duration || 0); +export const DisplayTimeForTimesheet = ({ timesheetLog, logType }: { timesheetLog: TimesheetLog, logType?: 'TRACKED' | 'MANUAL' | 'IDLE' | undefined }) => { + + const seconds = differenceBetweenHours( + timesheetLog.startedAt instanceof Date ? timesheetLog.startedAt : new Date(timesheetLog.startedAt), + timesheetLog.stoppedAt instanceof Date ? timesheetLog.stoppedAt : new Date(timesheetLog.stoppedAt) + ); + + const { h: hours, m: minute } = secondsToTime(seconds); const iconClasses = 'text-[14px] h-4 w-4'; const icons = { @@ -89,24 +99,31 @@ export const DisplayTimeForTimesheet = ({ duration, logType }: { duration: numbe }; const resolvedLogType: keyof typeof icons = logType ?? 'TRACKED'; return ( -
+
{icons[resolvedLogType]} -
+
{formatTime(hours, minute)}
); - } -export const TotalTimeDisplay = React.memo(({ timesheetLog }: { timesheetLog: TimesheetLog[] }) => { +export const TotalTimeDisplay = React.memo(({ timesheetLog, className }: { timesheetLog: TimesheetLog[], className?: string }) => { + const totalDuration = Array.isArray(timesheetLog) - ? timesheetLog.reduce((acc, curr) => acc + (curr.timesheet?.duration || 0), 0) + ? timesheetLog.reduce((acc, item) => { + const seconds = differenceBetweenHours( + item.startedAt instanceof Date ? item.startedAt : new Date(item.startedAt), + item.stoppedAt instanceof Date ? item.stoppedAt : new Date(item.stoppedAt) + ); + return acc + seconds + }, 0) : 0; + const { h: hours, m: minute } = secondsToTime(totalDuration || 0); return ( -
+
{formatTime(hours, minute)}
) }); @@ -119,9 +136,18 @@ export const TotalDurationByDate = React.memo( 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); + + const totalDurationInSeconds = Array.isArray(filteredLogs) + ? filteredLogs.reduce((acc, item) => { + const seconds = differenceBetweenHours( + item.startedAt instanceof Date ? item.startedAt : new Date(item.startedAt), + item.stoppedAt instanceof Date ? item.stoppedAt : new Date(item.stoppedAt) + ) + return acc + seconds + }, 0) + : 0; + + const { h: hours, m: minutes, } = secondsToTime(totalDurationInSeconds); return (
{formatTime(hours, minutes)} diff --git a/apps/web/package.json b/apps/web/package.json index 608b20c86..41d0cd574 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -101,7 +101,7 @@ "lucide-react": "^0.453.0", "moment": "^2.29.4", "moment-timezone": "^0.5.42", - "nanoid": "5.0.1", + "nanoid": "5.0.9", "next": "14.2.17", "next-auth": "^5.0.0-beta.18", "next-intl": "^3.3.2", diff --git a/yarn.lock b/yarn.lock index 5bbcaf487..a049bc3d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20143,17 +20143,12 @@ nanoclone@^0.2.1: resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== -nanoid@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.1.tgz#3e95d775a8bc8a98afbf0a237e2bbc6a71b0662e" - integrity sha512-vWeVtV5Cw68aML/QaZvqN/3QQXc6fBfIieAlu05m7FZW2Dgb+3f0xc0TTxuJW+7u30t7iSDTV/j3kVI0oJqIfQ== - -nanoid@^3.3.6: - version "3.3.6" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" - integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== +nanoid@5.0.9: + version "5.0.9" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.9.tgz#977dcbaac055430ce7b1e19cf0130cea91a20e50" + integrity sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q== -nanoid@^3.3.7: +nanoid@^3.3.6, nanoid@^3.3.7: version "3.3.7" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== @@ -24733,7 +24728,16 @@ string-to-color@^2.2.2: lodash.words "^4.2.0" rgb-hex "^3.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -24843,7 +24847,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -26893,7 +26904,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -26911,6 +26922,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"