diff --git a/packages/webapp/pages/recruiter/review/[id].tsx b/packages/webapp/pages/recruiter/review/[id].tsx new file mode 100644 index 0000000000..eed9a9db43 --- /dev/null +++ b/packages/webapp/pages/recruiter/review/[id].tsx @@ -0,0 +1,455 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useRouter } from 'next/router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { opportunityByIdOptions } from '@dailydotdev/shared/src/features/opportunity/queries'; +import { updateOpportunityStateOptions } from '@dailydotdev/shared/src/features/opportunity/mutations'; +import { OpportunityState } from '@dailydotdev/shared/src/features/opportunity/protobuf/opportunity'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { FlexCol } from '@dailydotdev/shared/src/components/utilities'; +import { + ProfilePicture, + ProfileImageSize, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { VIcon, ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; +import { getLayout } from '../../../components/layouts/RecruiterLayout'; + +const STATE_LABELS: Record = { + [OpportunityState.DRAFT]: 'Draft', + [OpportunityState.IN_REVIEW]: 'In Review', + [OpportunityState.LIVE]: 'Live', + [OpportunityState.CLOSED]: 'Closed', + [OpportunityState.UNSPECIFIED]: 'Unknown', +}; + +const STATE_COLORS: Record = { + [OpportunityState.DRAFT]: 'bg-surface-float text-text-tertiary', + [OpportunityState.IN_REVIEW]: 'bg-status-warning text-white', + [OpportunityState.LIVE]: 'bg-accent-cabbage-default text-white', + [OpportunityState.CLOSED]: 'bg-surface-float text-text-tertiary', + [OpportunityState.UNSPECIFIED]: 'bg-surface-float text-text-tertiary', +}; + +type ContentSectionProps = { + title: string; + html?: string; +}; + +const ContentSection = ({ + title, + html, +}: ContentSectionProps): ReactElement | null => { + if (!html) { + return null; + } + + return ( +
+ + {title} + +
+
+ ); +}; + +const ReviewDetailPage = (): ReactElement => { + const router = useRouter(); + const { id } = router.query; + const { user, isAuthReady } = useAuthContext(); + const { displayToast } = useToastNotification(); + const queryClient = useQueryClient(); + + const { data: opportunity, isLoading } = useQuery( + opportunityByIdOptions({ id: id as string }), + ); + + const { mutate: updateState, isPending } = useMutation({ + ...updateOpportunityStateOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: opportunityByIdOptions({ id: id as string }).queryKey, + }); + displayToast('Job status updated'); + }, + onError: () => { + displayToast('Failed to update job status'); + }, + }); + + const handleApprove = () => { + updateState({ + id: id as string, + state: OpportunityState.LIVE, + }); + }; + + if (!isAuthReady || isLoading) { + return ( +
+ +
+ ); + } + + // Only team members can access this page + if (!user?.isTeamMember) { + router.replace('/'); + return null; + } + + if (!opportunity) { + return ( +
+ + Job not found + + +
+ ); + } + + const recruiter = opportunity.recruiters?.[0]; + const location = opportunity.locations?.[0]; + + return ( +
+ {/* Header */} +
+ + + +
+ + {/* Status Banner */} +
+
+ + {STATE_LABELS[opportunity.state]} + + + ID: {opportunity.id} + +
+ {opportunity.state === OpportunityState.IN_REVIEW && ( + + )} +
+ + {/* Company & Recruiter Info */} +
+ {/* Company */} +
+ + Company + +
+ {opportunity.organization?.image && ( + {opportunity.organization.name} + )} +
+ + {opportunity.organization?.name || 'No company'} + + {opportunity.organization?.website && ( + + {opportunity.organization.website} + + )} +
+
+
+ + {/* Recruiter */} +
+ + Recruiter + + {recruiter ? ( +
+ +
+ + {recruiter.name} + + + {recruiter.title} + +
+
+ ) : ( + + No recruiter assigned + + )} +
+
+ + {/* Job Details */} + +
+ + {opportunity.title} + + {location && ( + + {location.location?.city} + {location.location?.country && `, ${location.location.country}`} + {location.type && ` (${location.type})`} + + )} +
+ + {/* TLDR */} + {opportunity.tldr && ( +
+ + {opportunity.tldr} + +
+ )} + + {/* Keywords */} + {opportunity.keywords && opportunity.keywords.length > 0 && ( +
+ + Tech Stack / Keywords + +
+ {opportunity.keywords.map((kw) => ( + + {kw.keyword} + + ))} +
+
+ )} + + {/* Meta Info */} +
+ {opportunity.meta?.employmentType && ( +
+ + Employment Type + + + {opportunity.meta.employmentType === 1 && 'Full-time'} + {opportunity.meta.employmentType === 2 && 'Part-time'} + {opportunity.meta.employmentType === 3 && 'Contract'} + +
+ )} + {opportunity.meta?.seniorityLevel && ( +
+ + Seniority + + + Level {opportunity.meta.seniorityLevel} + +
+ )} + {opportunity.meta?.teamSize && ( +
+ + Team Size + + + {opportunity.meta.teamSize} + +
+ )} + {opportunity.meta?.salary && ( +
+ + Salary Range + + + ${opportunity.meta.salary.min?.toLocaleString()} - $ + {opportunity.meta.salary.max?.toLocaleString()} + +
+ )} +
+
+ + {/* Content Sections */} + + + Job Description Content + + + + + + + + + {/* Screening Questions */} + {opportunity.questions && opportunity.questions.length > 0 && ( + + + Screening Questions ({opportunity.questions.length}) + +
+ {opportunity.questions.map((question, index) => ( +
+
+ + {index + 1} + + + {question.title} + +
+ {question.placeholder && ( + + Hint: {question.placeholder} + + )} +
+ ))} +
+
+ )} + + {/* Payment/Plan Info */} + + + Payment & Plan + +
+
+ + Plan + + + {opportunity.flags?.plan || 'No plan selected'} + +
+
+ + Batch Size + + + {opportunity.flags?.batchSize || 'N/A'} + +
+
+
+
+ ); +}; + +ReviewDetailPage.getLayout = getLayout; + +export default ReviewDetailPage; diff --git a/packages/webapp/pages/recruiter/review/index.tsx b/packages/webapp/pages/recruiter/review/index.tsx new file mode 100644 index 0000000000..2a0a7a5ab2 --- /dev/null +++ b/packages/webapp/pages/recruiter/review/index.tsx @@ -0,0 +1,149 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getOpportunitiesOptions } from '@dailydotdev/shared/src/features/opportunity/queries'; +import type { Opportunity } from '@dailydotdev/shared/src/features/opportunity/types'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { getLayout } from '../../../components/layouts/RecruiterLayout'; + +type OpportunityCardProps = { + opportunity: Opportunity; +}; + +const OpportunityCard = ({ + opportunity, +}: OpportunityCardProps): ReactElement => { + const { title, organization, id, tldr } = opportunity; + + return ( + + ); +}; + +const ReviewListPage = (): ReactElement => { + const router = useRouter(); + const { user, isAuthReady } = useAuthContext(); + + // Fetch opportunities in IN_REVIEW state (state = 4) + const { data: opportunitiesData, isLoading } = useQuery( + getOpportunitiesOptions('4'), + ); + + const opportunities = opportunitiesData?.edges.map((edge) => edge.node) || []; + + if (!isAuthReady) { + return ( +
+ +
+ ); + } + + // Only team members can access this page + if (!user?.isTeamMember) { + router.replace('/'); + return null; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + Job Review Queue + + + Review and approve job descriptions before they go live + +
+ + {/* Pending Review */} +
+
+ + Pending Review + + + {opportunities.length} + +
+ {opportunities.length > 0 ? ( +
+ {opportunities.map((opportunity) => ( + + ))} +
+ ) : ( +
+ + No jobs pending review + +
+ )} +
+
+ ); +}; + +ReviewListPage.getLayout = getLayout; + +export default ReviewListPage;