because I got bored of customising my CV for every job

feat(client): add education and institution management

+735
+244
apps/client/src/features/education/components/EducationForm.tsx
··· 1 + import { Button, Calendar, Checkbox, Textarea, TextInput } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useId, useState } from "react"; 4 + import { useToast } from "@/contexts/ToastContext"; 5 + import { SelectedSkillsDisplay } from "@/features/job-experience/components/SelectedSkillsDisplay"; 6 + import { SkillsSelect } from "@/features/job-experience/components/SkillsSelect"; 7 + import { useCreateEducationMutation } from "@/generated/graphql"; 8 + import { 9 + type EducationFormData, 10 + educationFormSchema, 11 + } from "./education.schema"; 12 + import { InstitutionSelect } from "./InstitutionSelect"; 13 + 14 + interface EducationFormProps { 15 + onSuccess?: () => void; 16 + onCancel?: () => void; 17 + } 18 + 19 + export const EducationForm = ({ onSuccess, onCancel }: EducationFormProps) => { 20 + const { mutateAsync: createEducation, isPending: loading } = 21 + useCreateEducationMutation(); 22 + const queryClient = useQueryClient(); 23 + const { showSuccess, showError } = useToast(); 24 + 25 + const startDateId = useId(); 26 + const endDateId = useId(); 27 + 28 + const [formData, setFormData] = useState<EducationFormData>({ 29 + institutionId: "", 30 + degree: "", 31 + fieldOfStudy: "", 32 + startDate: null, 33 + endDate: null, 34 + description: "", 35 + isCurrentEducation: false, 36 + skillIds: [], 37 + }); 38 + 39 + const [errors, setErrors] = useState< 40 + Partial<Record<keyof EducationFormData, string>> 41 + >({}); 42 + 43 + const handleSubmit = async (e: React.FormEvent) => { 44 + e.preventDefault(); 45 + 46 + // Validate form using Zod 47 + const result = educationFormSchema.safeParse(formData); 48 + 49 + if (!result.success) { 50 + const newErrors: Partial<Record<keyof EducationFormData, string>> = {}; 51 + result.error.errors.forEach((error) => { 52 + if (error.path.length > 0) { 53 + newErrors[error.path[0] as keyof EducationFormData] = error.message; 54 + } 55 + }); 56 + setErrors(newErrors); 57 + return; 58 + } 59 + 60 + setErrors({}); 61 + 62 + try { 63 + await createEducation({ 64 + institutionId: formData.institutionId, 65 + degree: formData.degree, 66 + fieldOfStudy: formData.fieldOfStudy || null, 67 + startDate: formData.startDate || new Date(), 68 + endDate: formData.isCurrentEducation ? null : formData.endDate || null, 69 + description: formData.description || null, 70 + skillIds: 71 + formData.skillIds && formData.skillIds.length > 0 72 + ? formData.skillIds 73 + : null, 74 + }); 75 + 76 + // Invalidate and refetch education queries 77 + await queryClient.invalidateQueries({ 78 + queryKey: ["MeEducation"], 79 + }); 80 + 81 + showSuccess( 82 + "Education Created", 83 + "Your education has been successfully added.", 84 + ); 85 + 86 + onSuccess?.(); 87 + } catch (error) { 88 + console.error("Error creating education:", error); 89 + showError( 90 + "Failed to Create Education", 91 + "There was an error creating the education. Please try again.", 92 + ); 93 + } 94 + }; 95 + 96 + const handleInputChange = (field: string, value: string) => { 97 + setFormData((prev) => ({ ...prev, [field]: value })); 98 + }; 99 + 100 + const handleCheckboxChange = (field: string, checked: boolean) => { 101 + setFormData((prev) => ({ ...prev, [field]: checked })); 102 + }; 103 + 104 + const handleDateChange = (field: string, date: Date | null) => { 105 + setFormData((prev) => ({ ...prev, [field]: date })); 106 + }; 107 + 108 + const handleSkillToggle = (skillId: string) => { 109 + setFormData((prev) => ({ 110 + ...prev, 111 + skillIds: (prev.skillIds || []).includes(skillId) 112 + ? (prev.skillIds || []).filter((id) => id !== skillId) 113 + : [...(prev.skillIds || []), skillId], 114 + })); 115 + }; 116 + 117 + return ( 118 + <form onSubmit={handleSubmit} className="space-y-6"> 119 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 120 + <InstitutionSelect 121 + value={formData.institutionId} 122 + onChange={(value: string) => 123 + handleInputChange("institutionId", value) 124 + } 125 + error={errors.institutionId} 126 + /> 127 + 128 + <TextInput 129 + label="Degree" 130 + value={formData.degree} 131 + onChange={(value: string) => handleInputChange("degree", value)} 132 + required 133 + error={errors.degree} 134 + placeholder="e.g., Bachelor of Science" 135 + /> 136 + 137 + <TextInput 138 + label="Field of Study" 139 + value={formData.fieldOfStudy || ""} 140 + onChange={(value: string) => handleInputChange("fieldOfStudy", value)} 141 + error={errors.fieldOfStudy} 142 + placeholder="e.g., Computer Science" 143 + /> 144 + 145 + <div> 146 + <label 147 + htmlFor={startDateId} 148 + className="block text-sm font-medium text-ctp-text mb-2" 149 + > 150 + Start Date <span className="text-ctp-red">*</span> 151 + </label> 152 + <Calendar 153 + value={formData.startDate} 154 + onChange={(date: Date | null) => 155 + handleDateChange("startDate", date) 156 + } 157 + placeholder="Select start date" 158 + showTime={false} 159 + format="long" 160 + className="w-full" 161 + /> 162 + {errors.startDate && ( 163 + <p className="text-sm text-ctp-red mt-1">{errors.startDate}</p> 164 + )} 165 + </div> 166 + 167 + <div> 168 + <label 169 + htmlFor={endDateId} 170 + className="block text-sm font-medium text-ctp-text mb-2" 171 + > 172 + End Date 173 + </label> 174 + <Calendar 175 + value={formData.endDate} 176 + onChange={(date: Date | null) => handleDateChange("endDate", date)} 177 + placeholder="Select end date" 178 + showTime={false} 179 + format="long" 180 + className="w-full" 181 + disabled={formData.isCurrentEducation} 182 + /> 183 + {errors.endDate && ( 184 + <p className="text-sm text-ctp-red mt-1">{errors.endDate}</p> 185 + )} 186 + </div> 187 + </div> 188 + 189 + <Checkbox 190 + label="Currently studying here" 191 + checked={formData.isCurrentEducation} 192 + onChange={(checked: boolean) => 193 + handleCheckboxChange("isCurrentEducation", checked) 194 + } 195 + /> 196 + 197 + <Textarea 198 + label="Description" 199 + value={formData.description || ""} 200 + onChange={(value: string) => handleInputChange("description", value)} 201 + rows={4} 202 + placeholder="Additional details about your education..." 203 + /> 204 + 205 + <div className="space-y-4"> 206 + <SkillsSelect 207 + value="" 208 + onChange={(value: string) => { 209 + if (!formData.skillIds?.includes(value)) { 210 + setFormData((prev) => ({ 211 + ...prev, 212 + skillIds: [...(prev.skillIds || []), value], 213 + })); 214 + } 215 + }} 216 + /> 217 + 218 + <SelectedSkillsDisplay 219 + skillIds={formData.skillIds || []} 220 + onRemoveSkill={handleSkillToggle} 221 + /> 222 + </div> 223 + 224 + <div className="flex space-x-4"> 225 + <Button 226 + type="submit" 227 + disabled={ 228 + loading || 229 + !formData.institutionId || 230 + !formData.degree || 231 + !formData.startDate 232 + } 233 + > 234 + {loading ? "Creating..." : "Create Education"} 235 + </Button> 236 + {onCancel && ( 237 + <Button type="button" variant="ghost" onClick={onCancel}> 238 + Cancel 239 + </Button> 240 + )} 241 + </div> 242 + </form> 243 + ); 244 + };
+156
apps/client/src/features/education/components/EducationTable.tsx
··· 1 + import { 2 + DeleteIcon, 3 + EditIcon, 4 + FormattedDateRange, 5 + IconButton, 6 + Placeholder, 7 + Table, 8 + TableBody, 9 + TableCell, 10 + TableHeader, 11 + TableHeaderCell, 12 + TableRow, 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 { MeEducationQuery } from "@/generated/graphql"; 18 + import { useDeleteEducationMutation } from "@/generated/graphql"; 19 + 20 + interface EducationTableProps { 21 + educations: NonNullable< 22 + NonNullable<MeEducationQuery["me"]>["educationHistory"] 23 + >["edges"][number]["node"][]; 24 + onEdit?: ( 25 + education: NonNullable< 26 + NonNullable<MeEducationQuery["me"]>["educationHistory"] 27 + >["edges"][number]["node"], 28 + ) => void; 29 + onDelete?: () => void; 30 + } 31 + 32 + export const EducationTable = ({ 33 + educations, 34 + onEdit, 35 + onDelete, 36 + }: EducationTableProps) => { 37 + const { mutateAsync: deleteEducation, isPending: loading } = 38 + useDeleteEducationMutation(); 39 + const queryClient = useQueryClient(); 40 + const { showSuccess, showError } = useToast(); 41 + const { showConfirmation } = useConfirmationModal(); 42 + 43 + const handleDeleteClick = async (educationId: string) => { 44 + const education = educations.find(({ id }) => id === educationId); 45 + 46 + await showConfirmation({ 47 + title: "Delete Education", 48 + message: `Are you sure you want to delete your ${education?.degree} from ${education?.institution?.name}? This action cannot be undone.`, 49 + confirmText: "Delete", 50 + cancelText: "Cancel", 51 + variant: "danger", 52 + onConfirm: async () => { 53 + try { 54 + await deleteEducation({ 55 + id: educationId, 56 + }); 57 + 58 + // Invalidate and refetch education queries 59 + await queryClient.invalidateQueries({ 60 + queryKey: ["MeEducation"], 61 + }); 62 + 63 + showSuccess( 64 + "Education Deleted", 65 + `Successfully deleted ${education?.degree} from ${education?.institution?.name}`, 66 + ); 67 + 68 + onDelete?.(); 69 + } catch (error) { 70 + console.error("Error deleting education:", error); 71 + showError( 72 + "Failed to Delete Education", 73 + "There was an error deleting the education. Please try again.", 74 + ); 75 + } 76 + }, 77 + }); 78 + }; 79 + 80 + if (educations.length === 0) { 81 + return ( 82 + <Placeholder 83 + variant="empty" 84 + message="Create your first education entry to get started" 85 + /> 86 + ); 87 + } 88 + 89 + return ( 90 + <Table> 91 + <TableHeader> 92 + <TableRow> 93 + <TableHeaderCell>Institution</TableHeaderCell> 94 + <TableHeaderCell>Degree</TableHeaderCell> 95 + <TableHeaderCell>Field of Study</TableHeaderCell> 96 + <TableHeaderCell>Duration</TableHeaderCell> 97 + <TableHeaderCell>Actions</TableHeaderCell> 98 + </TableRow> 99 + </TableHeader> 100 + <TableBody> 101 + {educations.map((education) => { 102 + const { id, institution, degree, fieldOfStudy, startDate, endDate } = 103 + education; 104 + 105 + return ( 106 + <TableRow key={id}> 107 + <TableCell> 108 + <div className="font-medium text-ctp-text p-2"> 109 + {institution?.name || "—"} 110 + </div> 111 + </TableCell> 112 + <TableCell> 113 + <div className="font-medium text-ctp-text p-2">{degree}</div> 114 + </TableCell> 115 + <TableCell> 116 + <div className="text-ctp-subtext0 p-2"> 117 + {fieldOfStudy || "—"} 118 + </div> 119 + </TableCell> 120 + <TableCell> 121 + <div className="p-2"> 122 + <FormattedDateRange 123 + startDate={startDate} 124 + endDate={endDate} 125 + variant="muted" 126 + /> 127 + </div> 128 + </TableCell> 129 + <TableCell> 130 + <div className="flex items-center gap-2 p-2"> 131 + <IconButton 132 + icon={<EditIcon />} 133 + label="Edit education" 134 + variant="primary" 135 + size="xs" 136 + showColorOnHover={true} 137 + onClick={() => onEdit?.(education)} 138 + /> 139 + <IconButton 140 + icon={<DeleteIcon />} 141 + label="Delete education" 142 + variant="destructive" 143 + size="xs" 144 + showColorOnHover={true} 145 + onClick={() => handleDeleteClick(id)} 146 + disabled={loading} 147 + /> 148 + </div> 149 + </TableCell> 150 + </TableRow> 151 + ); 152 + })} 153 + </TableBody> 154 + </Table> 155 + ); 156 + };
+64
apps/client/src/features/education/components/InstitutionSelect.tsx
··· 1 + import { SearchableSelect } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import { 5 + useCreateInstitutionMutation, 6 + useInstitutionsQuery, 7 + } from "@/generated/graphql"; 8 + 9 + interface InstitutionSelectProps { 10 + value: string; 11 + onChange: (value: string) => void; 12 + error?: string; 13 + } 14 + 15 + export const InstitutionSelect = ({ 16 + value, 17 + onChange, 18 + error, 19 + }: InstitutionSelectProps) => { 20 + const { data, isLoading } = useInstitutionsQuery(); 21 + const { mutateAsync: createInstitution } = useCreateInstitutionMutation(); 22 + const queryClient = useQueryClient(); 23 + const { showSuccess, showError } = useToast(); 24 + 25 + const institutions = data?.institutions || []; 26 + 27 + const handleAddNew = async (name: string) => { 28 + try { 29 + const result = await createInstitution({ name }); 30 + // Invalidate and refetch institutions 31 + await queryClient.invalidateQueries({ 32 + queryKey: ["Institutions"], 33 + }); 34 + // Select the newly created institution 35 + onChange(result.createInstitution.id); 36 + showSuccess("Institution Created", `Successfully created "${name}"`); 37 + } catch (error) { 38 + console.error("Error creating institution:", error); 39 + showError( 40 + "Failed to Create Institution", 41 + "There was an error creating the institution. Please try again.", 42 + ); 43 + } 44 + }; 45 + 46 + return ( 47 + <SearchableSelect 48 + label="Institution" 49 + options={institutions.map((institution) => ({ 50 + value: institution.id, 51 + label: institution.name, 52 + }))} 53 + value={value} 54 + onChange={onChange} 55 + placeholder="Select an institution" 56 + required 57 + error={error} 58 + isLoading={isLoading} 59 + allowAddNew 60 + onAddNew={handleAddNew} 61 + addNewLabel="Add institution" 62 + /> 63 + ); 64 + };
+33
apps/client/src/features/education/components/education.schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const educationFormSchema = z 4 + .object({ 5 + institutionId: z.string().min(1, "Institution is required"), 6 + degree: z 7 + .string() 8 + .min(1, "Degree is required") 9 + .max(200, "Degree must be less than 200 characters"), 10 + fieldOfStudy: z 11 + .string() 12 + .max(200, "Field of study must be less than 200 characters") 13 + .optional(), 14 + startDate: z.date({ required_error: "Start date is required" }), 15 + endDate: z.date().optional().nullable(), 16 + description: z.string().optional(), 17 + isCurrentEducation: z.boolean().default(false), 18 + skillIds: z.array(z.string()).optional().default([]), 19 + }) 20 + .refine( 21 + (data) => { 22 + if (data.endDate && data.startDate && data.endDate < data.startDate) { 23 + return false; 24 + } 25 + return true; 26 + }, 27 + { 28 + message: "End date must be after start date", 29 + path: ["endDate"], 30 + }, 31 + ); 32 + 33 + export type EducationFormData = z.infer<typeof educationFormSchema>;
+3
apps/client/src/features/education/components/index.ts
··· 1 + export { EducationForm } from "./EducationForm"; 2 + export { EducationTable } from "./EducationTable"; 3 + export { InstitutionSelect } from "./InstitutionSelect";
+38
apps/client/src/features/education/queries/create-education.graphql
··· 1 + mutation CreateEducation( 2 + $institutionId: String! 3 + $degree: String! 4 + $fieldOfStudy: String 5 + $startDate: DateTime! 6 + $endDate: DateTime 7 + $description: String 8 + $skillIds: [String!] 9 + ) { 10 + createEducation( 11 + institutionId: $institutionId 12 + degree: $degree 13 + fieldOfStudy: $fieldOfStudy 14 + startDate: $startDate 15 + endDate: $endDate 16 + description: $description 17 + skillIds: $skillIds 18 + ) { 19 + id 20 + institutionId 21 + institution { 22 + id 23 + name 24 + } 25 + degree 26 + fieldOfStudy 27 + startDate 28 + endDate 29 + description 30 + skills { 31 + id 32 + name 33 + description 34 + } 35 + createdAt 36 + updatedAt 37 + } 38 + }
+9
apps/client/src/features/education/queries/create-institution.graphql
··· 1 + mutation CreateInstitution($name: String!, $description: String) { 2 + createInstitution(name: $name, description: $description) { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + }
+3
apps/client/src/features/education/queries/delete-education.graphql
··· 1 + mutation DeleteEducation($id: String!) { 2 + deleteEducation(id: $id) 3 + }
+9
apps/client/src/features/education/queries/institutions.graphql
··· 1 + query Institutions { 2 + institutions { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + }
+37
apps/client/src/features/education/queries/me-education.graphql
··· 1 + query MeEducation { 2 + me { 3 + educationHistory { 4 + edges { 5 + node { 6 + id 7 + userId 8 + institutionId 9 + institution { 10 + id 11 + name 12 + description 13 + } 14 + degree 15 + fieldOfStudy 16 + startDate 17 + endDate 18 + description 19 + skills { 20 + id 21 + name 22 + description 23 + } 24 + createdAt 25 + updatedAt 26 + } 27 + } 28 + pageInfo { 29 + hasNextPage 30 + hasPreviousPage 31 + startCursor 32 + endCursor 33 + } 34 + totalCount 35 + } 36 + } 37 + }
+26
apps/client/src/pages/CreateEducationPage.tsx
··· 1 + import { PageHeader } from "@cv/ui"; 2 + import { useNavigate } from "react-router-dom"; 3 + import { EducationForm } from "@/features/education/components/EducationForm"; 4 + 5 + export default function CreateEducationPage() { 6 + const navigate = useNavigate(); 7 + 8 + const handleSuccess = () => { 9 + navigate("/education"); 10 + }; 11 + 12 + const handleCancel = () => { 13 + navigate("/education"); 14 + }; 15 + 16 + return ( 17 + <div className="space-y-6"> 18 + <PageHeader 19 + title="Add Education" 20 + description="Add a new educational qualification to your profile" 21 + /> 22 + 23 + <EducationForm onSuccess={handleSuccess} onCancel={handleCancel} /> 24 + </div> 25 + ); 26 + }
+113
apps/client/src/pages/EducationPage.tsx
··· 1 + import { Button, PageHeader, Placeholder } from "@cv/ui"; 2 + import { useNavigate } from "react-router-dom"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import { EducationTable } from "@/features/education/components"; 5 + import type { MeEducationQuery } from "@/generated/graphql"; 6 + import { useMeEducationQuery } from "@/generated/graphql"; 7 + 8 + export default function EducationPage() { 9 + const { data, isPending: loading, error, refetch } = useMeEducationQuery(); 10 + const { showInfo } = useToast(); 11 + const navigate = useNavigate(); 12 + 13 + const handleEdit = ( 14 + education: NonNullable< 15 + NonNullable<MeEducationQuery["me"]>["educationHistory"] 16 + >["edges"][number]["node"], 17 + ) => { 18 + // TODO: Implement edit functionality 19 + showInfo("Edit Feature", "Edit functionality is coming soon!"); 20 + console.log("Edit education:", education); 21 + }; 22 + 23 + const handleDelete = async () => { 24 + try { 25 + await refetch(); 26 + } catch (error) { 27 + console.error("Error refreshing education history:", error); 28 + } 29 + }; 30 + 31 + if (loading) { 32 + return ( 33 + <div className="space-y-6"> 34 + <PageHeader 35 + title="Education History" 36 + description="Manage your educational background and qualifications" 37 + /> 38 + <Placeholder variant="loading" message="Loading education history..." /> 39 + </div> 40 + ); 41 + } 42 + 43 + if (error) { 44 + return ( 45 + <div className="space-y-6"> 46 + <PageHeader 47 + title="Education History" 48 + description="Manage your educational background and qualifications" 49 + /> 50 + <Placeholder 51 + variant="error" 52 + message="Error loading education history" 53 + /> 54 + </div> 55 + ); 56 + } 57 + 58 + const educations = 59 + data?.me?.educationHistory?.edges?.map((edge) => edge.node) || []; 60 + 61 + if (educations.length === 0) { 62 + return ( 63 + <div className="space-y-6"> 64 + <PageHeader 65 + title="Education History" 66 + description="Manage your educational background and qualifications" 67 + action={ 68 + <Button onClick={() => navigate("/education/create")}> 69 + Add Education 70 + </Button> 71 + } 72 + /> 73 + 74 + <Placeholder 75 + variant="empty" 76 + message="Create your first education entry to get started" 77 + > 78 + <Button 79 + onClick={() => navigate("/education/create")} 80 + className="mt-4" 81 + > 82 + Add Education 83 + </Button> 84 + </Placeholder> 85 + </div> 86 + ); 87 + } 88 + 89 + return ( 90 + <div className="space-y-6"> 91 + <PageHeader 92 + title="Education History" 93 + description="Manage your educational background and qualifications" 94 + action={ 95 + <Button onClick={() => navigate("/education/create")}> 96 + Add Education 97 + </Button> 98 + } 99 + /> 100 + 101 + <div> 102 + <h2 className="text-lg font-semibold text-ctp-text mb-4"> 103 + Your Education ({educations.length}) 104 + </h2> 105 + <EducationTable 106 + educations={educations} 107 + onEdit={handleEdit} 108 + onDelete={handleDelete} 109 + /> 110 + </div> 111 + </div> 112 + ); 113 + }