because I got bored of customising my CV for every job

refactor(client): update vacancy components and queries

+277 -135
+30 -23
apps/client/src/features/vacancies/components/VacancyCard.tsx
··· 1 + import { Button, DeleteIcon, FormattedDate, IconButton } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 1 3 import { useState } from "react"; 2 - import ConfirmationModal from "@/components/ConfirmationModal"; 3 - import { DeleteIcon } from "@/components/icons"; 4 + import { ConfirmationModal } from "@/components/ConfirmationModal"; 4 5 import { useToast } from "@/contexts/ToastContext"; 5 6 import { useDeleteVacancyMutation } from "@/generated/graphql"; 6 - import Button from "@/ui/Button"; 7 - import IconButton from "@/ui/IconButton"; 7 + import { formatDeadline } from "@/utils/dateUtils"; 8 8 9 9 interface VacancyCardProps { 10 10 vacancy: { 11 11 id: string; 12 12 title: string; 13 - company: string; 13 + company: { 14 + id: string; 15 + name: string; 16 + }; 14 17 description?: string | null; 15 18 requirements?: string | null; 16 19 location?: string | null; 17 - salary?: string | null; 20 + minSalary?: number | null; 21 + maxSalary?: number | null; 18 22 jobType?: string | null; 19 23 applicationUrl?: string | null; 20 24 deadline?: string | null; ··· 25 29 } 26 30 27 31 export const VacancyCard = ({ vacancy, onDelete }: VacancyCardProps) => { 28 - const [deleteVacancy, { loading }] = useDeleteVacancyMutation(); 32 + const { mutateAsync: deleteVacancy, isPending: loading } = 33 + useDeleteVacancyMutation(); 34 + const queryClient = useQueryClient(); 29 35 const { showSuccess, showError } = useToast(); 30 36 const [showDetails, setShowDetails] = useState(false); 31 37 const [showDeleteModal, setShowDeleteModal] = useState(false); ··· 37 43 const handleDeleteConfirm = async () => { 38 44 try { 39 45 await deleteVacancy({ 40 - variables: { id: vacancy.id }, 46 + id: vacancy.id, 47 + }); 48 + 49 + // Invalidate and refetch vacancy queries 50 + await queryClient.invalidateQueries({ 51 + queryKey: ["MyVacancies"], 41 52 }); 42 53 43 54 // Show success toast 44 55 showSuccess( 45 56 "Vacancy Deleted", 46 - `Successfully deleted vacancy for ${vacancy.title} at ${vacancy.company}`, 57 + `Successfully deleted vacancy for ${vacancy.title} at ${vacancy.company.name}`, 47 58 ); 48 59 49 60 setShowDeleteModal(false); ··· 61 72 setShowDeleteModal(false); 62 73 }; 63 74 64 - const formatDate = (dateString: string) => { 65 - return new Date(dateString).toLocaleDateString(); 66 - }; 67 - 68 - const formatDeadline = (deadlineString?: string | null) => { 69 - if (!deadlineString) return null; 70 - return new Date(deadlineString).toLocaleDateString(); 71 - }; 72 - 73 75 return ( 74 76 <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-6 shadow-sm"> 75 77 <div className="flex items-start justify-between"> ··· 90 92 </div> 91 93 92 94 <div className="text-ctp-subtext0 mb-2"> 93 - <div className="font-medium text-ctp-text">{vacancy.company}</div> 95 + <div className="font-medium text-ctp-text"> 96 + {vacancy.company.name} 97 + </div> 94 98 {vacancy.location && <div>{vacancy.location}</div>} 95 - {vacancy.salary && <div>{vacancy.salary}</div>} 99 + {(() => { 100 + const salary = formatSalary(vacancy.minSalary, vacancy.maxSalary); 101 + return salary ? <div>Salary: {salary}</div> : null; 102 + })()} 96 103 {vacancy.jobType && <div>{vacancy.jobType}</div>} 97 104 </div> 98 105 ··· 145 152 )} 146 153 147 154 <div className="text-xs text-ctp-subtext1 mt-2"> 148 - Created: {formatDate(vacancy.createdAt)} 155 + Created: <FormattedDate date={vacancy.createdAt} /> 149 156 </div> 150 157 </div> 151 158 ··· 155 162 label="Delete vacancy" 156 163 onClick={handleDeleteClick} 157 164 disabled={loading} 158 - className="text-ctp-red hover:bg-ctp-red/20" 165 + variant="destructive" 159 166 /> 160 167 </div> 161 168 </div> ··· 177 184 onClose={handleDeleteCancel} 178 185 onConfirm={handleDeleteConfirm} 179 186 title="Delete Vacancy" 180 - message={`Are you sure you want to delete the vacancy for ${vacancy.title} at ${vacancy.company}? This action cannot be undone.`} 187 + message={`Are you sure you want to delete the vacancy for ${vacancy.title} at ${vacancy.company.name}? This action cannot be undone.`} 181 188 confirmText="Delete" 182 189 cancelText="Cancel" 183 190 variant="danger"
+5 -8
apps/client/src/features/vacancies/components/VacancyCreationSelector/CreationMethodCard.tsx
··· 1 - import IconButton from "@/ui/IconButton"; 1 + import { cn } from "@cv/ui"; 2 + import { cloneElement } from "react"; 2 3 import type { CreationMethodCardProps } from "./types"; 3 4 import { 4 5 creationMethodCardVariants, ··· 21 22 > 22 23 <div className="flex items-center gap-3 mb-3"> 23 24 <div className={iconContainerVariants({ color })}> 24 - <IconButton 25 - icon={icon} 26 - label={title} 27 - variant="ghost" 28 - size="sm" 29 - className={iconButtonVariants({ color })} 30 - /> 25 + <div className={cn(iconButtonVariants({ color }), "w-5 h-5")}> 26 + {cloneElement(icon, { className: "w-5 h-5" })} 27 + </div> 31 28 </div> 32 29 <h3 className="font-semibold text-ctp-text">{title}</h3> 33 30 </div>
+1 -1
apps/client/src/features/vacancies/components/VacancyCreationSelector/PlaceholderForm.tsx
··· 1 - import Button from "@/ui/Button"; 1 + import { Button } from "@cv/ui"; 2 2 import type { PlaceholderFormProps } from "./types"; 3 3 4 4 export const PlaceholderForm = ({ title, onBack }: PlaceholderFormProps) => (
+1 -1
apps/client/src/features/vacancies/components/VacancyCreationSelector/VacancyCreationSelector.tsx
··· 1 + import { Button } from "@cv/ui"; 1 2 import { useState } from "react"; 2 - import Button from "@/ui/Button"; 3 3 import { VacancyForm } from "../VacancyForm"; 4 4 import { CreationMethodCard } from "./CreationMethodCard"; 5 5 import { creationMethods } from "./constants";
+1 -6
apps/client/src/features/vacancies/components/VacancyCreationSelector/constants.ts
··· 1 - import { 2 - DocumentIcon, 3 - EditIcon, 4 - LinkIcon, 5 - UploadIcon, 6 - } from "@/components/icons"; 1 + import { DocumentIcon, EditIcon, LinkIcon, UploadIcon } from "@cv/ui"; 7 2 import type { CreationMethod } from "./types"; 8 3 9 4 export const creationMethods = [
+97 -34
apps/client/src/features/vacancies/components/VacancyForm.tsx
··· 1 - import { useState } from "react"; 1 + import { Button, Calendar, Checkbox, Textarea, TextInput } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useId, useState } from "react"; 2 4 import { useToast } from "@/contexts/ToastContext"; 3 5 import { useCreateVacancyMutation } from "@/generated/graphql"; 4 - import Button from "@/ui/Button"; 5 - import Checkbox from "@/ui/Checkbox"; 6 - import Textarea from "@/ui/Textarea"; 7 - import TextInput from "@/ui/TextInput"; 8 6 import { type VacancyFormData, vacancyFormSchema } from "./vacancy.schema"; 9 7 10 8 interface VacancyFormProps { ··· 13 11 } 14 12 15 13 export const VacancyForm = ({ onSuccess, onCancel }: VacancyFormProps) => { 16 - const [createVacancy, { loading }] = useCreateVacancyMutation(); 14 + const { mutateAsync: createVacancy, isPending: loading } = 15 + useCreateVacancyMutation(); 16 + const queryClient = useQueryClient(); 17 17 const { showSuccess, showError } = useToast(); 18 + const deadlineId = useId(); 18 19 const [formData, setFormData] = useState<VacancyFormData>({ 19 20 title: "", 20 21 company: "", 22 + role: "", 21 23 description: "", 22 24 requirements: "", 23 25 location: "", 24 - salary: "", 26 + minSalary: undefined, 27 + maxSalary: undefined, 25 28 jobType: "", 26 29 applicationUrl: "", 27 - deadline: "", 30 + deadline: null, 28 31 isActive: true, 29 32 }); 30 33 const [errors, setErrors] = useState< ··· 52 55 53 56 try { 54 57 await createVacancy({ 55 - variables: { 56 - title: formData.title, 57 - company: formData.company, 58 - ...(formData.description && { description: formData.description }), 59 - ...(formData.requirements && { requirements: formData.requirements }), 60 - ...(formData.location && { location: formData.location }), 61 - ...(formData.salary && { salary: formData.salary }), 62 - ...(formData.jobType && { jobType: formData.jobType }), 63 - ...(formData.applicationUrl && { 64 - applicationUrl: formData.applicationUrl, 65 - }), 66 - ...(formData.deadline && { deadline: new Date(formData.deadline) }), 67 - isActive: formData.isActive, 68 - }, 58 + title: formData.title, 59 + companyId: formData.company, 60 + roleId: formData.role, 61 + ...(formData.description && { description: formData.description }), 62 + ...(formData.requirements && { requirements: formData.requirements }), 63 + ...(formData.location && { location: formData.location }), 64 + ...(formData.minSalary !== undefined && { 65 + minSalary: formData.minSalary, 66 + }), 67 + ...(formData.maxSalary !== undefined && { 68 + maxSalary: formData.maxSalary, 69 + }), 70 + ...(formData.jobType && { jobType: formData.jobType }), 71 + ...(formData.applicationUrl && { 72 + applicationUrl: formData.applicationUrl, 73 + }), 74 + ...(formData.deadline && { deadline: formData.deadline }), 75 + isActive: formData.isActive, 76 + }); 77 + 78 + // Invalidate and refetch vacancy queries 79 + await queryClient.invalidateQueries({ 80 + queryKey: ["MyVacancies"], 69 81 }); 70 82 71 83 // Show success toast ··· 78 90 setFormData({ 79 91 title: "", 80 92 company: "", 93 + role: "", 81 94 description: "", 82 95 requirements: "", 83 96 location: "", 84 - salary: "", 97 + minSalary: undefined, 98 + maxSalary: undefined, 85 99 jobType: "", 86 100 applicationUrl: "", 87 - deadline: "", 101 + deadline: null, 88 102 isActive: true, 89 103 }); 90 104 ··· 102 116 setFormData((prev) => ({ ...prev, [field]: value })); 103 117 }; 104 118 119 + const handleNumberInputChange = (field: string, value: string) => { 120 + const numValue = value === "" ? undefined : Number(value); 121 + setFormData((prev) => ({ ...prev, [field]: numValue })); 122 + }; 123 + 105 124 const handleCheckboxChange = (field: string, checked: boolean) => { 106 125 setFormData((prev) => ({ ...prev, [field]: checked })); 126 + }; 127 + 128 + const handleDateChange = (date: Date | null) => { 129 + setFormData((prev) => ({ ...prev, deadline: date })); 107 130 }; 108 131 109 132 return ( ··· 126 149 /> 127 150 128 151 <TextInput 152 + label="Role" 153 + value={formData.role} 154 + onChange={(value: string) => handleInputChange("role", value)} 155 + required 156 + error={errors.role} 157 + /> 158 + 159 + <TextInput 129 160 label="Location" 130 161 value={formData.location} 131 162 onChange={(value: string) => handleInputChange("location", value)} 132 163 /> 133 164 134 165 <TextInput 135 - label="Salary" 136 - value={formData.salary} 137 - onChange={(value: string) => handleInputChange("salary", value)} 166 + label="Min Salary" 167 + type="number" 168 + value={formData.minSalary?.toString() ?? ""} 169 + onChange={(value: string) => 170 + handleNumberInputChange("minSalary", value) 171 + } 172 + placeholder="e.g., 50000" 173 + error={errors.minSalary} 174 + /> 175 + 176 + <TextInput 177 + label="Max Salary" 178 + type="number" 179 + value={formData.maxSalary?.toString() ?? ""} 180 + onChange={(value: string) => 181 + handleNumberInputChange("maxSalary", value) 182 + } 183 + placeholder="e.g., 70000" 184 + error={errors.maxSalary} 138 185 /> 139 186 140 187 <TextInput ··· 153 200 /> 154 201 155 202 <div className="md:col-span-2"> 156 - <TextInput 157 - label="Deadline" 158 - type="datetime-local" 159 - value={formData.deadline} 160 - onChange={(value: string) => handleInputChange("deadline", value)} 161 - /> 203 + <div className="space-y-2"> 204 + <label 205 + htmlFor={deadlineId} 206 + className="block text-sm font-medium text-ctp-text" 207 + > 208 + Application Deadline 209 + </label> 210 + <Calendar 211 + value={formData.deadline} 212 + onChange={handleDateChange} 213 + placeholder="Select application deadline" 214 + showTime={true} 215 + format="long" 216 + minDate={new Date()} 217 + className="w-full" 218 + /> 219 + {errors.deadline && ( 220 + <p className="text-sm text-ctp-red">{errors.deadline}</p> 221 + )} 222 + </div> 162 223 </div> 163 224 </div> 164 225 ··· 189 250 <div className="flex space-x-4"> 190 251 <Button 191 252 type="submit" 192 - disabled={loading || !formData.title || !formData.company} 253 + disabled={ 254 + loading || !formData.title || !formData.company || !formData.role 255 + } 193 256 > 194 257 {loading ? "Creating..." : "Create Vacancy"} 195 258 </Button>
+64 -34
apps/client/src/features/vacancies/components/VacancyList.tsx
··· 1 - import { DeleteIcon, EditIcon } from "@/components/icons"; 2 - import { useConfirmationModal } from "@/contexts/ConfirmationModalContext"; 3 - import { useToast } from "@/contexts/ToastContext"; 4 - import { useDeleteVacancyMutation } from "@/generated/graphql"; 5 - import IconButton from "@/ui/IconButton"; 6 - import { StatusBadge } from "@/ui/StatusBadge"; 7 1 import { 2 + DeleteIcon, 3 + EditIcon, 4 + FormattedDate, 5 + IconButton, 6 + Placeholder, 7 + StatusBadge, 8 8 Table, 9 9 TableBody, 10 10 TableCell, 11 11 TableHeader, 12 12 TableHeaderCell, 13 13 TableRow, 14 - } from "@/ui/Table"; 15 - import { formatDeadline, formatSimpleDate } from "@/utils/dateUtils"; 14 + } from "@cv/ui"; 15 + import { useQueryClient } from "@tanstack/react-query"; 16 + import { useConfirmationModal } from "@/contexts/ConfirmationModalContext"; 17 + import { useToast } from "@/contexts/ToastContext"; 18 + import { useDeleteVacancyMutation } from "@/generated/graphql"; 19 + import { formatDeadline } from "@/utils/dateUtils"; 20 + import { formatSalary } from "@/utils/salaryFormatter"; 16 21 17 22 interface VacancyListProps { 18 23 vacancies: Array<{ 19 24 id: string; 20 25 title: string; 21 - company: string; 26 + company: { 27 + id: string; 28 + name: string; 29 + }; 22 30 description?: string | null; 23 31 requirements?: string | null; 24 32 location?: string | null; 25 - salary?: string | null; 33 + minSalary?: number | null; 34 + maxSalary?: number | null; 26 35 jobType?: string | null; 27 36 applicationUrl?: string | null; 28 37 deadline?: string | null; ··· 33 42 } 34 43 35 44 export const VacancyList = ({ vacancies, onDelete }: VacancyListProps) => { 36 - const [deleteVacancy, { loading }] = useDeleteVacancyMutation(); 45 + const { mutateAsync: deleteVacancy, isPending: loading } = 46 + useDeleteVacancyMutation(); 47 + const queryClient = useQueryClient(); 37 48 const { showSuccess, showError } = useToast(); 38 49 const { showConfirmation } = useConfirmationModal(); 39 50 ··· 42 53 43 54 const _confirmed = await showConfirmation({ 44 55 title: "Delete Vacancy", 45 - message: `Are you sure you want to delete the vacancy for ${vacancy?.title} at ${vacancy?.company}? This action cannot be undone.`, 56 + message: `Are you sure you want to delete the vacancy for ${vacancy?.title} at ${vacancy?.company.name}? This action cannot be undone.`, 46 57 confirmText: "Delete", 47 58 cancelText: "Cancel", 48 59 variant: "danger", 49 60 onConfirm: async () => { 50 61 try { 51 62 await deleteVacancy({ 52 - variables: { id: vacancyId }, 63 + id: vacancyId, 64 + }); 65 + 66 + // Invalidate and refetch vacancy queries 67 + await queryClient.invalidateQueries({ 68 + queryKey: ["MyVacancies"], 53 69 }); 54 70 55 71 showSuccess( 56 72 "Vacancy Deleted", 57 - `Successfully deleted vacancy for ${vacancy?.title} at ${vacancy?.company}`, 73 + `Successfully deleted vacancy for ${vacancy?.title} at ${vacancy?.company.name}`, 58 74 ); 59 75 60 76 onDelete?.(); ··· 71 87 72 88 if (vacancies.length === 0) { 73 89 return ( 74 - <div className="text-center py-8"> 75 - <div className="text-ctp-subtext0 mb-2">No vacancies found</div> 76 - <div className="text-sm text-ctp-subtext1"> 77 - Create your first job vacancy to get started 78 - </div> 79 - </div> 90 + <Placeholder 91 + variant="empty" 92 + message="Create your first job vacancy to get started" 93 + /> 80 94 ); 81 95 } 82 96 ··· 97 111 {vacancies.map((vacancy) => ( 98 112 <TableRow key={vacancy.id}> 99 113 <TableCell> 100 - <div> 114 + <div className="p-2"> 101 115 <div className="font-medium text-ctp-text">{vacancy.title}</div> 102 116 <div className="text-sm text-ctp-subtext0"> 103 - {vacancy.company} 117 + {vacancy.company.name} 104 118 </div> 105 119 </div> 106 120 </TableCell> 107 - <TableCell>{vacancy.location || "—"}</TableCell> 108 - <TableCell>{vacancy.salary || "—"}</TableCell> 109 - <TableCell>{formatDeadline(vacancy.deadline)}</TableCell> 121 + <TableCell> 122 + <div className="p-2">{vacancy.location || "—"}</div> 123 + </TableCell> 124 + <TableCell> 125 + <div className="p-2"> 126 + {formatSalary(vacancy.minSalary, vacancy.maxSalary) || "—"} 127 + </div> 128 + </TableCell> 129 + <TableCell> 130 + <div className="p-2">{formatDeadline(vacancy.deadline)}</div> 131 + </TableCell> 132 + <TableCell> 133 + <div className="p-2"> 134 + <StatusBadge 135 + status={vacancy.isActive ? "active" : "inactive"} 136 + /> 137 + </div> 138 + </TableCell> 110 139 <TableCell> 111 - <StatusBadge isActive={vacancy.isActive} /> 140 + <div className="p-2"> 141 + <FormattedDate date={vacancy.createdAt} /> 142 + </div> 112 143 </TableCell> 113 - <TableCell>{formatSimpleDate(vacancy.createdAt)}</TableCell> 114 144 <TableCell> 115 - <div className="flex items-center gap-2"> 145 + <div className="flex items-center gap-2 p-2"> 116 146 <IconButton 117 147 icon={<EditIcon />} 118 148 label="Edit vacancy" 119 - variant="ghost" 120 - size="sm" 121 - className="text-ctp-blue hover:bg-ctp-blue/20" 149 + variant="primary" 150 + size="xs" 151 + showColorOnHover={true} 122 152 onClick={() => { 123 153 // TODO: Implement edit functionality 124 154 console.log("Edit vacancy:", vacancy.id); ··· 127 157 <IconButton 128 158 icon={<DeleteIcon />} 129 159 label="Delete vacancy" 130 - variant="ghost" 131 - size="sm" 132 - className="text-ctp-red hover:bg-ctp-red/20" 160 + variant="destructive" 161 + size="xs" 162 + showColorOnHover={true} 133 163 onClick={() => handleDeleteClick(vacancy.id)} 134 164 disabled={loading} 135 165 />
+7 -2
apps/client/src/features/vacancies/components/vacancy.schema.ts
··· 10 10 .string() 11 11 .min(1, "Company is required") 12 12 .max(100, "Company must be less than 100 characters"), 13 + role: z 14 + .string() 15 + .min(1, "Role is required") 16 + .max(100, "Role must be less than 100 characters"), 13 17 description: z.string().optional(), 14 18 requirements: z.string().optional(), 15 19 location: z.string().optional(), 16 - salary: z.string().optional(), 20 + minSalary: z.number().min(0, "Min salary must be positive").optional(), 21 + maxSalary: z.number().min(0, "Max salary must be positive").optional(), 17 22 jobType: z.string().optional(), 18 23 applicationUrl: z 19 24 .string() 20 25 .url("Must be a valid URL") 21 26 .optional() 22 27 .or(z.literal("")), 23 - deadline: z.string().optional(), 28 + deadline: z.date().optional().nullable(), 24 29 isActive: z.boolean().default(true), 25 30 }) 26 31 .refine(
+22 -10
apps/client/src/features/vacancies/queries/create-vacancy.graphql
··· 1 1 mutation CreateVacancy( 2 2 $title: String! 3 - $company: String! 3 + $companyId: String! 4 + $roleId: String! 5 + $levelId: String 6 + $jobTypeId: String 4 7 $description: String 5 8 $requirements: String 6 9 $location: String 7 - $salary: String 8 - $jobType: String 10 + $minSalary: Float 11 + $maxSalary: Float 9 12 $applicationUrl: String 10 13 $deadline: DateTime 11 14 $isActive: Boolean 15 + $isPublic: Boolean 12 16 ) { 13 17 createVacancy( 14 18 title: $title 15 - company: $company 19 + companyId: $companyId 20 + roleId: $roleId 21 + levelId: $levelId 22 + jobTypeId: $jobTypeId 16 23 description: $description 17 24 requirements: $requirements 18 25 location: $location 19 - salary: $salary 20 - jobType: $jobType 26 + minSalary: $minSalary 27 + maxSalary: $maxSalary 21 28 applicationUrl: $applicationUrl 22 29 deadline: $deadline 23 30 isActive: $isActive 31 + isPublic: $isPublic 24 32 ) { 25 33 id 26 - userId 27 34 title 28 - company 35 + ownerId 36 + companyId 37 + roleId 38 + levelId 39 + jobTypeId 29 40 description 30 41 requirements 31 42 location 32 - salary 33 - jobType 43 + minSalary 44 + maxSalary 34 45 applicationUrl 35 46 deadline 36 47 isActive 48 + isPublic 37 49 createdAt 38 50 updatedAt 39 51 }
+49 -16
apps/client/src/features/vacancies/queries/my-vacancies.graphql
··· 1 - query MyVacancies { 2 - myVacancies { 3 - id 4 - userId 5 - title 6 - company 7 - description 8 - requirements 9 - location 10 - salary 11 - jobType 12 - applicationUrl 13 - deadline 14 - isActive 15 - createdAt 16 - updatedAt 1 + query MyVacancies($filter: VacancyFilterInput) { 2 + me { 3 + vacancies(filter: $filter) { 4 + edges { 5 + node { 6 + id 7 + title 8 + ownerId 9 + companyId 10 + roleId 11 + levelId 12 + jobTypeId 13 + description 14 + requirements 15 + location 16 + minSalary 17 + maxSalary 18 + applicationUrl 19 + deadline 20 + isActive 21 + isPublic 22 + createdAt 23 + updatedAt 24 + company { 25 + id 26 + name 27 + } 28 + role { 29 + id 30 + name 31 + } 32 + level { 33 + id 34 + name 35 + } 36 + skills { 37 + id 38 + name 39 + } 40 + } 41 + } 42 + pageInfo { 43 + hasNextPage 44 + hasPreviousPage 45 + startCursor 46 + endCursor 47 + } 48 + totalCount 49 + } 17 50 } 18 51 }