diff --git a/src/apps/calendar/src/lib/components/Calendar/Calendar.module.scss b/src/apps/calendar/src/lib/components/Calendar/Calendar.module.scss index 29891004a..134c333e4 100644 --- a/src/apps/calendar/src/lib/components/Calendar/Calendar.module.scss +++ b/src/apps/calendar/src/lib/components/Calendar/Calendar.module.scss @@ -111,6 +111,7 @@ } .cell { - min-height: 72px; + min-height: 60px; + padding: 6px; } } diff --git a/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.module.scss b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.module.scss index d0ff3d3b8..58ba914d7 100644 --- a/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.module.scss +++ b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.module.scss @@ -80,7 +80,6 @@ } .userItem { - width: 100%; border-radius: 8px; padding: 8px 10px; font-weight: 700; @@ -129,16 +128,16 @@ @media (max-width: 768px) { .teamCalendar { - padding: 12px; + padding: 5px; } .grid { - gap: 8px; + gap: 6px; } .cell { - min-height: 96px; - padding: 10px; + min-height: 60px; + padding: 6px; } .userItem { @@ -148,4 +147,89 @@ .dateNumber { font-size: 15px; } + + .cellButton { + cursor: pointer; + } +} + +.cellButton { + all: unset; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + gap: 8px; + cursor: default; +} + + +.countBadge { + align-self: center; + font-size: 12px; + font-weight: 800; + padding: 4px 6px; + border-radius: 999px; + background: #acaeb3; + color: #ffffff; + line-height: 1; +} + +.modalRoot { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.backdrop { + position: absolute; + inset: 0; + background: var(--text-secondary); +} + +.popover { + position: relative; + z-index: 1; + + width: calc(100vw - 32px); + max-width: 420px; + max-height: 70vh; + overflow-y: auto; + + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 16px; + box-shadow: 0 24px 64px $tc-black; +} + +.popoverHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid #e5e7eb; +} + +.popoverTitle { + font-weight: 800; + color: #111827; +} + +.closeBtn { + all: unset; + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 6px 8px; + border-radius: 8px; +} + +.popoverBody { + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; } diff --git a/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.tsx b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.tsx index 6ce6f8f9d..7aa3bf2b4 100644 --- a/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.tsx +++ b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.tsx @@ -1,8 +1,9 @@ -import { isWeekend } from 'date-fns' -import { FC, useMemo } from 'react' +import { format, isWeekend } from 'date-fns' +import { FC, useCallback, useMemo, useState } from 'react' import classNames from 'classnames' import { LoadingSpinner } from '~/libs/ui' +import { useCheckIsMobile } from '~/libs/shared' import { LeaveStatus, TeamLeaveDate } from '../../models' import { getDateKey, getMonthDates } from '../../utils' @@ -60,6 +61,25 @@ export const TeamCalendar: FC = (props: TeamCalendarProps) => const currentDate = props.currentDate const isLoading = props.isLoading const teamLeaveDates = props.teamLeaveDates + const [openDateKey, setOpenDateKey] = useState(undefined) + + const isMobile: boolean = useCheckIsMobile() + + const closePopover = useCallback(() => { + setOpenDateKey(undefined) + }, []) + + const handleCellClick = useCallback( + (e: React.MouseEvent) => { + if (!isMobile) return + + const dateKey = e.currentTarget.dataset.dateKey + if (!dateKey) return + + setOpenDateKey(prev => (prev === dateKey ? undefined : dateKey)) + }, + [isMobile], + ) const monthDates = useMemo( () => getMonthDates(currentDate.getFullYear(), currentDate.getMonth()), @@ -116,44 +136,126 @@ export const TeamCalendar: FC = (props: TeamCalendarProps) => const overflowCount = sortedUsers.length - displayedUsers.length const weekendClass = isWeekend(date) ? styles.weekend : undefined + // Mobile popover open/close + const isOpen = openDateKey === dateKey + const leaveCount = sortedUsers.length + return (
0, })} > - {date.getDate()} -
- {displayedUsers.length > 0 - && displayedUsers.map((user, userIndex) => { - const isHolidayStatus = user.status === LeaveStatus.WIPRO_HOLIDAY - || user.status === LeaveStatus.HOLIDAY + {/* Whole cell tappable on mobile */} + +
+ ) + })} +
+ + {isMobile && openDateKey && (() => { + const selectedDate = paddedDates.find(d => d && getDateKey(d) === openDateKey) + if (!selectedDate) return undefined + + const selectedEntry = teamLeaveDates.find(item => item.date === openDateKey) + const selectedUsers = [...(selectedEntry?.usersOnLeave ?? [])].sort(compareUsersByName) + + return ( +
+
+ +
+
+
+ {format(selectedDate, 'EEE, dd MMM yyyy')} +
+ + +
+ +
+ {selectedUsers.length === 0 ? ( +
No leave
+ ) : ( + selectedUsers.map((user, idx) => { + const isHolidayStatus + = user.status === LeaveStatus.WIPRO_HOLIDAY + || user.status === LeaveStatus.HOLIDAY return (
{getUserDisplayName(user)}
) - })} - {overflowCount > 0 && ( -
- {`+${overflowCount} more`} -
+ }) )}
- ) - })} -
+
+ ) + })()} {isLoading && (
@@ -162,6 +264,7 @@ export const TeamCalendar: FC = (props: TeamCalendarProps) => )}
) + } export default TeamCalendar diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index 87f9f0c0a..5ca6cb9cf 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -1,5 +1,5 @@ import { FC, useContext, useEffect, useMemo, useState } from 'react' -import { bind, debounce, isEmpty } from 'lodash' +import { bind, debounce, isEmpty, pick } from 'lodash' import { toast } from 'react-toastify' import { Params, useNavigate, useParams, useSearchParams } from 'react-router-dom' import classNames from 'classnames' @@ -7,7 +7,7 @@ import classNames from 'classnames' import { profileContext, ProfileContextData } from '~/libs/core' import { Button, IconSolid, InputDatePicker, InputMultiselectOption, InputRadio, InputSelect, InputSelectReact, InputText, InputTextarea } from '~/libs/ui' -import { InputSkillSelector } from '~/libs/shared' +import { extractSkillsFromText, InputSkillSelector } from '~/libs/shared' import { getProject, getProjects, ProjectsResponse, useProjects } from '../../services/projects' import { ProjectTypes, ProjectTypeValues } from '../../constants' @@ -17,6 +17,8 @@ import { rootRoute } from '../../copilots.routes' import styles from './styles.module.scss' +const MIN_OVERVIEW_LENGTH = 50 + const editableFields = [ 'projectId', 'opportunityTitle', @@ -46,6 +48,8 @@ const CopilotRequestForm: FC<{}> = () => { const [formErrors, setFormErrors] = useState({}) const [paymentType, setPaymentType] = useState('') const [projectFromQuery, setProjectFromQuery] = useState() + const [isGeneratingSkills, setIsGeneratingSkills] = useState(false) + const [aiGenerationError, setAiGenerationError] = useState() const activeProjectStatuses = ['active', 'approved', 'draft', 'new'] const { data: copilotRequestData }: CopilotRequestResponse = useCopilotRequest(routeParams.requestId) @@ -209,6 +213,63 @@ const CopilotRequestForm: FC<{}> = () => { setIsFormChanged(true) } + async function handleGenerateSkillsWithAI(): Promise { + setIsGeneratingSkills(true) + setAiGenerationError(undefined) + + try { + const result = await extractSkillsFromText(formValues.overview) + + if (!result.matches || result.matches.length === 0) { + setAiGenerationError('No skills were extracted. Try providing more details in the project overview.') + toast.warning( + 'No skills found for the provided project overview. Please add more details about the project.', + ) + return + } + + // Add extracted skills to existing skills (avoid duplicates) + const existingSkillIds = new Set((formValues.skills || []).map((s: any) => s.id)) + const newSkills = result.matches + .filter(skill => !existingSkillIds.has(skill.id)) + .map(skill => pick(skill, ['id', 'name'])) + + if (newSkills.length === 0) { + toast.info('All extracted skills are already in the list.') + return + } + + setFormValues((prevFormValues: any) => ({ + ...prevFormValues, + skills: [...(prevFormValues.skills || []), ...newSkills], + })) + + setFormErrors((prevFormErrors: any) => { + const updatedErrors = { ...prevFormErrors } + delete updatedErrors.skills + return updatedErrors + }) + setIsFormChanged(true) + + toast.success( + `Successfully added ${newSkills.length} skill${newSkills.length > 1 ? 's' : ''} from AI analysis!`, + ) + } catch (error) { + const errorMessage = (error as Error).message + setAiGenerationError(errorMessage) + console.error('AI skills generation failed:', error) + toast.error(`Failed to generate skills: ${errorMessage}`) + } finally { + setIsGeneratingSkills(false) + } + } + + // Check if overview has enough content for AI processing + const canGenerateSkills = useMemo(() => { + const overview = formValues.overview?.trim() || '' + return overview.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills + }, [formValues.overview, isGeneratingSkills]) + function handleFormAction(): void { const updatedFormErrors: { [key: string]: string } = {} @@ -478,8 +539,39 @@ const CopilotRequestForm: FC<{}> = () => { error={formErrors.overview} dirty /> -

Any specific skills or technology requirements that come to mind?

-
+
+
+

+ Any specific skills or technology requirements that come to mind? +

+
+ {!canGenerateSkills + && formValues.overview + && formValues.overview.trim().length < MIN_OVERVIEW_LENGTH + && ( +

+ Add at least + {' '} + {MIN_OVERVIEW_LENGTH} + {' '} + characters to the project overview to use AI generation +

+ )} +
+
= () => { onChange={handleSkillsChange} />
+ {isGeneratingSkills && ( +

+ 🤖 AI is analyzing your project overview to extract relevant skills... +

+ )} {formErrors.skills && (

{formErrors.skills}

)} + {aiGenerationError && ( +

+ + {aiGenerationError} +

+ )}

What's the planned start date for the copilot?

{ const isProfileReady = profileContext.initialized const isLoggedIn = profileContext.isLoggedIn const userId = profileContext.profile?.userId + const profileHandle = profileContext.profile?.handle + const profileCompleteness = useProfileCompleteness(profileHandle) const [engagement, setEngagement] = useState(undefined) const [loading, setLoading] = useState(true) @@ -528,6 +530,8 @@ const EngagementDetailPage: FC = () => { normalizedUserEmail, normalizedUserId, }) + const [profileGateError, setProfileGateError] = useState() + const isPrivateEngagement = Boolean(engagement?.isPrivate) const fetchEngagement = useCallback(async (): Promise => { @@ -652,8 +656,25 @@ const EngagementDetailPage: FC = () => { }, [fetchPendingTerm, navigateToApply]) const handleApplyClick = useCallback(() => { + setProfileGateError(undefined) + + if (profileCompleteness?.isLoading) { + return + } + + if ( + profileCompleteness + && typeof profileCompleteness.percent === 'number' + && profileCompleteness.percent < 100 + ) { + setProfileGateError( + 'Your profile must be 100% complete before applying.', + ) + return + } + openNextPendingTerm() - }, [openNextPendingTerm]) + }, [openNextPendingTerm, profileCompleteness]) const handleBackClick = useCallback(() => navigate(rootRoute || '/'), [navigate]) @@ -933,6 +954,22 @@ const EngagementDetailPage: FC = () => { return termsGate } + if (profileGateError) { + return ( +
+ + {profileGateError} + + + Please update your profile here. + +
+ ) + } + return (