because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(client): update job experience components to use UI package

+618 -200
+84
apps/client/src/features/job-experience/components/CompanySelect.tsx
··· 1 + import { SearchableSelect } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import { 5 + useCreateCompanyMutation, 6 + useInfiniteCompaniesConnectionQuery, 7 + } from "@/generated/graphql"; 8 + 9 + interface CompanySelectProps { 10 + value: string; 11 + onChange: (value: string) => void; 12 + error?: string; 13 + } 14 + 15 + export const CompanySelect = ({ 16 + value, 17 + onChange, 18 + error, 19 + }: CompanySelectProps) => { 20 + const { 21 + data: companiesData, 22 + fetchNextPage: fetchNextCompanies, 23 + hasNextPage: hasNextCompaniesPage, 24 + isFetchingNextPage: isLoadingMoreCompanies, 25 + } = useInfiniteCompaniesConnectionQuery( 26 + {}, 27 + { 28 + initialPageParam: { after: null }, 29 + getNextPageParam: (lastPage) => 30 + lastPage.companies.pageInfo.hasNextPage 31 + ? { after: lastPage.companies.pageInfo.endCursor } 32 + : undefined, 33 + }, 34 + ); 35 + 36 + const { mutateAsync: createCompany } = useCreateCompanyMutation(); 37 + const queryClient = useQueryClient(); 38 + const { showSuccess, showError } = useToast(); 39 + 40 + const companies = 41 + companiesData?.pages.flatMap((page) => 42 + page.companies.edges.map(({ node }) => node), 43 + ) || []; 44 + 45 + const handleAddNew = async (name: string) => { 46 + try { 47 + const result = await createCompany({ name }); 48 + // Invalidate and refetch companies 49 + await queryClient.invalidateQueries({ 50 + queryKey: ["CompaniesConnection"], 51 + }); 52 + // Select the newly created company 53 + onChange(result.createCompany.id); 54 + showSuccess("Company Created", `Successfully created "${name}"`); 55 + } catch (error) { 56 + console.error("Error creating company:", error); 57 + showError( 58 + "Failed to Create Company", 59 + "There was an error creating the company. Please try again.", 60 + ); 61 + } 62 + }; 63 + 64 + return ( 65 + <SearchableSelect 66 + label="Company" 67 + options={companies.map((company) => ({ 68 + value: company.id, 69 + label: company.name, 70 + }))} 71 + value={value} 72 + onChange={onChange} 73 + placeholder="Select a company" 74 + required 75 + error={error} 76 + hasNextPage={hasNextCompaniesPage} 77 + onLoadMore={() => fetchNextCompanies()} 78 + isLoading={isLoadingMoreCompanies} 79 + allowAddNew 80 + onAddNew={handleAddNew} 81 + addNewLabel="Add company" 82 + /> 83 + ); 84 + };
+14 -8
apps/client/src/features/job-experience/components/JobExperienceCard.tsx
··· 1 + import { DeleteIcon, EditIcon, IconButton, LoadingIcon } from "@cv/ui"; 1 2 import { useState } from "react"; 2 - import ConfirmationModal from "@/components/ConfirmationModal"; 3 - import { DeleteIcon, EditIcon, LoadingIcon } from "@/components/icons"; 3 + import { ConfirmationModal } from "@/components/ConfirmationModal"; 4 4 import type { MeJobExperienceQuery } from "@/generated/graphql"; 5 - import IconButton from "@/ui/IconButton"; 6 5 import { calculateDuration, formatDateRange } from "@/utils/dateUtils"; 7 6 8 7 interface JobExperienceCardProps { 9 - experience: MeJobExperienceQuery["myEmploymentHistory"][0]; 8 + experience: NonNullable< 9 + NonNullable<MeJobExperienceQuery["me"]>["experience"] 10 + >[0]; 10 11 onEdit?: 11 - | ((experience: MeJobExperienceQuery["myEmploymentHistory"][0]) => void) 12 + | (( 13 + experience: NonNullable< 14 + NonNullable<MeJobExperienceQuery["me"]>["experience"] 15 + >[0], 16 + ) => void) 12 17 | undefined; 13 18 onDelete?: ((experienceId: string) => void) | undefined; 14 19 isDeleting?: boolean; ··· 17 22 /** 18 23 * Individual job experience card component 19 24 */ 20 - export default function JobExperienceCard({ 25 + export const JobExperienceCard = ({ 21 26 experience, 22 27 onEdit, 23 28 onDelete, 24 29 isDeleting = false, 25 - }: JobExperienceCardProps) { 30 + }: JobExperienceCardProps) => { 26 31 const [showDeleteModal, setShowDeleteModal] = useState(false); 27 32 28 33 const handleEdit = () => { ··· 41 46 const handleDeleteCancel = () => { 42 47 setShowDeleteModal(false); 43 48 }; 49 + 44 50 return ( 45 51 <div className="bg-white rounded-lg border-2 border-gray-200 p-6 shadow-lg hover:shadow-xl transition-all duration-200"> 46 52 <div className="flex justify-between items-start mb-4"> ··· 140 146 /> 141 147 </div> 142 148 ); 143 - } 149 + };
+1 -1
apps/client/src/features/job-experience/components/JobExperienceCreationSelector.tsx
··· 1 + import { Button } from "@cv/ui"; 1 2 import { useState } from "react"; 2 3 import { CreationMethodCard } from "@/features/vacancies/components/VacancyCreationSelector/CreationMethodCard"; 3 4 import { creationMethods } from "@/features/vacancies/components/VacancyCreationSelector/constants"; 4 5 import { PlaceholderForm } from "@/features/vacancies/components/VacancyCreationSelector/PlaceholderForm"; 5 6 import type { CreationMethod } from "@/features/vacancies/components/VacancyCreationSelector/types"; 6 - import Button from "@/ui/Button"; 7 7 import { JobExperienceForm } from "./JobExperienceForm"; 8 8 9 9 interface JobExperienceCreationSelectorProps {
+2 -2
apps/client/src/features/job-experience/components/JobExperienceEmpty.tsx
··· 1 1 /** 2 2 * Empty state component for when user has no job experience 3 3 */ 4 - export default function JobExperienceEmpty() { 4 + export const JobExperienceEmpty = () => { 5 5 return ( 6 6 <div className="min-h-screen bg-white"> 7 7 <div className="max-w-4xl mx-auto px-4 py-8"> ··· 21 21 </div> 22 22 </div> 23 23 ); 24 - } 24 + };
+94 -96
apps/client/src/features/job-experience/components/JobExperienceForm.tsx
··· 1 - import { useState } from "react"; 1 + import { Button, Calendar, Checkbox, Textarea } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useId, useState } from "react"; 2 4 import { useToast } from "@/contexts/ToastContext"; 3 - import { 4 - useCreateJobExperienceMutation, 5 - useJobExperienceFormDataQuery, 6 - } from "@/generated/graphql"; 7 - import Button from "@/ui/Button"; 8 - import Checkbox from "@/ui/Checkbox"; 9 - import { Select } from "@/ui/Select"; 10 - import Textarea from "@/ui/Textarea"; 11 - import TextInput from "@/ui/TextInput"; 5 + import { useCreateJobExperienceMutation } from "@/generated/graphql"; 6 + import { CompanySelect } from "./CompanySelect"; 12 7 import { 13 8 type JobExperienceFormData, 14 9 jobExperienceFormSchema, 15 10 } from "./jobExperience.schema"; 11 + import { LevelSelect } from "./LevelSelect"; 12 + import { RoleSelect } from "./RoleSelect"; 13 + import { SelectedSkillsDisplay } from "./SelectedSkillsDisplay"; 14 + import { SkillsSelect } from "./SkillsSelect"; 16 15 17 16 interface JobExperienceFormProps { 18 17 onSuccess?: () => void; ··· 23 22 onSuccess, 24 23 onCancel, 25 24 }: JobExperienceFormProps) => { 26 - const [createJobExperience, { loading }] = useCreateJobExperienceMutation(); 25 + const { mutateAsync: createJobExperience, isPending: loading } = 26 + useCreateJobExperienceMutation(); 27 + const queryClient = useQueryClient(); 27 28 const { showSuccess, showError } = useToast(); 28 29 29 - // Fetch all form data in one query 30 - const { data: formDataQuery } = useJobExperienceFormDataQuery(); 30 + const startDateId = useId(); 31 + const endDateId = useId(); 31 32 32 33 const [formData, setFormData] = useState<JobExperienceFormData>({ 33 34 companyId: "", 34 35 roleId: "", 35 36 levelId: "", 36 - startDate: "", 37 - endDate: "", 37 + startDate: null, 38 + endDate: null, 38 39 description: "", 39 40 skillIds: [], 40 41 isCurrentPosition: false, ··· 67 68 68 69 try { 69 70 await createJobExperience({ 70 - variables: { 71 - companyId: formData.companyId, 72 - roleId: formData.roleId, 73 - levelId: formData.levelId, 74 - startDate: new Date(formData.startDate), 75 - endDate: formData.isCurrentPosition 76 - ? null 77 - : formData.endDate 78 - ? new Date(formData.endDate) 79 - : null, 80 - description: formData.description || null, 81 - skillIds: 82 - formData.skillIds && formData.skillIds.length > 0 83 - ? formData.skillIds 84 - : null, 85 - }, 71 + companyId: formData.companyId, 72 + roleId: formData.roleId, 73 + levelId: formData.levelId, 74 + startDate: formData.startDate || new Date(), 75 + endDate: formData.isCurrentPosition ? null : formData.endDate, 76 + description: formData.description || null, 77 + skillIds: 78 + formData.skillIds && formData.skillIds.length > 0 79 + ? formData.skillIds 80 + : null, 81 + }); 82 + 83 + // Invalidate and refetch job experience queries 84 + await queryClient.invalidateQueries({ 85 + queryKey: ["MeJobExperience"], 86 86 }); 87 87 88 88 showSuccess( ··· 113 113 setFormData((prev) => ({ 114 114 ...prev, 115 115 isCurrentPosition: checked, 116 - endDate: checked ? "" : prev.endDate, 116 + endDate: checked ? null : prev.endDate, 117 117 })); 118 118 }; 119 - 120 - const companies = formDataQuery?.companies || []; 121 - const roles = formDataQuery?.roles || []; 122 - const levels = formDataQuery?.levels || []; 123 - const skills = formDataQuery?.skills || []; 124 119 125 120 return ( 126 121 <div className="max-w-2xl mx-auto"> ··· 135 130 136 131 <form onSubmit={handleSubmit} className="space-y-6"> 137 132 <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> 138 - <Select 139 - label="Company" 140 - options={companies.map((company) => ({ 141 - value: company.id, 142 - label: company.name, 143 - }))} 133 + <CompanySelect 144 134 value={formData.companyId} 145 135 onChange={(value: string) => 146 136 setFormData((prev) => ({ ...prev, companyId: value })) 147 137 } 148 - placeholder="Select a company" 149 - required 150 138 error={errors.companyId} 151 139 /> 152 140 153 - <Select 154 - label="Role" 155 - options={roles.map((role) => ({ 156 - value: role.id, 157 - label: role.name, 158 - }))} 141 + <RoleSelect 159 142 value={formData.roleId} 160 143 onChange={(value: string) => 161 144 setFormData((prev) => ({ ...prev, roleId: value })) 162 145 } 163 - placeholder="Select a role" 164 - required 165 146 error={errors.roleId} 166 147 /> 167 148 168 - <Select 169 - label="Level" 170 - options={levels.map((level) => ({ 171 - value: level.id, 172 - label: level.name, 173 - }))} 149 + <LevelSelect 174 150 value={formData.levelId} 175 151 onChange={(value: string) => 176 152 setFormData((prev) => ({ ...prev, levelId: value })) 177 153 } 178 - placeholder="Select a level" 179 - required 180 154 error={errors.levelId} 181 155 /> 182 156 183 - <TextInput 184 - label="Start Date" 185 - type="date" 186 - value={formData.startDate} 187 - onChange={(value: string) => 188 - setFormData((prev) => ({ ...prev, startDate: value })) 189 - } 190 - required 191 - error={errors.startDate} 192 - /> 157 + <div className="space-y-2"> 158 + <label 159 + htmlFor={startDateId} 160 + className="block text-sm font-medium text-ctp-text" 161 + > 162 + Start Date * 163 + </label> 164 + <Calendar 165 + value={formData.startDate} 166 + onChange={(date: Date | null) => 167 + setFormData((prev) => ({ ...prev, startDate: date })) 168 + } 169 + placeholder="Select start date" 170 + format="short" 171 + maxDate={new Date()} 172 + className="w-full" 173 + /> 174 + {errors.startDate && ( 175 + <p className="text-sm text-ctp-red">{errors.startDate}</p> 176 + )} 177 + </div> 193 178 </div> 194 179 195 180 <div className="space-y-4"> ··· 200 185 /> 201 186 202 187 {!formData.isCurrentPosition && ( 203 - <TextInput 204 - label="End Date" 205 - type="date" 206 - value={formData.endDate} 207 - onChange={(value: string) => 208 - setFormData((prev) => ({ ...prev, endDate: value })) 209 - } 210 - required={!formData.isCurrentPosition} 211 - error={errors.endDate} 212 - /> 188 + <div className="space-y-2"> 189 + <label 190 + htmlFor={endDateId} 191 + className="block text-sm font-medium text-ctp-text" 192 + > 193 + End Date * 194 + </label> 195 + <Calendar 196 + value={formData.endDate} 197 + onChange={(date: Date | null) => 198 + setFormData((prev) => ({ ...prev, endDate: date })) 199 + } 200 + placeholder="Select end date" 201 + format="short" 202 + minDate={formData.startDate || undefined} 203 + maxDate={new Date()} 204 + className="w-full" 205 + /> 206 + {errors.endDate && ( 207 + <p className="text-sm text-ctp-red">{errors.endDate}</p> 208 + )} 209 + </div> 213 210 )} 214 211 </div> 215 212 ··· 223 220 rows={4} 224 221 /> 225 222 226 - <div className="space-y-3"> 227 - <span className="block text-sm font-medium text-ctp-text"> 228 - Skills 229 - </span> 230 - <div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-40 overflow-y-auto"> 231 - {skills.map((skill) => ( 232 - <Checkbox 233 - key={skill.id} 234 - label={skill.name} 235 - checked={(formData.skillIds || []).includes(skill.id)} 236 - onChange={() => handleSkillToggle(skill.id)} 237 - /> 238 - ))} 239 - </div> 240 - </div> 223 + <SkillsSelect 224 + value="" 225 + onChange={(value: string) => { 226 + if (!formData.skillIds?.includes(value)) { 227 + setFormData((prev) => ({ 228 + ...prev, 229 + skillIds: [...(prev.skillIds || []), value], 230 + })); 231 + } 232 + }} 233 + /> 234 + 235 + <SelectedSkillsDisplay 236 + skillIds={formData.skillIds || []} 237 + onRemoveSkill={handleSkillToggle} 238 + /> 241 239 242 240 <div className="flex gap-3 pt-6"> 243 241 <Button type="submit" disabled={loading} className="flex-1">
+2 -2
apps/client/src/features/job-experience/components/JobExperienceHeader.tsx
··· 1 1 /** 2 2 * Header component for job experience page 3 3 */ 4 - export default function JobExperienceHeader() { 4 + export const JobExperienceHeader = () => { 5 5 return ( 6 6 <div className="mb-8"> 7 7 <h1 className="text-3xl font-bold text-gray-900 mb-2"> ··· 12 12 </p> 13 13 </div> 14 14 ); 15 - } 15 + };
+12 -6
apps/client/src/features/job-experience/components/JobExperienceList.tsx
··· 1 1 import type { MeJobExperienceQuery } from "@/generated/graphql"; 2 - import JobExperienceCard from "./JobExperienceCard"; 2 + import { JobExperienceCard } from "./JobExperienceCard"; 3 3 4 4 interface JobExperienceListProps { 5 - experiences: MeJobExperienceQuery["myEmploymentHistory"]; 6 - onEdit?: (experience: MeJobExperienceQuery["myEmploymentHistory"][0]) => void; 5 + experiences: NonNullable< 6 + NonNullable<MeJobExperienceQuery["me"]>["experience"] 7 + >; 8 + onEdit?: ( 9 + experience: NonNullable< 10 + NonNullable<MeJobExperienceQuery["me"]>["experience"] 11 + >[0], 12 + ) => void; 7 13 onDelete?: (experienceId: string) => void; 8 14 isDeleting?: boolean; 9 15 } ··· 11 17 /** 12 18 * List component for displaying job experiences 13 19 */ 14 - export default function JobExperienceList({ 20 + export const JobExperienceList = ({ 15 21 experiences, 16 22 onEdit, 17 23 onDelete, 18 24 isDeleting = false, 19 - }: JobExperienceListProps) { 25 + }: JobExperienceListProps) => { 20 26 return ( 21 27 <div className="space-y-6"> 22 28 {experiences.map((experience) => ( ··· 30 36 ))} 31 37 </div> 32 38 ); 33 - } 39 + };
+2 -2
apps/client/src/features/job-experience/components/JobExperienceLoading.tsx
··· 1 1 /** 2 2 * Loading component for job experience page 3 3 */ 4 - export default function JobExperienceLoading() { 4 + export const JobExperienceLoading = () => { 5 5 return ( 6 6 <div className="flex items-center justify-center min-h-screen bg-white"> 7 7 <div className="text-center"> ··· 10 10 </div> 11 11 </div> 12 12 ); 13 - } 13 + };
+97 -81
apps/client/src/features/job-experience/components/JobExperienceTable.tsx
··· 1 - import { DeleteIcon, EditIcon } from "@/components/icons"; 2 - import { useConfirmationModal } from "@/contexts/ConfirmationModalContext"; 3 - import { useToast } from "@/contexts/ToastContext"; 4 - import type { MeJobExperienceQuery } from "@/generated/graphql"; 5 - import { useDeleteJobExperienceMutation } from "@/generated/graphql"; 6 - import IconButton from "@/ui/IconButton"; 7 1 import { 2 + DeleteIcon, 3 + EditIcon, 4 + FormattedDateRange, 5 + IconButton, 6 + Placeholder, 8 7 Table, 9 8 TableBody, 10 9 TableCell, 11 10 TableHeader, 12 11 TableHeaderCell, 13 12 TableRow, 14 - } from "@/ui/Table"; 13 + } from "@cv/ui"; 14 + import { useQueryClient } from "@tanstack/react-query"; 15 + import { useConfirmationModal } from "@/contexts/ConfirmationModalContext"; 16 + import { useToast } from "@/contexts/ToastContext"; 17 + import type { MeJobExperienceQuery } from "@/generated/graphql"; 18 + import { useDeleteJobExperienceMutation } from "@/generated/graphql"; 15 19 16 20 interface JobExperienceTableProps { 17 - experiences: MeJobExperienceQuery["myEmploymentHistory"]; 18 - onEdit?: (experience: MeJobExperienceQuery["myEmploymentHistory"][0]) => void; 21 + experiences: NonNullable< 22 + NonNullable<MeJobExperienceQuery["me"]>["experience"] 23 + >["edges"][number]["node"][]; 24 + onEdit?: ( 25 + experience: NonNullable< 26 + NonNullable<MeJobExperienceQuery["me"]>["experience"] 27 + >["edges"][number]["node"], 28 + ) => void; 19 29 onDelete?: () => void; 20 30 } 21 31 ··· 24 34 onEdit, 25 35 onDelete, 26 36 }: JobExperienceTableProps) => { 27 - const [deleteJobExperience, { loading }] = useDeleteJobExperienceMutation(); 37 + const { mutateAsync: deleteJobExperience, isPending: loading } = 38 + useDeleteJobExperienceMutation(); 39 + const queryClient = useQueryClient(); 28 40 const { showSuccess, showError } = useToast(); 29 41 const { showConfirmation } = useConfirmationModal(); 30 42 31 43 const handleDeleteClick = async (experienceId: string) => { 32 - const experience = experiences.find((e) => e.id === experienceId); 44 + const experience = experiences.find(({ id }) => id === experienceId); 33 45 34 - const _confirmed = await showConfirmation({ 46 + await showConfirmation({ 35 47 title: "Delete Job Experience", 36 48 message: `Are you sure you want to delete the ${experience?.role.name} position at ${experience?.company.name}? This action cannot be undone.`, 37 49 confirmText: "Delete", ··· 40 52 onConfirm: async () => { 41 53 try { 42 54 await deleteJobExperience({ 43 - variables: { id: experienceId }, 55 + id: experienceId, 56 + }); 57 + 58 + // Invalidate and refetch job experience queries 59 + await queryClient.invalidateQueries({ 60 + queryKey: ["MeJobExperience"], 44 61 }); 45 62 46 63 showSuccess( ··· 60 77 }); 61 78 }; 62 79 63 - const formatDate = (dateString: string) => { 64 - return new Date(dateString).toLocaleDateString(); 65 - }; 66 - 67 - const formatDateRange = (startDate: string, endDate?: string | null) => { 68 - const start = formatDate(startDate); 69 - const end = endDate ? formatDate(endDate) : "Present"; 70 - return `${start} - ${end}`; 71 - }; 72 - 73 80 const getSkillsList = ( 74 81 skills: Array<{ name: string }>, 75 82 maxSkills: number = 3, 76 83 ) => { 77 - if (skills.length === 0) return "—"; 84 + if (skills.length === 0) { 85 + return "—"; 86 + } 87 + 78 88 if (skills.length <= maxSkills) { 79 89 return skills.map((skill) => skill.name).join(", "); 80 90 } 91 + 81 92 const displayedSkills = skills.slice(0, maxSkills); 82 93 const remainingCount = skills.length - maxSkills; 83 94 return `${displayedSkills.map((skill) => skill.name).join(", ")} (and ${remainingCount} more)`; ··· 85 96 86 97 if (experiences.length === 0) { 87 98 return ( 88 - <div className="text-center py-8"> 89 - <div className="text-ctp-subtext0 mb-2">No job experiences found</div> 90 - <div className="text-sm text-ctp-subtext1"> 91 - Create your first job experience to get started 92 - </div> 93 - </div> 99 + <Placeholder 100 + variant="empty" 101 + message="Create your first job experience to get started" 102 + /> 94 103 ); 95 104 } 96 105 ··· 107 116 </TableRow> 108 117 </TableHeader> 109 118 <TableBody> 110 - {experiences.map((experience) => ( 111 - <TableRow key={experience.id}> 112 - <TableCell> 113 - <div className="font-medium text-ctp-text"> 114 - {experience.company.name} 115 - </div> 116 - </TableCell> 117 - <TableCell> 118 - <div className="font-medium text-ctp-text"> 119 - {experience.role.name} 120 - </div> 121 - </TableCell> 122 - <TableCell> 123 - <div className="text-ctp-subtext0">{experience.level.name}</div> 124 - </TableCell> 125 - <TableCell> 126 - <div className="text-ctp-subtext0"> 127 - {formatDateRange(experience.startDate, experience.endDate)} 128 - </div> 129 - </TableCell> 130 - <TableCell> 131 - <div 132 - className="text-ctp-subtext0 max-w-xs truncate" 133 - title={experience.skills.map((s) => s.name).join(", ")} 134 - > 135 - {getSkillsList(experience.skills)} 136 - </div> 137 - </TableCell> 138 - <TableCell> 139 - <div className="flex items-center gap-2"> 140 - <IconButton 141 - icon={<EditIcon />} 142 - label="Edit experience" 143 - variant="ghost" 144 - size="sm" 145 - className="text-ctp-blue hover:bg-ctp-blue/20" 146 - onClick={() => onEdit?.(experience)} 147 - /> 148 - <IconButton 149 - icon={<DeleteIcon />} 150 - label="Delete experience" 151 - variant="ghost" 152 - size="sm" 153 - className="text-ctp-red hover:bg-ctp-red/20" 154 - onClick={() => handleDeleteClick(experience.id)} 155 - disabled={loading} 156 - /> 157 - </div> 158 - </TableCell> 159 - </TableRow> 160 - ))} 119 + {experiences.map((experience) => { 120 + const { id, company, role, level, startDate, endDate, skills } = 121 + experience; 122 + 123 + return ( 124 + <TableRow key={id}> 125 + <TableCell> 126 + <div className="font-medium text-ctp-text p-2"> 127 + {company.name} 128 + </div> 129 + </TableCell> 130 + <TableCell> 131 + <div className="font-medium text-ctp-text p-2">{role.name}</div> 132 + </TableCell> 133 + <TableCell> 134 + <div className="text-ctp-subtext0 p-2">{level.name}</div> 135 + </TableCell> 136 + <TableCell> 137 + <div className="p-2"> 138 + <FormattedDateRange 139 + startDate={startDate} 140 + endDate={endDate} 141 + variant="muted" 142 + /> 143 + </div> 144 + </TableCell> 145 + <TableCell> 146 + <div 147 + className="text-ctp-subtext0 max-w-xs truncate p-2" 148 + title={skills.map(({ name }) => name).join(", ")} 149 + > 150 + {getSkillsList(skills)} 151 + </div> 152 + </TableCell> 153 + <TableCell> 154 + <div className="flex items-center gap-2 p-2"> 155 + <IconButton 156 + icon={<EditIcon />} 157 + label="Edit experience" 158 + variant="primary" 159 + size="xs" 160 + showColorOnHover={true} 161 + onClick={() => onEdit?.(experience)} 162 + /> 163 + <IconButton 164 + icon={<DeleteIcon />} 165 + label="Delete experience" 166 + variant="destructive" 167 + size="xs" 168 + showColorOnHover={true} 169 + onClick={() => handleDeleteClick(id)} 170 + disabled={loading} 171 + /> 172 + </div> 173 + </TableCell> 174 + </TableRow> 175 + ); 176 + })} 161 177 </TableBody> 162 178 </Table> 163 179 );
+80
apps/client/src/features/job-experience/components/LevelSelect.tsx
··· 1 + import { SearchableSelect } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import { 5 + useCreateLevelMutation, 6 + useInfiniteLevelsConnectionQuery, 7 + } from "@/generated/graphql"; 8 + 9 + interface LevelSelectProps { 10 + value: string; 11 + onChange: (value: string) => void; 12 + error?: string; 13 + } 14 + 15 + export const LevelSelect = ({ value, onChange, error }: LevelSelectProps) => { 16 + const { 17 + data: levelsData, 18 + fetchNextPage: fetchNextLevels, 19 + hasNextPage: hasNextLevelsPage, 20 + isFetchingNextPage: isLoadingMoreLevels, 21 + } = useInfiniteLevelsConnectionQuery( 22 + {}, 23 + { 24 + initialPageParam: { after: null }, 25 + getNextPageParam: (lastPage) => 26 + lastPage.levels.pageInfo.hasNextPage 27 + ? { after: lastPage.levels.pageInfo.endCursor } 28 + : undefined, 29 + }, 30 + ); 31 + 32 + const { mutateAsync: createLevel } = useCreateLevelMutation(); 33 + const queryClient = useQueryClient(); 34 + const { showSuccess, showError } = useToast(); 35 + 36 + const levels = 37 + levelsData?.pages.flatMap((page) => 38 + page.levels.edges.map(({ node }) => node), 39 + ) || []; 40 + 41 + const handleAddNew = async (name: string) => { 42 + try { 43 + const result = await createLevel({ name }); 44 + // Invalidate and refetch levels 45 + await queryClient.invalidateQueries({ 46 + queryKey: ["LevelsConnection"], 47 + }); 48 + // Select the newly created level 49 + onChange(result.createLevel.id); 50 + showSuccess("Level Created", `Successfully created "${name}"`); 51 + } catch (error) { 52 + console.error("Error creating level:", error); 53 + showError( 54 + "Failed to Create Level", 55 + "There was an error creating the level. Please try again.", 56 + ); 57 + } 58 + }; 59 + 60 + return ( 61 + <SearchableSelect 62 + label="Level" 63 + options={levels.map((level) => ({ 64 + value: level.id, 65 + label: level.name, 66 + }))} 67 + value={value} 68 + onChange={onChange} 69 + placeholder="Select a level" 70 + required 71 + error={error} 72 + hasNextPage={hasNextLevelsPage} 73 + onLoadMore={() => fetchNextLevels()} 74 + isLoading={isLoadingMoreLevels} 75 + allowAddNew 76 + onAddNew={handleAddNew} 77 + addNewLabel="Add level" 78 + /> 79 + ); 80 + };
+80
apps/client/src/features/job-experience/components/RoleSelect.tsx
··· 1 + import { SearchableSelect } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import { 5 + useCreateRoleMutation, 6 + useInfiniteRolesConnectionQuery, 7 + } from "@/generated/graphql"; 8 + 9 + interface RoleSelectProps { 10 + value: string; 11 + onChange: (value: string) => void; 12 + error?: string; 13 + } 14 + 15 + export const RoleSelect = ({ value, onChange, error }: RoleSelectProps) => { 16 + const { 17 + data: rolesData, 18 + fetchNextPage: fetchNextRoles, 19 + hasNextPage: hasNextRolesPage, 20 + isFetchingNextPage: isLoadingMoreRoles, 21 + } = useInfiniteRolesConnectionQuery( 22 + {}, 23 + { 24 + initialPageParam: { after: null }, 25 + getNextPageParam: ({ roles }) => 26 + roles.pageInfo.hasNextPage 27 + ? { after: roles.pageInfo.endCursor } 28 + : undefined, 29 + }, 30 + ); 31 + 32 + const { mutateAsync: createRole } = useCreateRoleMutation(); 33 + const queryClient = useQueryClient(); 34 + const { showSuccess, showError } = useToast(); 35 + 36 + const roles = 37 + rolesData?.pages.flatMap((page) => 38 + page.roles.edges.map(({ node }) => node), 39 + ) || []; 40 + 41 + const handleAddNew = async (name: string) => { 42 + try { 43 + const result = await createRole({ name }); 44 + // Invalidate and refetch roles 45 + await queryClient.invalidateQueries({ 46 + queryKey: ["RolesConnection"], 47 + }); 48 + // Select the newly created role 49 + onChange(result.createRole.id); 50 + showSuccess("Role Created", `Successfully created "${name}"`); 51 + } catch (error) { 52 + console.error("Error creating role:", error); 53 + showError( 54 + "Failed to Create Role", 55 + "There was an error creating the role. Please try again.", 56 + ); 57 + } 58 + }; 59 + 60 + return ( 61 + <SearchableSelect 62 + label="Role" 63 + options={roles.map(({ id, name }) => ({ 64 + value: id, 65 + label: name, 66 + }))} 67 + value={value} 68 + onChange={onChange} 69 + placeholder="Select a role" 70 + required 71 + error={error} 72 + hasNextPage={hasNextRolesPage} 73 + onLoadMore={() => fetchNextRoles()} 74 + isLoading={isLoadingMoreRoles} 75 + allowAddNew 76 + onAddNew={handleAddNew} 77 + addNewLabel="Add role" 78 + /> 79 + ); 80 + };
+60
apps/client/src/features/job-experience/components/SelectedSkillsDisplay.tsx
··· 1 + import { useInfiniteSkillsQuery } from "@/generated/graphql"; 2 + 3 + interface SelectedSkillsDisplayProps { 4 + skillIds: string[]; 5 + onRemoveSkill: (skillId: string) => void; 6 + } 7 + 8 + export const SelectedSkillsDisplay = ({ 9 + skillIds, 10 + onRemoveSkill, 11 + }: SelectedSkillsDisplayProps) => { 12 + const { data: skillsData } = useInfiniteSkillsQuery( 13 + { 14 + first: 50, // Get enough skills to display selected ones 15 + }, 16 + { 17 + initialPageParam: { after: null }, 18 + getNextPageParam: (lastPage) => 19 + lastPage.skills.pageInfo.hasNextPage 20 + ? { after: lastPage.skills.pageInfo.endCursor } 21 + : undefined, 22 + }, 23 + ); 24 + 25 + const skills = 26 + skillsData?.pages.flatMap((page) => 27 + page.skills.edges.map(({ node }) => node), 28 + ) || []; 29 + if (!skillIds || skillIds.length === 0) { 30 + return null; 31 + } 32 + 33 + return ( 34 + <div className="space-y-2"> 35 + <span className="text-sm font-medium text-ctp-text"> 36 + Selected Skills ({skillIds.length}) 37 + </span> 38 + <div className="flex flex-wrap gap-2"> 39 + {skillIds.map((skillId) => { 40 + const skill = skills.find((s) => s.id === skillId); 41 + return skill ? ( 42 + <div 43 + key={skillId} 44 + className="flex items-center gap-2 bg-ctp-surface0 px-3 py-1 rounded-full text-sm" 45 + > 46 + <span>{skill.name}</span> 47 + <button 48 + type="button" 49 + onClick={() => onRemoveSkill(skillId)} 50 + className="text-ctp-red hover:text-ctp-red/80" 51 + > 52 + × 53 + </button> 54 + </div> 55 + ) : null; 56 + })} 57 + </div> 58 + </div> 59 + ); 60 + };
+79
apps/client/src/features/job-experience/components/SkillsSelect.tsx
··· 1 + import { SearchableSelect } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import { 5 + useCreateSkillMutation, 6 + useInfiniteSkillsQuery, 7 + } from "@/generated/graphql"; 8 + 9 + interface SkillsSelectProps { 10 + value: string; 11 + onChange: (value: string) => void; 12 + } 13 + 14 + export const SkillsSelect = ({ value, onChange }: SkillsSelectProps) => { 15 + const { 16 + data: skillsData, 17 + fetchNextPage: fetchNextSkills, 18 + hasNextPage: hasNextSkillsPage, 19 + isFetchingNextPage: isLoadingMoreSkills, 20 + } = useInfiniteSkillsQuery( 21 + { 22 + first: 10, // Load only 10 items initially 23 + }, 24 + { 25 + initialPageParam: { after: null }, 26 + getNextPageParam: (lastPage) => 27 + lastPage.skills.pageInfo.hasNextPage 28 + ? { after: lastPage.skills.pageInfo.endCursor } 29 + : undefined, 30 + }, 31 + ); 32 + 33 + const { mutateAsync: createSkill } = useCreateSkillMutation(); 34 + const queryClient = useQueryClient(); 35 + const { showSuccess, showError } = useToast(); 36 + 37 + const skills = 38 + skillsData?.pages.flatMap((page) => 39 + page.skills.edges.map(({ node }) => node), 40 + ) || []; 41 + 42 + const handleAddNew = async (name: string) => { 43 + try { 44 + const result = await createSkill({ name }); 45 + // Invalidate and refetch skills 46 + await queryClient.invalidateQueries({ 47 + queryKey: ["Skills"], 48 + }); 49 + // Select the newly created skill 50 + onChange(result.createSkill.id); 51 + showSuccess("Skill Created", `Successfully created "${name}"`); 52 + } catch (error) { 53 + console.error("Error creating skill:", error); 54 + showError( 55 + "Failed to Create Skill", 56 + "There was an error creating the skill. Please try again.", 57 + ); 58 + } 59 + }; 60 + 61 + return ( 62 + <SearchableSelect 63 + label="Skills" 64 + options={skills.map((skill) => ({ 65 + value: skill.id, 66 + label: skill.name, 67 + }))} 68 + value={value} 69 + onChange={onChange} 70 + placeholder="Search and select skills..." 71 + hasNextPage={hasNextSkillsPage} 72 + onLoadMore={() => fetchNextSkills()} 73 + isLoading={isLoadingMoreSkills} 74 + allowAddNew 75 + onAddNew={handleAddNew} 76 + addNewLabel="Add skill" 77 + /> 78 + ); 79 + };
+11 -2
apps/client/src/features/job-experience/components/jobExperience.schema.ts
··· 5 5 companyId: z.string().min(1, "Company is required"), 6 6 roleId: z.string().min(1, "Role is required"), 7 7 levelId: z.string().min(1, "Level is required"), 8 - startDate: z.string().min(1, "Start date is required"), 9 - endDate: z.string().optional(), 8 + startDate: z.date().nullable(), 9 + endDate: z.date().nullable().optional(), 10 10 description: z.string().optional(), 11 11 skillIds: z.array(z.string()).optional(), 12 12 isCurrentPosition: z.boolean().default(false), 13 13 }) 14 + .refine( 15 + (data) => { 16 + return data.startDate !== null; 17 + }, 18 + { 19 + message: "Start date is required", 20 + path: ["startDate"], 21 + }, 22 + ) 14 23 .refine( 15 24 (data) => { 16 25 if (!(data.isCurrentPosition || data.endDate)) {