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.

feat(client): add application flow and management features

+1594
+182
apps/client/src/features/applications/components/ApplicationFlow.tsx
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 2 + import { useState } from "react"; 3 + import { useNavigate } from "react-router-dom"; 4 + import { Progress, useProgressContext } from "@/components/Progress"; 5 + import { useCreateApplicationMutation, useCvQuery } from "@/generated/graphql"; 6 + import { CVTemplateSelector } from "./CVTemplateSelector"; 7 + import { VacancySelector } from "./VacancySelector"; 8 + 9 + export const ApplicationFlow = () => { 10 + const navigate = useNavigate(); 11 + const queryClient = useQueryClient(); 12 + const [selectedVacancyId, setSelectedVacancyId] = useState<string | null>( 13 + null, 14 + ); 15 + const [selectedCVId, setSelectedCVId] = useState<string | null>(null); 16 + const [coverLetter, setCoverLetter] = useState(""); 17 + 18 + const createApplicationMutation = useCreateApplicationMutation(); 19 + 20 + const handleCancel = () => { 21 + navigate("/dashboard"); 22 + }; 23 + 24 + const handleSubmitApplication = async () => { 25 + if (!(selectedVacancyId && selectedCVId)) { 26 + return; 27 + } 28 + 29 + try { 30 + await createApplicationMutation.mutateAsync({ 31 + input: { 32 + vacancyId: selectedVacancyId, 33 + cvId: selectedCVId, 34 + coverLetter: coverLetter || null, 35 + }, 36 + }); 37 + 38 + // Invalidate and refetch application queries 39 + await queryClient.invalidateQueries({ 40 + queryKey: ["MyApplications"], 41 + }); 42 + 43 + navigate("/applications", { 44 + state: { message: "Application submitted successfully!" }, 45 + }); 46 + } catch (error) { 47 + console.error("Failed to submit application:", error); 48 + // Handle error (show toast, etc.) 49 + } 50 + }; 51 + 52 + return ( 53 + <div className="min-h-screen bg-ctp-base p-6"> 54 + <div className="mx-auto max-w-4xl"> 55 + <Progress> 56 + <Progress.Step name="Select Vacancy"> 57 + <VacancySelector 58 + onSelectVacancy={setSelectedVacancyId} 59 + onCancel={handleCancel} 60 + /> 61 + </Progress.Step> 62 + <Progress.Step name="Select CV"> 63 + <CVTemplateSelector 64 + onSelectCV={setSelectedCVId} 65 + onCancel={handleCancel} 66 + /> 67 + </Progress.Step> 68 + <Progress.Step name="Review & Apply"> 69 + <ApplicationReview 70 + vacancyId={selectedVacancyId} 71 + cvId={selectedCVId} 72 + coverLetter={coverLetter} 73 + onCoverLetterChange={setCoverLetter} 74 + onCancel={handleCancel} 75 + onSubmit={handleSubmitApplication} 76 + isSubmitting={createApplicationMutation.isPending} 77 + /> 78 + </Progress.Step> 79 + </Progress> 80 + </div> 81 + </div> 82 + ); 83 + }; 84 + 85 + interface ApplicationReviewProps { 86 + vacancyId: string | null; 87 + cvId: string | null; 88 + coverLetter: string; 89 + onCoverLetterChange: (value: string) => void; 90 + onCancel: () => void; 91 + onSubmit: () => void; 92 + isSubmitting: boolean; 93 + } 94 + 95 + const ApplicationReview = ({ 96 + vacancyId, 97 + cvId, 98 + coverLetter, 99 + onCoverLetterChange, 100 + onCancel, 101 + onSubmit, 102 + isSubmitting, 103 + }: ApplicationReviewProps) => { 104 + const { back } = useProgressContext(); 105 + const { data: cvData, isLoading: cvLoading } = useCvQuery( 106 + { id: cvId ?? "" }, 107 + { enabled: !!cvId }, 108 + ); 109 + 110 + return ( 111 + <div className="space-y-6"> 112 + <div> 113 + <h2 className="text-2xl font-bold text-ctp-text">Review Application</h2> 114 + <p className="mt-2 text-ctp-subtext0"> 115 + Review your application details before submitting 116 + </p> 117 + </div> 118 + 119 + <div className="space-y-4"> 120 + <div className="rounded-lg bg-ctp-surface0 p-4"> 121 + <h3 className="font-semibold text-ctp-text">Selected Vacancy</h3> 122 + <p className="text-sm text-ctp-subtext0">Vacancy ID: {vacancyId}</p> 123 + </div> 124 + 125 + <div className="rounded-lg bg-ctp-surface0 p-4"> 126 + <h3 className="font-semibold text-ctp-text">Selected CV</h3> 127 + {cvLoading ? ( 128 + <p className="text-sm text-ctp-subtext0">Loading...</p> 129 + ) : cvData?.cv ? ( 130 + <p className="text-sm text-ctp-subtext0">{cvData.cv.title}</p> 131 + ) : ( 132 + <p className="text-sm text-ctp-subtext0">CV ID: {cvId}</p> 133 + )} 134 + </div> 135 + 136 + <div className="space-y-2"> 137 + <label 138 + className="block text-sm font-medium text-ctp-text" 139 + htmlFor="cover-letter" 140 + > 141 + Cover Letter (Optional) 142 + </label> 143 + <textarea 144 + name="cover-letter" 145 + value={coverLetter} 146 + onChange={(e) => onCoverLetterChange(e.target.value)} 147 + placeholder="Write a cover letter for this application..." 148 + className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-3 text-ctp-text placeholder-ctp-subtext0 focus:border-ctp-blue focus:outline-none" 149 + rows={6} 150 + /> 151 + </div> 152 + </div> 153 + 154 + <div className="flex justify-between"> 155 + <button 156 + type="button" 157 + onClick={back} 158 + className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2 text-ctp-text hover:bg-ctp-surface1" 159 + > 160 + Back 161 + </button> 162 + <div className="space-x-3"> 163 + <button 164 + type="button" 165 + onClick={onCancel} 166 + className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2 text-ctp-text hover:bg-ctp-surface1" 167 + > 168 + Cancel 169 + </button> 170 + <button 171 + type="button" 172 + onClick={onSubmit} 173 + disabled={isSubmitting || !vacancyId || !cvId} 174 + className="rounded-lg bg-ctp-blue px-4 py-2 text-ctp-base hover:bg-ctp-blue/90 disabled:opacity-50" 175 + > 176 + {isSubmitting ? "Submitting..." : "Submit Application"} 177 + </button> 178 + </div> 179 + </div> 180 + </div> 181 + ); 182 + };
+222
apps/client/src/features/applications/components/ApplicationsOverview.tsx
··· 1 + import { Badge, Button, FormattedDate, PageHeader, Placeholder } from "@cv/ui"; 2 + import { useMyApplicationsQuery } from "@/generated/graphql"; 3 + import { formatSalary } from "@/utils/salaryFormatter"; 4 + 5 + type BadgeColor = 6 + | "ctp-red" 7 + | "ctp-orange" 8 + | "ctp-yellow" 9 + | "ctp-green" 10 + | "ctp-teal" 11 + | "ctp-sky" 12 + | "ctp-sapphire" 13 + | "ctp-blue" 14 + | "ctp-lavender" 15 + | "ctp-mauve" 16 + | "ctp-pink" 17 + | "ctp-maroon" 18 + | "ctp-peach" 19 + | "ctp-rosewater" 20 + | "ctp-gray"; 21 + 22 + const getStatusColor = (statusName: string): BadgeColor => { 23 + switch (statusName.toLowerCase()) { 24 + case "applied": 25 + return "ctp-blue"; 26 + case "under review": 27 + return "ctp-yellow"; 28 + case "interview scheduled": 29 + return "ctp-orange"; 30 + case "interviewed": 31 + return "ctp-teal"; 32 + case "accepted": 33 + return "ctp-green"; 34 + case "rejected": 35 + return "ctp-red"; 36 + case "withdrawn": 37 + return "ctp-gray"; 38 + default: 39 + return "ctp-gray"; 40 + } 41 + }; 42 + 43 + export const ApplicationsOverview = () => { 44 + const { data, isLoading, error, refetch } = useMyApplicationsQuery(); 45 + 46 + const applications = data?.me?.applications?.edges || []; 47 + const totalCount = data?.me?.applications?.totalCount || 0; 48 + 49 + if (isLoading) { 50 + return ( 51 + <div className="space-y-6"> 52 + <PageHeader 53 + title="My Applications" 54 + description="Track your job applications and their status" 55 + /> 56 + <Placeholder variant="loading" message="Loading applications..." /> 57 + </div> 58 + ); 59 + } 60 + 61 + if (error) { 62 + return ( 63 + <div className="space-y-6"> 64 + <PageHeader 65 + title="My Applications" 66 + description="Track your job applications and their status" 67 + /> 68 + <Placeholder variant="error" message="Error loading applications" /> 69 + </div> 70 + ); 71 + } 72 + 73 + if (applications.length === 0) { 74 + return ( 75 + <div className="space-y-6"> 76 + <PageHeader 77 + title="My Applications" 78 + description="Track your job applications and their status" 79 + /> 80 + <Placeholder 81 + variant="empty" 82 + message="Start applying to vacancies to see your applications here" 83 + > 84 + <Button 85 + onClick={() => { 86 + window.location.href = "/applications/apply"; 87 + }} 88 + > 89 + Apply to Vacancy 90 + </Button> 91 + </Placeholder> 92 + </div> 93 + ); 94 + } 95 + 96 + return ( 97 + <div className="space-y-6"> 98 + <PageHeader 99 + title="My Applications" 100 + description={`${totalCount} application${totalCount !== 1 ? "s" : ""} total`} 101 + action={<Button onClick={() => refetch()}>Refresh</Button>} 102 + /> 103 + 104 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 105 + {applications.map((application) => ( 106 + <div 107 + key={application.id} 108 + className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-6 shadow-sm" 109 + > 110 + <div className="space-y-4"> 111 + <div className="flex items-start justify-between"> 112 + <div className="flex-1"> 113 + <h3 className="text-lg font-semibold text-ctp-text"> 114 + {application.vacancy.title} 115 + </h3> 116 + <p className="text-ctp-subtext0"> 117 + {application.vacancy.companyName} •{" "} 118 + {application.vacancy.roleName} 119 + </p> 120 + {application.vacancy.location && ( 121 + <p className="text-sm text-ctp-subtext0"> 122 + 📍 {application.vacancy.location} 123 + </p> 124 + )} 125 + </div> 126 + <Badge color={getStatusColor(application.status.name)}> 127 + {application.status.name} 128 + </Badge> 129 + </div> 130 + 131 + <div className="space-y-2 text-sm text-ctp-subtext0"> 132 + <div className="flex justify-between"> 133 + <span>Applied:</span> 134 + <span> 135 + <FormattedDate date={application.appliedAt} /> 136 + </span> 137 + </div> 138 + {application.cv && ( 139 + <div className="flex justify-between"> 140 + <span>CV:</span> 141 + <span className="truncate max-w-32"> 142 + {application.cv.title} 143 + </span> 144 + </div> 145 + )} 146 + {(() => { 147 + const salary = formatSalary( 148 + application.vacancy.minSalary, 149 + application.vacancy.maxSalary, 150 + ); 151 + return salary ? ( 152 + <div className="flex justify-between"> 153 + <span>Salary:</span> 154 + <span className="text-ctp-green">{salary}</span> 155 + </div> 156 + ) : null; 157 + })()} 158 + </div> 159 + 160 + {application.coverLetter && ( 161 + <div className="pt-2 border-t border-ctp-surface1"> 162 + <p className="text-sm text-ctp-subtext0 line-clamp-2"> 163 + {application.coverLetter} 164 + </p> 165 + </div> 166 + )} 167 + 168 + <div className="pt-2 border-t border-ctp-surface1"> 169 + <div className="flex justify-between text-xs text-ctp-subtext0"> 170 + <span>Application ID: {application.id.slice(-8)}</span> 171 + <span> 172 + Updated: <FormattedDate date={application.updatedAt} /> 173 + </span> 174 + </div> 175 + </div> 176 + </div> 177 + </div> 178 + ))} 179 + </div> 180 + 181 + {/* Summary Stats */} 182 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 183 + <div className="rounded-lg bg-ctp-surface0 p-4 text-center"> 184 + <div className="text-2xl font-bold text-ctp-blue"> 185 + {applications.filter((app) => app.status.name === "Applied").length} 186 + </div> 187 + <div className="text-sm text-ctp-subtext0">Applied</div> 188 + </div> 189 + <div className="rounded-lg bg-ctp-surface0 p-4 text-center"> 190 + <div className="text-2xl font-bold text-ctp-yellow"> 191 + { 192 + applications.filter((app) => app.status.name === "Under Review") 193 + .length 194 + } 195 + </div> 196 + <div className="text-sm text-ctp-subtext0">Under Review</div> 197 + </div> 198 + <div className="rounded-lg bg-ctp-surface0 p-4 text-center"> 199 + <div className="text-2xl font-bold text-ctp-orange"> 200 + { 201 + applications.filter((app) => 202 + ["Interview Scheduled", "Interviewed"].includes( 203 + app.status.name, 204 + ), 205 + ).length 206 + } 207 + </div> 208 + <div className="text-sm text-ctp-subtext0">Interviewing</div> 209 + </div> 210 + <div className="rounded-lg bg-ctp-surface0 p-4 text-center"> 211 + <div className="text-2xl font-bold text-ctp-green"> 212 + { 213 + applications.filter((app) => app.status.name === "Accepted") 214 + .length 215 + } 216 + </div> 217 + <div className="text-sm text-ctp-subtext0">Accepted</div> 218 + </div> 219 + </div> 220 + </div> 221 + ); 222 + };
+208
apps/client/src/features/applications/components/CVTemplateSelector.tsx
··· 1 + import { Button, TextInput } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { useProgressContext } from "@/components/Progress"; 4 + import { useCvTemplatesQuery, useMyCVsQuery } from "@/generated/graphql"; 5 + 6 + interface CVTemplateSelectorProps { 7 + onSelectCV?: (cvId: string) => void; 8 + onCancel: () => void; 9 + onBack?: () => void; 10 + } 11 + 12 + export const CVTemplateSelector = ({ 13 + onSelectCV, 14 + onCancel, 15 + onBack, 16 + }: CVTemplateSelectorProps) => { 17 + const [searchTerm, setSearchTerm] = useState(""); 18 + const [selectedCVId, setSelectedCVId] = useState<string | null>(null); 19 + const [showTemplates, setShowTemplates] = useState(false); 20 + 21 + // Use Progress context if available (components used in Progress will have this) 22 + const progressContext = useProgressContext(); 23 + 24 + const { data: cvsData, isLoading: cvsLoading } = useMyCVsQuery({ 25 + first: 50, 26 + }); 27 + 28 + const { data: templatesData, isLoading: templatesLoading } = 29 + useCvTemplatesQuery({ 30 + first: 50, 31 + }); 32 + 33 + const cvs = cvsData?.me?.cvs?.edges?.map((edge) => edge.node) || []; 34 + const templates = 35 + templatesData?.cvTemplates?.edges?.map((edge) => edge.node) || []; 36 + 37 + const filteredCVs = cvs.filter((cv) => 38 + cv.title.toLowerCase().includes(searchTerm.toLowerCase()), 39 + ); 40 + 41 + const filteredTemplates = templates.filter((template) => 42 + template.name.toLowerCase().includes(searchTerm.toLowerCase()), 43 + ); 44 + 45 + const handleSelectCV = (cvId: string) => { 46 + setSelectedCVId(cvId); 47 + }; 48 + 49 + const handleConfirm = () => { 50 + if (selectedCVId) { 51 + onSelectCV?.(selectedCVId); 52 + progressContext?.next(); 53 + } 54 + }; 55 + 56 + const isLoading = cvsLoading || templatesLoading; 57 + 58 + if (isLoading) { 59 + return ( 60 + <div className="flex items-center justify-center p-8"> 61 + <div className="text-ctp-subtext0">Loading CVs and templates...</div> 62 + </div> 63 + ); 64 + } 65 + 66 + return ( 67 + <div className="space-y-6"> 68 + <div> 69 + <h2 className="text-2xl font-bold text-ctp-text">Select a CV</h2> 70 + <p className="mt-2 text-ctp-subtext0"> 71 + Choose an existing CV or create a new one from a template 72 + </p> 73 + </div> 74 + 75 + <div> 76 + <TextInput 77 + placeholder="Search CVs or templates..." 78 + value={searchTerm} 79 + onChange={setSearchTerm} 80 + className="w-full" 81 + /> 82 + </div> 83 + 84 + <div className="flex space-x-2"> 85 + <Button 86 + variant={!showTemplates ? "primary" : "outline"} 87 + onClick={() => setShowTemplates(false)} 88 + > 89 + My CVs ({cvs.length}) 90 + </Button> 91 + <Button 92 + variant={showTemplates ? "primary" : "outline"} 93 + onClick={() => setShowTemplates(true)} 94 + > 95 + Templates ({templates.length}) 96 + </Button> 97 + </div> 98 + 99 + <div className="max-h-96 overflow-y-auto space-y-3"> 100 + {!showTemplates ? ( 101 + // Show existing CVs 102 + filteredCVs.length === 0 ? ( 103 + <div className="text-center py-8 text-ctp-subtext0"> 104 + {searchTerm 105 + ? "No CVs match your search" 106 + : "You don't have any CVs yet"} 107 + </div> 108 + ) : ( 109 + filteredCVs.map((cv) => ( 110 + <button 111 + key={cv.id} 112 + type="button" 113 + className={`cursor-pointer transition-colors rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 shadow-sm text-left w-full ${ 114 + selectedCVId === cv.id 115 + ? "border-ctp-blue bg-ctp-blue/10" 116 + : "hover:bg-ctp-surface0" 117 + }`} 118 + onClick={() => handleSelectCV(cv.id)} 119 + > 120 + <div className="p-4"> 121 + <div className="flex items-start justify-between"> 122 + <div className="flex-1"> 123 + <h3 className="text-lg font-semibold text-ctp-text"> 124 + {cv.title} 125 + </h3> 126 + <p className="text-ctp-subtext0"> 127 + Template: {cv.template.name} 128 + </p> 129 + <p className="text-sm text-ctp-subtext0 mt-1"> 130 + Updated: {new Date(cv.updatedAt).toLocaleDateString()} 131 + </p> 132 + </div> 133 + <div className="ml-4"> 134 + {selectedCVId === cv.id && ( 135 + <div className="h-4 w-4 rounded-full bg-ctp-blue"></div> 136 + )} 137 + </div> 138 + </div> 139 + </div> 140 + </button> 141 + )) 142 + ) 143 + ) : // Show templates 144 + filteredTemplates.length === 0 ? ( 145 + <div className="text-center py-8 text-ctp-subtext0"> 146 + {searchTerm 147 + ? "No templates match your search" 148 + : "No templates available"} 149 + </div> 150 + ) : ( 151 + filteredTemplates.map((template) => ( 152 + <button 153 + key={template.id} 154 + type="button" 155 + className={`cursor-pointer transition-colors rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 shadow-sm text-left w-full ${ 156 + selectedCVId === template.id 157 + ? "border-ctp-blue bg-ctp-blue/10" 158 + : "hover:bg-ctp-surface0" 159 + }`} 160 + onClick={() => handleSelectCV(template.id)} 161 + > 162 + <div className="p-4"> 163 + <div className="flex items-start justify-between"> 164 + <div className="flex-1"> 165 + <h3 className="text-lg font-semibold text-ctp-text"> 166 + {template.name} 167 + </h3> 168 + <p className="text-ctp-subtext0"> 169 + {template.description || "Professional CV template"} 170 + </p> 171 + <p className="text-sm text-ctp-green mt-1"> 172 + Create new CV from template 173 + </p> 174 + </div> 175 + <div className="ml-4"> 176 + {selectedCVId === template.id && ( 177 + <div className="h-4 w-4 rounded-full bg-ctp-blue"></div> 178 + )} 179 + </div> 180 + </div> 181 + </div> 182 + </button> 183 + )) 184 + )} 185 + </div> 186 + 187 + <div className="flex justify-between"> 188 + <Button 189 + variant="outline" 190 + onClick={() => { 191 + onBack?.(); 192 + progressContext?.back(); 193 + }} 194 + > 195 + Back 196 + </Button> 197 + <div className="space-x-3"> 198 + <Button variant="outline" onClick={onCancel}> 199 + Cancel 200 + </Button> 201 + <Button onClick={handleConfirm} disabled={!selectedCVId}> 202 + {showTemplates ? "Create CV & Apply" : "Apply with Selected CV"} 203 + </Button> 204 + </div> 205 + </div> 206 + </div> 207 + ); 208 + };
+148
apps/client/src/features/applications/components/MatchIndicator.tsx
··· 1 + import { useRef, useState } from "react"; 2 + import { useMeJobExperienceQuery } from "@/generated/graphql"; 3 + import { calculateSkillsMatch } from "@/utils/skillsMatcher"; 4 + import { MatchTooltip } from "./MatchTooltip"; 5 + 6 + interface MatchIndicatorProps { 7 + vacancy: { 8 + id: string; 9 + skills?: Array<{ id: string; name: string }> | null; 10 + }; 11 + compact?: boolean; 12 + } 13 + 14 + const getMatchLevel = (percent: number): "high" | "medium" | "low" => { 15 + if (percent >= 75) { 16 + return "high"; 17 + } 18 + if (percent >= 50) { 19 + return "medium"; 20 + } 21 + return "low"; 22 + }; 23 + 24 + const getConfidenceConfig = (confidence: "high" | "medium" | "low") => { 25 + switch (confidence) { 26 + case "high": 27 + return { text: "Great Match", color: "text-ctp-green" }; 28 + case "medium": 29 + return { text: "Good Match", color: "text-ctp-yellow" }; 30 + case "low": 31 + return { text: "Low Match", color: "text-ctp-red" }; 32 + } 33 + }; 34 + 35 + export const MatchIndicator = ({ 36 + vacancy, 37 + compact = false, 38 + }: MatchIndicatorProps) => { 39 + const [isTooltipOpen, setIsTooltipOpen] = useState(false); 40 + const [isClicked, setIsClicked] = useState(false); 41 + const containerRef = useRef<HTMLDivElement>(null); 42 + 43 + const { data: jobExperienceData } = useMeJobExperienceQuery(); 44 + 45 + // Extract skills from user's job experience 46 + const experiences = 47 + jobExperienceData?.me?.experience?.edges?.map((edge) => edge.node) || []; 48 + const userSkills = experiences.flatMap( 49 + (job) => 50 + job.skills?.map((skill: { id: string; name: string }) => ({ 51 + id: skill.id, 52 + name: skill.name, 53 + })) ?? [], 54 + ); 55 + 56 + // Remove duplicates by ID 57 + const uniqueUserSkills = Array.from( 58 + new Map(userSkills.map((s) => [s.id, s])).values(), 59 + ); 60 + 61 + const vacancySkills = vacancy.skills ?? []; 62 + 63 + // Calculate match 64 + const match = calculateSkillsMatch(uniqueUserSkills, vacancySkills); 65 + 66 + const { percentage } = match; 67 + 68 + const confidence = getMatchLevel(percentage); 69 + const { color: confidenceColor } = getConfidenceConfig(confidence); 70 + 71 + if (!vacancySkills || vacancySkills.length === 0) { 72 + return null; 73 + } 74 + 75 + if (compact) { 76 + return ( 77 + <div ref={containerRef} className="relative group"> 78 + <button 79 + type="button" 80 + className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium transition-all duration-200 cursor-pointer opacity-75 hover:opacity-100" 81 + onClick={(e) => { 82 + e.stopPropagation(); 83 + setIsClicked(true); 84 + setIsTooltipOpen(!isTooltipOpen); 85 + }} 86 + onMouseEnter={() => { 87 + if (!isClicked) { 88 + setIsTooltipOpen(true); 89 + } 90 + }} 91 + onMouseLeave={() => { 92 + if (!isClicked) { 93 + setIsTooltipOpen(false); 94 + } 95 + }} 96 + aria-label={`Match percentage: ${percentage}%. Click or hover to view details.`} 97 + > 98 + <span className={`text-xs font-semibold ${confidenceColor}`}> 99 + {percentage}% 100 + </span> 101 + </button> 102 + {isTooltipOpen && ( 103 + <MatchTooltip 104 + match={match} 105 + position="right" 106 + triggerRef={containerRef} 107 + /> 108 + )} 109 + </div> 110 + ); 111 + } 112 + 113 + return ( 114 + <div ref={containerRef} className="relative group"> 115 + <button 116 + type="button" 117 + className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 cursor-pointer bg-ctp-surface0 hover:bg-ctp-surface1 border border-ctp-surface1" 118 + onClick={(e) => { 119 + e.stopPropagation(); 120 + setIsClicked(true); 121 + setIsTooltipOpen(!isTooltipOpen); 122 + }} 123 + onMouseEnter={() => { 124 + if (!isClicked) { 125 + setIsTooltipOpen(true); 126 + } 127 + }} 128 + onMouseLeave={() => { 129 + if (!isClicked) { 130 + setIsTooltipOpen(false); 131 + } 132 + }} 133 + aria-label={`Match percentage: ${percentage}%. Click or hover to view details.`} 134 + > 135 + <span className={`text-sm font-semibold ${confidenceColor}`}> 136 + {percentage}% Match 137 + </span> 138 + </button> 139 + {isTooltipOpen && ( 140 + <MatchTooltip 141 + match={match} 142 + position="right" 143 + triggerRef={containerRef} 144 + /> 145 + )} 146 + </div> 147 + ); 148 + };
+77
apps/client/src/features/applications/components/MatchTooltip.tsx
··· 1 + import { Badge } from "@cv/ui"; 2 + import { Tooltip } from "@/components/Tooltip"; 3 + 4 + interface MatchTooltipProps { 5 + match: { 6 + percentage: number; 7 + matchedSkills: Array<{ id: string; name: string }>; 8 + missingSkills: Array<{ id: string; name: string }>; 9 + }; 10 + position?: "left" | "right" | "top" | "bottom"; 11 + triggerRef: React.RefObject<HTMLElement>; 12 + } 13 + 14 + const getConfidenceConfig = (percentage: number) => { 15 + if (percentage >= 75) { 16 + return { text: "Great Match", color: "bg-ctp-green text-ctp-base" }; 17 + } else if (percentage >= 50) { 18 + return { text: "Good Match", color: "bg-ctp-yellow text-ctp-base" }; 19 + } else { 20 + return { text: "Low Match", color: "bg-ctp-red text-ctp-base" }; 21 + } 22 + }; 23 + 24 + export const MatchTooltip = ({ 25 + match, 26 + position = "right", 27 + triggerRef, 28 + }: MatchTooltipProps) => { 29 + const confidence = getConfidenceConfig(match.percentage); 30 + 31 + return ( 32 + <Tooltip position={position} triggerRef={triggerRef}> 33 + <div className="bg-ctp-crust border border-ctp-overlay0 rounded-lg p-4 shadow-lg min-w-64 max-w-96"> 34 + <div className="space-y-3"> 35 + <div className="flex items-center justify-between"> 36 + <span className="text-sm font-semibold text-ctp-text"> 37 + Match Details 38 + </span> 39 + <span 40 + className={`px-2 py-0.5 rounded-full text-xs font-medium ${confidence.color}`} 41 + > 42 + {confidence.text} 43 + </span> 44 + </div> 45 + 46 + <div> 47 + <div className="text-xs text-ctp-subtext0 mb-1"> 48 + Matched Skills ({match.matchedSkills.length}) 49 + </div> 50 + <div className="flex flex-wrap gap-1"> 51 + {match.matchedSkills.map((skill) => ( 52 + <Badge key={skill.id} color="ctp-green"> 53 + {skill.name} 54 + </Badge> 55 + ))} 56 + </div> 57 + </div> 58 + 59 + {match.missingSkills.length > 0 && ( 60 + <div> 61 + <div className="text-xs text-ctp-subtext0 mb-1"> 62 + Missing Skills ({match.missingSkills.length}) 63 + </div> 64 + <div className="flex flex-wrap gap-1"> 65 + {match.missingSkills.map((skill) => ( 66 + <Badge key={skill.id} color="ctp-gray"> 67 + {skill.name} 68 + </Badge> 69 + ))} 70 + </div> 71 + </div> 72 + )} 73 + </div> 74 + </div> 75 + </Tooltip> 76 + ); 77 + };
+126
apps/client/src/features/applications/components/VacancyCard.tsx
··· 1 + import { Button, LocationIcon, SalaryIcon } from "@cv/ui"; 2 + import { formatSalary } from "@/utils/salaryFormatter"; 3 + import { MatchIndicator } from "./MatchIndicator"; 4 + 5 + interface VacancyCardProps { 6 + vacancy: { 7 + id: string; 8 + title: string; 9 + description?: string | null; 10 + requirements?: string | null; 11 + company?: { 12 + name: string; 13 + } | null; 14 + role?: { 15 + name: string; 16 + } | null; 17 + level?: { 18 + name: string; 19 + } | null; 20 + location?: string | null; 21 + minSalary?: number | null; 22 + maxSalary?: number | null; 23 + applicationUrl?: string | null; 24 + }; 25 + isSelected: boolean; 26 + onSelect: (vacancyId: string) => void; 27 + } 28 + 29 + export const VacancyCard = ({ 30 + vacancy, 31 + isSelected, 32 + onSelect, 33 + }: VacancyCardProps) => { 34 + const { 35 + id, 36 + title, 37 + description, 38 + company, 39 + role, 40 + level, 41 + location, 42 + minSalary, 43 + maxSalary, 44 + applicationUrl, 45 + } = vacancy; 46 + 47 + return ( 48 + <button 49 + type="button" 50 + className={`group relative cursor-pointer transition-colors rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 shadow-sm text-left w-full ${ 51 + isSelected ? "border-ctp-blue bg-ctp-blue/10" : "hover:bg-ctp-surface1" 52 + }`} 53 + onClick={() => onSelect(id)} 54 + > 55 + <div className="flex items-start justify-between"> 56 + <div className="flex-1"> 57 + <h3 className="text-lg font-semibold text-ctp-text">{title}</h3> 58 + 59 + <div className="mt-1 flex flex-wrap gap-2 text-sm text-ctp-subtext0"> 60 + {company?.name && ( 61 + <span className="font-medium">{company.name}</span> 62 + )} 63 + {role?.name && <span>• {role.name}</span>} 64 + {level?.name && <span>• {level.name}</span>} 65 + {location && ( 66 + <span className="flex items-center gap-1"> 67 + <LocationIcon /> 68 + {location} 69 + </span> 70 + )} 71 + {(() => { 72 + const salary = formatSalary(minSalary, maxSalary); 73 + return salary ? ( 74 + <span className="flex items-center gap-1"> 75 + <SalaryIcon /> 76 + {salary} 77 + </span> 78 + ) : null; 79 + })()} 80 + </div> 81 + 82 + {description && ( 83 + <p className="text-sm text-ctp-subtext0 mt-2 line-clamp-2"> 84 + {description} 85 + </p> 86 + )} 87 + </div> 88 + 89 + <div className="ml-4 flex flex-col items-end gap-2"> 90 + <MatchIndicator vacancy={vacancy} compact /> 91 + {isSelected && ( 92 + <div className="h-4 w-4 rounded-full bg-ctp-blue"></div> 93 + )} 94 + </div> 95 + </div> 96 + 97 + {/* Action Buttons - Always visible on mobile, hover on desktop */} 98 + <div className="mt-4 flex justify-end gap-2"> 99 + {applicationUrl && ( 100 + <Button 101 + variant="ghost" 102 + size="sm" 103 + onClick={(e) => { 104 + e.stopPropagation(); 105 + window.open(applicationUrl, "_blank"); 106 + }} 107 + className="opacity-0 group-hover:opacity-100 md:opacity-0 md:group-hover:opacity-100 sm:opacity-100 transition-opacity" 108 + > 109 + View Vacancy 110 + </Button> 111 + )} 112 + <Button 113 + variant="outline" 114 + size="sm" 115 + onClick={(e) => { 116 + e.stopPropagation(); 117 + onSelect(id); 118 + }} 119 + className="opacity-0 group-hover:opacity-100 md:opacity-0 md:group-hover:opacity-100 sm:opacity-100 transition-opacity" 120 + > 121 + Select Vacancy 122 + </Button> 123 + </div> 124 + </button> 125 + ); 126 + };
+325
apps/client/src/features/applications/components/VacancyFilterPanel.tsx
··· 1 + import { Button, RangeSlider } from "@cv/ui"; 2 + import { useMemo, useState } from "react"; 3 + import { useVacancyFilterOptionsQuery } from "@/generated/graphql"; 4 + 5 + interface VacancyData { 6 + company?: { 7 + name: string; 8 + id: string; 9 + } | null; 10 + level?: { 11 + name: string; 12 + id: string; 13 + } | null; 14 + location: string | null; 15 + } 16 + 17 + interface FilterOptions { 18 + jobTypes: string[]; 19 + levels: string[]; 20 + companies: string[]; 21 + locations: string[]; 22 + salaryRange: { 23 + min: number | null; 24 + max: number | null; 25 + }; 26 + } 27 + 28 + interface VacancyFilterPanelProps { 29 + isOpen: boolean; 30 + onClose: () => void; 31 + onApplyFilters: (filters: FilterOptions) => void; 32 + currentFilters: FilterOptions; 33 + } 34 + 35 + export const VacancyFilterPanel = ({ 36 + isOpen, 37 + onClose, 38 + onApplyFilters, 39 + currentFilters, 40 + }: VacancyFilterPanelProps) => { 41 + const [filters, setFilters] = useState<FilterOptions>(currentFilters); 42 + 43 + // Fetch filter options data 44 + const { 45 + data: filterOptionsData, 46 + isLoading, 47 + error, 48 + } = useVacancyFilterOptionsQuery(); 49 + 50 + // Extract available options from the data 51 + const availableOptions = useMemo(() => { 52 + if (!filterOptionsData?.me?.vacancies) { 53 + return { 54 + jobTypes: [], 55 + levels: [], 56 + companies: [], 57 + locations: [], 58 + }; 59 + } 60 + 61 + const vacancies = 62 + (filterOptionsData.me.vacancies?.edges?.map( 63 + (edge) => edge.node, 64 + ) as VacancyData[]) || []; 65 + 66 + // Extract unique companies 67 + const companies = vacancies 68 + .map((v) => v.company) 69 + .filter( 70 + (company): company is NonNullable<typeof company> => company !== null, 71 + ) 72 + .filter( 73 + (company, index, self) => 74 + index === self.findIndex((c) => c.id === company.id), 75 + ) 76 + .map((company) => ({ id: company.id, name: company.name })); 77 + 78 + // Extract unique levels 79 + const levels = vacancies 80 + .map((v) => v.level) 81 + .filter((level): level is NonNullable<typeof level> => level !== null) 82 + .filter( 83 + (level, index, self) => 84 + index === self.findIndex((l) => l.id === level.id), 85 + ) 86 + .map((level) => ({ id: level.id, name: level.name })); 87 + 88 + // Extract unique locations 89 + const locations = Array.from( 90 + new Set( 91 + vacancies 92 + .map((v) => v.location) 93 + .filter((location): location is string => location !== null), 94 + ), 95 + ); 96 + 97 + return { 98 + jobTypes: [], // No job types available in current data 99 + levels, 100 + companies, 101 + locations, 102 + }; 103 + }, [filterOptionsData]); 104 + 105 + const handleJobTypeToggle = (jobTypeId: string) => { 106 + setFilters((prev) => ({ 107 + ...prev, 108 + jobTypes: prev.jobTypes.includes(jobTypeId) 109 + ? prev.jobTypes.filter((id) => id !== jobTypeId) 110 + : [...prev.jobTypes, jobTypeId], 111 + })); 112 + }; 113 + 114 + const handleLevelToggle = (levelId: string) => { 115 + setFilters((prev) => ({ 116 + ...prev, 117 + levels: prev.levels.includes(levelId) 118 + ? prev.levels.filter((id) => id !== levelId) 119 + : [...prev.levels, levelId], 120 + })); 121 + }; 122 + 123 + const handleCompanyToggle = (companyId: string) => { 124 + setFilters((prev) => ({ 125 + ...prev, 126 + companies: prev.companies.includes(companyId) 127 + ? prev.companies.filter((id) => id !== companyId) 128 + : [...prev.companies, companyId], 129 + })); 130 + }; 131 + 132 + const handleLocationToggle = (location: string) => { 133 + setFilters((prev) => ({ 134 + ...prev, 135 + locations: prev.locations.includes(location) 136 + ? prev.locations.filter((l) => l !== location) 137 + : [...prev.locations, location], 138 + })); 139 + }; 140 + 141 + const handleClear = () => { 142 + setFilters({ 143 + jobTypes: [], 144 + levels: [], 145 + companies: [], 146 + locations: [], 147 + salaryRange: { min: null, max: null }, 148 + }); 149 + }; 150 + 151 + const handleApply = () => { 152 + onApplyFilters(filters); 153 + onClose(); 154 + }; 155 + 156 + if (!isOpen) { 157 + return null; 158 + } 159 + 160 + return ( 161 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 162 + <div className="bg-ctp-base rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto"> 163 + <div className="flex items-center justify-between mb-6"> 164 + <h2 className="text-2xl font-bold text-ctp-text">Filter Vacancies</h2> 165 + <Button variant="ghost" onClick={onClose}> 166 + 167 + </Button> 168 + </div> 169 + 170 + {isLoading && ( 171 + <div className="flex items-center justify-center py-8"> 172 + <div className="text-ctp-subtext0">Loading filter options...</div> 173 + </div> 174 + )} 175 + 176 + {error && ( 177 + <div className="flex items-center justify-center py-8"> 178 + <div className="text-ctp-red"> 179 + Error loading filter options. Please try again. 180 + </div> 181 + </div> 182 + )} 183 + 184 + {!(isLoading || error) && ( 185 + <> 186 + <div className="space-y-6"> 187 + {/* Job Types - Currently no job types available */} 188 + {availableOptions.jobTypes.length > 0 && ( 189 + <div> 190 + <h3 className="text-lg font-semibold text-ctp-text mb-3"> 191 + Job Types 192 + </h3> 193 + <div className="flex flex-wrap gap-2"> 194 + {availableOptions.jobTypes.map((jobType) => ( 195 + <button 196 + key={jobType.id} 197 + type="button" 198 + onClick={() => handleJobTypeToggle(jobType.id)} 199 + className={`px-3 py-2 rounded-full text-sm transition-colors ${ 200 + filters.jobTypes.includes(jobType.id) 201 + ? "bg-ctp-blue text-ctp-base" 202 + : "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface2" 203 + }`} 204 + > 205 + {jobType.name} 206 + </button> 207 + ))} 208 + </div> 209 + </div> 210 + )} 211 + 212 + {/* Experience Levels */} 213 + <div> 214 + <h3 className="text-lg font-semibold text-ctp-text mb-3"> 215 + Experience Levels 216 + </h3> 217 + <div className="flex flex-wrap gap-2"> 218 + {availableOptions.levels.map((level) => ( 219 + <button 220 + key={level.id} 221 + type="button" 222 + onClick={() => handleLevelToggle(level.id)} 223 + className={`px-3 py-2 rounded-full text-sm transition-colors ${ 224 + filters.levels.includes(level.id) 225 + ? "bg-ctp-blue text-ctp-base" 226 + : "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface2" 227 + }`} 228 + > 229 + {level.name} 230 + </button> 231 + ))} 232 + </div> 233 + </div> 234 + 235 + {/* Companies */} 236 + <div> 237 + <h3 className="text-lg font-semibold text-ctp-text mb-3"> 238 + Companies 239 + </h3> 240 + <div className="flex flex-wrap gap-2"> 241 + {availableOptions.companies.map((company) => ( 242 + <button 243 + key={company.id} 244 + type="button" 245 + onClick={() => handleCompanyToggle(company.id)} 246 + className={`px-3 py-2 rounded-full text-sm transition-colors ${ 247 + filters.companies.includes(company.id) 248 + ? "bg-ctp-blue text-ctp-base" 249 + : "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface2" 250 + }`} 251 + > 252 + {company.name} 253 + </button> 254 + ))} 255 + </div> 256 + </div> 257 + 258 + {/* Locations */} 259 + <div> 260 + <h3 className="text-lg font-semibold text-ctp-text mb-3"> 261 + Locations 262 + </h3> 263 + <div className="flex flex-wrap gap-2"> 264 + {availableOptions.locations.map((location) => ( 265 + <button 266 + key={location} 267 + type="button" 268 + onClick={() => handleLocationToggle(location)} 269 + className={`px-3 py-2 rounded-full text-sm transition-colors ${ 270 + filters.locations.includes(location) 271 + ? "bg-ctp-blue text-ctp-base" 272 + : "bg-ctp-surface1 text-ctp-text hover:bg-ctp-surface2" 273 + }`} 274 + > 275 + {location} 276 + </button> 277 + ))} 278 + </div> 279 + </div> 280 + 281 + {/* Salary Range */} 282 + <div> 283 + <h3 className="text-lg font-semibold text-ctp-text mb-3"> 284 + Salary Range 285 + </h3> 286 + <RangeSlider 287 + min={20000} 288 + max={200000} 289 + value={[ 290 + filters.salaryRange.min ?? 20000, 291 + filters.salaryRange.max ?? 200000, 292 + ]} 293 + onChange={([min, max]: [number, number]) => { 294 + setFilters((prev) => ({ 295 + ...prev, 296 + salaryRange: { 297 + min: min === 20000 ? null : min, 298 + max: max === 200000 ? null : max, 299 + }, 300 + })); 301 + }} 302 + step={5000} 303 + formatValue={(value: number) => `$${value.toLocaleString()}`} 304 + showLabels={true} 305 + showValues={true} 306 + className="px-2" 307 + /> 308 + </div> 309 + </div> 310 + 311 + <div className="flex justify-end gap-3 mt-8"> 312 + <Button variant="outline" onClick={handleClear}> 313 + Clear All 314 + </Button> 315 + <Button variant="outline" onClick={onClose}> 316 + Cancel 317 + </Button> 318 + <Button onClick={handleApply}>Apply Filters</Button> 319 + </div> 320 + </> 321 + )} 322 + </div> 323 + </div> 324 + ); 325 + };
+160
apps/client/src/features/applications/components/VacancySelector.tsx
··· 1 + import { Button, Placeholder, TextInput } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { useProgressContext } from "@/components/Progress"; 4 + import { useMyVacanciesQuery } from "@/generated/graphql"; 5 + import { useVacancyFilters } from "./useVacancyFilters"; 6 + import { VacancyCard } from "./VacancyCard"; 7 + import { VacancyFilterPanel } from "./VacancyFilterPanel"; 8 + 9 + interface Vacancy { 10 + id: string; 11 + title: string; 12 + description?: string | null; 13 + requirements?: string | null; 14 + company?: { 15 + name: string; 16 + } | null; 17 + role?: { 18 + name: string; 19 + } | null; 20 + level?: { 21 + name: string; 22 + } | null; 23 + location?: string | null; 24 + minSalary?: number | null; 25 + maxSalary?: number | null; 26 + applicationUrl?: string | null; 27 + } 28 + 29 + interface VacancySelectorProps { 30 + onSelectVacancy?: (vacancyId: string) => void; 31 + onCancel: () => void; 32 + } 33 + 34 + export const VacancySelector = ({ 35 + onSelectVacancy, 36 + onCancel, 37 + }: VacancySelectorProps) => { 38 + const [searchTerm, setSearchTerm] = useState(""); 39 + const [selectedVacancyId, setSelectedVacancyId] = useState<string | null>( 40 + null, 41 + ); 42 + const [isFilterOpen, setIsFilterOpen] = useState(false); 43 + const { filters, setFilters, activeFiltersCount, serverFilter } = 44 + useVacancyFilters(searchTerm); 45 + 46 + // Use Progress context if available (components used in Progress will have this) 47 + const progressContext = useProgressContext(); 48 + 49 + // Fetch all vacancies with server-side filtering 50 + const { data, isLoading, error } = useMyVacanciesQuery({ 51 + filter: serverFilter, 52 + }); 53 + 54 + // Merge vacancies, prioritizing own vacancies first 55 + const allVacancies = 56 + data?.me?.vacancies?.edges?.map((edge) => edge.node) || []; 57 + const myVacancies = allVacancies.filter((v) => !v.isPublic); 58 + const publicVacancies = allVacancies.filter((v) => v.isPublic); 59 + 60 + // Create a set of own vacancy IDs to avoid duplicates 61 + const myVacancyIds = new Set(myVacancies.map((v) => v.id)); 62 + 63 + // Combine: own vacancies first, then public vacancies that aren't already in own list 64 + const vacancies = [ 65 + ...myVacancies.map((v) => ({ ...v, isOwn: true })), 66 + ...publicVacancies 67 + .filter((v) => !myVacancyIds.has(v.id)) 68 + .map((v) => ({ ...v, isOwn: false })), 69 + ]; 70 + 71 + if (isLoading) { 72 + return <Placeholder variant="loading" message="Loading vacancies..." />; 73 + } 74 + 75 + if (error) { 76 + return <Placeholder variant="error" message="Error loading vacancies" />; 77 + } 78 + 79 + return ( 80 + <div className="space-y-6"> 81 + <div> 82 + <h2 className="text-2xl font-bold text-ctp-text">Select a Vacancy</h2> 83 + <p className="mt-2 text-ctp-subtext0"> 84 + Choose the vacancy you want to apply for{" "} 85 + <button 86 + type="button" 87 + onClick={() => window.open("/vacancies/create", "_blank")} 88 + className="text-ctp-blue hover:text-ctp-blue/80 underline font-medium" 89 + > 90 + or create your own 91 + </button> 92 + </p> 93 + </div> 94 + 95 + <div className="flex items-center space-x-3"> 96 + <TextInput 97 + placeholder="Search vacancies by title, company, or role..." 98 + value={searchTerm} 99 + onChange={setSearchTerm} 100 + className="flex-1" 101 + /> 102 + <Button 103 + variant="outline" 104 + onClick={() => setIsFilterOpen(true)} 105 + className="relative" 106 + > 107 + 🔍 Filter 108 + {activeFiltersCount > 0 && ( 109 + <span className="absolute -top-2 -right-2 bg-ctp-blue text-ctp-base text-xs rounded-full h-5 w-5 flex items-center justify-center"> 110 + {activeFiltersCount} 111 + </span> 112 + )} 113 + </Button> 114 + </div> 115 + 116 + <div className="max-h-96 overflow-y-auto space-y-3"> 117 + {vacancies.length === 0 ? ( 118 + <div className="text-center py-8 text-ctp-subtext0"> 119 + {searchTerm || activeFiltersCount > 0 120 + ? "No vacancies match your search" 121 + : "No vacancies available"} 122 + </div> 123 + ) : ( 124 + vacancies.map((vacancy: Vacancy) => ( 125 + <VacancyCard 126 + key={vacancy.id} 127 + vacancy={vacancy} 128 + isSelected={selectedVacancyId === vacancy.id} 129 + onSelect={setSelectedVacancyId} 130 + /> 131 + )) 132 + )} 133 + </div> 134 + 135 + <div className="flex justify-end space-x-3"> 136 + <Button variant="outline" onClick={onCancel}> 137 + Cancel 138 + </Button> 139 + <Button 140 + onClick={() => { 141 + if (selectedVacancyId) { 142 + onSelectVacancy?.(selectedVacancyId); 143 + progressContext?.next(); 144 + } 145 + }} 146 + disabled={!selectedVacancyId} 147 + > 148 + Next: Select CV Template 149 + </Button> 150 + </div> 151 + 152 + <VacancyFilterPanel 153 + isOpen={isFilterOpen} 154 + onClose={() => setIsFilterOpen(false)} 155 + onApplyFilters={setFilters} 156 + currentFilters={filters} 157 + /> 158 + </div> 159 + ); 160 + };
+59
apps/client/src/features/applications/components/useVacancyFilters.ts
··· 1 + import { useMemo, useState } from "react"; 2 + 3 + export interface FilterOptions { 4 + jobTypes: string[]; 5 + levels: string[]; 6 + companies: string[]; 7 + locations: string[]; 8 + salaryRange: { 9 + min: number | null; 10 + max: number | null; 11 + }; 12 + } 13 + 14 + export interface VacancyFilterInput { 15 + searchTerm?: string; 16 + companies?: string[]; 17 + locations?: string[]; 18 + levels?: string[]; 19 + minSalary?: number; 20 + maxSalary?: number; 21 + } 22 + 23 + export const useVacancyFilters = (searchTerm?: string) => { 24 + const [filters, setFilters] = useState<FilterOptions>({ 25 + jobTypes: [], 26 + levels: [], 27 + companies: [], 28 + locations: [], 29 + salaryRange: { min: null, max: null }, 30 + }); 31 + 32 + const { activeFiltersCount, serverFilter } = useMemo(() => { 33 + const count = 34 + filters.jobTypes.length + 35 + filters.levels.length + 36 + filters.companies.length + 37 + filters.locations.length + 38 + (filters.salaryRange.min ? 1 : 0) + 39 + (filters.salaryRange.max ? 1 : 0); 40 + 41 + const filter: VacancyFilterInput = { 42 + searchTerm: searchTerm || undefined, 43 + companies: filters.companies.length > 0 ? filters.companies : undefined, 44 + locations: filters.locations.length > 0 ? filters.locations : undefined, 45 + levels: filters.levels.length > 0 ? filters.levels : undefined, 46 + minSalary: filters.salaryRange.min ?? undefined, 47 + maxSalary: filters.salaryRange.max ?? undefined, 48 + }; 49 + 50 + return { activeFiltersCount: count, serverFilter: filter }; 51 + }, [searchTerm, filters]); 52 + 53 + return { 54 + filters, 55 + setFilters, 56 + activeFiltersCount, 57 + serverFilter, 58 + }; 59 + };
+36
apps/client/src/features/applications/mutations/create-application.graphql
··· 1 + mutation CreateApplication($input: CreateApplicationInput!) { 2 + createApplication(input: $input) { 3 + id 4 + userId 5 + vacancyId 6 + cvId 7 + coverLetter 8 + statusId 9 + appliedAt 10 + createdAt 11 + updatedAt 12 + status { 13 + id 14 + name 15 + description 16 + createdAt 17 + updatedAt 18 + } 19 + vacancy { 20 + id 21 + title 22 + description 23 + location 24 + minSalary 25 + maxSalary 26 + companyName 27 + roleName 28 + levelName 29 + jobTypeName 30 + } 31 + cv { 32 + id 33 + title 34 + } 35 + } 36 + }
+41
apps/client/src/features/applications/queries/my-applications.graphql
··· 1 + query MyApplications { 2 + me { 3 + applications { 4 + edges { 5 + id 6 + userId 7 + vacancyId 8 + cvId 9 + coverLetter 10 + statusId 11 + appliedAt 12 + createdAt 13 + updatedAt 14 + status { 15 + id 16 + name 17 + description 18 + createdAt 19 + updatedAt 20 + } 21 + vacancy { 22 + id 23 + title 24 + description 25 + location 26 + minSalary 27 + maxSalary 28 + companyName 29 + roleName 30 + levelName 31 + jobTypeName 32 + } 33 + cv { 34 + id 35 + title 36 + } 37 + } 38 + totalCount 39 + } 40 + } 41 + }
+5
apps/client/src/pages/ApplicationFlowPage.tsx
··· 1 + import { ApplicationFlow } from "@/features/applications/components/ApplicationFlow"; 2 + 3 + export default function ApplicationFlowPage() { 4 + return <ApplicationFlow />; 5 + }
+5
apps/client/src/pages/ApplicationsPage.tsx
··· 1 + import { ApplicationsOverview } from "@/features/applications/components/ApplicationsOverview"; 2 + 3 + export default function ApplicationsPage() { 4 + return <ApplicationsOverview />; 5 + }